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