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_uri, get_value, get_window_end, \ 28 is_new_object, uri_dict, uri_item, uri_values 29 from imiptools.dates import format_datetime, get_default_timezone, to_timezone 30 from imiptools.period import can_schedule, insert_period, remove_period, \ 31 remove_affected_period, update_freebusy 32 from imiptools.profile import Preferences 33 from socket import gethostname 34 import imip_store 35 36 try: 37 from cStringIO import StringIO 38 except ImportError: 39 from StringIO import StringIO 40 41 # Handler mechanism objects. 42 43 def handle_itip_part(part, handlers): 44 45 """ 46 Handle the given iTIP 'part' using the given 'handlers' dictionary. 47 48 Return a list of responses, each response being a tuple of the form 49 (outgoing-recipients, message-part). 50 """ 51 52 method = part.get_param("method") 53 54 # Decode the data and parse it. 55 56 f = StringIO(part.get_payload(decode=True)) 57 58 itip = parse_object(f, part.get_content_charset(), "VCALENDAR") 59 60 # Ignore the part if not a calendar object. 61 62 if not itip: 63 return 64 65 # Require consistency between declared and employed methods. 66 67 if get_value(itip, "METHOD") == method: 68 69 # Look for different kinds of sections. 70 71 all_results = [] 72 73 for name, items in itip.items(): 74 75 # Get a handler for the given section. 76 77 handler = handlers.get(name) 78 if not handler: 79 continue 80 81 for item in items: 82 83 # Dispatch to a handler and obtain any response. 84 85 handler.set_object(Object({name : item})) 86 methods[method](handler)() 87 88 # References to the Web interface. 89 90 def get_manager_url(): 91 url_base = MANAGER_URL or "http://%s/" % gethostname() 92 return "%s/%s" % (url_base.rstrip("/"), MANAGER_PATH.lstrip("/")) 93 94 def get_object_url(uid): 95 return "%s/%s" % (get_manager_url().rstrip("/"), uid) 96 97 class Handler: 98 99 "General handler support." 100 101 def __init__(self, senders=None, recipient=None, messenger=None): 102 103 """ 104 Initialise the handler with the calendar 'obj' and the 'senders' and 105 'recipient' of the object (if specifically indicated). 106 """ 107 108 self.senders = senders and set(map(get_address, senders)) 109 self.recipient = recipient and get_address(recipient) 110 self.messenger = messenger 111 112 self.results = [] 113 self.outgoing_methods = set() 114 115 self.obj = None 116 self.uid = None 117 self.recurrenceid = None 118 self.sequence = None 119 self.dtstamp = None 120 121 self.store = imip_store.FileStore() 122 123 try: 124 self.publisher = imip_store.FilePublisher() 125 except OSError: 126 self.publisher = None 127 128 def set_object(self, obj): 129 self.obj = obj 130 self.uid = self.obj.get_value("UID") 131 self.recurrenceid = format_datetime(self.obj.get_utc_datetime("RECURRENCE-ID")) 132 self.sequence = self.obj.get_value("SEQUENCE") 133 self.dtstamp = self.obj.get_value("DTSTAMP") 134 135 def wrap(self, text, link=True): 136 137 "Wrap any valid message for passing to the recipient." 138 139 texts = [] 140 texts.append(text) 141 if link: 142 texts.append("If your mail program cannot handle this " 143 "message, you may view the details here:\n\n%s" % 144 get_object_url(self.uid)) 145 146 return self.add_result(None, None, MIMEText("\n".join(texts))) 147 148 # Result registration. 149 150 def add_result(self, method, outgoing_recipients, part): 151 152 """ 153 Record a result having the given 'method', 'outgoing_recipients' and 154 message part. 155 """ 156 157 if outgoing_recipients: 158 self.outgoing_methods.add(method) 159 self.results.append((outgoing_recipients, part)) 160 161 def get_results(self): 162 return self.results 163 164 def get_outgoing_methods(self): 165 return self.outgoing_methods 166 167 # Convenience methods for modifying free/busy collections. 168 169 def remove_from_freebusy(self, freebusy): 170 171 "Remove this event from the given 'freebusy' collection." 172 173 remove_period(freebusy, self.uid, self.recurrenceid) 174 175 def remove_freebusy_for_original_recurrence(self, freebusy): 176 177 """ 178 Remove from 'freebusy' any specific recurrence from parent free/busy 179 details for the current object. 180 """ 181 182 if self.recurrenceid: 183 remove_affected_period(freebusy, self.uid, self.recurrenceid) 184 185 def _update_freebusy(self, freebusy, periods, recurrenceid, transp=None, replace_all=False): 186 187 """ 188 Update the 'freebusy' collection with the given 'periods', indicating an 189 explicit 'recurrenceid' to affect either a recurrence or the parent 190 event. 191 """ 192 193 update_freebusy(freebusy, periods, transp or self.obj.get_value("TRANSP"), 194 self.uid, recurrenceid, replace_all) 195 196 def update_freebusy(self, freebusy, periods, transp=None, replace_all=False): 197 198 """ 199 Update the 'freebusy' collection for this event with the given 200 'periods'. 201 """ 202 203 self._update_freebusy(freebusy, periods, self.recurrenceid, transp, replace_all) 204 205 def update_freebusy_for_participant(self, freebusy, periods, attr, for_organiser=False, has_moved=False): 206 207 """ 208 Update the 'freebusy' collection using the given 'periods', subject to 209 the 'attr' provided for the participant, indicating whether this is 210 being generated 'for_organiser' or not. 211 """ 212 213 # Organisers employ a special transparency. 214 215 if for_organiser or attr.get("PARTSTAT") != "DECLINED": 216 self.update_freebusy(freebusy, periods, transp=(for_organiser and "ORG" or None), replace_all=has_moved) 217 else: 218 self.remove_from_freebusy(freebusy) 219 220 # Convenience methods for updating stored free/busy information. 221 222 def update_freebusy_from_participant(self, user, participant_item, for_organiser, has_moved=False): 223 224 """ 225 For the given 'user', record the free/busy information for the 226 'participant_item' (a value plus attributes) representing a different 227 identity, thus maintaining a separate record of their free/busy details. 228 """ 229 230 participant, participant_attr = participant_item 231 232 if participant == user: 233 return 234 235 freebusy = self.store.get_freebusy_for_other(user, participant) 236 tzid = self.get_tzid(user) 237 window_end = get_window_end(tzid) 238 periods = self.obj.get_periods_for_freebusy(tzid, window_end) 239 240 # Record in the free/busy details unless a non-participating attendee. 241 242 self.update_freebusy_for_participant(freebusy, periods, participant_attr, 243 for_organiser and self.is_not_attendee(participant, self.obj), 244 has_moved) 245 246 self.remove_freebusy_for_original_recurrence(freebusy) 247 self.store.set_freebusy_for_other(user, freebusy, participant) 248 249 def update_freebusy_from_organiser(self, attendee, organiser_item, has_moved=False): 250 251 """ 252 For the 'attendee', record free/busy information from the 253 'organiser_item' (a value plus attributes). 254 """ 255 256 self.update_freebusy_from_participant(attendee, organiser_item, True, has_moved) 257 258 def update_freebusy_from_attendees(self, organiser, attendees): 259 260 "For the 'organiser', record free/busy information from 'attendees'." 261 262 for attendee_item in attendees.items(): 263 self.update_freebusy_from_participant(organiser, attendee_item, False) 264 265 # Logic, filtering and access to calendar structures and other data. 266 267 def is_not_attendee(self, identity, obj): 268 269 "Return whether 'identity' is not an attendee in 'obj'." 270 271 return identity not in uri_values(obj.get_values("ATTENDEE")) 272 273 def can_schedule(self, freebusy, periods): 274 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 275 276 def filter_by_senders(self, mapping): 277 278 """ 279 Return a list of items from 'mapping' filtered using sender information. 280 """ 281 282 if self.senders: 283 284 # Get a mapping from senders to identities. 285 286 identities = self.get_sender_identities(mapping) 287 288 # Find the senders that are valid. 289 290 senders = map(get_address, identities) 291 valid = self.senders.intersection(senders) 292 293 # Return the true identities. 294 295 return [identities[get_uri(address)] for address in valid] 296 else: 297 return mapping 298 299 def filter_by_recipient(self, mapping): 300 301 """ 302 Return a list of items from 'mapping' filtered using recipient 303 information. 304 """ 305 306 if self.recipient: 307 addresses = set(map(get_address, mapping)) 308 return map(get_uri, addresses.intersection([self.recipient])) 309 else: 310 return mapping 311 312 def require_organiser(self, from_organiser=True): 313 314 """ 315 Return the organiser for the current object, filtered for the sender or 316 recipient of interest. Return None if no identities are eligible. 317 318 The organiser identity is normalized. 319 """ 320 321 organiser_item = uri_item(self.obj.get_item("ORGANIZER")) 322 323 # Only provide details for an organiser who sent/receives the message. 324 325 organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient 326 327 if not organiser_filter_fn(dict([organiser_item])): 328 return None 329 330 return organiser_item 331 332 def require_attendees(self, from_organiser=True): 333 334 """ 335 Return the attendees for the current object, filtered for the sender or 336 recipient of interest. Return None if no identities are eligible. 337 338 The attendee identities are normalized. 339 """ 340 341 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 342 343 # Only provide details for attendees who sent/receive the message. 344 345 attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders 346 347 attendees = {} 348 for attendee in attendee_filter_fn(attendee_map): 349 attendees[attendee] = attendee_map[attendee] 350 351 return attendees 352 353 def require_organiser_and_attendees(self, from_organiser=True): 354 355 """ 356 Return the organiser and attendees for the current object, filtered for 357 the recipient of interest. Return None if no identities are eligible. 358 359 Organiser and attendee identities are normalized. 360 """ 361 362 organiser_item = self.require_organiser(from_organiser) 363 attendees = self.require_attendees(from_organiser) 364 365 if not attendees or not organiser_item: 366 return None 367 368 return organiser_item, attendees 369 370 def get_sender_identities(self, mapping): 371 372 """ 373 Return a mapping from actual senders to the identities for which they 374 have provided data, extracting this information from the given 375 'mapping'. 376 """ 377 378 senders = {} 379 380 for value, attr in mapping.items(): 381 sent_by = attr.get("SENT-BY") 382 if sent_by: 383 senders[get_uri(sent_by)] = value 384 else: 385 senders[value] = value 386 387 return senders 388 389 def _get_object(self, user, uid, recurrenceid): 390 391 """ 392 Return the stored object for the given 'user', 'uid' and 'recurrenceid'. 393 """ 394 395 fragment = self.store.get_event(user, uid, recurrenceid) 396 return fragment and Object(fragment) 397 398 def get_object(self, user): 399 400 """ 401 Return the stored object to which the current object refers for the 402 given 'user'. 403 """ 404 405 return self._get_object(user, self.uid, self.recurrenceid) 406 407 def get_parent_object(self, user): 408 409 """ 410 Return the parent object to which the current object refers for the 411 given 'user'. 412 """ 413 414 return self.recurrenceid and self._get_object(user, self.uid, None) or None 415 416 def have_new_object(self, attendee, obj=None): 417 418 """ 419 Return whether the current object is new to the 'attendee' (or if the 420 given 'obj' is new). 421 """ 422 423 obj = obj or self.get_object(attendee) 424 425 # If found, compare SEQUENCE and potentially DTSTAMP. 426 427 if obj: 428 sequence = obj.get_value("SEQUENCE") 429 dtstamp = obj.get_value("DTSTAMP") 430 431 # If the request refers to an older version of the object, ignore 432 # it. 433 434 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 435 self.is_partstat_updated(obj)) 436 437 return True 438 439 def is_partstat_updated(self, obj): 440 441 """ 442 Return whether the participant status has been updated in the current 443 object in comparison to the given 'obj'. 444 445 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 446 NOTE: and make it invalid. Thus, such attendance information may also be 447 NOTE: incorporated into any new object assessment. 448 """ 449 450 old_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 451 new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE")) 452 453 for attendee, attr in old_attendees.items(): 454 old_partstat = attr.get("PARTSTAT") 455 new_attr = new_attendees.get(attendee) 456 new_partstat = new_attr and new_attr.get("PARTSTAT") 457 458 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 459 new_partstat != old_partstat: 460 461 return True 462 463 return False 464 465 def merge_attendance(self, attendees, identity): 466 467 """ 468 Merge attendance from the current object's 'attendees' into the version 469 stored for the given 'identity'. 470 """ 471 472 obj = self.get_object(identity) 473 474 if not obj or not self.have_new_object(identity, obj=obj): 475 return False 476 477 # Get attendee details in a usable form. 478 479 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 480 481 for attendee, attendee_attr in attendees.items(): 482 483 # Update attendance in the loaded object. 484 485 attendee_map[attendee] = attendee_attr 486 487 # Set the new details and store the object. 488 489 obj["ATTENDEE"] = attendee_map.items() 490 491 # Set the complete event if not an additional occurrence. 492 493 event = obj.to_node() 494 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 495 496 self.store.set_event(identity, self.uid, self.recurrenceid, event) 497 498 return True 499 500 def update_dtstamp(self): 501 502 "Update the DTSTAMP in the current object." 503 504 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 505 utcnow = to_timezone(datetime.utcnow(), "UTC") 506 self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 507 508 def set_sequence(self, increment=False): 509 510 "Update the SEQUENCE in the current object." 511 512 sequence = self.obj.get_value("SEQUENCE") or "0" 513 self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 514 515 # Store modifications. 516 517 def handle_moved_event(self, identity): 518 519 """ 520 Determine whether a complete event is being moved, removing special 521 recurrences in anticipation of their republication. 522 """ 523 524 if self.recurrenceid: 525 return False 526 527 obj = self.get_object(identity) 528 529 if obj.get_utc_datetime("DTSTART") == self.obj.get_utc_datetime("DTSTART"): 530 return False 531 532 # Remove recurrences associated with old recurrence identifiers. 533 534 self.store.remove_recurrences(identity, self.uid) 535 return True 536 537 def detach_recurrence(self, identity): 538 539 "Detach the current object from its parent if it is a recurrence." 540 541 # Where a recurring object is updated by a specific occurrence, the 542 # details of the recurring "parent" must be changed. 543 544 obj = self.get_parent_object(identity) 545 if not obj: 546 return 547 548 # The original recurrence is obtained, although the recurrence 549 # identifier could be converted back to a UTC datetime and used 550 # instead. 551 552 recurrence = self.obj.get_datetime("RECURRENCE-ID") 553 if not obj.has_recurrence(self.get_tzid(identity), recurrence): 554 return 555 556 # To detach the occurrence, the exceptions to the defined recurrence are 557 # modified. 558 559 item = obj.get_item("EXDATE") 560 if item: 561 exdates, exdate_attr = item 562 if not isinstance(exdates, list): 563 exdates = [exdates] 564 else: 565 exdates, exdate_attr = [], {} 566 567 # Convert the occurrence to the same time regime as the other 568 # exceptions. 569 570 exdate_tzid = exdate_attr.get("TZID") 571 exdate = recurrence 572 if exdate_tzid: 573 exdate = to_timezone(exdate, exdate_tzid) 574 else: 575 exdate = to_timezone(exdate, "UTC") 576 577 # Update the exceptions and store the modified parent event. 578 579 exdates.append(format_datetime(exdate)) 580 obj["EXDATE"] = [(exdates, exdate_attr)] 581 582 self.store.set_event(identity, self.uid, None, obj.to_node()) 583 584 def get_tzid(self, identity): 585 586 "Return the time regime applicable for the given 'identity'." 587 588 preferences = Preferences(identity) 589 return preferences.get("TZID") or get_default_timezone() 590 591 # Handler registry. 592 593 methods = { 594 "ADD" : lambda handler: handler.add, 595 "CANCEL" : lambda handler: handler.cancel, 596 "COUNTER" : lambda handler: handler.counter, 597 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 598 "PUBLISH" : lambda handler: handler.publish, 599 "REFRESH" : lambda handler: handler.refresh, 600 "REPLY" : lambda handler: handler.reply, 601 "REQUEST" : lambda handler: handler.request, 602 } 603 604 # vim: tabstop=4 expandtab shiftwidth=4