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