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.config import MANAGER_PATH, MANAGER_URL 26 from imiptools.data import Object, parse_object, \ 27 get_address, get_fragments, get_uri, get_value, \ 28 is_new_object, uri_dict, uri_item 29 from imiptools.dates import * 30 from imiptools.period import have_conflict, insert_period, remove_period 31 from pytz import timezone 32 from socket import gethostname 33 from vRecurrence import get_parameters, get_rule 34 import imip_store 35 36 try: 37 from cStringIO import StringIO 38 except ImportError: 39 from StringIO import StringIO 40 41 # NOTE: Need to expose the 100 day window for recurring events in the 42 # NOTE: configuration. 43 44 def get_periods(obj, window_size=100): 45 46 """ 47 Return periods for the given object 'obj', confining materialised periods 48 to the given 'window_size' in days starting from the present moment. 49 """ 50 51 dtstart = obj.get_utc_datetime("DTSTART") 52 dtend = obj.get_utc_datetime("DTEND") 53 54 # NOTE: Need also DURATION support. 55 56 duration = dtend - dtstart 57 58 # Recurrence rules create multiple instances to be checked. 59 # Conflicts may only be assessed within a period defined by policy 60 # for the agent, with instances outside that period being considered 61 # unchecked. 62 63 window_end = datetime.now() + timedelta(window_size) 64 65 # NOTE: Need also RDATE and EXDATE support. 66 67 rrule = obj.get_value("RRULE") 68 69 if rrule: 70 selector = get_rule(dtstart, rrule) 71 parameters = get_parameters(rrule) 72 periods = [] 73 for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")): 74 start = datetime(*start, tzinfo=timezone("UTC")) 75 end = start + duration 76 periods.append((format_datetime(start), format_datetime(end))) 77 else: 78 periods = [(format_datetime(dtstart), format_datetime(dtend))] 79 80 return periods 81 82 def remove_from_freebusy(freebusy, attendee, uid, store): 83 84 """ 85 For the given 'attendee', remove periods from 'freebusy' that are associated 86 with 'uid' in the 'store'. 87 """ 88 89 remove_period(freebusy, uid) 90 store.set_freebusy(attendee, freebusy) 91 92 def remove_from_freebusy_for_other(freebusy, user, other, uid, store): 93 94 """ 95 For the given 'user', remove for the 'other' party periods from 'freebusy' 96 that are associated with 'uid' in the 'store'. 97 """ 98 99 remove_period(freebusy, uid) 100 store.set_freebusy_for_other(user, freebusy, other) 101 102 def _update_freebusy(freebusy, periods, transp, uid): 103 104 """ 105 Update the free/busy details with the given 'periods', 'transp' setting and 106 'uid'. 107 """ 108 109 remove_period(freebusy, uid) 110 111 for start, end in periods: 112 insert_period(freebusy, (start, end, uid, transp)) 113 114 def update_freebusy(freebusy, attendee, periods, transp, uid, store): 115 116 """ 117 For the given 'attendee', update the free/busy details with the given 118 'periods', 'transp' setting and 'uid' in the 'store'. 119 """ 120 121 _update_freebusy(freebusy, periods, transp, uid) 122 store.set_freebusy(attendee, freebusy) 123 124 def update_freebusy_for_other(freebusy, user, other, periods, transp, uid, store): 125 126 """ 127 For the given 'user', update the free/busy details of 'other' with the given 128 'periods', 'transp' setting and 'uid' in the 'store'. 129 """ 130 131 _update_freebusy(freebusy, periods, transp, uid) 132 store.set_freebusy_for_other(user, freebusy, other) 133 134 def can_schedule(freebusy, periods, uid): 135 136 """ 137 Return whether the 'freebusy' list can accommodate the given 'periods' 138 employing the specified 'uid'. 139 """ 140 141 for conflict in have_conflict(freebusy, periods, True): 142 start, end, found_uid, found_transp = conflict 143 if found_uid != uid: 144 return False 145 146 return True 147 148 # Handler mechanism objects. 149 150 def handle_itip_part(part, senders, recipient, handlers, messenger): 151 152 """ 153 Handle the given iTIP 'part' from the given 'senders' for the given 154 'recipient' using the given 'handlers' and information provided by the 155 given 'messenger'. Return a list of responses, each response being a tuple 156 of the form (outgoing-recipients, message-part). 157 """ 158 159 method = part.get_param("method") 160 161 # Decode the data and parse it. 162 163 f = StringIO(part.get_payload(decode=True)) 164 165 itip = parse_object(f, part.get_content_charset(), "VCALENDAR") 166 167 # Ignore the part if not a calendar object. 168 169 if not itip: 170 return [] 171 172 # Require consistency between declared and employed methods. 173 174 if get_value(itip, "METHOD") == method: 175 176 # Look for different kinds of sections. 177 178 all_results = [] 179 180 for name, cls in handlers: 181 for fragment in get_fragments(itip, name): 182 183 # Dispatch to a handler and obtain any response. 184 185 handler = cls(Object(fragment), senders, recipient, messenger) 186 results = methods[method](handler)() 187 188 # Aggregate responses for a single message. 189 190 if results: 191 for result in results: 192 outgoing_recipients, part = result 193 all_results.append((outgoing_recipients, part)) 194 195 return all_results 196 197 return [] 198 199 # References to the Web interface. 200 201 def get_manager_url(): 202 url_base = MANAGER_URL or "http://%s/" % gethostname() 203 return "%s/%s" % (url_base.rstrip("/"), MANAGER_PATH.lstrip("/")) 204 205 def get_object_url(uid): 206 return "%s/%s" % (get_manager_url().rstrip("/"), uid) 207 208 class Handler: 209 210 "General handler support." 211 212 def __init__(self, obj, senders=None, recipient=None, messenger=None): 213 214 """ 215 Initialise the handler with the calendar 'obj' and the 'senders' and 216 'recipient' of the object (if specifically indicated). 217 """ 218 219 self.obj = obj 220 self.senders = senders and set(map(get_address, senders)) 221 self.recipient = recipient and get_address(recipient) 222 self.messenger = messenger 223 224 self.uid = self.obj.get_value("UID") 225 self.sequence = self.obj.get_value("SEQUENCE") 226 self.dtstamp = self.obj.get_value("DTSTAMP") 227 228 self.store = imip_store.FileStore() 229 230 try: 231 self.publisher = imip_store.FilePublisher() 232 except OSError: 233 self.publisher = None 234 235 def wrap(self, text, link=True): 236 237 "Wrap any valid message for passing to the recipient." 238 239 texts = [] 240 texts.append(text) 241 if link: 242 texts.append("If your mail program cannot handle this " 243 "message, you may view the details here:\n\n%s" % 244 get_object_url(self.uid)) 245 246 return [(None, MIMEText("\n".join(texts)))] 247 248 # Access to calendar structures and other data. 249 250 def get_periods(self): 251 return get_periods(self.obj) 252 253 def remove_from_freebusy(self, freebusy, attendee): 254 remove_from_freebusy(freebusy, attendee, self.uid, self.store) 255 256 def remove_from_freebusy_for_other(self, freebusy, user, other): 257 remove_from_freebusy_for_other(freebusy, user, other, self.uid, self.store) 258 259 def update_freebusy(self, freebusy, attendee, periods): 260 return update_freebusy(freebusy, attendee, periods, self.obj.get_value("TRANSP"), self.uid, self.store) 261 262 def update_freebusy_for_other(self, freebusy, user, other, periods): 263 return update_freebusy_for_other(freebusy, user, other, periods, self.obj.get_value("TRANSP"), self.uid, self.store) 264 265 def can_schedule(self, freebusy, periods): 266 return can_schedule(freebusy, periods, self.uid) 267 268 def filter_by_senders(self, mapping): 269 270 """ 271 Return a list of items from 'mapping' filtered using sender information. 272 """ 273 274 if self.senders: 275 276 # Get a mapping from senders to identities. 277 278 identities = self.get_sender_identities(mapping) 279 280 # Find the senders that are valid. 281 282 senders = map(get_address, identities) 283 valid = self.senders.intersection(senders) 284 285 # Return the true identities. 286 287 return [identities[get_uri(address)] for address in valid] 288 else: 289 return mapping 290 291 def filter_by_recipient(self, mapping): 292 293 """ 294 Return a list of items from 'mapping' filtered using recipient 295 information. 296 """ 297 298 if self.recipient: 299 addresses = set(map(get_address, mapping)) 300 return map(get_uri, addresses.intersection([self.recipient])) 301 else: 302 return mapping 303 304 def require_organiser_and_attendees(self, from_organiser=True): 305 306 """ 307 Return the organiser and attendees for the current object, filtered for 308 the recipient of interest. Return None if no identities are eligible. 309 310 Organiser and attendee identities are provided as lower case values. 311 """ 312 313 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 314 organiser_item = uri_item(self.obj.get_item("ORGANIZER")) 315 316 # Only provide details for attendees who sent/receive the message. 317 318 attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders 319 320 attendees = {} 321 for attendee in attendee_filter_fn(attendee_map): 322 attendees[attendee] = attendee_map[attendee] 323 324 if not attendees or not organiser_item: 325 return None 326 327 # Only provide details for an organiser who sent/receives the message. 328 329 organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient 330 331 if not organiser_filter_fn(dict([organiser_item])): 332 return None 333 334 return organiser_item, attendees 335 336 def get_sender_identities(self, mapping): 337 338 """ 339 Return a mapping from actual senders to the identities for which they 340 have provided data, extracting this information from the given 341 'mapping'. 342 """ 343 344 senders = {} 345 346 for value, attr in mapping.items(): 347 sent_by = attr.get("SENT-BY") 348 if sent_by: 349 senders[get_uri(sent_by)] = value 350 else: 351 senders[value] = value 352 353 return senders 354 355 def get_object(self, user): 356 357 """ 358 Return the stored object to which the current object refers for the 359 given 'user' and for the given 'objtype'. 360 """ 361 362 f = self.store.get_event(user, self.uid) 363 fragment = f and parse_object(f, "utf-8") 364 return fragment and Object(fragment) 365 366 def have_new_object(self, attendee, obj=None): 367 368 """ 369 Return whether the current object is new to the 'attendee' (or if the 370 given 'obj' is new). 371 """ 372 373 obj = obj or self.get_object(attendee) 374 375 # If found, compare SEQUENCE and potentially DTSTAMP. 376 377 if obj: 378 sequence = obj.get_value("SEQUENCE") 379 dtstamp = obj.get_value("DTSTAMP") 380 381 # If the request refers to an older version of the object, ignore 382 # it. 383 384 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 385 self.is_partstat_updated(obj)) 386 387 return True 388 389 def is_partstat_updated(self, obj): 390 391 """ 392 Return whether the participant status has been updated in the current 393 object in comparison to the given 'obj'. 394 395 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 396 NOTE: and make it invalid. Thus, such attendance information may also be 397 NOTE: incorporated into any new object assessment. 398 """ 399 400 old_attendees = obj.get_value_map("ATTENDEE") 401 new_attendees = self.obj.get_value_map("ATTENDEE") 402 403 for attendee, attr in old_attendees.items(): 404 old_partstat = attr.get("PARTSTAT") 405 new_attr = new_attendees.get(attendee) 406 new_partstat = new_attr and new_attr.get("PARTSTAT") 407 408 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 409 new_partstat != old_partstat: 410 411 return True 412 413 return False 414 415 def update_dtstamp(self): 416 417 "Update the DTSTAMP in the current object." 418 419 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 420 utcnow = to_timezone(datetime.utcnow(), "UTC") 421 self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 422 423 # Handler registry. 424 425 methods = { 426 "ADD" : lambda handler: handler.add, 427 "CANCEL" : lambda handler: handler.cancel, 428 "COUNTER" : lambda handler: handler.counter, 429 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 430 "PUBLISH" : lambda handler: handler.publish, 431 "REFRESH" : lambda handler: handler.refresh, 432 "REPLY" : lambda handler: handler.reply, 433 "REQUEST" : lambda handler: handler.request, 434 } 435 436 # vim: tabstop=4 expandtab shiftwidth=4