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