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.dates import * 26 from imiptools.period import have_conflict, insert_period, remove_period 27 from pytz import timezone 28 from vCalendar import parse, ParseError, to_dict 29 from vRecurrence import get_parameters, get_rule 30 import email.utils 31 import imip_store 32 33 try: 34 from cStringIO import StringIO 35 except ImportError: 36 from StringIO import StringIO 37 38 # Content interpretation. 39 40 def get_items(d, name, all=True): 41 42 """ 43 Get all items from 'd' with the given 'name', returning single items if 44 'all' is specified and set to a false value and if only one value is 45 present for the name. Return None if no items are found for the name or if 46 many items are found but 'all' is set to a false value. 47 """ 48 49 if d.has_key(name): 50 values = d[name] 51 if all: 52 return values 53 elif len(values) == 1: 54 return values[0] 55 else: 56 return None 57 else: 58 return None 59 60 def get_item(d, name): 61 return get_items(d, name, False) 62 63 def get_value_map(d, name): 64 65 """ 66 Return a dictionary for all items in 'd' having the given 'name'. The 67 dictionary will map values for the name to any attributes or qualifiers 68 that may have been present. 69 """ 70 71 items = get_items(d, name) 72 if items: 73 return dict(items) 74 else: 75 return {} 76 77 def get_values(d, name, all=True): 78 if d.has_key(name): 79 values = d[name] 80 if not all and len(values) == 1: 81 return values[0][0] 82 else: 83 return map(lambda x: x[0], values) 84 else: 85 return None 86 87 def get_value(d, name): 88 return get_values(d, name, False) 89 90 def get_utc_datetime(d, name): 91 value, attr = get_item(d, name) 92 dt = get_datetime(value, attr) 93 return to_utc_datetime(dt) 94 95 def get_addresses(values): 96 return [address for name, address in email.utils.getaddresses(values)] 97 98 def get_address(value): 99 return value.lower().startswith("mailto:") and value.lower()[7:] or value 100 101 def get_uri(value): 102 return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower() 103 104 def uri_dict(d): 105 return dict([(get_uri(key), value) for key, value in d.items()]) 106 107 def uri_item(item): 108 return get_uri(item[0]), item[1] 109 110 def uri_items(items): 111 return [(get_uri(value), attr) for value, attr in items] 112 113 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, partstat_set): 114 115 """ 116 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 117 'new_dtstamp', and the 'partstat_set' indication, whether the object 118 providing the new information is really newer than the object providing the 119 old information. 120 """ 121 122 have_sequence = old_sequence is not None and new_sequence is not None 123 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 124 125 have_dtstamp = old_dtstamp and new_dtstamp 126 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 127 128 is_old_sequence = have_sequence and ( 129 int(new_sequence) < int(old_sequence) or 130 is_same_sequence and is_old_dtstamp 131 ) 132 133 return is_same_sequence and partstat_set or not is_old_sequence 134 135 # NOTE: Need to expose the 100 day window for recurring events in the 136 # NOTE: configuration. 137 138 def get_periods(obj, window_size=100): 139 140 """ 141 Return periods for the given object 'obj', confining materialised periods 142 to the given 'window_size' in days starting from the present moment. 143 """ 144 145 dtstart = get_utc_datetime(obj, "DTSTART") 146 dtend = get_utc_datetime(obj, "DTEND") 147 148 # NOTE: Need also DURATION support. 149 150 duration = dtend - dtstart 151 152 # Recurrence rules create multiple instances to be checked. 153 # Conflicts may only be assessed within a period defined by policy 154 # for the agent, with instances outside that period being considered 155 # unchecked. 156 157 window_end = datetime.now() + timedelta(window_size) 158 159 # NOTE: Need also RDATE and EXDATE support. 160 161 rrule = get_value(obj, "RRULE") 162 163 if rrule: 164 selector = get_rule(dtstart, rrule) 165 parameters = get_parameters(rrule) 166 periods = [] 167 for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")): 168 start = datetime(*start, tzinfo=timezone("UTC")) 169 end = start + duration 170 periods.append((format_datetime(start), format_datetime(end))) 171 else: 172 periods = [(format_datetime(dtstart), format_datetime(dtend))] 173 174 return periods 175 176 def remove_from_freebusy(freebusy, attendee, uid, store): 177 178 """ 179 For the given 'attendee', remove periods from 'freebusy' that are associated 180 with 'uid' in the 'store'. 181 """ 182 183 remove_period(freebusy, uid) 184 store.set_freebusy(attendee, freebusy) 185 186 def remove_from_freebusy_for_other(freebusy, user, other, uid, store): 187 188 """ 189 For the given 'user', remove for the 'other' party periods from 'freebusy' 190 that are associated with 'uid' in the 'store'. 191 """ 192 193 remove_period(freebusy, uid) 194 store.set_freebusy_for_other(user, freebusy, other) 195 196 def _update_freebusy(freebusy, periods, transp, uid): 197 198 """ 199 Update the free/busy details with the given 'periods', 'transp' setting and 200 'uid'. 201 """ 202 203 remove_period(freebusy, uid) 204 205 for start, end in periods: 206 insert_period(freebusy, (start, end, uid, transp)) 207 208 def update_freebusy(freebusy, attendee, periods, transp, uid, store): 209 210 """ 211 For the given 'attendee', update the free/busy details with the given 212 'periods', 'transp' setting and 'uid' in the 'store'. 213 """ 214 215 _update_freebusy(freebusy, periods, transp, uid) 216 store.set_freebusy(attendee, freebusy) 217 218 def update_freebusy_for_other(freebusy, user, other, periods, transp, uid, store): 219 220 """ 221 For the given 'user', update the free/busy details of 'other' with the given 222 'periods', 'transp' setting and 'uid' in the 'store'. 223 """ 224 225 _update_freebusy(freebusy, periods, transp, uid) 226 store.set_freebusy_for_other(user, freebusy, other) 227 228 def can_schedule(freebusy, periods, uid): 229 230 """ 231 Return whether the 'freebusy' list can accommodate the given 'periods' 232 employing the specified 'uid'. 233 """ 234 235 for conflict in have_conflict(freebusy, periods, True): 236 start, end, found_uid, found_transp = conflict 237 if found_uid != uid: 238 return False 239 240 return True 241 242 # Handler mechanism objects. 243 244 def handle_itip_part(part, senders, recipient, handlers, messenger): 245 246 """ 247 Handle the given iTIP 'part' from the given 'senders' for the given 248 'recipient' using the given 'handlers' and information provided by the 249 given 'messenger'. Return a list of responses, each response being a tuple 250 of the form (is-outgoing, message-part). 251 """ 252 253 method = part.get_param("method") 254 255 # Decode the data and parse it. 256 257 f = StringIO(part.get_payload(decode=True)) 258 259 itip = parse_object(f, part.get_content_charset(), "VCALENDAR") 260 261 # Ignore the part if not a calendar object. 262 263 if not itip: 264 return [] 265 266 # Require consistency between declared and employed methods. 267 268 if get_value(itip, "METHOD") == method: 269 270 # Look for different kinds of sections. 271 272 all_results = [] 273 274 for name, cls in handlers: 275 for details in get_values(itip, name) or []: 276 277 # Dispatch to a handler and obtain any response. 278 279 handler = cls(details, senders, recipient, messenger) 280 result = methods[method](handler)() 281 282 # Aggregate responses for a single message. 283 284 if result: 285 response_method, part = result 286 outgoing = method != response_method 287 all_results.append((outgoing, part)) 288 289 return all_results 290 291 return [] 292 293 def parse_object(f, encoding, objtype=None): 294 295 """ 296 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 297 given, only objects of that type will be returned. Otherwise, the root of 298 the content will be returned as a dictionary with a single key indicating 299 the object type. 300 301 Return None if the content was not readable or suitable. 302 """ 303 304 try: 305 try: 306 doctype, attrs, elements = obj = parse(f, encoding=encoding) 307 if objtype and doctype == objtype: 308 return to_dict(obj)[objtype][0] 309 elif not objtype: 310 return to_dict(obj) 311 finally: 312 f.close() 313 314 # NOTE: Handle parse errors properly. 315 316 except (ParseError, ValueError): 317 pass 318 319 return None 320 321 def to_part(method, calendar): 322 323 """ 324 Write using the given 'method', the 'calendar' details to a MIME 325 text/calendar part. 326 """ 327 328 encoding = "utf-8" 329 out = StringIO() 330 try: 331 imip_store.to_stream(out, imip_store.make_calendar(calendar, method), encoding) 332 part = MIMEText(out.getvalue(), "calendar", encoding) 333 part.set_param("method", method) 334 return part 335 336 finally: 337 out.close() 338 339 class Handler: 340 341 "General handler support." 342 343 def __init__(self, details, senders=None, recipient=None, messenger=None): 344 345 """ 346 Initialise the handler with the 'details' of a calendar object and the 347 'senders' and 'recipient' of the object (if specifically indicated). 348 """ 349 350 self.details = details 351 self.senders = senders and set(map(get_address, senders)) 352 self.recipient = recipient and get_address(recipient) 353 self.messenger = messenger 354 355 self.uid = get_value(details, "UID") 356 self.sequence = get_value(details, "SEQUENCE") 357 self.dtstamp = get_value(details, "DTSTAMP") 358 359 self.store = imip_store.FileStore() 360 361 try: 362 self.publisher = imip_store.FilePublisher() 363 except OSError: 364 self.publisher = None 365 366 # Access to calendar structures and other data. 367 368 def get_items(self, name, all=True): 369 return get_items(self.details, name, all) 370 371 def get_item(self, name): 372 return get_item(self.details, name) 373 374 def get_value_map(self, name): 375 return get_value_map(self.details, name) 376 377 def get_values(self, name, all=True): 378 return get_values(self.details, name, all) 379 380 def get_value(self, name): 381 return get_value(self.details, name) 382 383 def get_utc_datetime(self, name): 384 return get_utc_datetime(self.details, name) 385 386 def get_periods(self): 387 return get_periods(self.details) 388 389 def remove_from_freebusy(self, freebusy, attendee): 390 remove_from_freebusy(freebusy, attendee, self.uid, self.store) 391 392 def remove_from_freebusy_for_other(self, freebusy, user, other): 393 remove_from_freebusy_for_other(freebusy, user, other, self.uid, self.store) 394 395 def update_freebusy(self, freebusy, attendee, periods): 396 return update_freebusy(freebusy, attendee, periods, self.get_value("TRANSP"), self.uid, self.store) 397 398 def update_freebusy_for_other(self, freebusy, user, other, periods): 399 return update_freebusy_for_other(freebusy, user, other, periods, self.get_value("TRANSP"), self.uid, self.store) 400 401 def can_schedule(self, freebusy, periods): 402 return can_schedule(freebusy, periods, self.uid) 403 404 def filter_by_senders(self, mapping): 405 406 """ 407 Return a list of items from 'mapping' filtered using sender information. 408 """ 409 410 if self.senders: 411 412 # Get a mapping from senders to identities. 413 414 identities = self.get_sender_identities(mapping) 415 416 # Find the senders that are valid. 417 418 senders = map(get_address, identities) 419 valid = self.senders.intersection(senders) 420 421 # Return the true identities. 422 423 return [identities[get_uri(address)] for address in valid] 424 else: 425 return mapping 426 427 def filter_by_recipient(self, mapping): 428 429 """ 430 Return a list of items from 'mapping' filtered using recipient 431 information. 432 """ 433 434 if self.recipient: 435 addresses = set(map(get_address, mapping)) 436 return map(get_uri, addresses.intersection([self.recipient])) 437 else: 438 return mapping 439 440 def require_organiser_and_attendees(self, from_organiser=True): 441 442 """ 443 Return the organiser and attendees for the current object, filtered for 444 the recipient of interest. Return None if no identities are eligible. 445 446 Organiser and attendee identities are provided as lower case values. 447 """ 448 449 attendee_map = uri_dict(self.get_value_map("ATTENDEE")) 450 organiser_item = uri_item(self.get_item("ORGANIZER")) 451 452 # Only provide details for attendees who sent/receive the message. 453 454 attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders 455 456 attendees = {} 457 for attendee in attendee_filter_fn(attendee_map): 458 attendees[attendee] = attendee_map[attendee] 459 460 if not attendees or not organiser_item: 461 return None 462 463 # Only provide details for an organiser who sent/receives the message. 464 465 organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient 466 467 if not organiser_filter_fn(dict([organiser_item])): 468 return None 469 470 return organiser_item, attendees 471 472 def get_sender_identities(self, mapping): 473 474 """ 475 Return a mapping from actual senders to the identities for which they 476 have provided data, extracting this information from the given 477 'mapping'. 478 """ 479 480 senders = {} 481 482 for value, attr in mapping.items(): 483 sent_by = attr.get("SENT-BY") 484 if sent_by: 485 senders[get_uri(sent_by)] = value 486 else: 487 senders[value] = value 488 489 return senders 490 491 def get_object(self, user, objtype): 492 493 """ 494 Return the stored object to which the current object refers for the 495 given 'user' and for the given 'objtype'. 496 """ 497 498 f = self.store.get_event(user, self.uid) 499 obj = f and parse_object(f, "utf-8", objtype) 500 return obj 501 502 def have_new_object(self, attendee, objtype, obj=None): 503 504 """ 505 Return whether the current object is new to the 'attendee' for the 506 given 'objtype'. 507 """ 508 509 obj = obj or self.get_object(attendee, objtype) 510 511 # If found, compare SEQUENCE and potentially DTSTAMP. 512 513 if obj: 514 sequence = get_value(obj, "SEQUENCE") 515 dtstamp = get_value(obj, "DTSTAMP") 516 517 # If the request refers to an older version of the object, ignore 518 # it. 519 520 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 521 self.is_partstat_updated(obj)) 522 523 return True 524 525 def is_partstat_updated(self, obj): 526 527 """ 528 Return whether the participant status has been updated in the current 529 object in comparison to the given 'obj'. 530 531 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 532 NOTE: and make it invalid. Thus, such attendance information may also be 533 NOTE: incorporated into any new object assessment. 534 """ 535 536 old_attendees = get_value_map(obj, "ATTENDEE") 537 new_attendees = self.get_value_map("ATTENDEE") 538 539 for attendee, attr in old_attendees.items(): 540 old_partstat = attr.get("PARTSTAT") 541 new_attr = new_attendees.get(attendee) 542 new_partstat = new_attr and new_attr.get("PARTSTAT") 543 544 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 545 new_partstat != old_partstat: 546 547 return True 548 549 return False 550 551 def update_dtstamp(self): 552 553 "Update the DTSTAMP in the current object." 554 555 dtstamp = self.get_utc_datetime("DTSTAMP") 556 utcnow = to_timezone(datetime.utcnow(), "UTC") 557 self.details["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 558 559 # Handler registry. 560 561 methods = { 562 "ADD" : lambda handler: handler.add, 563 "CANCEL" : lambda handler: handler.cancel, 564 "COUNTER" : lambda handler: handler.counter, 565 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 566 "PUBLISH" : lambda handler: handler.publish, 567 "REFRESH" : lambda handler: handler.refresh, 568 "REPLY" : lambda handler: handler.reply, 569 "REQUEST" : lambda handler: handler.request, 570 } 571 572 # vim: tabstop=4 expandtab shiftwidth=4