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.client import Client 24 from imiptools.content import handle_itip_part 25 from imiptools.data import get_address, get_addresses, get_uri 26 from imiptools.mail import Messenger 27 import imip_store 28 import sys 29 30 # Postfix exit codes. 31 32 EX_TEMPFAIL = 75 33 34 # Permitted iTIP content types. 35 36 itip_content_types = [ 37 "text/calendar", # from RFC 6047 38 "text/x-vcalendar", "application/ics", # other possibilities 39 ] 40 41 # Processing of incoming messages. 42 43 def get_all_values(msg, key): 44 l = [] 45 for v in msg.get_all(key) or []: 46 l += [s.strip() for s in v.split(",")] 47 return l 48 49 class Processor: 50 51 "The processing framework." 52 53 def __init__(self, handlers): 54 self.handlers = handlers 55 self.messenger = None 56 self.lmtp_socket = None 57 self.store_dir = None 58 self.publishing_dir = None 59 self.preferences_dir = None 60 self.debug = False 61 62 def get_store(self): 63 return imip_store.FileStore(self.store_dir) 64 65 def get_publisher(self): 66 return self.publishing_dir and imip_store.FilePublisher(self.publishing_dir) or None 67 68 def process(self, f, original_recipients, outgoing_only): 69 70 """ 71 Process content from the stream 'f' accompanied by the given 72 'original_recipients'. 73 """ 74 75 msg = message_from_file(f) 76 senders = get_addresses(get_all_values(msg, "Reply-To") or get_all_values(msg, "From") or []) 77 78 messenger = self.messenger 79 store = self.get_store() 80 publisher = self.get_publisher() 81 preferences_dir = self.preferences_dir 82 83 # Handle messages with iTIP parts. 84 # Typically, the details of recipients are of interest in handling 85 # messages. 86 87 if not outgoing_only: 88 original_recipients = original_recipients or get_addresses(get_all_values(msg, "To") or []) 89 for recipient in original_recipients: 90 Recipient(get_uri(recipient), messenger, store, publisher, preferences_dir, self.handlers, self.debug).process(msg, senders, outgoing_only) 91 92 # However, outgoing messages do not usually presume anything about the 93 # eventual recipients. 94 95 else: 96 Recipient(None, messenger, store, publisher, preferences_dir, self.handlers, self.debug).process(msg, senders, outgoing_only) 97 98 def process_args(self, args, stream): 99 100 """ 101 Interpret the given program arguments 'args' and process input from the 102 given 'stream'. 103 """ 104 105 # Obtain the different kinds of recipients plus sender address. 106 107 original_recipients = [] 108 recipients = [] 109 senders = [] 110 lmtp = [] 111 store_dir = [] 112 publishing_dir = [] 113 preferences_dir = [] 114 local_smtp = False 115 outgoing_only = False 116 117 l = [] 118 119 for arg in args: 120 121 # Detect outgoing processing mode. 122 123 if arg == "-O": 124 outgoing_only = True 125 126 # Switch to collecting recipients. 127 128 elif arg == "-o": 129 l = original_recipients 130 131 # Switch to collecting senders. 132 133 elif arg == "-s": 134 l = senders 135 136 # Switch to getting the LMTP socket. 137 138 elif arg == "-l": 139 l = lmtp 140 141 # Detect sending to local users via SMTP. 142 143 elif arg == "-L": 144 local_smtp = True 145 146 # Switch to getting the store directory. 147 148 elif arg == "-S": 149 l = store_dir 150 151 # Switch to getting the publishing directory. 152 153 elif arg == "-P": 154 l = publishing_dir 155 156 # Switch to getting the preferences directory. 157 158 elif arg == "-p": 159 l = preferences_dir 160 161 # Ignore debugging options. 162 163 elif arg == "-d": 164 self.debug = True 165 else: 166 l.append(arg) 167 168 self.messenger = Messenger(lmtp_socket=lmtp and lmtp[0] or None, local_smtp=local_smtp, sender=senders and senders[0] or None) 169 self.store_dir = store_dir and store_dir[0] or None 170 self.publishing_dir = publishing_dir and publishing_dir[0] or None 171 self.preferences_dir = preferences_dir and preferences_dir[0] or None 172 self.process(stream, original_recipients, outgoing_only) 173 174 def __call__(self): 175 176 """ 177 Obtain arguments from the command line to initialise the processor 178 before invoking it. 179 """ 180 181 args = sys.argv[1:] 182 183 if "-d" in args: 184 self.process_args(args, sys.stdin) 185 else: 186 try: 187 self.process_args(args, sys.stdin) 188 except SystemExit, value: 189 sys.exit(value) 190 except Exception, exc: 191 if "-v" in args: 192 raise 193 type, value, tb = sys.exc_info() 194 while tb.tb_next: 195 tb = tb.tb_next 196 f = tb.tb_frame 197 co = f and f.f_code 198 filename = co and co.co_filename 199 print >>sys.stderr, "Exception %s at %d in %s" % (exc, tb.tb_lineno, filename) 200 #import traceback 201 #traceback.print_exc(file=open("/tmp/mail.log", "a")) 202 sys.exit(EX_TEMPFAIL) 203 sys.exit(0) 204 205 class Recipient(Client): 206 207 "A processor acting as a client on behalf of a recipient." 208 209 def __init__(self, user, messenger, store, publisher, preferences_dir, handlers, debug): 210 211 """ 212 Initialise the recipient with the given 'user' identity, 'messenger', 213 'store', 'publisher', 'preferences_dir', 'handlers' and 'debug' status. 214 """ 215 216 Client.__init__(self, user, messenger, store, publisher, preferences_dir) 217 self.handlers = handlers 218 self.debug = debug 219 220 def process(self, msg, senders, outgoing_only): 221 222 """ 223 Process the given 'msg' for a single recipient, having the given 224 'senders', and with the given 'outgoing_only' status. 225 226 Processing individually means that contributions to resulting messages 227 may be constructed according to individual preferences. 228 """ 229 230 handlers = dict([(name, cls(senders, self.user and get_address(self.user), 231 self.messenger, self.store, self.publisher, 232 self.preferences_dir)) 233 for name, cls in self.handlers]) 234 handled = False 235 236 # Check for participating recipients. Non-participating recipients will 237 # have their messages left as being unhandled. 238 239 # Note that no user is set for outgoing messages, and so a check for 240 # their participation must be done in an outgoing handler once they are 241 # identified. 242 243 if outgoing_only or self.is_participating(): 244 245 # Check for returned messages. 246 247 for part in msg.walk(): 248 if part.get_content_type() == "message/delivery-status": 249 break 250 else: 251 for part in msg.walk(): 252 if part.get_content_type() in itip_content_types and \ 253 part.get_param("method"): 254 255 handle_itip_part(part, handlers) 256 handled = True 257 258 # When processing outgoing messages, no replies or deliveries are 259 # performed. 260 261 if outgoing_only: 262 return 263 264 # Get responses from the handlers. 265 266 all_responses = [] 267 for handler in handlers.values(): 268 all_responses += handler.get_results() 269 270 # Pack any returned parts into messages. 271 272 if all_responses: 273 outgoing_parts = {} 274 forwarded_parts = [] 275 276 for outgoing_recipients, part in all_responses: 277 if outgoing_recipients: 278 for outgoing_recipient in outgoing_recipients: 279 if not outgoing_parts.has_key(outgoing_recipient): 280 outgoing_parts[outgoing_recipient] = [] 281 outgoing_parts[outgoing_recipient].append(part) 282 else: 283 forwarded_parts.append(part) 284 285 # Reply using any outgoing parts in a new message. 286 287 if outgoing_parts: 288 289 # Obtain free/busy details, if configured to do so. 290 291 fb = self.can_provide_freebusy(handlers) and self.get_freebusy_part() 292 293 for outgoing_recipient, parts in outgoing_parts.items(): 294 295 # Bundle free/busy messages, if configured to do so. 296 297 if fb: parts.append(fb) 298 message = self.messenger.make_outgoing_message(parts, [outgoing_recipient]) 299 300 if self.debug: 301 print >>sys.stderr, "Outgoing parts for %s..." % outgoing_recipient 302 print message 303 else: 304 self.messenger.sendmail([outgoing_recipient], message.as_string()) 305 306 # Forward messages to their recipients either wrapping the existing 307 # message, accompanying it or replacing it. 308 309 if forwarded_parts: 310 311 # Determine whether to wrap, accompany or replace the message. 312 313 prefs = self.get_preferences() 314 incoming = prefs.get("incoming") 315 316 if incoming == "message-only": 317 messages = [msg] 318 else: 319 summary = self.messenger.make_summary_message(msg, forwarded_parts) 320 if incoming == "summary-then-message": 321 messages = [summary, msg] 322 elif incoming == "message-then-summary": 323 messages = [msg, summary] 324 elif incoming == "summary-only": 325 messages = [summary] 326 else: # incoming == "summary-wraps-message": 327 messages = [self.messenger.wrap_message(msg, forwarded_parts)] 328 329 for message in messages: 330 if self.debug: 331 print >>sys.stderr, "Forwarded parts..." 332 print message 333 elif self.messenger.local_delivery(): 334 self.messenger.sendmail([get_address(self.user)], message.as_string()) 335 336 # Unhandled messages are delivered as they are. 337 338 if not handled: 339 if self.debug: 340 print >>sys.stderr, "Unhandled parts..." 341 print msg 342 elif self.messenger.local_delivery(): 343 self.messenger.sendmail([get_address(self.user)], msg.as_string()) 344 345 def can_provide_freebusy(self, handlers): 346 347 "Test for any free/busy information produced by 'handlers'." 348 349 fbhandler = handlers.get("VFREEBUSY") 350 if fbhandler: 351 fbmethods = fbhandler.get_outgoing_methods() 352 return not "REPLY" in fbmethods and not "PUBLISH" in fbmethods 353 else: 354 return False 355 356 # vim: tabstop=4 expandtab shiftwidth=4