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