1.1 --- a/imip_agent.py Thu Oct 09 22:50:41 2014 +0200
1.2 +++ b/imip_agent.py Tue Oct 21 19:58:20 2014 +0200
1.3 @@ -1,23 +1,13 @@
1.4 #!/usr/bin/env python
1.5
1.6 -from bisect import bisect_left, insort_left
1.7 -from datetime import date, datetime, timedelta
1.8 from email import message_from_file
1.9 from email.mime.multipart import MIMEMultipart
1.10 from email.mime.text import MIMEText
1.11 -from pytz import timezone, UnknownTimeZoneError
1.12 from smtplib import SMTP
1.13 -from vCalendar import parse, ParseError, to_dict, to_node
1.14 -from vRecurrence import get_parameters, get_rule, to_tuple
1.15 -import imip_store
1.16 -import re
1.17 +from imiptools.content import handle_itip_part
1.18 +import imip_resource
1.19 import sys
1.20
1.21 -try:
1.22 - from cStringIO import StringIO
1.23 -except ImportError:
1.24 - from StringIO import StringIO
1.25 -
1.26 MESSAGE_SENDER = "resources+agent@example.com"
1.27
1.28 MESSAGE_SUBJECT = "Calendar system message"
1.29 @@ -37,134 +27,6 @@
1.30 "text/x-vcalendar", "application/ics", # other possibilities
1.31 ]
1.32
1.33 -# iCalendar date and datetime parsing (from DateSupport in MoinSupport).
1.34 -
1.35 -date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'
1.36 -datetime_icalendar_regexp_str = date_icalendar_regexp_str + \
1.37 - ur'(?:' \
1.38 - ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \
1.39 - ur'(?P<utc>Z)?' \
1.40 - ur')?'
1.41 -
1.42 -match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match
1.43 -match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match
1.44 -
1.45 -# Content interpretation.
1.46 -
1.47 -def get_items(d, name, all=True):
1.48 - if d.has_key(name):
1.49 - values = d[name]
1.50 - if not all and len(values) == 1:
1.51 - return values[0]
1.52 - else:
1.53 - return values
1.54 - else:
1.55 - return None
1.56 -
1.57 -def get_item(d, name):
1.58 - return get_items(d, name, False)
1.59 -
1.60 -def get_value_map(d, name):
1.61 - items = get_items(d, name)
1.62 - if items:
1.63 - return dict(items)
1.64 - else:
1.65 - return {}
1.66 -
1.67 -def get_values(d, name, all=True):
1.68 - if d.has_key(name):
1.69 - values = d[name]
1.70 - if not all and len(values) == 1:
1.71 - return values[0][0]
1.72 - else:
1.73 - return map(lambda x: x[0], values)
1.74 - else:
1.75 - return None
1.76 -
1.77 -def get_value(d, name):
1.78 - return get_values(d, name, False)
1.79 -
1.80 -def get_utc_datetime(d, name):
1.81 - value, attr = get_item(d, name)
1.82 - dt = get_datetime(value, attr)
1.83 - return to_utc_datetime(dt)
1.84 -
1.85 -def to_utc_datetime(dt):
1.86 - if not dt:
1.87 - return None
1.88 - elif isinstance(dt, datetime):
1.89 - return dt.astimezone(timezone("UTC"))
1.90 - else:
1.91 - return dt
1.92 -
1.93 -def format_datetime(dt):
1.94 - if not dt:
1.95 - return None
1.96 - elif isinstance(dt, datetime):
1.97 - return dt.strftime("%Y%m%dT%H%M%SZ")
1.98 - else:
1.99 - return dt.strftime("%Y%m%d")
1.100 -
1.101 -def get_address(value):
1.102 - return value.startswith("mailto:") and value[7:] or value
1.103 -
1.104 -def get_uri(value):
1.105 - return value.startswith("mailto:") and value or "mailto:%s" % value
1.106 -
1.107 -def get_datetime(value, attr):
1.108 - try:
1.109 - tz = attr.has_key("TZID") and timezone(attr["TZID"]) or None
1.110 - except UnknownTimeZoneError:
1.111 - tz = None
1.112 -
1.113 - if attr.get("VALUE") in (None, "DATE-TIME"):
1.114 - m = match_datetime_icalendar(value)
1.115 - if m:
1.116 - dt = datetime(
1.117 - int(m.group("year")), int(m.group("month")), int(m.group("day")),
1.118 - int(m.group("hour")), int(m.group("minute")), int(m.group("second"))
1.119 - )
1.120 -
1.121 - # Impose the indicated timezone.
1.122 - # NOTE: This needs an ambiguity policy for DST changes.
1.123 -
1.124 - tz = m.group("utc") and timezone("UTC") or tz or None
1.125 - if tz is not None:
1.126 - return tz.localize(dt)
1.127 - else:
1.128 - return dt
1.129 -
1.130 - if attr.get("VALUE") == "DATE":
1.131 - m = match_date_icalendar(value)
1.132 - if m:
1.133 - return date(
1.134 - int(m.group("year")), int(m.group("month")), int(m.group("day"))
1.135 - )
1.136 - return None
1.137 -
1.138 -# Time management.
1.139 -
1.140 -def insert_period(freebusy, period):
1.141 - insort_left(freebusy, period)
1.142 -
1.143 -def remove_period(freebusy, uid):
1.144 - i = 0
1.145 - while i < len(freebusy):
1.146 - t = freebusy[i]
1.147 - if len(t) >= 3 and t[2] == uid:
1.148 - del freebusy[i]
1.149 - else:
1.150 - i += 1
1.151 -
1.152 -def period_overlaps(freebusy, period):
1.153 - dtstart, dtend = period[:2]
1.154 - i = bisect_left(freebusy, (dtstart, dtend, None))
1.155 - return (
1.156 - i < len(freebusy) and (dtend is None or freebusy[i][0] < dtend)
1.157 - or
1.158 - i > 0 and freebusy[i - 1][1] > dtstart
1.159 - )
1.160 -
1.161 # Sending of outgoing messages.
1.162
1.163 def sendmail(sender, recipients, data):
1.164 @@ -194,7 +56,9 @@
1.165 if part.get_content_type() in itip_content_types and \
1.166 part.get_param("method"):
1.167
1.168 - all_parts += handle_itip_part(part, original_recipients)
1.169 + # NOTE: Act on behalf of resources for now.
1.170 +
1.171 + all_parts += handle_itip_part(part, original_recipients, imip_resource.handlers)
1.172
1.173 # Pack the parts into a single message.
1.174
1.175 @@ -216,447 +80,10 @@
1.176
1.177 def get_all_values(msg, key):
1.178 l = []
1.179 - for v in msg.get_all(key):
1.180 + for v in msg.get_all(key) or []:
1.181 l += [s.strip() for s in v.split(",")]
1.182 return l
1.183
1.184 -def to_part(method, calendar):
1.185 -
1.186 - """
1.187 - Write using the given 'method', the 'calendar' details to a MIME
1.188 - text/calendar part.
1.189 - """
1.190 -
1.191 - encoding = "utf-8"
1.192 - out = StringIO()
1.193 - try:
1.194 - imip_store.to_stream(out, imip_store.make_calendar(calendar, method), encoding)
1.195 - part = MIMEText(out.getvalue(), "calendar", encoding)
1.196 - part.set_param("method", method)
1.197 - return part
1.198 -
1.199 - finally:
1.200 - out.close()
1.201 -
1.202 -def parse_object(f, encoding, objtype):
1.203 -
1.204 - """
1.205 - Parse the iTIP content from 'f' having the given 'encoding'. Return None if
1.206 - the content was not readable or suitable.
1.207 - """
1.208 -
1.209 - try:
1.210 - try:
1.211 - doctype, attrs, elements = obj = parse(f, encoding=encoding)
1.212 - if doctype == objtype:
1.213 - return to_dict(obj)[objtype][0]
1.214 - finally:
1.215 - f.close()
1.216 - except (ParseError, ValueError):
1.217 - pass
1.218 -
1.219 - return None
1.220 -
1.221 -def handle_itip_part(part, recipients):
1.222 -
1.223 - "Handle the given iTIP 'part' for the given 'recipients'."
1.224 -
1.225 - method = part.get_param("method")
1.226 -
1.227 - # Decode the data and parse it.
1.228 -
1.229 - f = StringIO(part.get_payload(decode=True))
1.230 -
1.231 - itip = parse_object(f, part.get_content_charset(), "VCALENDAR")
1.232 -
1.233 - # Ignore the part if not a calendar object.
1.234 -
1.235 - if not itip:
1.236 - return []
1.237 -
1.238 - # Only handle calendar information.
1.239 -
1.240 - all_parts = []
1.241 -
1.242 - # Require consistency between declared and employed methods.
1.243 -
1.244 - if get_value(itip, "METHOD") == method:
1.245 -
1.246 - # Look for different kinds of sections.
1.247 -
1.248 - all_objects = []
1.249 -
1.250 - for name, cls in handlers:
1.251 - for details in get_values(itip, name) or []:
1.252 -
1.253 - # Dispatch to a handler and obtain any response.
1.254 -
1.255 - handler = cls(details, recipients)
1.256 - object = methods[method](handler)()
1.257 -
1.258 - # Concatenate responses for a single calendar object.
1.259 -
1.260 - if object:
1.261 - all_objects += object
1.262 -
1.263 - # Obtain a message part for the objects.
1.264 -
1.265 - if all_objects:
1.266 - all_parts.append(to_part(response_methods[method], all_objects))
1.267 -
1.268 - return all_parts
1.269 -
1.270 -class Handler:
1.271 -
1.272 - "General handler support."
1.273 -
1.274 - def __init__(self, details, recipients):
1.275 -
1.276 - """
1.277 - Initialise the handler with the 'details' of a calendar object and the
1.278 - 'recipients' of the object.
1.279 - """
1.280 -
1.281 - self.details = details
1.282 - self.recipients = set(recipients)
1.283 -
1.284 - self.uid = get_value(details, "UID")
1.285 - self.sequence = get_value(details, "SEQUENCE")
1.286 - self.dtstamp = get_value(details, "DTSTAMP")
1.287 -
1.288 - self.store = imip_store.FileStore()
1.289 -
1.290 - try:
1.291 - self.publisher = imip_store.FilePublisher()
1.292 - except OSError:
1.293 - self.publisher = None
1.294 -
1.295 - def get_items(self, name, all=True):
1.296 - return get_items(self.details, name, all)
1.297 -
1.298 - def get_item(self, name):
1.299 - return get_item(self.details, name)
1.300 -
1.301 - def get_value_map(self, name):
1.302 - return get_value_map(self.details, name)
1.303 -
1.304 - def get_values(self, name, all=True):
1.305 - return get_values(self.details, name, all)
1.306 -
1.307 - def get_value(self, name):
1.308 - return get_value(self.details, name)
1.309 -
1.310 - def get_utc_datetime(self, name):
1.311 - return get_utc_datetime(self.details, name)
1.312 -
1.313 - def filter_by_recipients(self, values):
1.314 - return self.recipients.intersection(map(get_address, values))
1.315 -
1.316 - def require_organiser_and_attendees(self):
1.317 - attendee_map = self.get_value_map("ATTENDEE")
1.318 - organiser = self.get_item("ORGANIZER")
1.319 -
1.320 - # Only provide details for recipients who are also attendees.
1.321 -
1.322 - attendees = {}
1.323 - for attendee in map(get_uri, self.filter_by_recipients(attendee_map)):
1.324 - attendees[attendee] = attendee_map[attendee]
1.325 -
1.326 - if not attendees and not organiser:
1.327 - return None
1.328 -
1.329 - return organiser, attendees
1.330 -
1.331 -class Event(Handler):
1.332 -
1.333 - "An event handler."
1.334 -
1.335 - def add(self):
1.336 - pass
1.337 -
1.338 - def cancel(self):
1.339 - pass
1.340 -
1.341 - def counter(self):
1.342 -
1.343 - "Since this handler does not send requests, it will not handle replies."
1.344 -
1.345 - pass
1.346 -
1.347 - def declinecounter(self):
1.348 -
1.349 - """
1.350 - Since this handler does not send counter proposals, it will not handle
1.351 - replies to such proposals.
1.352 - """
1.353 -
1.354 - pass
1.355 -
1.356 - def publish(self):
1.357 - pass
1.358 -
1.359 - def refresh(self):
1.360 - pass
1.361 -
1.362 - def reply(self):
1.363 -
1.364 - "Since this handler does not send requests, it will not handle replies."
1.365 -
1.366 - pass
1.367 -
1.368 - def request(self):
1.369 -
1.370 - """
1.371 - Respond to a request by preparing a reply containing accept/decline
1.372 - information for each indicated attendee.
1.373 -
1.374 - No support for countering requests is implemented.
1.375 - """
1.376 -
1.377 - oa = self.require_organiser_and_attendees()
1.378 - if not oa:
1.379 - return None
1.380 -
1.381 - (organiser, organiser_attr), attendees = oa
1.382 -
1.383 - # Process each attendee separately.
1.384 -
1.385 - calendar = []
1.386 -
1.387 - for attendee, attendee_attr in attendees.items():
1.388 -
1.389 - # Check for event using UID.
1.390 -
1.391 - f = self.store.get_event(attendee, self.uid)
1.392 - event = f and parse_object(f, "utf-8", "VEVENT")
1.393 -
1.394 - # If found, compare SEQUENCE and potentially DTSTAMP.
1.395 -
1.396 - if event:
1.397 - sequence = get_value(event, "SEQUENCE")
1.398 - dtstamp = get_value(event, "DTSTAMP")
1.399 -
1.400 - # If the request refers to an older version of the event, ignore
1.401 - # it.
1.402 -
1.403 - old_dtstamp = self.dtstamp < dtstamp
1.404 -
1.405 - if sequence is not None and (
1.406 - int(self.sequence) < int(sequence) or
1.407 - int(self.sequence) == int(sequence) and old_dtstamp
1.408 - ) or old_dtstamp:
1.409 -
1.410 - continue
1.411 -
1.412 - # If newer than any old version, discard old details from the
1.413 - # free/busy record and check for suitability.
1.414 -
1.415 - dtstart = self.get_utc_datetime("DTSTART")
1.416 - dtend = self.get_utc_datetime("DTEND")
1.417 -
1.418 - # NOTE: Need also DURATION support.
1.419 -
1.420 - duration = dtend - dtstart
1.421 -
1.422 - # Recurrence rules create multiple instances to be checked.
1.423 - # Conflicts may only be assessed within a period defined by policy
1.424 - # for the agent, with instances outside that period being considered
1.425 - # unchecked.
1.426 -
1.427 - window_end = datetime.now() + timedelta(100)
1.428 -
1.429 - # NOTE: Need also RDATE and EXDATE support.
1.430 -
1.431 - rrule = self.get_value("RRULE")
1.432 -
1.433 - if rrule:
1.434 - selector = get_rule(dtstart, rrule)
1.435 - parameters = get_parameters(rrule)
1.436 - periods = []
1.437 - for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")):
1.438 - start = datetime(*start, tzinfo=timezone("UTC"))
1.439 - end = start + duration
1.440 - periods.append((format_datetime(start), format_datetime(end)))
1.441 - else:
1.442 - periods = [(format_datetime(dtstart), format_datetime(dtend))]
1.443 -
1.444 - conflict = False
1.445 - freebusy = self.store.get_freebusy(attendee)
1.446 -
1.447 - if freebusy:
1.448 - remove_period(freebusy, self.uid)
1.449 - conflict = True
1.450 - for start, end in periods:
1.451 - if period_overlaps(freebusy, (start, end)):
1.452 - break
1.453 - else:
1.454 - conflict = False
1.455 - else:
1.456 - freebusy = []
1.457 -
1.458 - # If the event can be scheduled, it is registered and a reply sent
1.459 - # accepting the event. (The attendee has PARTSTAT=ACCEPTED as an
1.460 - # attribute.)
1.461 -
1.462 - if not conflict:
1.463 - for start, end in periods:
1.464 - insert_period(freebusy, (start, end, self.uid))
1.465 -
1.466 - if self.get_value("TRANSP") in (None, "OPAQUE"):
1.467 - self.store.set_freebusy(attendee, freebusy)
1.468 -
1.469 - if self.publisher:
1.470 - self.publisher.set_freebusy(attendee, freebusy)
1.471 -
1.472 - self.store.set_event(attendee, self.uid, to_node(
1.473 - {"VEVENT" : [(self.details, {})]}
1.474 - ))
1.475 - attendee_attr["PARTSTAT"] = "ACCEPTED"
1.476 -
1.477 - # If the event cannot be scheduled, it is not registered and a reply
1.478 - # sent declining the event. (The attendee has PARTSTAT=DECLINED as an
1.479 - # attribute.)
1.480 -
1.481 - else:
1.482 - attendee_attr["PARTSTAT"] = "DECLINED"
1.483 -
1.484 - self.details["ATTENDEE"] = [(attendee, attendee_attr)]
1.485 - calendar.append(to_node(
1.486 - {"VEVENT" : [(self.details, {})]}
1.487 - ))
1.488 -
1.489 - return calendar
1.490 -
1.491 -class Freebusy(Handler):
1.492 -
1.493 - "A free/busy handler."
1.494 -
1.495 - def publish(self):
1.496 - pass
1.497 -
1.498 - def reply(self):
1.499 -
1.500 - "Since this handler does not send requests, it will not handle replies."
1.501 -
1.502 - pass
1.503 -
1.504 - def request(self):
1.505 -
1.506 - """
1.507 - Respond to a request by preparing a reply containing free/busy
1.508 - information for each indicated attendee.
1.509 - """
1.510 -
1.511 - oa = self.require_organiser_and_attendees()
1.512 - if not oa:
1.513 - return None
1.514 -
1.515 - (organiser, organiser_attr), attendees = oa
1.516 -
1.517 - # Construct an appropriate fragment.
1.518 -
1.519 - calendar = []
1.520 - cwrite = calendar.append
1.521 -
1.522 - # Get the details for each attendee.
1.523 -
1.524 - for attendee, attendee_attr in attendees.items():
1.525 - freebusy = self.store.get_freebusy(attendee)
1.526 -
1.527 - if freebusy:
1.528 - record = []
1.529 - rwrite = record.append
1.530 -
1.531 - rwrite(("ORGANIZER", organiser_attr, organiser))
1.532 - rwrite(("ATTENDEE", attendee_attr, attendee))
1.533 - rwrite(("UID", {}, self.uid))
1.534 -
1.535 - for start, end, uid in freebusy:
1.536 - rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, [start, end]))
1.537 -
1.538 - cwrite(("VFREEBUSY", {}, record))
1.539 -
1.540 - # Return the reply.
1.541 -
1.542 - return calendar
1.543 -
1.544 -class Journal(Handler):
1.545 -
1.546 - "A journal entry handler."
1.547 -
1.548 - def add(self):
1.549 - pass
1.550 -
1.551 - def cancel(self):
1.552 - pass
1.553 -
1.554 - def publish(self):
1.555 - pass
1.556 -
1.557 -class Todo(Handler):
1.558 -
1.559 - "A to-do item handler."
1.560 -
1.561 - def add(self):
1.562 - pass
1.563 -
1.564 - def cancel(self):
1.565 - pass
1.566 -
1.567 - def counter(self):
1.568 -
1.569 - "Since this handler does not send requests, it will not handle replies."
1.570 -
1.571 - pass
1.572 -
1.573 - def declinecounter(self):
1.574 -
1.575 - """
1.576 - Since this handler does not send counter proposals, it will not handle
1.577 - replies to such proposals.
1.578 - """
1.579 -
1.580 - pass
1.581 -
1.582 - def publish(self):
1.583 - pass
1.584 -
1.585 - def refresh(self):
1.586 - pass
1.587 -
1.588 - def reply(self):
1.589 -
1.590 - "Since this handler does not send requests, it will not handle replies."
1.591 -
1.592 - pass
1.593 -
1.594 - def request(self):
1.595 - pass
1.596 -
1.597 -# Handler registry.
1.598 -
1.599 -handlers = [
1.600 - ("VFREEBUSY", Freebusy),
1.601 - ("VEVENT", Event),
1.602 - ("VTODO", Todo),
1.603 - ("VJOURNAL", Journal),
1.604 - ]
1.605 -
1.606 -methods = {
1.607 - "ADD" : lambda handler: handler.add,
1.608 - "CANCEL" : lambda handler: handler.cancel,
1.609 - "COUNTER" : lambda handler: handler.counter,
1.610 - "DECLINECOUNTER" : lambda handler: handler.declinecounter,
1.611 - "PUBLISH" : lambda handler: handler.publish,
1.612 - "REFRESH" : lambda handler: handler.refresh,
1.613 - "REPLY" : lambda handler: handler.reply,
1.614 - "REQUEST" : lambda handler: handler.request,
1.615 - }
1.616 -
1.617 -response_methods = {
1.618 - "REQUEST" : "REPLY",
1.619 - }
1.620 -
1.621 def main():
1.622
1.623 "Interpret program arguments and process input."