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