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