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