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