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