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): 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) 195 196 def update_freebusy(self, freebusy, periods, transp=None): 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) 204 205 def update_freebusy_for_participant(self, freebusy, periods, attr, for_organiser=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)) 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): 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 245 self.remove_freebusy_for_original_recurrence(freebusy) 246 self.store.set_freebusy_for_other(user, freebusy, participant) 247 248 def update_freebusy_from_organiser(self, attendee, organiser_item): 249 250 """ 251 For the 'attendee', record free/busy information from the 252 'organiser_item' (a value plus attributes). 253 """ 254 255 self.update_freebusy_from_participant(attendee, organiser_item, True) 256 257 def update_freebusy_from_attendees(self, organiser, attendees): 258 259 "For the 'organiser', record free/busy information from 'attendees'." 260 261 for attendee_item in attendees.items(): 262 self.update_freebusy_from_participant(organiser, attendee_item, False) 263 264 # Logic, filtering and access to calendar structures and other data. 265 266 def is_not_attendee(self, identity, obj): 267 268 "Return whether 'identity' is not an attendee in 'obj'." 269 270 return identity not in uri_values(obj.get_values("ATTENDEE")) 271 272 def can_schedule(self, freebusy, periods): 273 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 274 275 def filter_by_senders(self, mapping): 276 277 """ 278 Return a list of items from 'mapping' filtered using sender information. 279 """ 280 281 if self.senders: 282 283 # Get a mapping from senders to identities. 284 285 identities = self.get_sender_identities(mapping) 286 287 # Find the senders that are valid. 288 289 senders = map(get_address, identities) 290 valid = self.senders.intersection(senders) 291 292 # Return the true identities. 293 294 return [identities[get_uri(address)] for address in valid] 295 else: 296 return mapping 297 298 def filter_by_recipient(self, mapping): 299 300 """ 301 Return a list of items from 'mapping' filtered using recipient 302 information. 303 """ 304 305 if self.recipient: 306 addresses = set(map(get_address, mapping)) 307 return map(get_uri, addresses.intersection([self.recipient])) 308 else: 309 return mapping 310 311 def require_organiser(self, from_organiser=True): 312 313 """ 314 Return the organiser for the current object, filtered for the sender or 315 recipient of interest. Return None if no identities are eligible. 316 317 The organiser identity is normalized. 318 """ 319 320 organiser_item = uri_item(self.obj.get_item("ORGANIZER")) 321 322 # Only provide details for an organiser who sent/receives the message. 323 324 organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient 325 326 if not organiser_filter_fn(dict([organiser_item])): 327 return None 328 329 return organiser_item 330 331 def require_attendees(self, from_organiser=True): 332 333 """ 334 Return the attendees for the current object, filtered for the sender or 335 recipient of interest. Return None if no identities are eligible. 336 337 The attendee identities are normalized. 338 """ 339 340 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 341 342 # Only provide details for attendees who sent/receive the message. 343 344 attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders 345 346 attendees = {} 347 for attendee in attendee_filter_fn(attendee_map): 348 attendees[attendee] = attendee_map[attendee] 349 350 return attendees 351 352 def require_organiser_and_attendees(self, from_organiser=True): 353 354 """ 355 Return the organiser and attendees for the current object, filtered for 356 the recipient of interest. Return None if no identities are eligible. 357 358 Organiser and attendee identities are normalized. 359 """ 360 361 organiser_item = self.require_organiser(from_organiser) 362 attendees = self.require_attendees(from_organiser) 363 364 if not attendees or not organiser_item: 365 return None 366 367 return organiser_item, attendees 368 369 def get_sender_identities(self, mapping): 370 371 """ 372 Return a mapping from actual senders to the identities for which they 373 have provided data, extracting this information from the given 374 'mapping'. 375 """ 376 377 senders = {} 378 379 for value, attr in mapping.items(): 380 sent_by = attr.get("SENT-BY") 381 if sent_by: 382 senders[get_uri(sent_by)] = value 383 else: 384 senders[value] = value 385 386 return senders 387 388 def _get_object(self, user, uid, recurrenceid): 389 390 """ 391 Return the stored object for the given 'user', 'uid' and 'recurrenceid'. 392 """ 393 394 fragment = self.store.get_event(user, uid, recurrenceid) 395 return fragment and Object(fragment) 396 397 def get_object(self, user): 398 399 """ 400 Return the stored object to which the current object refers for the 401 given 'user'. 402 """ 403 404 return self._get_object(user, self.uid, self.recurrenceid) 405 406 def get_parent_object(self, user): 407 408 """ 409 Return the parent object to which the current object refers for the 410 given 'user'. 411 """ 412 413 return self.recurrenceid and self._get_object(user, self.uid, None) or None 414 415 def have_new_object(self, attendee, obj=None): 416 417 """ 418 Return whether the current object is new to the 'attendee' (or if the 419 given 'obj' is new). 420 """ 421 422 obj = obj or self.get_object(attendee) 423 424 # If found, compare SEQUENCE and potentially DTSTAMP. 425 426 if obj: 427 sequence = obj.get_value("SEQUENCE") 428 dtstamp = obj.get_value("DTSTAMP") 429 430 # If the request refers to an older version of the object, ignore 431 # it. 432 433 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 434 self.is_partstat_updated(obj)) 435 436 return True 437 438 def is_partstat_updated(self, obj): 439 440 """ 441 Return whether the participant status has been updated in the current 442 object in comparison to the given 'obj'. 443 444 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 445 NOTE: and make it invalid. Thus, such attendance information may also be 446 NOTE: incorporated into any new object assessment. 447 """ 448 449 old_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 450 new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE")) 451 452 for attendee, attr in old_attendees.items(): 453 old_partstat = attr.get("PARTSTAT") 454 new_attr = new_attendees.get(attendee) 455 new_partstat = new_attr and new_attr.get("PARTSTAT") 456 457 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 458 new_partstat != old_partstat: 459 460 return True 461 462 return False 463 464 def merge_attendance(self, attendees, identity): 465 466 """ 467 Merge attendance from the current object's 'attendees' into the version 468 stored for the given 'identity'. 469 """ 470 471 obj = self.get_object(identity) 472 473 if not obj or not self.have_new_object(identity, obj=obj): 474 return False 475 476 # Get attendee details in a usable form. 477 478 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 479 480 for attendee, attendee_attr in attendees.items(): 481 482 # Update attendance in the loaded object. 483 484 attendee_map[attendee] = attendee_attr 485 486 # Set the new details and store the object. 487 488 obj["ATTENDEE"] = attendee_map.items() 489 490 # Set the complete event if not an additional occurrence. 491 492 event = obj.to_node() 493 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 494 495 self.store.set_event(identity, self.uid, self.recurrenceid, event) 496 497 return True 498 499 def update_dtstamp(self): 500 501 "Update the DTSTAMP in the current object." 502 503 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 504 utcnow = to_timezone(datetime.utcnow(), "UTC") 505 self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 506 507 def set_sequence(self, increment=False): 508 509 "Update the SEQUENCE in the current object." 510 511 sequence = self.obj.get_value("SEQUENCE") or "0" 512 self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 513 514 def detach_recurrence(self, identity): 515 516 "Detach the current object from its parent if it is a recurrence." 517 518 # Where a recurring object is updated by a specific occurrence, the 519 # details of the recurring "parent" must be changed. 520 521 obj = self.get_parent_object(identity) 522 if not obj: 523 return 524 525 # The original recurrence is obtained, although the recurrence 526 # identifier could be converted back to a UTC datetime and used 527 # instead. 528 529 recurrence = self.obj.get_datetime("RECURRENCE-ID") 530 if not obj.has_recurrence(self.get_tzid(identity), recurrence): 531 return 532 533 # To detach the occurrence, the exceptions to the defined recurrence are 534 # modified. 535 536 item = obj.get_item("EXDATE") 537 if item: 538 exdates, exdate_attr = item 539 if not isinstance(exdates, list): 540 exdates = [exdates] 541 else: 542 exdates, exdate_attr = [], {} 543 544 # Convert the occurrence to the same time regime as the other 545 # exceptions. 546 547 exdate_tzid = exdate_attr.get("TZID") 548 exdate = recurrence 549 if exdate_tzid: 550 exdate = to_timezone(exdate, exdate_tzid) 551 else: 552 exdate = to_timezone(exdate, "UTC") 553 554 # Update the exceptions and store the modified parent event. 555 556 exdates.append(format_datetime(exdate)) 557 obj["EXDATE"] = [(exdates, exdate_attr)] 558 559 self.store.set_event(identity, self.uid, None, obj.to_node()) 560 561 def get_tzid(self, identity): 562 563 "Return the time regime applicable for the given 'identity'." 564 565 preferences = Preferences(identity) 566 return preferences.get("TZID") or get_default_timezone() 567 568 # Handler registry. 569 570 methods = { 571 "ADD" : lambda handler: handler.add, 572 "CANCEL" : lambda handler: handler.cancel, 573 "COUNTER" : lambda handler: handler.counter, 574 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 575 "PUBLISH" : lambda handler: handler.publish, 576 "REFRESH" : lambda handler: handler.refresh, 577 "REPLY" : lambda handler: handler.reply, 578 "REQUEST" : lambda handler: handler.request, 579 } 580 581 # vim: tabstop=4 expandtab shiftwidth=4