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'. 140 141 Return a list of responses, each response being a tuple of the form 142 (outgoing-recipients, message-part). 143 """ 144 145 handlers = dict(handlers) 146 method = part.get_param("method") 147 148 # Decode the data and parse it. 149 150 f = StringIO(part.get_payload(decode=True)) 151 152 itip = parse_object(f, part.get_content_charset(), "VCALENDAR") 153 154 # Ignore the part if not a calendar object. 155 156 if not itip: 157 return [] 158 159 # Require consistency between declared and employed methods. 160 161 if get_value(itip, "METHOD") == method: 162 163 # Look for different kinds of sections. 164 165 all_results = [] 166 167 for name, items in itip.items(): 168 169 # Get a handler for the given section. 170 171 handler = handlers.get(name) 172 if not handler: 173 continue 174 175 for item in items: 176 177 # Dispatch to a handler and obtain any response. 178 179 handler.set_object(Object({name : item})) 180 results = methods[method](handler)() 181 182 # Aggregate responses for a single message. 183 184 if results: 185 for result in results: 186 outgoing_recipients, part = result 187 all_results.append((outgoing_recipients, part)) 188 189 return all_results 190 191 return [] 192 193 # References to the Web interface. 194 195 def get_manager_url(): 196 url_base = MANAGER_URL or "http://%s/" % gethostname() 197 return "%s/%s" % (url_base.rstrip("/"), MANAGER_PATH.lstrip("/")) 198 199 def get_object_url(uid): 200 return "%s/%s" % (get_manager_url().rstrip("/"), uid) 201 202 class Handler: 203 204 "General handler support." 205 206 def __init__(self, senders=None, recipient=None, messenger=None): 207 208 """ 209 Initialise the handler with the calendar 'obj' and the 'senders' and 210 'recipient' of the object (if specifically indicated). 211 """ 212 213 self.senders = senders and set(map(get_address, senders)) 214 self.recipient = recipient and get_address(recipient) 215 self.messenger = messenger 216 217 self.obj = None 218 self.uid = None 219 self.sequence = None 220 self.dtstamp = None 221 222 self.store = imip_store.FileStore() 223 224 try: 225 self.publisher = imip_store.FilePublisher() 226 except OSError: 227 self.publisher = None 228 229 def set_object(self, obj): 230 self.obj = obj 231 self.uid = self.obj.get_value("UID") 232 self.sequence = self.obj.get_value("SEQUENCE") 233 self.dtstamp = self.obj.get_value("DTSTAMP") 234 235 def wrap(self, text, link=True): 236 237 "Wrap any valid message for passing to the recipient." 238 239 texts = [] 240 texts.append(text) 241 if link: 242 texts.append("If your mail program cannot handle this " 243 "message, you may view the details here:\n\n%s" % 244 get_object_url(self.uid)) 245 246 return [(None, MIMEText("\n".join(texts)))] 247 248 # Access to calendar structures and other data. 249 250 def get_periods(self): 251 return get_periods(self.obj) 252 253 def remove_from_freebusy(self, freebusy, attendee): 254 remove_from_freebusy(freebusy, attendee, self.uid, self.store) 255 256 def remove_from_freebusy_for_other(self, freebusy, user, other): 257 remove_from_freebusy_for_other(freebusy, user, other, self.uid, self.store) 258 259 def update_freebusy(self, freebusy, attendee, periods): 260 return update_freebusy(freebusy, attendee, periods, self.obj.get_value("TRANSP"), self.uid, self.store) 261 262 def update_freebusy_for_other(self, freebusy, user, other, periods): 263 return update_freebusy_for_other(freebusy, user, other, periods, self.obj.get_value("TRANSP"), self.uid, self.store) 264 265 def can_schedule(self, freebusy, periods): 266 return can_schedule(freebusy, periods, self.uid) 267 268 def filter_by_senders(self, mapping): 269 270 """ 271 Return a list of items from 'mapping' filtered using sender information. 272 """ 273 274 if self.senders: 275 276 # Get a mapping from senders to identities. 277 278 identities = self.get_sender_identities(mapping) 279 280 # Find the senders that are valid. 281 282 senders = map(get_address, identities) 283 valid = self.senders.intersection(senders) 284 285 # Return the true identities. 286 287 return [identities[get_uri(address)] for address in valid] 288 else: 289 return mapping 290 291 def filter_by_recipient(self, mapping): 292 293 """ 294 Return a list of items from 'mapping' filtered using recipient 295 information. 296 """ 297 298 if self.recipient: 299 addresses = set(map(get_address, mapping)) 300 return map(get_uri, addresses.intersection([self.recipient])) 301 else: 302 return mapping 303 304 def require_organiser_and_attendees(self, from_organiser=True): 305 306 """ 307 Return the organiser and attendees for the current object, filtered for 308 the recipient of interest. Return None if no identities are eligible. 309 310 Organiser and attendee identities are provided as lower case values. 311 """ 312 313 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 314 organiser_item = uri_item(self.obj.get_item("ORGANIZER")) 315 316 # Only provide details for attendees who sent/receive the message. 317 318 attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders 319 320 attendees = {} 321 for attendee in attendee_filter_fn(attendee_map): 322 attendees[attendee] = attendee_map[attendee] 323 324 if not attendees or not organiser_item: 325 return None 326 327 # Only provide details for an organiser who sent/receives the message. 328 329 organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient 330 331 if not organiser_filter_fn(dict([organiser_item])): 332 return None 333 334 return organiser_item, attendees 335 336 def get_sender_identities(self, mapping): 337 338 """ 339 Return a mapping from actual senders to the identities for which they 340 have provided data, extracting this information from the given 341 'mapping'. 342 """ 343 344 senders = {} 345 346 for value, attr in mapping.items(): 347 sent_by = attr.get("SENT-BY") 348 if sent_by: 349 senders[get_uri(sent_by)] = value 350 else: 351 senders[value] = value 352 353 return senders 354 355 def get_object(self, user): 356 357 """ 358 Return the stored object to which the current object refers for the 359 given 'user' and for the given 'objtype'. 360 """ 361 362 f = self.store.get_event(user, self.uid) 363 fragment = f and parse_object(f, "utf-8") 364 return fragment and Object(fragment) 365 366 def have_new_object(self, attendee, obj=None): 367 368 """ 369 Return whether the current object is new to the 'attendee' (or if the 370 given 'obj' is new). 371 """ 372 373 obj = obj or self.get_object(attendee) 374 375 # If found, compare SEQUENCE and potentially DTSTAMP. 376 377 if obj: 378 sequence = obj.get_value("SEQUENCE") 379 dtstamp = obj.get_value("DTSTAMP") 380 381 # If the request refers to an older version of the object, ignore 382 # it. 383 384 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 385 self.is_partstat_updated(obj)) 386 387 return True 388 389 def is_partstat_updated(self, obj): 390 391 """ 392 Return whether the participant status has been updated in the current 393 object in comparison to the given 'obj'. 394 395 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 396 NOTE: and make it invalid. Thus, such attendance information may also be 397 NOTE: incorporated into any new object assessment. 398 """ 399 400 old_attendees = obj.get_value_map("ATTENDEE") 401 new_attendees = self.obj.get_value_map("ATTENDEE") 402 403 for attendee, attr in old_attendees.items(): 404 old_partstat = attr.get("PARTSTAT") 405 new_attr = new_attendees.get(attendee) 406 new_partstat = new_attr and new_attr.get("PARTSTAT") 407 408 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 409 new_partstat != old_partstat: 410 411 return True 412 413 return False 414 415 def update_dtstamp(self): 416 417 "Update the DTSTAMP in the current object." 418 419 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 420 utcnow = to_timezone(datetime.utcnow(), "UTC") 421 self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 422 423 # Handler registry. 424 425 methods = { 426 "ADD" : lambda handler: handler.add, 427 "CANCEL" : lambda handler: handler.cancel, 428 "COUNTER" : lambda handler: handler.counter, 429 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 430 "PUBLISH" : lambda handler: handler.publish, 431 "REFRESH" : lambda handler: handler.refresh, 432 "REPLY" : lambda handler: handler.reply, 433 "REQUEST" : lambda handler: handler.request, 434 } 435 436 # vim: tabstop=4 expandtab shiftwidth=4