1 #!/usr/bin/env python 2 3 """ 4 A processing framework for iMIP content. 5 6 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from email import message_from_file 23 from imiptools.content import handle_itip_part 24 from imiptools.data import get_addresses, get_uri, make_freebusy, to_part 25 from imiptools.dates import get_timestamp 26 from imiptools.mail import Messenger 27 from imiptools.profile import Preferences 28 import imip_store 29 import sys 30 31 # Postfix exit codes. 32 33 EX_TEMPFAIL = 75 34 35 # Permitted iTIP content types. 36 37 itip_content_types = [ 38 "text/calendar", # from RFC 6047 39 "text/x-vcalendar", "application/ics", # other possibilities 40 ] 41 42 # Processing of incoming messages. 43 44 def get_all_values(msg, key): 45 l = [] 46 for v in msg.get_all(key) or []: 47 l += [s.strip() for s in v.split(",")] 48 return l 49 50 class Processor: 51 52 "The processing framework." 53 54 def __init__(self, handlers, messenger=None): 55 self.handlers = handlers 56 self.messenger = messenger or Messenger() 57 self.lmtp_socket = None 58 self.store_dir = None 59 self.publishing_dir = None 60 self.debug = False 61 62 def process(self, f, original_recipients, outgoing_only): 63 64 """ 65 Process content from the stream 'f' accompanied by the given 66 'original_recipients'. 67 """ 68 69 msg = message_from_file(f) 70 senders = get_addresses(msg.get_all("Reply-To") or msg.get_all("From") or []) 71 72 # Handle messages with iTIP parts. 73 # Typically, the details of recipients are of interest in handling 74 # messages. 75 76 if not outgoing_only: 77 original_recipients = original_recipients or get_addresses(get_all_values(msg, "To") or []) 78 for recipient in original_recipients: 79 self.process_for_recipient(msg, recipient, senders, outgoing_only) 80 81 # However, outgoing messages do not usually presume anything about the 82 # eventual recipients. 83 84 else: 85 self.process_for_recipient(msg, None, senders, outgoing_only) 86 87 def process_for_recipient(self, msg, recipient, senders, outgoing_only): 88 89 """ 90 Process the given 'msg' for a single 'recipient', having the given 91 'senders', and with the given 'outgoing_only' status. 92 93 Processing individually means that contributions to resulting messages 94 may be constructed according to individual preferences. 95 """ 96 97 store = self.store_dir and imip_store.FileStore(self.store_dir) or None 98 publisher = self.publishing_dir and imip_store.FilePublisher(self.publishing_dir) or None 99 100 handlers = dict([(name, cls(senders, recipient, self.messenger, store, publisher)) 101 for name, cls in self.handlers]) 102 handled = False 103 104 for part in msg.walk(): 105 if part.get_content_type() in itip_content_types and \ 106 part.get_param("method"): 107 108 handle_itip_part(part, handlers) 109 handled = True 110 111 # When processing outgoing messages, no replies or deliveries are 112 # performed. 113 114 if outgoing_only: 115 return 116 117 # Get responses from the handlers. 118 119 all_responses = [] 120 for handler in handlers.values(): 121 all_responses += handler.get_results() 122 123 # Pack any returned parts into messages. 124 125 if all_responses: 126 outgoing_parts = {} 127 forwarded_parts = [] 128 129 for outgoing_recipients, part in all_responses: 130 if outgoing_recipients: 131 for outgoing_recipient in outgoing_recipients: 132 if not outgoing_parts.has_key(outgoing_recipient): 133 outgoing_parts[outgoing_recipient] = [] 134 outgoing_parts[outgoing_recipient].append(part) 135 else: 136 forwarded_parts.append(part) 137 138 # Reply using any outgoing parts in a new message. 139 140 if outgoing_parts: 141 142 # Obtain free/busy details, if configured to do so. 143 144 fb = self.can_provide_freebusy(handlers) and self.get_freebusy_for_recipient(recipient) 145 146 for outgoing_recipient, parts in outgoing_parts.items(): 147 148 # Bundle free/busy messages, if configured to do so. 149 150 if fb: parts.append(fb) 151 message = self.messenger.make_outgoing_message(parts, [outgoing_recipient]) 152 153 if self.debug: 154 print >>sys.stderr, "Outgoing parts for %s..." % outgoing_recipient 155 print message 156 else: 157 self.messenger.sendmail([outgoing_recipient], message.as_string()) 158 159 # Forward messages to their recipients either wrapping the existing 160 # message, accompanying it or replacing it. 161 162 if forwarded_parts: 163 164 # Determine whether to wrap, accompany or replace the message. 165 166 preferences = Preferences(get_uri(recipient)) 167 168 incoming = preferences.get("incoming") 169 170 if incoming == "message-only": 171 messages = [msg] 172 else: 173 summary = self.messenger.make_summary_message(msg, forwarded_parts) 174 if incoming == "summary-then-message": 175 messages = [summary, msg] 176 elif incoming == "message-then-summary": 177 messages = [msg, summary] 178 elif incoming == "summary-only": 179 messages = [summary] 180 else: # incoming == "summary-wraps-message": 181 messages = [self.messenger.wrap_message(msg, forwarded_parts)] 182 183 for message in messages: 184 if self.debug: 185 print >>sys.stderr, "Forwarded parts..." 186 print message 187 elif self.lmtp_socket: 188 self.messenger.sendmail(recipient, message.as_string(), lmtp_socket=self.lmtp_socket) 189 190 # Unhandled messages are delivered as they are. 191 192 if not handled: 193 if self.debug: 194 print >>sys.stderr, "Unhandled parts..." 195 print msg 196 elif self.lmtp_socket: 197 self.messenger.sendmail(recipient, msg.as_string(), lmtp_socket=self.lmtp_socket) 198 199 def can_provide_freebusy(self, handlers): 200 201 "Test for any free/busy information produced by 'handlers'." 202 203 fbhandler = handlers.get("VFREEBUSY") 204 if fbhandler: 205 fbmethods = fbhandler.get_outgoing_methods() 206 return not "REPLY" in fbmethods and not "PUBLISH" in fbmethods 207 else: 208 return False 209 210 def get_freebusy_for_recipient(self, recipient): 211 212 """ 213 Return a list of responses containing free/busy information for the 214 given 'recipient'. 215 """ 216 217 organiser = get_uri(recipient) 218 preferences = Preferences(organiser) 219 220 organiser_attr = self.messenger and {"SENT-BY" : get_uri(self.messenger.sender)} or {} 221 222 if preferences.get("freebusy_sharing") == "share" and \ 223 preferences.get("freebusy_bundling") == "always": 224 225 # Invent a unique identifier. 226 227 utcnow = get_timestamp() 228 uid = "imip-agent-%s-%s" % (utcnow, recipient) 229 230 freebusy = imip_store.FileStore().get_freebusy(organiser) 231 return to_part("PUBLISH", [make_freebusy(freebusy, uid, organiser, organiser_attr)]) 232 233 return None 234 235 def process_args(self, args, stream): 236 237 """ 238 Interpret the given program arguments 'args' and process input from the 239 given 'stream'. 240 """ 241 242 # Obtain the different kinds of recipients plus sender address. 243 244 original_recipients = [] 245 recipients = [] 246 senders = [] 247 lmtp = [] 248 store_dir = [] 249 publishing_dir = [] 250 outgoing_only = False 251 252 l = [] 253 254 for arg in args: 255 256 # Detect outgoing processing mode. 257 258 if arg == "-O": 259 outgoing_only = True 260 261 # Switch to collecting recipients. 262 263 if arg == "-o": 264 l = original_recipients 265 266 # Switch to collecting senders. 267 268 elif arg == "-s": 269 l = senders 270 271 # Switch to getting the LMTP socket. 272 273 elif arg == "-l": 274 l = lmtp 275 276 # Switch to getting the store directory. 277 278 elif arg == "-S": 279 l = store_dir 280 281 # Switch to getting the publishing directory. 282 283 elif arg == "-P": 284 l = publishing_dir 285 286 # Ignore debugging options. 287 288 elif arg == "-d": 289 self.debug = True 290 else: 291 l.append(arg) 292 293 self.messenger.sender = senders and senders[0] or self.messenger.sender 294 self.lmtp_socket = lmtp and lmtp[0] or None 295 self.store_dir = store_dir and store_dir[0] or None 296 self.publishing_dir = publishing_dir and publishing_dir[0] or None 297 self.process(stream, original_recipients, outgoing_only) 298 299 def __call__(self): 300 301 """ 302 Obtain arguments from the command line to initialise the processor 303 before invoking it. 304 """ 305 306 args = sys.argv[1:] 307 308 if "-d" in args: 309 self.process_args(args, sys.stdin) 310 else: 311 try: 312 self.process_args(args, sys.stdin) 313 except SystemExit, value: 314 sys.exit(value) 315 except Exception, exc: 316 if "-v" in args: 317 raise 318 type, value, tb = sys.exc_info() 319 while tb.tb_next: 320 tb = tb.tb_next 321 f = tb.tb_frame 322 co = f and f.f_code 323 filename = co and co.co_filename 324 print >>sys.stderr, "Exception %s at %d in %s" % (exc, tb.tb_lineno, filename) 325 #import traceback 326 #traceback.print_exc(file=open("/tmp/mail.log", "a")) 327 sys.exit(EX_TEMPFAIL) 328 sys.exit(0) 329 330 # vim: tabstop=4 expandtab shiftwidth=4