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