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