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