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, \ 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.sequence = None 119 self.dtstamp = None 120 121 self.store = imip_store.FileStore() 122 123 try: 124 self.publisher = imip_store.FilePublisher() 125 except OSError: 126 self.publisher = None 127 128 def set_object(self, obj): 129 self.obj = obj 130 self.uid = self.obj.get_value("UID") 131 self.sequence = self.obj.get_value("SEQUENCE") 132 self.dtstamp = self.obj.get_value("DTSTAMP") 133 134 def wrap(self, text, link=True): 135 136 "Wrap any valid message for passing to the recipient." 137 138 texts = [] 139 texts.append(text) 140 if link: 141 texts.append("If your mail program cannot handle this " 142 "message, you may view the details here:\n\n%s" % 143 get_object_url(self.uid)) 144 145 return self.add_result(None, None, MIMEText("\n".join(texts))) 146 147 # Result registration. 148 149 def add_result(self, method, outgoing_recipients, part): 150 151 """ 152 Record a result having the given 'method', 'outgoing_recipients' and 153 message part. 154 """ 155 156 if outgoing_recipients: 157 self.outgoing_methods.add(method) 158 self.results.append((outgoing_recipients, part)) 159 160 def get_results(self): 161 return self.results 162 163 def get_outgoing_methods(self): 164 return self.outgoing_methods 165 166 # Access to calendar structures and other data. 167 168 def remove_from_freebusy(self, freebusy, attendee): 169 remove_from_freebusy(freebusy, attendee, self.uid, self.store) 170 171 def remove_from_freebusy_for_other(self, freebusy, user, other): 172 remove_from_freebusy_for_other(freebusy, user, other, self.uid, self.store) 173 174 def update_freebusy(self, freebusy, attendee, periods): 175 update_freebusy(freebusy, attendee, periods, self.obj.get_value("TRANSP"), 176 self.uid, self.store) 177 178 def update_freebusy_from_participant(self, user, participant_item): 179 180 """ 181 For the given 'user', record the free/busy information for the 182 'participant_item' (a value plus attributes), using the 'tzid' to define 183 period information. 184 """ 185 186 participant, participant_attr = participant_item 187 188 if participant != user: 189 freebusy = self.store.get_freebusy_for_other(user, participant) 190 191 if participant_attr.get("PARTSTAT") != "DECLINED": 192 update_freebusy_for_other(freebusy, user, participant, 193 self.obj.get_periods_for_freebusy(tzid=None), 194 self.obj.get_value("TRANSP"), 195 self.uid, self.store) 196 else: 197 self.remove_from_freebusy_for_other(freebusy, user, participant) 198 199 def update_freebusy_from_organiser(self, attendee, organiser_item): 200 201 """ 202 For the 'attendee', record free/busy information from the 203 'organiser_item' (a value plus attributes). 204 """ 205 206 self.update_freebusy_from_participant(attendee, organiser_item) 207 208 def update_freebusy_from_attendees(self, organiser, attendees): 209 210 "For the 'organiser', record free/busy information from 'attendees'." 211 212 for attendee_item in attendees.items(): 213 self.update_freebusy_from_participant(organiser, attendee_item) 214 215 def can_schedule(self, freebusy, periods): 216 return can_schedule(freebusy, periods, self.uid) 217 218 def filter_by_senders(self, mapping): 219 220 """ 221 Return a list of items from 'mapping' filtered using sender information. 222 """ 223 224 if self.senders: 225 226 # Get a mapping from senders to identities. 227 228 identities = self.get_sender_identities(mapping) 229 230 # Find the senders that are valid. 231 232 senders = map(get_address, identities) 233 valid = self.senders.intersection(senders) 234 235 # Return the true identities. 236 237 return [identities[get_uri(address)] for address in valid] 238 else: 239 return mapping 240 241 def filter_by_recipient(self, mapping): 242 243 """ 244 Return a list of items from 'mapping' filtered using recipient 245 information. 246 """ 247 248 if self.recipient: 249 addresses = set(map(get_address, mapping)) 250 return map(get_uri, addresses.intersection([self.recipient])) 251 else: 252 return mapping 253 254 def require_organiser(self, from_organiser=True): 255 256 """ 257 Return the organiser for the current object, filtered for the sender or 258 recipient of interest. Return None if no identities are eligible. 259 260 The organiser identity is normalized. 261 """ 262 263 organiser_item = uri_item(self.obj.get_item("ORGANIZER")) 264 265 # Only provide details for an organiser who sent/receives the message. 266 267 organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient 268 269 if not organiser_filter_fn(dict([organiser_item])): 270 return None 271 272 return organiser_item 273 274 def require_attendees(self, from_organiser=True): 275 276 """ 277 Return the attendees for the current object, filtered for the sender or 278 recipient of interest. Return None if no identities are eligible. 279 280 The attendee identities are normalized. 281 """ 282 283 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 284 285 # Only provide details for attendees who sent/receive the message. 286 287 attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders 288 289 attendees = {} 290 for attendee in attendee_filter_fn(attendee_map): 291 attendees[attendee] = attendee_map[attendee] 292 293 return attendees 294 295 def require_organiser_and_attendees(self, from_organiser=True): 296 297 """ 298 Return the organiser and attendees for the current object, filtered for 299 the recipient of interest. Return None if no identities are eligible. 300 301 Organiser and attendee identities are normalized. 302 """ 303 304 organiser_item = self.require_organiser(from_organiser) 305 attendees = self.require_attendees(from_organiser) 306 307 if not attendees or not organiser_item: 308 return None 309 310 return organiser_item, attendees 311 312 def get_sender_identities(self, mapping): 313 314 """ 315 Return a mapping from actual senders to the identities for which they 316 have provided data, extracting this information from the given 317 'mapping'. 318 """ 319 320 senders = {} 321 322 for value, attr in mapping.items(): 323 sent_by = attr.get("SENT-BY") 324 if sent_by: 325 senders[get_uri(sent_by)] = value 326 else: 327 senders[value] = value 328 329 return senders 330 331 def get_object(self, user): 332 333 """ 334 Return the stored object to which the current object refers for the 335 given 'user' and for the given 'objtype'. 336 """ 337 338 fragment = self.store.get_event(user, self.uid) 339 return fragment and Object(fragment) 340 341 def have_new_object(self, attendee, obj=None): 342 343 """ 344 Return whether the current object is new to the 'attendee' (or if the 345 given 'obj' is new). 346 """ 347 348 obj = obj or self.get_object(attendee) 349 350 # If found, compare SEQUENCE and potentially DTSTAMP. 351 352 if obj: 353 sequence = obj.get_value("SEQUENCE") 354 dtstamp = obj.get_value("DTSTAMP") 355 356 # If the request refers to an older version of the object, ignore 357 # it. 358 359 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 360 self.is_partstat_updated(obj)) 361 362 return True 363 364 def is_partstat_updated(self, obj): 365 366 """ 367 Return whether the participant status has been updated in the current 368 object in comparison to the given 'obj'. 369 370 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 371 NOTE: and make it invalid. Thus, such attendance information may also be 372 NOTE: incorporated into any new object assessment. 373 """ 374 375 old_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 376 new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE")) 377 378 for attendee, attr in old_attendees.items(): 379 old_partstat = attr.get("PARTSTAT") 380 new_attr = new_attendees.get(attendee) 381 new_partstat = new_attr and new_attr.get("PARTSTAT") 382 383 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 384 new_partstat != old_partstat: 385 386 return True 387 388 return False 389 390 def merge_attendance(self, attendees, identity): 391 392 """ 393 Merge attendance from the current object's 'attendees' into the version 394 stored for the given 'identity'. 395 """ 396 397 obj = self.get_object(identity) 398 399 if not obj or not self.have_new_object(identity, obj=obj): 400 return False 401 402 # Get attendee details in a usable form. 403 404 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 405 406 for attendee, attendee_attr in attendees.items(): 407 408 # Update attendance in the loaded object. 409 410 attendee_map[attendee] = attendee_attr 411 412 # Set the new details and store the object. 413 414 obj["ATTENDEE"] = attendee_map.items() 415 416 self.store.set_event(identity, self.uid, obj.to_node()) 417 418 return True 419 420 def update_dtstamp(self): 421 422 "Update the DTSTAMP in the current object." 423 424 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 425 utcnow = to_timezone(datetime.utcnow(), "UTC") 426 self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 427 428 def set_sequence(self, increment=False): 429 430 "Update the SEQUENCE in the current object." 431 432 sequence = self.obj.get_value("SEQUENCE") or "0" 433 self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 434 435 # Handler registry. 436 437 methods = { 438 "ADD" : lambda handler: handler.add, 439 "CANCEL" : lambda handler: handler.cancel, 440 "COUNTER" : lambda handler: handler.counter, 441 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 442 "PUBLISH" : lambda handler: handler.publish, 443 "REFRESH" : lambda handler: handler.refresh, 444 "REPLY" : lambda handler: handler.reply, 445 "REQUEST" : lambda handler: handler.request, 446 } 447 448 # vim: tabstop=4 expandtab shiftwidth=4