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