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