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_uri, get_value, get_window_end, \ 28 is_new_object, uri_dict, uri_item 29 from imiptools.dates import format_datetime, to_timezone 30 from imiptools.period import can_schedule, insert_period, remove_period, \ 31 remove_from_freebusy, \ 32 remove_from_freebusy_for_other, \ 33 update_freebusy, update_freebusy_for_other 34 from socket import gethostname 35 import imip_store 36 37 try: 38 from cStringIO import StringIO 39 except ImportError: 40 from StringIO import StringIO 41 42 # Handler mechanism objects. 43 44 def handle_itip_part(part, handlers): 45 46 """ 47 Handle the given iTIP 'part' using the given 'handlers' dictionary. 48 49 Return a list of responses, each response being a tuple of the form 50 (outgoing-recipients, message-part). 51 """ 52 53 method = part.get_param("method") 54 55 # Decode the data and parse it. 56 57 f = StringIO(part.get_payload(decode=True)) 58 59 itip = parse_object(f, part.get_content_charset(), "VCALENDAR") 60 61 # Ignore the part if not a calendar object. 62 63 if not itip: 64 return 65 66 # Require consistency between declared and employed methods. 67 68 if get_value(itip, "METHOD") == method: 69 70 # Look for different kinds of sections. 71 72 all_results = [] 73 74 for name, items in itip.items(): 75 76 # Get a handler for the given section. 77 78 handler = handlers.get(name) 79 if not handler: 80 continue 81 82 for item in items: 83 84 # Dispatch to a handler and obtain any response. 85 86 handler.set_object(Object({name : item})) 87 methods[method](handler)() 88 89 # References to the Web interface. 90 91 def get_manager_url(): 92 url_base = MANAGER_URL or "http://%s/" % gethostname() 93 return "%s/%s" % (url_base.rstrip("/"), MANAGER_PATH.lstrip("/")) 94 95 def get_object_url(uid): 96 return "%s/%s" % (get_manager_url().rstrip("/"), uid) 97 98 class Handler: 99 100 "General handler support." 101 102 def __init__(self, senders=None, recipient=None, messenger=None): 103 104 """ 105 Initialise the handler with the calendar 'obj' and the 'senders' and 106 'recipient' of the object (if specifically indicated). 107 """ 108 109 self.senders = senders and set(map(get_address, senders)) 110 self.recipient = recipient and get_address(recipient) 111 self.messenger = messenger 112 113 self.results = [] 114 self.outgoing_methods = set() 115 116 self.obj = None 117 self.uid = None 118 self.recurrenceid = None 119 self.sequence = None 120 self.dtstamp = None 121 122 self.store = imip_store.FileStore() 123 124 try: 125 self.publisher = imip_store.FilePublisher() 126 except OSError: 127 self.publisher = None 128 129 def set_object(self, obj): 130 self.obj = obj 131 self.uid = self.obj.get_value("UID") 132 self.recurrenceid = format_datetime(self.obj.get_utc_datetime("RECURRENCE-ID")) 133 self.sequence = self.obj.get_value("SEQUENCE") 134 self.dtstamp = self.obj.get_value("DTSTAMP") 135 136 def wrap(self, text, link=True): 137 138 "Wrap any valid message for passing to the recipient." 139 140 texts = [] 141 texts.append(text) 142 if link: 143 texts.append("If your mail program cannot handle this " 144 "message, you may view the details here:\n\n%s" % 145 get_object_url(self.uid)) 146 147 return self.add_result(None, None, MIMEText("\n".join(texts))) 148 149 # Result registration. 150 151 def add_result(self, method, outgoing_recipients, part): 152 153 """ 154 Record a result having the given 'method', 'outgoing_recipients' and 155 message part. 156 """ 157 158 if outgoing_recipients: 159 self.outgoing_methods.add(method) 160 self.results.append((outgoing_recipients, part)) 161 162 def get_results(self): 163 return self.results 164 165 def get_outgoing_methods(self): 166 return self.outgoing_methods 167 168 # Access to calendar structures and other data. 169 170 def remove_from_freebusy(self, freebusy, attendee): 171 remove_from_freebusy(freebusy, attendee, self.uid, self.recurrenceid, self.store) 172 173 def remove_from_freebusy_for_other(self, freebusy, user, other): 174 remove_from_freebusy_for_other(freebusy, user, other, self.uid, self.recurrenceid, self.store) 175 176 def _update_freebusy(self, freebusy, attendee, periods, recurrenceid): 177 update_freebusy(freebusy, attendee, periods, self.obj.get_value("TRANSP"), 178 self.uid, recurrenceid, self.store) 179 180 def update_freebusy(self, freebusy, attendee, periods): 181 self._update_freebusy(freebusy, attendee, periods, self.recurrenceid) 182 183 def update_freebusy_from_participant(self, user, participant_item): 184 185 """ 186 For the given 'user', record the free/busy information for the 187 'participant_item' (a value plus attributes), using the 'tzid' to define 188 period information. 189 """ 190 191 participant, participant_attr = participant_item 192 193 if participant != user: 194 freebusy = self.store.get_freebusy_for_other(user, participant) 195 196 window_end = get_window_end(tzid=None) 197 198 if participant_attr.get("PARTSTAT") != "DECLINED": 199 update_freebusy_for_other(freebusy, user, participant, 200 self.obj.get_periods_for_freebusy(tzid=None, end=window_end), 201 self.obj.get_value("TRANSP"), 202 self.uid, self.recurrenceid, self.store) 203 else: 204 self.remove_from_freebusy_for_other(freebusy, user, participant) 205 206 def update_freebusy_from_organiser(self, attendee, organiser_item): 207 208 """ 209 For the 'attendee', record free/busy information from the 210 'organiser_item' (a value plus attributes). 211 """ 212 213 self.update_freebusy_from_participant(attendee, organiser_item) 214 215 def update_freebusy_from_attendees(self, organiser, attendees): 216 217 "For the 'organiser', record free/busy information from 'attendees'." 218 219 for attendee_item in attendees.items(): 220 self.update_freebusy_from_participant(organiser, attendee_item) 221 222 def can_schedule(self, freebusy, periods): 223 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 224 225 def filter_by_senders(self, mapping): 226 227 """ 228 Return a list of items from 'mapping' filtered using sender information. 229 """ 230 231 if self.senders: 232 233 # Get a mapping from senders to identities. 234 235 identities = self.get_sender_identities(mapping) 236 237 # Find the senders that are valid. 238 239 senders = map(get_address, identities) 240 valid = self.senders.intersection(senders) 241 242 # Return the true identities. 243 244 return [identities[get_uri(address)] for address in valid] 245 else: 246 return mapping 247 248 def filter_by_recipient(self, mapping): 249 250 """ 251 Return a list of items from 'mapping' filtered using recipient 252 information. 253 """ 254 255 if self.recipient: 256 addresses = set(map(get_address, mapping)) 257 return map(get_uri, addresses.intersection([self.recipient])) 258 else: 259 return mapping 260 261 def require_organiser(self, from_organiser=True): 262 263 """ 264 Return the organiser for the current object, filtered for the sender or 265 recipient of interest. Return None if no identities are eligible. 266 267 The organiser identity is normalized. 268 """ 269 270 organiser_item = uri_item(self.obj.get_item("ORGANIZER")) 271 272 # Only provide details for an organiser who sent/receives the message. 273 274 organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient 275 276 if not organiser_filter_fn(dict([organiser_item])): 277 return None 278 279 return organiser_item 280 281 def require_attendees(self, from_organiser=True): 282 283 """ 284 Return the attendees for the current object, filtered for the sender or 285 recipient of interest. Return None if no identities are eligible. 286 287 The attendee identities are normalized. 288 """ 289 290 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 291 292 # Only provide details for attendees who sent/receive the message. 293 294 attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders 295 296 attendees = {} 297 for attendee in attendee_filter_fn(attendee_map): 298 attendees[attendee] = attendee_map[attendee] 299 300 return attendees 301 302 def require_organiser_and_attendees(self, from_organiser=True): 303 304 """ 305 Return the organiser and attendees for the current object, filtered for 306 the recipient of interest. Return None if no identities are eligible. 307 308 Organiser and attendee identities are normalized. 309 """ 310 311 organiser_item = self.require_organiser(from_organiser) 312 attendees = self.require_attendees(from_organiser) 313 314 if not attendees or not organiser_item: 315 return None 316 317 return organiser_item, attendees 318 319 def get_sender_identities(self, mapping): 320 321 """ 322 Return a mapping from actual senders to the identities for which they 323 have provided data, extracting this information from the given 324 'mapping'. 325 """ 326 327 senders = {} 328 329 for value, attr in mapping.items(): 330 sent_by = attr.get("SENT-BY") 331 if sent_by: 332 senders[get_uri(sent_by)] = value 333 else: 334 senders[value] = value 335 336 return senders 337 338 def _get_object(self, user, uid, recurrenceid): 339 340 """ 341 Return the stored object for the given 'user', 'uid' and 'recurrenceid'. 342 """ 343 344 fragment = self.store.get_event(user, uid, recurrenceid) 345 return fragment and Object(fragment) 346 347 def get_object(self, user): 348 349 """ 350 Return the stored object to which the current object refers for the 351 given 'user'. 352 """ 353 354 return self._get_object(user, self.uid, self.recurrenceid) 355 356 def get_parent_object(self, user): 357 358 """ 359 Return the parent object to which the current object refers for the 360 given 'user'. 361 """ 362 363 return self._get_object(user, self.uid, None) 364 365 def have_new_object(self, attendee, obj=None): 366 367 """ 368 Return whether the current object is new to the 'attendee' (or if the 369 given 'obj' is new). 370 """ 371 372 obj = obj or self.get_object(attendee) 373 374 # If found, compare SEQUENCE and potentially DTSTAMP. 375 376 if obj: 377 sequence = obj.get_value("SEQUENCE") 378 dtstamp = obj.get_value("DTSTAMP") 379 380 # If the request refers to an older version of the object, ignore 381 # it. 382 383 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 384 self.is_partstat_updated(obj)) 385 386 return True 387 388 def is_partstat_updated(self, obj): 389 390 """ 391 Return whether the participant status has been updated in the current 392 object in comparison to the given 'obj'. 393 394 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 395 NOTE: and make it invalid. Thus, such attendance information may also be 396 NOTE: incorporated into any new object assessment. 397 """ 398 399 old_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 400 new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE")) 401 402 for attendee, attr in old_attendees.items(): 403 old_partstat = attr.get("PARTSTAT") 404 new_attr = new_attendees.get(attendee) 405 new_partstat = new_attr and new_attr.get("PARTSTAT") 406 407 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 408 new_partstat != old_partstat: 409 410 return True 411 412 return False 413 414 def merge_attendance(self, attendees, identity): 415 416 """ 417 Merge attendance from the current object's 'attendees' into the version 418 stored for the given 'identity'. 419 """ 420 421 obj = self.get_object(identity) 422 423 if not obj or not self.have_new_object(identity, obj=obj): 424 return False 425 426 # Get attendee details in a usable form. 427 428 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 429 430 for attendee, attendee_attr in attendees.items(): 431 432 # Update attendance in the loaded object. 433 434 attendee_map[attendee] = attendee_attr 435 436 # Set the new details and store the object. 437 438 obj["ATTENDEE"] = attendee_map.items() 439 440 # Set the complete event if not an additional occurrence. 441 442 event = obj.to_node() 443 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 444 445 self.store.set_event(identity, self.uid, self.recurrenceid, event) 446 447 return True 448 449 def update_dtstamp(self): 450 451 "Update the DTSTAMP in the current object." 452 453 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 454 utcnow = to_timezone(datetime.utcnow(), "UTC") 455 self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 456 457 def set_sequence(self, increment=False): 458 459 "Update the SEQUENCE in the current object." 460 461 sequence = self.obj.get_value("SEQUENCE") or "0" 462 self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 463 464 # Handler registry. 465 466 methods = { 467 "ADD" : lambda handler: handler.add, 468 "CANCEL" : lambda handler: handler.cancel, 469 "COUNTER" : lambda handler: handler.counter, 470 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 471 "PUBLISH" : lambda handler: handler.publish, 472 "REFRESH" : lambda handler: handler.refresh, 473 "REPLY" : lambda handler: handler.reply, 474 "REQUEST" : lambda handler: handler.request, 475 } 476 477 # vim: tabstop=4 expandtab shiftwidth=4