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