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