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