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