1.1 --- a/imip_agent.py Tue Sep 23 16:19:50 2014 +0200
1.2 +++ b/imip_agent.py Tue Sep 23 16:21:03 2014 +0200
1.3 @@ -1,10 +1,11 @@
1.4 #!/usr/bin/env python
1.5
1.6 +from bisect import bisect_left, insort_left
1.7 from email import message_from_file
1.8 from email.mime.multipart import MIMEMultipart
1.9 from email.mime.text import MIMEText
1.10 from smtplib import SMTP
1.11 -from vCalendar import parse, iterwrite, ParseError, SECTION_TYPES
1.12 +from vCalendar import parse, ParseError, SECTION_TYPES
1.13 import imip_store
1.14 import sys
1.15
1.16 @@ -40,6 +41,88 @@
1.17 "text/x-vcalendar", "application/ics", # other possibilities
1.18 ]
1.19
1.20 +# Content interpretation.
1.21 +
1.22 +def get_itip_structure(elements):
1.23 + d = {}
1.24 + for name, attr, value in elements:
1.25 + if not d.has_key(name):
1.26 + d[name] = []
1.27 + if name in SECTION_TYPES:
1.28 + d[name].append((get_itip_structure(value), attr))
1.29 + else:
1.30 + d[name].append((value, attr))
1.31 + return d
1.32 +
1.33 +def get_structure_items(d):
1.34 + items = []
1.35 + for name, value in d.items():
1.36 + if isinstance(value, list):
1.37 + for v, a in value:
1.38 + items.append((name, a, v))
1.39 + else:
1.40 + v, a = value
1.41 + items.append((name, a, get_structure_items(v)))
1.42 + return items
1.43 +
1.44 +def get_items(d, name, all=True):
1.45 + if d.has_key(name):
1.46 + values = d[name]
1.47 + if not all and len(values) == 1:
1.48 + return values[0]
1.49 + else:
1.50 + return values
1.51 + else:
1.52 + return None
1.53 +
1.54 +def get_item(d, name):
1.55 + return get_items(d, name, False)
1.56 +
1.57 +def get_value_map(d, name):
1.58 + items = get_items(d, name)
1.59 + if items:
1.60 + return dict(items)
1.61 + else:
1.62 + return {}
1.63 +
1.64 +def get_values(d, name, all=True):
1.65 + if d.has_key(name):
1.66 + values = d[name]
1.67 + if not all and len(values) == 1:
1.68 + return values[0][0]
1.69 + else:
1.70 + return map(lambda x: x[0], values)
1.71 + else:
1.72 + return None
1.73 +
1.74 +def get_value(d, name):
1.75 + return get_values(d, name, False)
1.76 +
1.77 +def get_address(value):
1.78 + return value.startswith("mailto:") and value[7:] or value
1.79 +
1.80 +def get_uri(value):
1.81 + return value.startswith("mailto:") and value or "mailto:%s" % value
1.82 +
1.83 +# Time management.
1.84 +
1.85 +def insert_period(freebusy, period):
1.86 + insort_left(freebusy, period)
1.87 +
1.88 +def remove_period(freebusy, uid):
1.89 + i = 0
1.90 + while i < len(freebusy):
1.91 + t = freebusy[i]
1.92 + if len(t) >= 3 and t[2] == uid:
1.93 + del freebusy[i]
1.94 + else:
1.95 + i += 1
1.96 +
1.97 +def period_overlaps(freebusy, period):
1.98 + dtstart, dtend = period[:2]
1.99 + i = bisect_left(freebusy, (dtstart, dtend, None))
1.100 + return i < len(freebusy) and (dtend is None or freebusy[i][0] < dtend)
1.101 +
1.102 # Sending of outgoing messages.
1.103
1.104 def sendmail(sender, recipients, data):
1.105 @@ -86,69 +169,44 @@
1.106 else:
1.107 sendmail(OWNER, senders, message.as_string())
1.108
1.109 -def get_itip_elements(elements):
1.110 - d = {}
1.111 - for name, attr, value in elements:
1.112 - if not d.has_key(name):
1.113 - d[name] = []
1.114 - if name in SECTION_TYPES:
1.115 - d[name].append((get_itip_elements(value), attr))
1.116 - else:
1.117 - d[name].append((value, attr))
1.118 - return d
1.119 -
1.120 -def get_items(d, name, all=True):
1.121 - if d.has_key(name):
1.122 - values = d[name]
1.123 - if not all and len(values) == 1:
1.124 - return values[0]
1.125 - else:
1.126 - return values
1.127 - else:
1.128 - return None
1.129 -
1.130 -def get_item(d, name):
1.131 - return get_items(d, name, False)
1.132 +def to_part(method, calendar):
1.133
1.134 -def get_value_map(d, name):
1.135 - items = get_items(d, name)
1.136 - if items:
1.137 - return dict(items)
1.138 - else:
1.139 - return {}
1.140 + """
1.141 + Write using the given 'method', the 'calendar' details to a MIME
1.142 + text/calendar part.
1.143 + """
1.144
1.145 -def get_values(d, name, all=True):
1.146 - if d.has_key(name):
1.147 - values = d[name]
1.148 - if not all and len(values) == 1:
1.149 - return values[0][0]
1.150 - else:
1.151 - return map(lambda x: x[0], values)
1.152 - else:
1.153 - return None
1.154 -
1.155 -def get_value(d, name):
1.156 - return get_values(d, name, False)
1.157 -
1.158 -def get_address(value):
1.159 - return value.startswith("mailto:") and value[7:] or value
1.160 -
1.161 -def get_uri(value):
1.162 - return value.startswith("mailto:") and value or "mailto:%s" % value
1.163 -
1.164 -def to_part(method, calendar):
1.165 + encoding = "utf-8"
1.166 out = StringIO()
1.167 try:
1.168 - w = iterwrite(out, encoding="utf-8")
1.169 calendar[:0] = [
1.170 ("METHOD", {}, method),
1.171 ("VERSION", {}, "2.0")
1.172 ]
1.173 - w.write("VCALENDAR", {}, calendar)
1.174 - return MIMEText(out.getvalue(), "calendar", "utf-8")
1.175 + imip_store.to_stream(out, calendar, "VCALENDAR", encoding)
1.176 + return MIMEText(out.getvalue(), "calendar", encoding)
1.177 finally:
1.178 out.close()
1.179
1.180 +def parse_object(f, encoding, objtype):
1.181 +
1.182 + """
1.183 + Parse the iTIP content from 'f' having the given 'encoding'. Return None if
1.184 + the content was not readable or suitable.
1.185 + """
1.186 +
1.187 + try:
1.188 + try:
1.189 + doctype, attrs, elements = parse(f, encoding=encoding)
1.190 + if doctype == objtype:
1.191 + return get_itip_structure(elements)
1.192 + finally:
1.193 + f.close()
1.194 + except (ParseError, ValueError):
1.195 + pass
1.196 +
1.197 + return None
1.198 +
1.199 def handle_itip_part(part, recipients):
1.200
1.201 "Handle the given iTIP 'part' for the given 'recipients'."
1.202 @@ -159,43 +217,39 @@
1.203
1.204 f = StringIO(part.get_payload(decode=True))
1.205
1.206 - try:
1.207 - doctype, attrs, elements = parse(f, encoding=part.get_content_charset())
1.208 - except (ParseError, ValueError):
1.209 + itip = parse_object(f, part.get_content_charset(), "VCALENDAR")
1.210 + if not itip:
1.211 sys.exit(EX_DATAERR)
1.212
1.213 # Only handle calendar information.
1.214
1.215 all_parts = []
1.216
1.217 - if doctype == "VCALENDAR":
1.218 - itip = get_itip_elements(elements)
1.219 + # Require consistency between declared and employed methods.
1.220
1.221 - # Require consistency between declared and employed methods.
1.222 + if get_value(itip, "METHOD") == method:
1.223
1.224 - if get_value(itip, "METHOD") == method:
1.225 + # Look for different kinds of sections.
1.226
1.227 - # Look for different kinds of sections.
1.228 + all_objects = []
1.229
1.230 - all_objects = []
1.231 + for name, cls in handlers:
1.232 + for details in get_values(itip, name) or []:
1.233
1.234 - for name, cls in handlers:
1.235 - for details in get_values(itip, name) or []:
1.236 + # Dispatch to a handler and obtain any response.
1.237
1.238 - # Dispatch to a handler and obtain any response.
1.239 + handler = cls(details, recipients)
1.240 + object = methods[method](handler)()
1.241
1.242 - handler = cls(details, recipients)
1.243 - object = methods[method](handler)()
1.244 -
1.245 - # Concatenate responses for a single calendar object.
1.246 + # Concatenate responses for a single calendar object.
1.247
1.248 - if object:
1.249 - all_objects += object
1.250 + if object:
1.251 + all_objects += object
1.252
1.253 - # Obtain a message part for the objects.
1.254 + # Obtain a message part for the objects.
1.255
1.256 - if all_objects:
1.257 - all_parts.append(to_part(method, all_objects))
1.258 + if all_objects:
1.259 + all_parts.append(to_part(method, all_objects))
1.260
1.261 return all_parts
1.262
1.263 @@ -215,6 +269,8 @@
1.264
1.265 self.uid = get_value(details, "UID")
1.266 self.sequence = get_value(details, "SEQUENCE")
1.267 + self.dtstamp = get_value(details, "DTSTAMP")
1.268 +
1.269 self.store = imip_store.FileStore()
1.270
1.271 def get_items(self, name, all=True):
1.272 @@ -261,9 +317,18 @@
1.273 pass
1.274
1.275 def counter(self):
1.276 +
1.277 + "Since this handler does not send requests, it will not handle replies."
1.278 +
1.279 pass
1.280
1.281 def declinecounter(self):
1.282 +
1.283 + """
1.284 + Since this handler does not send counter proposals, it will not handle
1.285 + replies to such proposals.
1.286 + """
1.287 +
1.288 pass
1.289
1.290 def publish(self):
1.291 @@ -283,8 +348,78 @@
1.292 """
1.293 Respond to a request by preparing a reply containing accept/decline
1.294 information for each indicated attendee.
1.295 +
1.296 + No support for countering requests is implemented.
1.297 """
1.298
1.299 + oa = self.require_organiser_and_attendees()
1.300 + if not oa:
1.301 + return None
1.302 +
1.303 + (organiser, organiser_attr), attendees = oa
1.304 +
1.305 + # Process each attendee separately.
1.306 +
1.307 + for attendee, attendee_attr in attendees.items():
1.308 +
1.309 + # Check for event using UID.
1.310 +
1.311 + f = self.store.get_event(attendee, self.uid)
1.312 + event = f and parse_object(f, "utf-8", "VEVENT")
1.313 +
1.314 + # If found, compare SEQUENCE and potentially DTSTAMP.
1.315 +
1.316 + if event:
1.317 + sequence = get_value(event, "SEQUENCE")
1.318 + dtstamp = get_value(event, "DTSTAMP")
1.319 +
1.320 + # If the request refers to an older version of the event, ignore
1.321 + # it.
1.322 +
1.323 + old_dtstamp = self.dtstamp <= dtstamp
1.324 +
1.325 + if sequence is not None and (
1.326 + int(self.sequence) < int(sequence) or
1.327 + int(self.sequence) == int(sequence) and old_dtstamp
1.328 + ) or old_dtstamp:
1.329 +
1.330 + continue
1.331 +
1.332 + # If newer than any old version, discard old details from the
1.333 + # free/busy record and check for suitability.
1.334 +
1.335 + dtstart = self.get_value("DTSTART")
1.336 + dtend = self.get_value("DTEND")
1.337 +
1.338 + conflict = False
1.339 + freebusy = self.store.get_freebusy(attendee)
1.340 +
1.341 + if freebusy:
1.342 + remove_period(freebusy, self.uid)
1.343 + conflict = period_overlaps(freebusy, (dtstart, dtend))
1.344 + else:
1.345 + freebusy = []
1.346 +
1.347 + # If the event can be scheduled, it is registered and a reply sent
1.348 + # accepting the event. (The attendee has PARTSTAT=ACCEPTED as an
1.349 + # attribute.)
1.350 +
1.351 + if not conflict:
1.352 + insert_period(freebusy, (dtstart, dtend, self.uid))
1.353 + self.store.set_freebusy(attendee, freebusy)
1.354 + self.store.set_event(attendee, self.uid, get_structure_items(self.details))
1.355 + attendee_attr["PARTSTAT"] = "ACCEPTED"
1.356 +
1.357 + # If the event cannot be scheduled, it is not registered and a reply
1.358 + # sent declining the event. (The attendee has PARTSTAT=DECLINED as an
1.359 + # attribute.)
1.360 +
1.361 + else:
1.362 + attendee_attr["PARTSTAT"] = "DECLINED"
1.363 +
1.364 + self.details["ATTENDEE"] = [(attendee, attendee_attr)]
1.365 + return get_structure_items(self.details)
1.366 +
1.367 class Freebusy(Handler):
1.368
1.369 "A free/busy handler."
1.370 @@ -329,7 +464,7 @@
1.371 rwrite(("ATTENDEE", attendee_attr, attendee))
1.372 rwrite(("UID", {}, self.uid))
1.373
1.374 - for start, end in freebusy:
1.375 + for start, end, uid in freebusy:
1.376 rwrite(("FREEBUSY", {}, [start, end]))
1.377
1.378 cwrite(("VFREEBUSY", {}, record))
1.379 @@ -362,9 +497,18 @@
1.380 pass
1.381
1.382 def counter(self):
1.383 +
1.384 + "Since this handler does not send requests, it will not handle replies."
1.385 +
1.386 pass
1.387
1.388 def declinecounter(self):
1.389 +
1.390 + """
1.391 + Since this handler does not send counter proposals, it will not handle
1.392 + replies to such proposals.
1.393 + """
1.394 +
1.395 pass
1.396
1.397 def publish(self):