1 #!/usr/bin/env python 2 3 """ 4 A processing framework for iMIP content. 5 6 Copyright (C) 2014, 2015, 2016, 2017 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.config import settings 24 from imiptools.client import Client 25 from imiptools.content import handle_itip_part, have_itip_part, \ 26 is_returned_message 27 from imiptools.data import get_address, get_addresses, get_uri 28 from imiptools.mail import Messenger 29 from imiptools.stores import get_store, get_publisher, get_journal, \ 30 StoreInitialisationError 31 import sys, os 32 33 # Postfix exit codes. 34 35 EX_TEMPFAIL = 75 36 37 # Processing of incoming messages. 38 39 def get_all_values(msg, key): 40 41 "Return all values in 'msg' for 'key'." 42 43 l = [] 44 for v in msg.get_all(key) or []: 45 for s in v.split(","): 46 l.append(s.strip()) 47 return l 48 49 class Processor: 50 51 "The processing framework." 52 53 def __init__(self, handlers, outgoing_only=False): 54 self.handlers = handlers 55 self.outgoing_only = outgoing_only 56 self.messenger = None 57 self.lmtp_socket = None 58 self.store_type = 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 67 "Return any configured store." 68 69 return get_store(self.store_type, self.store_dir) 70 71 def get_publisher(self): 72 73 "Return any configured publisher or None if not configured." 74 75 try: 76 return get_publisher(self.publishing_dir) 77 except StoreInitialisationError: 78 return None 79 80 def get_journal(self): 81 82 "Return any configured journal or None if not configured." 83 84 try: 85 return get_journal(self.store_type, self.journal_dir) 86 except StoreInitialisationError: 87 return None 88 89 def process(self, f, original_recipients): 90 91 """ 92 Process content from the stream 'f' accompanied by the given 93 'original_recipients'. 94 """ 95 96 msg = message_from_file(f) 97 98 messenger = self.messenger 99 store = self.get_store() 100 publisher = self.get_publisher() 101 journal = self.get_journal() 102 preferences_dir = self.preferences_dir 103 104 # Handle messages with iTIP parts. 105 # Typically, the details of recipients are of interest in handling 106 # messages. 107 108 if not self.outgoing_only: 109 senders = get_addresses(get_all_values(msg, "Reply-To") or get_all_values(msg, "From") or []) 110 original_recipients = original_recipients or get_addresses(get_all_values(msg, "To") or []) 111 users = [] 112 113 for recipient in original_recipients: 114 users.append(get_uri(recipient)) 115 116 # However, outgoing messages do not usually presume anything about the 117 # eventual recipients and focus on the sender instead. If possible, the 118 # sender is identified, but since this may be the calendar system (and 119 # the actual sender is defined in the object), and since the recipient 120 # may be in a Bcc header that is not available here, it may be left as 121 # None and deduced from the object content later. 122 123 else: 124 senders = [] 125 126 for sender in get_addresses(get_all_values(msg, "From") or []): 127 if sender != settings["MESSAGE_SENDER"]: 128 senders.append(sender) 129 130 users = [senders and get_uri(senders[0]) or None] 131 132 # Process the message for each recipient. 133 134 if self.debug: 135 print >>sys.stderr, "Recipients..." 136 print >>sys.stderr, ", ".join(map(str, users)) 137 138 for user in users: 139 Recipient(user, messenger, store, publisher, journal, 140 preferences_dir, self.handlers, self.outgoing_only, 141 self.debug).process(msg, senders) 142 143 def process_args(self, args, stream): 144 145 """ 146 Interpret the given program arguments 'args' and process input from the 147 given 'stream'. 148 """ 149 150 args = parse_args(args, {"--show-config" : ("show_config", False)}) 151 152 self.debug = args["debug"] 153 154 # Obtain arguments or configured defaults. 155 156 self.store_type = args.get("store_type") 157 self.store_dir = args.get("store_dir") 158 self.journal_dir = args.get("journal_dir") 159 self.preferences_dir = args.get("preferences_dir") or settings["PREFERENCES_DIR"] 160 self.publishing_dir = args.get("publishing_dir") 161 162 self.messenger = Messenger(lmtp_socket=args["lmtp"], 163 local_smtp=args["local_smtp"], 164 sender=(args["senders"] or [None])[0]) 165 166 # Show configuration and exit if requested. 167 168 if args["show_config"]: 169 print """\ 170 Store type: %s (%s) 171 Store directory: %s (%s) 172 Journal directory: %s (%s) 173 Preferences directory: %s 174 Publishing directory: %s (%s) 175 """ % ( 176 self.store_type, settings["STORE_TYPE"], 177 self.store_dir, settings["STORE_DIR"], 178 self.journal_dir, settings["JOURNAL_DIR"], 179 self.preferences_dir, 180 self.publishing_dir, settings["PUBLISH_DIR"]) 181 return 182 183 # If debug mode is set, extend the line length for convenience. 184 185 if self.debug: 186 settings["CALENDAR_LINE_LENGTH"] = 1000 187 print >>sys.stderr, "Store type", self.store_type, "at", self.store_dir 188 189 # Process the input. 190 191 self.process(stream, args["original_recipients"]) 192 193 def __call__(self): 194 195 """ 196 Obtain arguments from the command line to initialise the processor 197 before invoking it. 198 """ 199 200 args = sys.argv[1:] 201 202 # Show the help text if requested. 203 204 if "--help" in args: 205 show_help(os.path.split(sys.argv[0])[-1]) 206 207 # In debug mode, process the message without exception handling. 208 209 elif "-d" in args: 210 self.process_args(args, sys.stdin) 211 212 # Otherwise, process the message and handle exceptions gracefully. 213 214 else: 215 try: 216 self.process_args(args, sys.stdin) 217 except SystemExit, value: 218 sys.exit(value) 219 except Exception, exc: 220 if "-v" in args: 221 raise 222 223 # Obtain the exception origin. 224 225 type, value, tb = sys.exc_info() 226 while tb.tb_next: 227 tb = tb.tb_next 228 f = tb.tb_frame 229 co = f and f.f_code 230 filename = co and co.co_filename 231 232 print >>sys.stderr, "Exception %s at %d in %s" % (exc, tb.tb_lineno, filename) 233 234 #import traceback 235 #traceback.print_exc(file=open("/tmp/mail.log", "a")) 236 237 sys.exit(EX_TEMPFAIL) 238 239 sys.exit(0) 240 241 class Recipient(Client): 242 243 "A processor acting as a client on behalf of a recipient." 244 245 def __init__(self, user, messenger, store, publisher, journal, preferences_dir, 246 handlers, outgoing_only, debug): 247 248 """ 249 Initialise the recipient with the given 'user' identity, 'messenger', 250 'store', 'publisher', 'journal', 'preferences_dir', 'handlers', 251 'outgoing_only' and 'debug' status. 252 """ 253 254 Client.__init__(self, user, messenger, store, publisher, journal, preferences_dir) 255 self.handlers = handlers 256 self.outgoing_only = outgoing_only 257 self.debug = debug 258 259 def process(self, msg, senders): 260 261 """ 262 Process the given 'msg' for a single recipient, having the given 263 'senders'. 264 265 Processing individually means that contributions to resulting messages 266 may be constructed according to individual preferences. 267 """ 268 269 handlers = {} 270 271 # Instantiate handlers for the supported methods. 272 273 for name, cls in self.handlers: 274 handlers[name] = cls(senders, self.user and get_address(self.user), 275 self.messenger, self.store, self.publisher, 276 self.journal, self.preferences_dir) 277 278 handled = False 279 280 # Check for participating recipients. Non-participating recipients will 281 # have their messages left as being unhandled. 282 283 if not is_returned_message(msg) and (self.outgoing_only or self.is_participating()): 284 285 # Handle parts. 286 287 for part in msg.walk(): 288 if self.debug and have_itip_part(part): 289 print >>sys.stderr, "Handle method %s..." % part.get_param("method") 290 291 handled = handle_itip_part(part, handlers) or handled 292 293 # When processing outgoing messages, no replies or deliveries are 294 # performed. 295 296 if self.outgoing_only: 297 return 298 299 # Get responses from the handlers. 300 301 all_responses = [] 302 for handler in handlers.values(): 303 all_responses += handler.get_results() 304 305 # Pack any returned parts into messages. 306 307 if all_responses: 308 outgoing_parts = {} 309 forwarded_parts = [] 310 311 for outgoing_recipients, part in all_responses: 312 if outgoing_recipients: 313 for outgoing_recipient in outgoing_recipients: 314 if not outgoing_parts.has_key(outgoing_recipient): 315 outgoing_parts[outgoing_recipient] = [] 316 outgoing_parts[outgoing_recipient].append(part) 317 else: 318 forwarded_parts.append(part) 319 320 # Reply using any outgoing parts in a new message. 321 322 if outgoing_parts: 323 324 # Obtain free/busy details, if configured to do so. 325 326 fb = self.can_provide_freebusy(handlers) and self.get_freebusy_part() 327 328 for outgoing_recipient, parts in outgoing_parts.items(): 329 330 # Bundle free/busy messages, if configured to do so. 331 332 if fb: parts.append(fb) 333 message = self.messenger.make_outgoing_message(parts, [outgoing_recipient]) 334 335 if self.debug: 336 print >>sys.stderr, "Outgoing parts for %s..." % outgoing_recipient 337 print message 338 else: 339 self.messenger.sendmail([outgoing_recipient], message.as_string()) 340 341 # Forward messages to their recipients either wrapping the existing 342 # message, accompanying it or replacing it. 343 344 if forwarded_parts: 345 346 # Determine whether to wrap, accompany or replace the message. 347 348 prefs = self.get_preferences() 349 incoming = prefs.get("incoming", settings["INCOMING_DEFAULT"]) 350 351 if incoming == "message-only": 352 messages = [msg] 353 else: 354 summary = self.messenger.make_summary_message(msg, forwarded_parts) 355 if incoming == "summary-then-message": 356 messages = [summary, msg] 357 elif incoming == "message-then-summary": 358 messages = [msg, summary] 359 elif incoming == "summary-only": 360 messages = [summary] 361 else: # incoming == "summary-wraps-message": 362 messages = [self.messenger.wrap_message(msg, forwarded_parts)] 363 364 for message in messages: 365 if self.debug: 366 print >>sys.stderr, "Forwarded parts..." 367 print message 368 elif self.messenger.local_delivery(): 369 self.messenger.sendmail([get_address(self.user)], message.as_string()) 370 371 # Unhandled messages are delivered as they are. 372 373 if not handled: 374 if self.debug: 375 print >>sys.stderr, "Unhandled parts..." 376 print msg 377 elif self.messenger.local_delivery(): 378 self.messenger.sendmail([get_address(self.user)], msg.as_string()) 379 380 def can_provide_freebusy(self, handlers): 381 382 "Test for any free/busy information produced by 'handlers'." 383 384 fbhandler = handlers.get("VFREEBUSY") 385 if fbhandler: 386 fbmethods = fbhandler.get_outgoing_methods() 387 return not "REPLY" in fbmethods and not "PUBLISH" in fbmethods 388 else: 389 return False 390 391 # Standard arguments used by imip-agent programs. 392 393 def parse_args(args, extra_argdefs=None): 394 395 """ 396 Interpret the given program arguments 'args'. Any 'extra_argdefs' define a 397 mapping from option arguments to (option name, starting value) tuples to be 398 considered in addition to (or as replacements for) the default definitions. 399 """ 400 401 argdefs = { 402 "-d" : ("debug", False), 403 "-j" : ("journal_dir", None), 404 "-l" : ("lmtp", None), 405 "-L" : ("local_smtp", False), 406 "-o" : ("original_recipients", []), 407 "-p" : ("preferences_dir", None), 408 "-P" : ("publishing_dir", None), 409 "-s" : ("senders", []), 410 "-S" : ("store_dir", None), 411 "-T" : ("store_type", None), 412 } 413 414 argdefs.update(extra_argdefs) 415 416 l = [] 417 option = None 418 419 for arg in args: 420 421 # Set any selected option value. 422 423 if option and argdefs.has_key(option): 424 name, value = argdefs[option] 425 argdefs[option] = name, arg 426 option = None 427 428 # Where recognised, obtain the option name and value. 429 430 elif argdefs.has_key(arg): 431 name, value = argdefs[arg] 432 433 # For boolean options, invert the current value. 434 435 if isinstance(value, bool): 436 argdefs[arg] = name, not value 437 438 # For list options, switch to the given list and collect arguments. 439 440 elif isinstance(value, list): 441 l = value 442 443 # Otherwise, select the option. 444 445 else: 446 option = arg 447 448 # Where unrecognised, collect the argument in the current list. 449 450 else: 451 l.append(arg) 452 453 # Return a mapping from option names to values. 454 455 return dict(argdefs.values()) 456 457 def show_help(progname): 458 print >>sys.stderr, help_text % progname 459 460 help_text = """\ 461 Usage: %s [ -o <recipient> ... ] [-s <sender> ... ] [ -l <socket> | -L ] \\ 462 [ -T <store type ] \\ 463 [ -S <store directory> ] [ -P <publishing directory> ] \\ 464 [ -p <preferences directory> ] [ -j <journal directory> ] \\ 465 [ -d ] [ --show-config ] 466 467 Address options: 468 469 -o Indicate the original recipients of the message, overriding any found in 470 the message headers 471 -s Indicate the senders of the message, overriding any found in the message 472 headers 473 474 Delivery options: 475 476 -l The socket filename for LMTP communication with a mailbox solution, 477 selecting the LMTP delivery method 478 -L Selects the local SMTP delivery method, requiring a suitable mail system 479 configuration 480 481 (Where a program needs to deliver messages, one of the above options must be 482 specified.) 483 484 Configuration options (overriding configured defaults): 485 486 -j Indicates the location of quota-related journal information 487 -P Indicates the location of published free/busy resources 488 -p Indicates the location of user preference directories 489 -S Indicates the location of the calendar data store containing user storage 490 directories 491 -T Indicates the store and journal type (the configured value if omitted) 492 493 Output options: 494 495 -d Run in debug mode, producing informative output describing the behaviour 496 of the program, displaying responses on standard output instead of sending 497 messages 498 499 Diagnostic options: 500 501 --show-config Show the configuration with the specified options and exit 502 without performing any actions 503 """ 504 505 # vim: tabstop=4 expandtab shiftwidth=4