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