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