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