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