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