1 #!/usr/bin/env python 2 3 """ 4 Interpretation and preparation of iMIP content, together with a content handling 5 mechanism employed by specific recipients. 6 7 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk> 8 9 This program is free software; you can redistribute it and/or modify it under 10 the terms of the GNU General Public License as published by the Free Software 11 Foundation; either version 3 of the License, or (at your option) any later 12 version. 13 14 This program is distributed in the hope that it will be useful, but WITHOUT 15 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 16 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 17 details. 18 19 You should have received a copy of the GNU General Public License along with 20 this program. If not, see <http://www.gnu.org/licenses/>. 21 """ 22 23 from datetime import date, datetime, timedelta 24 from email.mime.text import MIMEText 25 from imiptools.period import have_conflict, insert_period, remove_period 26 from pytz import timezone, UnknownTimeZoneError 27 from vCalendar import parse, ParseError, to_dict 28 from vRecurrence import get_parameters, get_rule 29 import email.utils 30 import imip_store 31 import re 32 33 try: 34 from cStringIO import StringIO 35 except ImportError: 36 from StringIO import StringIO 37 38 # iCalendar date and datetime parsing (from DateSupport in MoinSupport). 39 40 date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})' 41 datetime_icalendar_regexp_str = date_icalendar_regexp_str + \ 42 ur'(?:' \ 43 ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \ 44 ur'(?P<utc>Z)?' \ 45 ur')?' 46 47 match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match 48 match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match 49 50 # Content interpretation. 51 52 def get_items(d, name, all=True): 53 54 """ 55 Get all items from 'd' with the given 'name', returning single items if 56 'all' is specified and set to a false value and if only one value is 57 present for the name. Return None if no items are found for the name. 58 """ 59 60 if d.has_key(name): 61 values = d[name] 62 if not all and len(values) == 1: 63 return values[0] 64 else: 65 return values 66 else: 67 return None 68 69 def get_item(d, name): 70 return get_items(d, name, False) 71 72 def get_value_map(d, name): 73 74 """ 75 Return a dictionary for all items in 'd' having the given 'name'. The 76 dictionary will map values for the name to any attributes or qualifiers 77 that may have been present. 78 """ 79 80 items = get_items(d, name) 81 if items: 82 return dict(items) 83 else: 84 return {} 85 86 def get_values(d, name, all=True): 87 if d.has_key(name): 88 values = d[name] 89 if not all and len(values) == 1: 90 return values[0][0] 91 else: 92 return map(lambda x: x[0], values) 93 else: 94 return None 95 96 def get_value(d, name): 97 return get_values(d, name, False) 98 99 def get_utc_datetime(d, name): 100 value, attr = get_item(d, name) 101 dt = get_datetime(value, attr) 102 return to_utc_datetime(dt) 103 104 def to_utc_datetime(dt): 105 if not dt: 106 return None 107 elif isinstance(dt, datetime): 108 return dt.astimezone(timezone("UTC")) 109 else: 110 return dt 111 112 def to_timezone(dt, name): 113 try: 114 tz = name and timezone(name) or None 115 except UnknownTimeZoneError: 116 tz = None 117 if tz is not None: 118 if not dt.tzinfo: 119 return tz.localize(dt) 120 else: 121 return dt.astimezone(tz) 122 else: 123 return dt 124 125 def format_datetime(dt): 126 if not dt: 127 return None 128 elif isinstance(dt, datetime): 129 if dt.tzname() == "UTC": 130 return dt.strftime("%Y%m%dT%H%M%SZ") 131 else: 132 return dt.strftime("%Y%m%dT%H%M%S") 133 else: 134 return dt.strftime("%Y%m%d") 135 136 def get_addresses(values): 137 return [address for name, address in email.utils.getaddresses(values)] 138 139 def get_address(value): 140 return value.lower().startswith("mailto:") and value.lower()[7:] or value 141 142 def get_uri(value): 143 return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower() 144 145 def uri_dict(d): 146 return dict([(get_uri(key), value) for key, value in d.items()]) 147 148 def uri_item(item): 149 return get_uri(item[0]), item[1] 150 151 def uri_items(items): 152 return [(get_uri(value), attr) for value, attr in items] 153 154 def get_datetime(value, attr=None): 155 156 """ 157 Return a datetime object from the given 'value' in iCalendar format, using 158 the 'attr' mapping (if specified) to control the conversion. 159 """ 160 161 if not attr or attr.get("VALUE") in (None, "DATE-TIME"): 162 m = match_datetime_icalendar(value) 163 if m: 164 dt = datetime( 165 int(m.group("year")), int(m.group("month")), int(m.group("day")), 166 int(m.group("hour")), int(m.group("minute")), int(m.group("second")) 167 ) 168 169 # Impose the indicated timezone. 170 # NOTE: This needs an ambiguity policy for DST changes. 171 172 return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None) 173 174 if not attr or attr.get("VALUE") == "DATE": 175 m = match_date_icalendar(value) 176 if m: 177 return date( 178 int(m.group("year")), int(m.group("month")), int(m.group("day")) 179 ) 180 return None 181 182 # NOTE: Need to expose the 100 day window for recurring events in the 183 # NOTE: configuration. 184 185 def get_periods(obj, window_size=100): 186 187 """ 188 Return periods for the given object 'obj', confining materialised periods 189 to the given 'window_size' in days starting from the present moment. 190 """ 191 192 dtstart = get_utc_datetime(obj, "DTSTART") 193 dtend = get_utc_datetime(obj, "DTEND") 194 195 # NOTE: Need also DURATION support. 196 197 duration = dtend - dtstart 198 199 # Recurrence rules create multiple instances to be checked. 200 # Conflicts may only be assessed within a period defined by policy 201 # for the agent, with instances outside that period being considered 202 # unchecked. 203 204 window_end = datetime.now() + timedelta(window_size) 205 206 # NOTE: Need also RDATE and EXDATE support. 207 208 rrule = get_value(obj, "RRULE") 209 210 if rrule: 211 selector = get_rule(dtstart, rrule) 212 parameters = get_parameters(rrule) 213 periods = [] 214 for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")): 215 start = datetime(*start, tzinfo=timezone("UTC")) 216 end = start + duration 217 periods.append((format_datetime(start), format_datetime(end))) 218 else: 219 periods = [(format_datetime(dtstart), format_datetime(dtend))] 220 221 return periods 222 223 def remove_from_freebusy(freebusy, attendee, uid, store): 224 225 """ 226 For the given 'attendee', remove periods from 'freebusy' that are associated 227 with 'uid' in the 'store'. 228 """ 229 230 remove_period(freebusy, uid) 231 store.set_freebusy(attendee, freebusy) 232 233 def update_freebusy(freebusy, attendee, periods, transp, uid, store): 234 235 """ 236 For the given 'attendee', update the free/busy details with the given 237 'periods', 'transp' setting and 'uid' in the 'store'. 238 """ 239 240 remove_period(freebusy, uid) 241 242 for start, end in periods: 243 insert_period(freebusy, (start, end, uid, transp)) 244 245 store.set_freebusy(attendee, freebusy) 246 247 def can_schedule(freebusy, periods, uid): 248 249 """ 250 Return whether the 'freebusy' list can accommodate the given 'periods' 251 employing the specified 'uid'. 252 """ 253 254 for conflict in have_conflict(freebusy, periods, True): 255 start, end, found_uid, found_transp = conflict 256 if found_uid != uid: 257 return False 258 259 return True 260 261 # Handler mechanism objects. 262 263 def handle_itip_part(part, senders, recipients, handlers, messenger): 264 265 """ 266 Handle the given iTIP 'part' from the given 'senders' for the given 267 'recipients' using the given 'handlers' and information provided by the 268 given 'messenger'. Return a list of responses, each response being a tuple 269 of the form (is-outgoing, message-part). 270 """ 271 272 method = part.get_param("method") 273 274 # Decode the data and parse it. 275 276 f = StringIO(part.get_payload(decode=True)) 277 278 itip = parse_object(f, part.get_content_charset(), "VCALENDAR") 279 280 # Ignore the part if not a calendar object. 281 282 if not itip: 283 return [] 284 285 # Require consistency between declared and employed methods. 286 287 if get_value(itip, "METHOD") == method: 288 289 # Look for different kinds of sections. 290 291 all_results = [] 292 293 for name, cls in handlers: 294 for details in get_values(itip, name) or []: 295 296 # Dispatch to a handler and obtain any response. 297 298 handler = cls(details, senders, recipients, messenger) 299 result = methods[method](handler)() 300 301 # Aggregate responses for a single message. 302 303 if result: 304 response_method, part = result 305 outgoing = method != response_method 306 all_results.append((outgoing, part)) 307 308 return all_results 309 310 return [] 311 312 def parse_object(f, encoding, objtype=None): 313 314 """ 315 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 316 given, only objects of that type will be returned. Otherwise, the root of 317 the content will be returned as a dictionary with a single key indicating 318 the object type. 319 320 Return None if the content was not readable or suitable. 321 """ 322 323 try: 324 try: 325 doctype, attrs, elements = obj = parse(f, encoding=encoding) 326 if objtype and doctype == objtype: 327 return to_dict(obj)[objtype][0] 328 elif not objtype: 329 return to_dict(obj) 330 finally: 331 f.close() 332 333 # NOTE: Handle parse errors properly. 334 335 except (ParseError, ValueError): 336 pass 337 338 return None 339 340 def to_part(method, calendar): 341 342 """ 343 Write using the given 'method', the 'calendar' details to a MIME 344 text/calendar part. 345 """ 346 347 encoding = "utf-8" 348 out = StringIO() 349 try: 350 imip_store.to_stream(out, imip_store.make_calendar(calendar, method), encoding) 351 part = MIMEText(out.getvalue(), "calendar", encoding) 352 part.set_param("method", method) 353 return part 354 355 finally: 356 out.close() 357 358 class Handler: 359 360 "General handler support." 361 362 def __init__(self, details, senders=None, recipients=None, messenger=None): 363 364 """ 365 Initialise the handler with the 'details' of a calendar object and the 366 'senders' and 'recipients' of the object (if specifically indicated). 367 """ 368 369 self.details = details 370 self.senders = senders and set(map(get_address, senders)) 371 self.recipients = recipients and set(map(get_address, recipients)) 372 self.messenger = messenger 373 374 self.uid = get_value(details, "UID") 375 self.sequence = get_value(details, "SEQUENCE") 376 self.dtstamp = get_value(details, "DTSTAMP") 377 378 self.store = imip_store.FileStore() 379 380 try: 381 self.publisher = imip_store.FilePublisher() 382 except OSError: 383 self.publisher = None 384 385 # Access to calendar structures and other data. 386 387 def get_items(self, name, all=True): 388 return get_items(self.details, name, all) 389 390 def get_item(self, name): 391 return get_item(self.details, name) 392 393 def get_value_map(self, name): 394 return get_value_map(self.details, name) 395 396 def get_values(self, name, all=True): 397 return get_values(self.details, name, all) 398 399 def get_value(self, name): 400 return get_value(self.details, name) 401 402 def get_utc_datetime(self, name): 403 return get_utc_datetime(self.details, name) 404 405 def get_periods(self): 406 return get_periods(self.details) 407 408 def remove_from_freebusy(self, freebusy, attendee): 409 remove_from_freebusy(freebusy, attendee, self.uid, self.store) 410 411 def update_freebusy(self, freebusy, attendee, periods): 412 return update_freebusy(freebusy, attendee, periods, self.get_value("TRANSP"), self.uid, self.store) 413 414 def can_schedule(self, freebusy, periods): 415 return can_schedule(freebusy, periods, self.uid) 416 417 def filter_by_senders(self, values): 418 addresses = map(get_address, values) 419 if self.senders: 420 return self.senders.intersection(addresses) 421 else: 422 return addresses 423 424 def filter_by_recipients(self, values): 425 addresses = map(get_address, values) 426 if self.recipients: 427 return self.recipients.intersection(addresses) 428 else: 429 return addresses 430 431 def require_organiser_and_attendees(self, from_organiser=True): 432 433 """ 434 Return the organiser and attendees for the current object, filtered by 435 the recipients of interest. Return None if no identities are eligible. 436 437 Organiser and attendee identities are provided as lower case values. 438 """ 439 440 attendee_map = uri_dict(self.get_value_map("ATTENDEE")) 441 organiser = uri_item(self.get_item("ORGANIZER")) 442 443 # Only provide details for recipients who are also attendees. 444 445 filter_fn = from_organiser and self.filter_by_recipients or self.filter_by_senders 446 447 attendees = {} 448 for attendee in map(get_uri, filter_fn(attendee_map)): 449 attendees[attendee] = attendee_map[attendee] 450 451 if not attendees or not organiser: 452 return None 453 454 return organiser, attendees 455 456 def validate_identities(self, items): 457 458 """ 459 Validate the 'items' against the known senders, obtaining sent-by 460 addresses from attributes provided by the items. 461 """ 462 463 # Reject organisers that do not match any senders. 464 465 identities = [] 466 467 for value, attr in items: 468 identities.append(value) 469 sent_by = attr.get("SENT-BY") 470 if sent_by: 471 identities.append(get_uri(sent_by)) 472 473 return self.filter_by_senders(identities) 474 475 def get_object(self, user, objtype): 476 477 """ 478 Return the stored object to which the current object refers for the 479 given 'user' and for the given 'objtype'. 480 """ 481 482 f = self.store.get_event(user, self.uid) 483 obj = f and parse_object(f, "utf-8", objtype) 484 return obj 485 486 def have_new_object(self, attendee, objtype, obj=None): 487 488 """ 489 Return whether the current object is new to the 'attendee' for the 490 given 'objtype'. 491 """ 492 493 obj = obj or self.get_object(attendee, objtype) 494 495 # If found, compare SEQUENCE and potentially DTSTAMP. 496 497 if obj: 498 sequence = get_value(obj, "SEQUENCE") 499 dtstamp = get_value(obj, "DTSTAMP") 500 501 # If the request refers to an older version of the object, ignore 502 # it. 503 504 old_dtstamp = self.dtstamp < dtstamp 505 506 have_sequence = sequence is not None and self.sequence is not None 507 508 if have_sequence and ( 509 int(self.sequence) < int(sequence) or 510 int(self.sequence) == int(sequence) and old_dtstamp 511 ) or not have_sequence and old_dtstamp: 512 513 return False 514 515 return True 516 517 # Handler registry. 518 519 methods = { 520 "ADD" : lambda handler: handler.add, 521 "CANCEL" : lambda handler: handler.cancel, 522 "COUNTER" : lambda handler: handler.counter, 523 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 524 "PUBLISH" : lambda handler: handler.publish, 525 "REFRESH" : lambda handler: handler.refresh, 526 "REPLY" : lambda handler: handler.reply, 527 "REQUEST" : lambda handler: handler.request, 528 } 529 530 # vim: tabstop=4 expandtab shiftwidth=4