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