1 #!/usr/bin/env python 2 3 """ 4 Interpretation and preparation of iMIP content, together with a content handling 5 mechanism employed by specific recipients. 6 7 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk> 8 9 This program is free software; you can redistribute it and/or modify it under 10 the terms of the GNU General Public License as published by the Free Software 11 Foundation; either version 3 of the License, or (at your option) any later 12 version. 13 14 This program is distributed in the hope that it will be useful, but WITHOUT 15 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 16 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 17 details. 18 19 You should have received a copy of the GNU General Public License along with 20 this program. If not, see <http://www.gnu.org/licenses/>. 21 """ 22 23 from datetime import datetime, timedelta 24 from email.mime.text import MIMEText 25 from imiptools.config import MANAGER_PATH, MANAGER_URL 26 from imiptools.data import Object, parse_object, \ 27 get_address, get_fragments, \ 28 get_uri, get_value, uri_dict, uri_item 29 from imiptools.dates import * 30 from imiptools.period import have_conflict, insert_period, remove_period 31 from pytz import timezone 32 from socket import gethostname 33 from vRecurrence import get_parameters, get_rule 34 import imip_store 35 36 try: 37 from cStringIO import StringIO 38 except ImportError: 39 from StringIO import StringIO 40 41 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, partstat_set): 42 43 """ 44 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 45 'new_dtstamp', and the 'partstat_set' indication, whether the object 46 providing the new information is really newer than the object providing the 47 old information. 48 """ 49 50 have_sequence = old_sequence is not None and new_sequence is not None 51 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 52 53 have_dtstamp = old_dtstamp and new_dtstamp 54 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 55 56 is_old_sequence = have_sequence and ( 57 int(new_sequence) < int(old_sequence) or 58 is_same_sequence and is_old_dtstamp 59 ) 60 61 return is_same_sequence and partstat_set or not is_old_sequence 62 63 # NOTE: Need to expose the 100 day window for recurring events in the 64 # NOTE: configuration. 65 66 def get_periods(obj, window_size=100): 67 68 """ 69 Return periods for the given object 'obj', confining materialised periods 70 to the given 'window_size' in days starting from the present moment. 71 """ 72 73 dtstart = obj.get_utc_datetime("DTSTART") 74 dtend = obj.get_utc_datetime("DTEND") 75 76 # NOTE: Need also DURATION support. 77 78 duration = dtend - dtstart 79 80 # Recurrence rules create multiple instances to be checked. 81 # Conflicts may only be assessed within a period defined by policy 82 # for the agent, with instances outside that period being considered 83 # unchecked. 84 85 window_end = datetime.now() + timedelta(window_size) 86 87 # NOTE: Need also RDATE and EXDATE support. 88 89 rrule = obj.get_value("RRULE") 90 91 if rrule: 92 selector = get_rule(dtstart, rrule) 93 parameters = get_parameters(rrule) 94 periods = [] 95 for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")): 96 start = datetime(*start, tzinfo=timezone("UTC")) 97 end = start + duration 98 periods.append((format_datetime(start), format_datetime(end))) 99 else: 100 periods = [(format_datetime(dtstart), format_datetime(dtend))] 101 102 return periods 103 104 def remove_from_freebusy(freebusy, attendee, uid, store): 105 106 """ 107 For the given 'attendee', remove periods from 'freebusy' that are associated 108 with 'uid' in the 'store'. 109 """ 110 111 remove_period(freebusy, uid) 112 store.set_freebusy(attendee, freebusy) 113 114 def remove_from_freebusy_for_other(freebusy, user, other, uid, store): 115 116 """ 117 For the given 'user', remove for the 'other' party periods from 'freebusy' 118 that are associated with 'uid' in the 'store'. 119 """ 120 121 remove_period(freebusy, uid) 122 store.set_freebusy_for_other(user, freebusy, other) 123 124 def _update_freebusy(freebusy, periods, transp, uid): 125 126 """ 127 Update the free/busy details with the given 'periods', 'transp' setting and 128 'uid'. 129 """ 130 131 remove_period(freebusy, uid) 132 133 for start, end in periods: 134 insert_period(freebusy, (start, end, uid, transp)) 135 136 def update_freebusy(freebusy, attendee, periods, transp, uid, store): 137 138 """ 139 For the given 'attendee', update the free/busy details with the given 140 'periods', 'transp' setting and 'uid' in the 'store'. 141 """ 142 143 _update_freebusy(freebusy, periods, transp, uid) 144 store.set_freebusy(attendee, freebusy) 145 146 def update_freebusy_for_other(freebusy, user, other, periods, transp, uid, store): 147 148 """ 149 For the given 'user', update the free/busy details of 'other' with the given 150 'periods', 'transp' setting and 'uid' in the 'store'. 151 """ 152 153 _update_freebusy(freebusy, periods, transp, uid) 154 store.set_freebusy_for_other(user, freebusy, other) 155 156 def can_schedule(freebusy, periods, uid): 157 158 """ 159 Return whether the 'freebusy' list can accommodate the given 'periods' 160 employing the specified 'uid'. 161 """ 162 163 for conflict in have_conflict(freebusy, periods, True): 164 start, end, found_uid, found_transp = conflict 165 if found_uid != uid: 166 return False 167 168 return True 169 170 # Handler mechanism objects. 171 172 def handle_itip_part(part, senders, recipient, handlers, messenger): 173 174 """ 175 Handle the given iTIP 'part' from the given 'senders' for the given 176 'recipient' using the given 'handlers' and information provided by the 177 given 'messenger'. Return a list of responses, each response being a tuple 178 of the form (is-outgoing, message-part). 179 """ 180 181 method = part.get_param("method") 182 183 # Decode the data and parse it. 184 185 f = StringIO(part.get_payload(decode=True)) 186 187 itip = parse_object(f, part.get_content_charset(), "VCALENDAR") 188 189 # Ignore the part if not a calendar object. 190 191 if not itip: 192 return [] 193 194 # Require consistency between declared and employed methods. 195 196 if get_value(itip, "METHOD") == method: 197 198 # Look for different kinds of sections. 199 200 all_results = [] 201 202 for name, cls in handlers: 203 for fragment in get_fragments(itip, name): 204 205 # Dispatch to a handler and obtain any response. 206 207 handler = cls(Object(fragment), senders, recipient, messenger) 208 results = methods[method](handler)() 209 210 # Aggregate responses for a single message. 211 212 if results: 213 for result in results: 214 outgoing_recipients, part = result 215 all_results.append((outgoing_recipients, part)) 216 217 return all_results 218 219 return [] 220 221 # References to the Web interface. 222 223 def get_manager_url(): 224 url_base = MANAGER_URL or "http://%s/" % gethostname() 225 return "%s/%s" % (url_base.rstrip("/"), MANAGER_PATH.lstrip("/")) 226 227 def get_object_url(uid): 228 return "%s/%s" % (get_manager_url().rstrip("/"), uid) 229 230 class Handler: 231 232 "General handler support." 233 234 def __init__(self, obj, senders=None, recipient=None, messenger=None): 235 236 """ 237 Initialise the handler with the calendar 'obj' and the 'senders' and 238 'recipient' of the object (if specifically indicated). 239 """ 240 241 self.obj = obj 242 self.senders = senders and set(map(get_address, senders)) 243 self.recipient = recipient and get_address(recipient) 244 self.messenger = messenger 245 246 self.uid = self.obj.get_value("UID") 247 self.sequence = self.obj.get_value("SEQUENCE") 248 self.dtstamp = self.obj.get_value("DTSTAMP") 249 250 self.store = imip_store.FileStore() 251 252 try: 253 self.publisher = imip_store.FilePublisher() 254 except OSError: 255 self.publisher = None 256 257 def wrap(self, text, link=True): 258 259 "Wrap any valid message for passing to the recipient." 260 261 texts = [] 262 texts.append(text) 263 if link: 264 texts.append("If your mail program cannot handle this " 265 "message, you may view the details here:\n\n%s" % 266 get_object_url(self.uid)) 267 268 return [(None, MIMEText("\n".join(texts)))] 269 270 # Access to calendar structures and other data. 271 272 def get_periods(self): 273 return get_periods(self.obj) 274 275 def remove_from_freebusy(self, freebusy, attendee): 276 remove_from_freebusy(freebusy, attendee, self.uid, self.store) 277 278 def remove_from_freebusy_for_other(self, freebusy, user, other): 279 remove_from_freebusy_for_other(freebusy, user, other, self.uid, self.store) 280 281 def update_freebusy(self, freebusy, attendee, periods): 282 return update_freebusy(freebusy, attendee, periods, self.obj.get_value("TRANSP"), self.uid, self.store) 283 284 def update_freebusy_for_other(self, freebusy, user, other, periods): 285 return update_freebusy_for_other(freebusy, user, other, periods, self.obj.get_value("TRANSP"), self.uid, self.store) 286 287 def can_schedule(self, freebusy, periods): 288 return can_schedule(freebusy, periods, self.uid) 289 290 def filter_by_senders(self, mapping): 291 292 """ 293 Return a list of items from 'mapping' filtered using sender information. 294 """ 295 296 if self.senders: 297 298 # Get a mapping from senders to identities. 299 300 identities = self.get_sender_identities(mapping) 301 302 # Find the senders that are valid. 303 304 senders = map(get_address, identities) 305 valid = self.senders.intersection(senders) 306 307 # Return the true identities. 308 309 return [identities[get_uri(address)] for address in valid] 310 else: 311 return mapping 312 313 def filter_by_recipient(self, mapping): 314 315 """ 316 Return a list of items from 'mapping' filtered using recipient 317 information. 318 """ 319 320 if self.recipient: 321 addresses = set(map(get_address, mapping)) 322 return map(get_uri, addresses.intersection([self.recipient])) 323 else: 324 return mapping 325 326 def require_organiser_and_attendees(self, from_organiser=True): 327 328 """ 329 Return the organiser and attendees for the current object, filtered for 330 the recipient of interest. Return None if no identities are eligible. 331 332 Organiser and attendee identities are provided as lower case values. 333 """ 334 335 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 336 organiser_item = uri_item(self.obj.get_item("ORGANIZER")) 337 338 # Only provide details for attendees who sent/receive the message. 339 340 attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders 341 342 attendees = {} 343 for attendee in attendee_filter_fn(attendee_map): 344 attendees[attendee] = attendee_map[attendee] 345 346 if not attendees or not organiser_item: 347 return None 348 349 # Only provide details for an organiser who sent/receives the message. 350 351 organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient 352 353 if not organiser_filter_fn(dict([organiser_item])): 354 return None 355 356 return organiser_item, attendees 357 358 def get_sender_identities(self, mapping): 359 360 """ 361 Return a mapping from actual senders to the identities for which they 362 have provided data, extracting this information from the given 363 'mapping'. 364 """ 365 366 senders = {} 367 368 for value, attr in mapping.items(): 369 sent_by = attr.get("SENT-BY") 370 if sent_by: 371 senders[get_uri(sent_by)] = value 372 else: 373 senders[value] = value 374 375 return senders 376 377 def get_object(self, user): 378 379 """ 380 Return the stored object to which the current object refers for the 381 given 'user' and for the given 'objtype'. 382 """ 383 384 f = self.store.get_event(user, self.uid) 385 fragment = f and parse_object(f, "utf-8") 386 return fragment and Object(fragment) 387 388 def have_new_object(self, attendee, obj=None): 389 390 """ 391 Return whether the current object is new to the 'attendee' (or if the 392 given 'obj' is new). 393 """ 394 395 obj = obj or self.get_object(attendee) 396 397 # If found, compare SEQUENCE and potentially DTSTAMP. 398 399 if obj: 400 sequence = obj.get_value("SEQUENCE") 401 dtstamp = obj.get_value("DTSTAMP") 402 403 # If the request refers to an older version of the object, ignore 404 # it. 405 406 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 407 self.is_partstat_updated(obj)) 408 409 return True 410 411 def is_partstat_updated(self, obj): 412 413 """ 414 Return whether the participant status has been updated in the current 415 object in comparison to the given 'obj'. 416 417 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 418 NOTE: and make it invalid. Thus, such attendance information may also be 419 NOTE: incorporated into any new object assessment. 420 """ 421 422 old_attendees = obj.get_value_map("ATTENDEE") 423 new_attendees = self.obj.get_value_map("ATTENDEE") 424 425 for attendee, attr in old_attendees.items(): 426 old_partstat = attr.get("PARTSTAT") 427 new_attr = new_attendees.get(attendee) 428 new_partstat = new_attr and new_attr.get("PARTSTAT") 429 430 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 431 new_partstat != old_partstat: 432 433 return True 434 435 return False 436 437 def update_dtstamp(self): 438 439 "Update the DTSTAMP in the current object." 440 441 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 442 utcnow = to_timezone(datetime.utcnow(), "UTC") 443 self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 444 445 # Handler registry. 446 447 methods = { 448 "ADD" : lambda handler: handler.add, 449 "CANCEL" : lambda handler: handler.cancel, 450 "COUNTER" : lambda handler: handler.counter, 451 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 452 "PUBLISH" : lambda handler: handler.publish, 453 "REFRESH" : lambda handler: handler.refresh, 454 "REPLY" : lambda handler: handler.reply, 455 "REQUEST" : lambda handler: handler.request, 456 } 457 458 # vim: tabstop=4 expandtab shiftwidth=4