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