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 datetime, timedelta 24 from email.mime.text import MIMEText 25 from imiptools.dates import * 26 from imiptools.period import have_conflict, insert_period, remove_period 27 from pytz import timezone 28 from vCalendar import parse, ParseError, to_dict 29 from vRecurrence import get_parameters, get_rule 30 import email.utils 31 import imip_store 32 33 try: 34 from cStringIO import StringIO 35 except ImportError: 36 from StringIO import StringIO 37 38 # Content interpretation. 39 40 def get_items(d, name, all=True): 41 42 """ 43 Get all items from 'd' with the given 'name', returning single items if 44 'all' is specified and set to a false value and if only one value is 45 present for the name. Return None if no items are found for the name. 46 """ 47 48 if d.has_key(name): 49 values = d[name] 50 if not all and len(values) == 1: 51 return values[0] 52 else: 53 return values 54 else: 55 return None 56 57 def get_item(d, name): 58 return get_items(d, name, False) 59 60 def get_value_map(d, name): 61 62 """ 63 Return a dictionary for all items in 'd' having the given 'name'. The 64 dictionary will map values for the name to any attributes or qualifiers 65 that may have been present. 66 """ 67 68 items = get_items(d, name) 69 if items: 70 return dict(items) 71 else: 72 return {} 73 74 def get_values(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][0] 79 else: 80 return map(lambda x: x[0], values) 81 else: 82 return None 83 84 def get_value(d, name): 85 return get_values(d, name, False) 86 87 def get_utc_datetime(d, name): 88 value, attr = get_item(d, name) 89 dt = get_datetime(value, attr) 90 return to_utc_datetime(dt) 91 92 def get_addresses(values): 93 return [address for name, address in email.utils.getaddresses(values)] 94 95 def get_address(value): 96 return value.lower().startswith("mailto:") and value.lower()[7:] or value 97 98 def get_uri(value): 99 return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower() 100 101 def uri_dict(d): 102 return dict([(get_uri(key), value) for key, value in d.items()]) 103 104 def uri_item(item): 105 return get_uri(item[0]), item[1] 106 107 def uri_items(items): 108 return [(get_uri(value), attr) for value, attr in items] 109 110 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, partstat_set): 111 112 """ 113 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 114 'new_dtstamp', and the 'partstat_set' indication, whether the object 115 providing the new information is really newer than the object providing the 116 old information. 117 """ 118 119 have_sequence = old_sequence is not None and new_sequence is not None 120 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 121 122 have_dtstamp = old_dtstamp and new_dtstamp 123 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 124 125 is_old_sequence = have_sequence and ( 126 int(new_sequence) < int(old_sequence) or 127 is_same_sequence and is_old_dtstamp 128 ) 129 130 return is_same_sequence and partstat_set or not is_old_sequence 131 132 # NOTE: Need to expose the 100 day window for recurring events in the 133 # NOTE: configuration. 134 135 def get_periods(obj, window_size=100): 136 137 """ 138 Return periods for the given object 'obj', confining materialised periods 139 to the given 'window_size' in days starting from the present moment. 140 """ 141 142 dtstart = get_utc_datetime(obj, "DTSTART") 143 dtend = get_utc_datetime(obj, "DTEND") 144 145 # NOTE: Need also DURATION support. 146 147 duration = dtend - dtstart 148 149 # Recurrence rules create multiple instances to be checked. 150 # Conflicts may only be assessed within a period defined by policy 151 # for the agent, with instances outside that period being considered 152 # unchecked. 153 154 window_end = datetime.now() + timedelta(window_size) 155 156 # NOTE: Need also RDATE and EXDATE support. 157 158 rrule = get_value(obj, "RRULE") 159 160 if rrule: 161 selector = get_rule(dtstart, rrule) 162 parameters = get_parameters(rrule) 163 periods = [] 164 for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")): 165 start = datetime(*start, tzinfo=timezone("UTC")) 166 end = start + duration 167 periods.append((format_datetime(start), format_datetime(end))) 168 else: 169 periods = [(format_datetime(dtstart), format_datetime(dtend))] 170 171 return periods 172 173 def remove_from_freebusy(freebusy, attendee, uid, store): 174 175 """ 176 For the given 'attendee', remove periods from 'freebusy' that are associated 177 with 'uid' in the 'store'. 178 """ 179 180 remove_period(freebusy, uid) 181 store.set_freebusy(attendee, freebusy) 182 183 def update_freebusy(freebusy, attendee, periods, transp, uid, store): 184 185 """ 186 For the given 'attendee', update the free/busy details with the given 187 'periods', 'transp' setting and 'uid' in the 'store'. 188 """ 189 190 remove_period(freebusy, uid) 191 192 for start, end in periods: 193 insert_period(freebusy, (start, end, uid, transp)) 194 195 store.set_freebusy(attendee, freebusy) 196 197 def can_schedule(freebusy, periods, uid): 198 199 """ 200 Return whether the 'freebusy' list can accommodate the given 'periods' 201 employing the specified 'uid'. 202 """ 203 204 for conflict in have_conflict(freebusy, periods, True): 205 start, end, found_uid, found_transp = conflict 206 if found_uid != uid: 207 return False 208 209 return True 210 211 # Handler mechanism objects. 212 213 def handle_itip_part(part, senders, recipients, handlers, messenger): 214 215 """ 216 Handle the given iTIP 'part' from the given 'senders' for the given 217 'recipients' using the given 'handlers' and information provided by the 218 given 'messenger'. Return a list of responses, each response being a tuple 219 of the form (is-outgoing, message-part). 220 """ 221 222 method = part.get_param("method") 223 224 # Decode the data and parse it. 225 226 f = StringIO(part.get_payload(decode=True)) 227 228 itip = parse_object(f, part.get_content_charset(), "VCALENDAR") 229 230 # Ignore the part if not a calendar object. 231 232 if not itip: 233 return [] 234 235 # Require consistency between declared and employed methods. 236 237 if get_value(itip, "METHOD") == method: 238 239 # Look for different kinds of sections. 240 241 all_results = [] 242 243 for name, cls in handlers: 244 for details in get_values(itip, name) or []: 245 246 # Dispatch to a handler and obtain any response. 247 248 handler = cls(details, senders, recipients, messenger) 249 result = methods[method](handler)() 250 251 # Aggregate responses for a single message. 252 253 if result: 254 response_method, part = result 255 outgoing = method != response_method 256 all_results.append((outgoing, part)) 257 258 return all_results 259 260 return [] 261 262 def parse_object(f, encoding, objtype=None): 263 264 """ 265 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 266 given, only objects of that type will be returned. Otherwise, the root of 267 the content will be returned as a dictionary with a single key indicating 268 the object type. 269 270 Return None if the content was not readable or suitable. 271 """ 272 273 try: 274 try: 275 doctype, attrs, elements = obj = parse(f, encoding=encoding) 276 if objtype and doctype == objtype: 277 return to_dict(obj)[objtype][0] 278 elif not objtype: 279 return to_dict(obj) 280 finally: 281 f.close() 282 283 # NOTE: Handle parse errors properly. 284 285 except (ParseError, ValueError): 286 pass 287 288 return None 289 290 def to_part(method, calendar): 291 292 """ 293 Write using the given 'method', the 'calendar' details to a MIME 294 text/calendar part. 295 """ 296 297 encoding = "utf-8" 298 out = StringIO() 299 try: 300 imip_store.to_stream(out, imip_store.make_calendar(calendar, method), encoding) 301 part = MIMEText(out.getvalue(), "calendar", encoding) 302 part.set_param("method", method) 303 return part 304 305 finally: 306 out.close() 307 308 class Handler: 309 310 "General handler support." 311 312 def __init__(self, details, senders=None, recipients=None, messenger=None): 313 314 """ 315 Initialise the handler with the 'details' of a calendar object and the 316 'senders' and 'recipients' of the object (if specifically indicated). 317 """ 318 319 self.details = details 320 self.senders = senders and set(map(get_address, senders)) 321 self.recipients = recipients and set(map(get_address, recipients)) 322 self.messenger = messenger 323 324 self.uid = get_value(details, "UID") 325 self.sequence = get_value(details, "SEQUENCE") 326 self.dtstamp = get_value(details, "DTSTAMP") 327 328 self.store = imip_store.FileStore() 329 330 try: 331 self.publisher = imip_store.FilePublisher() 332 except OSError: 333 self.publisher = None 334 335 # Access to calendar structures and other data. 336 337 def get_items(self, name, all=True): 338 return get_items(self.details, name, all) 339 340 def get_item(self, name): 341 return get_item(self.details, name) 342 343 def get_value_map(self, name): 344 return get_value_map(self.details, name) 345 346 def get_values(self, name, all=True): 347 return get_values(self.details, name, all) 348 349 def get_value(self, name): 350 return get_value(self.details, name) 351 352 def get_utc_datetime(self, name): 353 return get_utc_datetime(self.details, name) 354 355 def get_periods(self): 356 return get_periods(self.details) 357 358 def remove_from_freebusy(self, freebusy, attendee): 359 remove_from_freebusy(freebusy, attendee, self.uid, self.store) 360 361 def update_freebusy(self, freebusy, attendee, periods): 362 return update_freebusy(freebusy, attendee, periods, self.get_value("TRANSP"), self.uid, self.store) 363 364 def can_schedule(self, freebusy, periods): 365 return can_schedule(freebusy, periods, self.uid) 366 367 def filter_by_senders(self, values): 368 addresses = map(get_address, values) 369 if self.senders: 370 return self.senders.intersection(addresses) 371 else: 372 return addresses 373 374 def filter_by_recipients(self, values): 375 addresses = map(get_address, values) 376 if self.recipients: 377 return self.recipients.intersection(addresses) 378 else: 379 return addresses 380 381 def require_organiser_and_attendees(self, from_organiser=True): 382 383 """ 384 Return the organiser and attendees for the current object, filtered by 385 the recipients of interest. Return None if no identities are eligible. 386 387 Organiser and attendee identities are provided as lower case values. 388 """ 389 390 attendee_map = uri_dict(self.get_value_map("ATTENDEE")) 391 organiser = uri_item(self.get_item("ORGANIZER")) 392 393 # Only provide details for recipients who are also attendees. 394 395 filter_fn = from_organiser and self.filter_by_recipients or self.filter_by_senders 396 397 attendees = {} 398 for attendee in map(get_uri, filter_fn(attendee_map)): 399 attendees[attendee] = attendee_map[attendee] 400 401 if not attendees or not organiser: 402 return None 403 404 return organiser, attendees 405 406 def validate_identities(self, items): 407 408 """ 409 Validate the 'items' against the known senders, obtaining sent-by 410 addresses from attributes provided by the items. 411 """ 412 413 # Reject organisers that do not match any senders. 414 415 identities = [] 416 417 for value, attr in items: 418 identities.append(value) 419 sent_by = attr.get("SENT-BY") 420 if sent_by: 421 identities.append(get_uri(sent_by)) 422 423 return self.filter_by_senders(identities) 424 425 def get_object(self, user, objtype): 426 427 """ 428 Return the stored object to which the current object refers for the 429 given 'user' and for the given 'objtype'. 430 """ 431 432 f = self.store.get_event(user, self.uid) 433 obj = f and parse_object(f, "utf-8", objtype) 434 return obj 435 436 def have_new_object(self, attendee, objtype, obj=None): 437 438 """ 439 Return whether the current object is new to the 'attendee' for the 440 given 'objtype'. 441 """ 442 443 obj = obj or self.get_object(attendee, objtype) 444 445 # If found, compare SEQUENCE and potentially DTSTAMP. 446 447 if obj: 448 sequence = get_value(obj, "SEQUENCE") 449 dtstamp = get_value(obj, "DTSTAMP") 450 451 # NOTE: Some clients like Claws Mail erase time information from DTSTAMP 452 # NOTE: and make it invalid. Thus, attendance information may also be 453 # NOTE: checked. 454 455 _attendee, old_attr = get_item(obj, "ATTENDEE") 456 _attendee, new_attr = self.get_item("ATTENDEE") 457 old_partstat = old_attr.get("PARTSTAT") 458 new_partstat = new_attr.get("PARTSTAT") 459 460 partstat_set = old_partstat == "NEEDS-ACTION" and new_partstat and \ 461 new_partstat != old_partstat 462 463 # If the request refers to an older version of the object, ignore 464 # it. 465 466 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, partstat_set) 467 468 return True 469 470 def update_dtstamp(self): 471 472 "Update the DTSTAMP in the current object." 473 474 dtstamp = self.get_utc_datetime("DTSTAMP") 475 utcnow = to_timezone(datetime.utcnow(), "UTC") 476 self.details["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 477 478 # Handler registry. 479 480 methods = { 481 "ADD" : lambda handler: handler.add, 482 "CANCEL" : lambda handler: handler.cancel, 483 "COUNTER" : lambda handler: handler.counter, 484 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 485 "PUBLISH" : lambda handler: handler.publish, 486 "REFRESH" : lambda handler: handler.refresh, 487 "REPLY" : lambda handler: handler.reply, 488 "REQUEST" : lambda handler: handler.request, 489 } 490 491 # vim: tabstop=4 expandtab shiftwidth=4