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