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