1 #!/usr/bin/env python 2 3 from bisect import bisect_left, insort_left 4 from datetime import date, datetime, timedelta 5 from email import message_from_file 6 from email.mime.multipart import MIMEMultipart 7 from email.mime.text import MIMEText 8 from pytz import timezone, UnknownTimeZoneError 9 from smtplib import SMTP 10 from vCalendar import parse, ParseError, to_dict, to_node 11 from vRecurrence import get_parameters, get_rule, to_tuple 12 import imip_store 13 import re 14 import sys 15 16 try: 17 from cStringIO import StringIO 18 except ImportError: 19 from StringIO import StringIO 20 21 MESSAGE_SENDER = "resources+agent@example.com" 22 23 MESSAGE_SUBJECT = "Calendar system message" 24 25 MESSAGE_TEXT = """\ 26 This is a response to a calendar message sent by your calendar program. 27 """ 28 29 # Postfix exit codes. 30 31 EX_TEMPFAIL = 75 32 33 # Permitted iTIP content types. 34 35 itip_content_types = [ 36 "text/calendar", # from RFC 6047 37 "text/x-vcalendar", "application/ics", # other possibilities 38 ] 39 40 # iCalendar date and datetime parsing (from DateSupport in MoinSupport). 41 42 date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})' 43 datetime_icalendar_regexp_str = date_icalendar_regexp_str + \ 44 ur'(?:' \ 45 ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \ 46 ur'(?P<utc>Z)?' \ 47 ur')?' 48 49 match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match 50 match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match 51 52 # Content interpretation. 53 54 def get_items(d, name, all=True): 55 if d.has_key(name): 56 values = d[name] 57 if not all and len(values) == 1: 58 return values[0] 59 else: 60 return values 61 else: 62 return None 63 64 def get_item(d, name): 65 return get_items(d, name, False) 66 67 def get_value_map(d, name): 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 to_utc_datetime(dt): 93 if not dt: 94 return None 95 elif isinstance(dt, datetime): 96 return dt.astimezone(timezone("UTC")) 97 else: 98 return dt 99 100 def format_datetime(dt): 101 if not dt: 102 return None 103 elif isinstance(dt, datetime): 104 return dt.strftime("%Y%m%dT%H%M%SZ") 105 else: 106 return dt.strftime("%Y%m%d") 107 108 def get_address(value): 109 return value.startswith("mailto:") and value[7:] or value 110 111 def get_uri(value): 112 return value.startswith("mailto:") and value or "mailto:%s" % value 113 114 def get_datetime(value, attr): 115 try: 116 tz = attr.has_key("TZID") and timezone(attr["TZID"]) or None 117 except UnknownTimeZoneError: 118 tz = None 119 120 if attr.get("VALUE") in (None, "DATE-TIME"): 121 m = match_datetime_icalendar(value) 122 if m: 123 dt = datetime( 124 int(m.group("year")), int(m.group("month")), int(m.group("day")), 125 int(m.group("hour")), int(m.group("minute")), int(m.group("second")) 126 ) 127 128 # Impose the indicated timezone. 129 # NOTE: This needs an ambiguity policy for DST changes. 130 131 tz = m.group("utc") and timezone("UTC") or tz or None 132 if tz is not None: 133 return tz.localize(dt) 134 else: 135 return dt 136 137 if attr.get("VALUE") == "DATE": 138 m = match_date_icalendar(value) 139 if m: 140 return date( 141 int(m.group("year")), int(m.group("month")), int(m.group("day")) 142 ) 143 return None 144 145 # Time management. 146 147 def insert_period(freebusy, period): 148 insort_left(freebusy, period) 149 150 def remove_period(freebusy, uid): 151 i = 0 152 while i < len(freebusy): 153 t = freebusy[i] 154 if len(t) >= 3 and t[2] == uid: 155 del freebusy[i] 156 else: 157 i += 1 158 159 def period_overlaps(freebusy, period): 160 dtstart, dtend = period[:2] 161 i = bisect_left(freebusy, (dtstart, dtend, None)) 162 return ( 163 i < len(freebusy) and (dtend is None or freebusy[i][0] < dtend) 164 or 165 i > 0 and freebusy[i - 1][1] > dtstart 166 ) 167 168 # Sending of outgoing messages. 169 170 def sendmail(sender, recipients, data): 171 smtp = SMTP("localhost") 172 smtp.sendmail(sender, recipients, data) 173 smtp.quit() 174 175 # Processing of incoming messages. 176 177 def process(f, original_recipients, recipients, this_sender): 178 179 """ 180 Process content from the stream 'f' accompanied by the given 181 'original_recipients' and 'recipients', together with details of 182 'this_sender' (the sender address on messages sent by this service). 183 """ 184 185 msg = message_from_file(f) 186 senders = msg.get_all("Reply-To") or msg.get_all("From") 187 original_recipients = original_recipients or get_all_values(msg, "To") 188 189 # Handle messages with iTIP parts. 190 191 all_parts = [] 192 193 for part in msg.walk(): 194 if part.get_content_type() in itip_content_types and \ 195 part.get_param("method"): 196 197 all_parts += handle_itip_part(part, original_recipients) 198 199 # Pack the parts into a single message. 200 201 if all_parts: 202 text_part = MIMEText(MESSAGE_TEXT) 203 all_parts.insert(0, text_part) 204 message = MIMEMultipart("alternative", _subparts=all_parts) 205 message.preamble = MESSAGE_TEXT 206 207 message["From"] = this_sender 208 for sender in senders: 209 message["To"] = sender 210 message["Subject"] = MESSAGE_SUBJECT 211 212 if "-d" in sys.argv: 213 print message 214 else: 215 sendmail(this_sender, senders, message.as_string()) 216 217 def get_all_values(msg, key): 218 l = [] 219 for v in msg.get_all(key): 220 l += [s.strip() for s in v.split(",")] 221 return l 222 223 def to_part(method, calendar): 224 225 """ 226 Write using the given 'method', the 'calendar' details to a MIME 227 text/calendar part. 228 """ 229 230 encoding = "utf-8" 231 out = StringIO() 232 try: 233 imip_store.to_stream(out, imip_store.make_calendar(calendar, method), encoding) 234 part = MIMEText(out.getvalue(), "calendar", encoding) 235 part.set_param("method", method) 236 return part 237 238 finally: 239 out.close() 240 241 def parse_object(f, encoding, objtype): 242 243 """ 244 Parse the iTIP content from 'f' having the given 'encoding'. Return None if 245 the content was not readable or suitable. 246 """ 247 248 try: 249 try: 250 doctype, attrs, elements = obj = parse(f, encoding=encoding) 251 if doctype == objtype: 252 return to_dict(obj)[objtype][0] 253 finally: 254 f.close() 255 except (ParseError, ValueError): 256 pass 257 258 return None 259 260 def handle_itip_part(part, recipients): 261 262 "Handle the given iTIP 'part' for the given 'recipients'." 263 264 method = part.get_param("method") 265 266 # Decode the data and parse it. 267 268 f = StringIO(part.get_payload(decode=True)) 269 270 itip = parse_object(f, part.get_content_charset(), "VCALENDAR") 271 272 # Ignore the part if not a calendar object. 273 274 if not itip: 275 return [] 276 277 # Only handle calendar information. 278 279 all_parts = [] 280 281 # Require consistency between declared and employed methods. 282 283 if get_value(itip, "METHOD") == method: 284 285 # Look for different kinds of sections. 286 287 all_objects = [] 288 289 for name, cls in handlers: 290 for details in get_values(itip, name) or []: 291 292 # Dispatch to a handler and obtain any response. 293 294 handler = cls(details, recipients) 295 object = methods[method](handler)() 296 297 # Concatenate responses for a single calendar object. 298 299 if object: 300 all_objects += object 301 302 # Obtain a message part for the objects. 303 304 if all_objects: 305 all_parts.append(to_part(response_methods[method], all_objects)) 306 307 return all_parts 308 309 class Handler: 310 311 "General handler support." 312 313 def __init__(self, details, recipients): 314 315 """ 316 Initialise the handler with the 'details' of a calendar object and the 317 'recipients' of the object. 318 """ 319 320 self.details = details 321 self.recipients = set(recipients) 322 323 self.uid = get_value(details, "UID") 324 self.sequence = get_value(details, "SEQUENCE") 325 self.dtstamp = get_value(details, "DTSTAMP") 326 327 self.store = imip_store.FileStore() 328 329 try: 330 self.publisher = imip_store.FilePublisher() 331 except OSError: 332 self.publisher = None 333 334 def get_items(self, name, all=True): 335 return get_items(self.details, name, all) 336 337 def get_item(self, name): 338 return get_item(self.details, name) 339 340 def get_value_map(self, name): 341 return get_value_map(self.details, name) 342 343 def get_values(self, name, all=True): 344 return get_values(self.details, name, all) 345 346 def get_value(self, name): 347 return get_value(self.details, name) 348 349 def get_utc_datetime(self, name): 350 return get_utc_datetime(self.details, name) 351 352 def filter_by_recipients(self, values): 353 return self.recipients.intersection(map(get_address, values)) 354 355 def require_organiser_and_attendees(self): 356 attendee_map = self.get_value_map("ATTENDEE") 357 organiser = self.get_item("ORGANIZER") 358 359 # Only provide details for recipients who are also attendees. 360 361 attendees = {} 362 for attendee in map(get_uri, self.filter_by_recipients(attendee_map)): 363 attendees[attendee] = attendee_map[attendee] 364 365 if not attendees and not organiser: 366 return None 367 368 return organiser, attendees 369 370 class Event(Handler): 371 372 "An event handler." 373 374 def add(self): 375 pass 376 377 def cancel(self): 378 pass 379 380 def counter(self): 381 382 "Since this handler does not send requests, it will not handle replies." 383 384 pass 385 386 def declinecounter(self): 387 388 """ 389 Since this handler does not send counter proposals, it will not handle 390 replies to such proposals. 391 """ 392 393 pass 394 395 def publish(self): 396 pass 397 398 def refresh(self): 399 pass 400 401 def reply(self): 402 403 "Since this handler does not send requests, it will not handle replies." 404 405 pass 406 407 def request(self): 408 409 """ 410 Respond to a request by preparing a reply containing accept/decline 411 information for each indicated attendee. 412 413 No support for countering requests is implemented. 414 """ 415 416 oa = self.require_organiser_and_attendees() 417 if not oa: 418 return None 419 420 (organiser, organiser_attr), attendees = oa 421 422 # Process each attendee separately. 423 424 calendar = [] 425 426 for attendee, attendee_attr in attendees.items(): 427 428 # Check for event using UID. 429 430 f = self.store.get_event(attendee, self.uid) 431 event = f and parse_object(f, "utf-8", "VEVENT") 432 433 # If found, compare SEQUENCE and potentially DTSTAMP. 434 435 if event: 436 sequence = get_value(event, "SEQUENCE") 437 dtstamp = get_value(event, "DTSTAMP") 438 439 # If the request refers to an older version of the event, ignore 440 # it. 441 442 old_dtstamp = self.dtstamp < dtstamp 443 444 if sequence is not None and ( 445 int(self.sequence) < int(sequence) or 446 int(self.sequence) == int(sequence) and old_dtstamp 447 ) or old_dtstamp: 448 449 continue 450 451 # If newer than any old version, discard old details from the 452 # free/busy record and check for suitability. 453 454 dtstart = self.get_utc_datetime("DTSTART") 455 dtend = self.get_utc_datetime("DTEND") 456 457 # NOTE: Need also DURATION support. 458 459 duration = dtend - dtstart 460 461 # Recurrence rules create multiple instances to be checked. 462 # Conflicts may only be assessed within a period defined by policy 463 # for the agent, with instances outside that period being considered 464 # unchecked. 465 466 window_end = datetime.now() + timedelta(100) 467 468 # NOTE: Need also RDATE and EXDATE support. 469 470 rrule = self.get_value("RRULE") 471 472 if rrule: 473 selector = get_rule(dtstart, rrule) 474 parameters = get_parameters(rrule) 475 periods = [] 476 for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")): 477 start = datetime(*start, tzinfo=timezone("UTC")) 478 end = start + duration 479 periods.append((format_datetime(start), format_datetime(end))) 480 else: 481 periods = [(format_datetime(dtstart), format_datetime(dtend))] 482 483 conflict = False 484 freebusy = self.store.get_freebusy(attendee) 485 486 if freebusy: 487 remove_period(freebusy, self.uid) 488 conflict = True 489 for start, end in periods: 490 if period_overlaps(freebusy, (start, end)): 491 break 492 else: 493 conflict = False 494 else: 495 freebusy = [] 496 497 # If the event can be scheduled, it is registered and a reply sent 498 # accepting the event. (The attendee has PARTSTAT=ACCEPTED as an 499 # attribute.) 500 501 if not conflict: 502 for start, end in periods: 503 insert_period(freebusy, (start, end, self.uid)) 504 505 if self.get_value("TRANSP") in (None, "OPAQUE"): 506 self.store.set_freebusy(attendee, freebusy) 507 508 if self.publisher: 509 self.publisher.set_freebusy(attendee, freebusy) 510 511 self.store.set_event(attendee, self.uid, to_node( 512 {"VEVENT" : [(self.details, {})]} 513 )) 514 attendee_attr["PARTSTAT"] = "ACCEPTED" 515 516 # If the event cannot be scheduled, it is not registered and a reply 517 # sent declining the event. (The attendee has PARTSTAT=DECLINED as an 518 # attribute.) 519 520 else: 521 attendee_attr["PARTSTAT"] = "DECLINED" 522 523 self.details["ATTENDEE"] = [(attendee, attendee_attr)] 524 calendar.append(to_node( 525 {"VEVENT" : [(self.details, {})]} 526 )) 527 528 return calendar 529 530 class Freebusy(Handler): 531 532 "A free/busy handler." 533 534 def publish(self): 535 pass 536 537 def reply(self): 538 539 "Since this handler does not send requests, it will not handle replies." 540 541 pass 542 543 def request(self): 544 545 """ 546 Respond to a request by preparing a reply containing free/busy 547 information for each indicated attendee. 548 """ 549 550 oa = self.require_organiser_and_attendees() 551 if not oa: 552 return None 553 554 (organiser, organiser_attr), attendees = oa 555 556 # Construct an appropriate fragment. 557 558 calendar = [] 559 cwrite = calendar.append 560 561 # Get the details for each attendee. 562 563 for attendee, attendee_attr in attendees.items(): 564 freebusy = self.store.get_freebusy(attendee) 565 566 if freebusy: 567 record = [] 568 rwrite = record.append 569 570 rwrite(("ORGANIZER", organiser_attr, organiser)) 571 rwrite(("ATTENDEE", attendee_attr, attendee)) 572 rwrite(("UID", {}, self.uid)) 573 574 for start, end, uid in freebusy: 575 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, [start, end])) 576 577 cwrite(("VFREEBUSY", {}, record)) 578 579 # Return the reply. 580 581 return calendar 582 583 class Journal(Handler): 584 585 "A journal entry handler." 586 587 def add(self): 588 pass 589 590 def cancel(self): 591 pass 592 593 def publish(self): 594 pass 595 596 class Todo(Handler): 597 598 "A to-do item handler." 599 600 def add(self): 601 pass 602 603 def cancel(self): 604 pass 605 606 def counter(self): 607 608 "Since this handler does not send requests, it will not handle replies." 609 610 pass 611 612 def declinecounter(self): 613 614 """ 615 Since this handler does not send counter proposals, it will not handle 616 replies to such proposals. 617 """ 618 619 pass 620 621 def publish(self): 622 pass 623 624 def refresh(self): 625 pass 626 627 def reply(self): 628 629 "Since this handler does not send requests, it will not handle replies." 630 631 pass 632 633 def request(self): 634 pass 635 636 # Handler registry. 637 638 handlers = [ 639 ("VFREEBUSY", Freebusy), 640 ("VEVENT", Event), 641 ("VTODO", Todo), 642 ("VJOURNAL", Journal), 643 ] 644 645 methods = { 646 "ADD" : lambda handler: handler.add, 647 "CANCEL" : lambda handler: handler.cancel, 648 "COUNTER" : lambda handler: handler.counter, 649 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 650 "PUBLISH" : lambda handler: handler.publish, 651 "REFRESH" : lambda handler: handler.refresh, 652 "REPLY" : lambda handler: handler.reply, 653 "REQUEST" : lambda handler: handler.request, 654 } 655 656 response_methods = { 657 "REQUEST" : "REPLY", 658 } 659 660 def main(): 661 662 "Interpret program arguments and process input." 663 664 # Obtain the different kinds of recipients plus sender address. 665 666 original_recipients = [] 667 recipients = [] 668 senders = [] 669 670 l = [] 671 672 for arg in sys.argv[1:]: 673 674 # Switch to collecting recipients. 675 676 if arg == "-o": 677 l = original_recipients 678 elif arg == "-r": 679 l = recipients 680 681 # Switch to collecting senders. 682 683 elif arg == "-s": 684 l = senders 685 686 # Ignore debugging options. 687 688 elif arg == "-d": 689 pass 690 else: 691 l.append(arg) 692 693 process(sys.stdin, original_recipients, recipients, senders and senders[0] or MESSAGE_SENDER) 694 695 if __name__ == "__main__": 696 if "-d" in sys.argv[1:]: 697 main() 698 else: 699 try: 700 main() 701 except SystemExit, value: 702 sys.exit(value) 703 except Exception, exc: 704 type, value, tb = sys.exc_info() 705 print >>sys.stderr, "Exception %s at %d" % (exc, tb.tb_lineno) 706 sys.exit(EX_TEMPFAIL) 707 sys.exit(0) 708 709 # vim: tabstop=4 expandtab shiftwidth=4