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") or settings["STORE_TYPE"] 157 self.store_dir = args.get("store_dir") or settings["STORE_DIR"] 158 self.journal_dir = args.get("journal_dir") or settings["JOURNAL_DIR"] 159 self.preferences_dir = args.get("preferences_dir") or settings["PREFERENCES_DIR"] 160 self.publishing_dir = args.get("publishing_dir") or settings["PUBLISH_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 171 Store directory: %s 172 Journal directory: %s 173 Preferences directory: %s 174 Publishing directory: %s""" % ( 175 self.store_type, self.store_dir, self.journal_dir, 176 self.preferences_dir, self.publishing_dir) 177 return 178 179 # If debug mode is set, extend the line length for convenience. 180 181 if self.debug: 182 settings["CALENDAR_LINE_LENGTH"] = 1000 183 print >>sys.stderr, "Store type", self.store_type, "at", self.store_dir 184 185 # Process the input. 186 187 self.process(stream, args["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 # Show the help text if requested. 199 200 if "--help" in args: 201 show_help(os.path.split(sys.argv[0])[-1]) 202 203 # In debug mode, process the message without exception handling. 204 205 elif "-d" in args: 206 self.process_args(args, sys.stdin) 207 208 # Otherwise, process the message and handle exceptions gracefully. 209 210 else: 211 try: 212 self.process_args(args, sys.stdin) 213 except SystemExit, value: 214 sys.exit(value) 215 except Exception, exc: 216 if "-v" in args: 217 raise 218 219 # Obtain the exception origin. 220 221 type, value, tb = sys.exc_info() 222 while tb.tb_next: 223 tb = tb.tb_next 224 f = tb.tb_frame 225 co = f and f.f_code 226 filename = co and co.co_filename 227 228 print >>sys.stderr, "Exception %s at %d in %s" % (exc, tb.tb_lineno, filename) 229 230 #import traceback 231 #traceback.print_exc(file=open("/tmp/mail.log", "a")) 232 233 sys.exit(EX_TEMPFAIL) 234 235 sys.exit(0) 236 237 class Recipient(Client): 238 239 "A processor acting as a client on behalf of a recipient." 240 241 def __init__(self, user, messenger, store, publisher, journal, preferences_dir, 242 handlers, outgoing_only, debug): 243 244 """ 245 Initialise the recipient with the given 'user' identity, 'messenger', 246 'store', 'publisher', 'journal', 'preferences_dir', 'handlers', 247 'outgoing_only' and 'debug' status. 248 """ 249 250 Client.__init__(self, user, messenger, store, publisher, journal, preferences_dir) 251 self.handlers = handlers 252 self.outgoing_only = outgoing_only 253 self.debug = debug 254 255 def process(self, msg, senders): 256 257 """ 258 Process the given 'msg' for a single recipient, having the given 259 'senders'. 260 261 Processing individually means that contributions to resulting messages 262 may be constructed according to individual preferences. 263 """ 264 265 handlers = {} 266 267 # Instantiate handlers for the supported methods. 268 269 for name, cls in self.handlers: 270 handlers[name] = cls(senders, self.user and get_address(self.user), 271 self.messenger, self.store, self.publisher, 272 self.journal, self.preferences_dir) 273 274 handled = False 275 276 # Check for participating recipients. Non-participating recipients will 277 # have their messages left as being unhandled. 278 279 if not is_returned_message(msg) and (self.outgoing_only or self.is_participating()): 280 281 # Handle parts. 282 283 for part in msg.walk(): 284 if self.debug and have_itip_part(part): 285 print >>sys.stderr, "Handle method %s..." % part.get_param("method") 286 287 handled = handle_itip_part(part, handlers) or handled 288 289 # When processing outgoing messages, no replies or deliveries are 290 # performed. 291 292 if self.outgoing_only: 293 return 294 295 # Get responses from the handlers. 296 297 all_responses = [] 298 for handler in handlers.values(): 299 all_responses += handler.get_results() 300 301 # Pack any returned parts into messages. 302 303 if all_responses: 304 outgoing_parts = {} 305 forwarded_parts = [] 306 307 for outgoing_recipients, part in all_responses: 308 if outgoing_recipients: 309 for outgoing_recipient in outgoing_recipients: 310 if not outgoing_parts.has_key(outgoing_recipient): 311 outgoing_parts[outgoing_recipient] = [] 312 outgoing_parts[outgoing_recipient].append(part) 313 else: 314 forwarded_parts.append(part) 315 316 # Reply using any outgoing parts in a new message. 317 318 if outgoing_parts: 319 320 # Obtain free/busy details, if configured to do so. 321 322 fb = self.can_provide_freebusy(handlers) and self.get_freebusy_part() 323 324 for outgoing_recipient, parts in outgoing_parts.items(): 325 326 # Bundle free/busy messages, if configured to do so. 327 328 if fb: parts.append(fb) 329 message = self.messenger.make_outgoing_message(parts, [outgoing_recipient]) 330 331 if self.debug: 332 print >>sys.stderr, "Outgoing parts for %s..." % outgoing_recipient 333 print message 334 else: 335 self.messenger.sendmail([outgoing_recipient], message.as_string()) 336 337 # Forward messages to their recipients either wrapping the existing 338 # message, accompanying it or replacing it. 339 340 if forwarded_parts: 341 342 # Determine whether to wrap, accompany or replace the message. 343 344 prefs = self.get_preferences() 345 incoming = prefs.get("incoming", settings["INCOMING_DEFAULT"]) 346 347 if incoming == "message-only": 348 messages = [msg] 349 else: 350 summary = self.messenger.make_summary_message(msg, forwarded_parts) 351 if incoming == "summary-then-message": 352 messages = [summary, msg] 353 elif incoming == "message-then-summary": 354 messages = [msg, summary] 355 elif incoming == "summary-only": 356 messages = [summary] 357 else: # incoming == "summary-wraps-message": 358 messages = [self.messenger.wrap_message(msg, forwarded_parts)] 359 360 for message in messages: 361 if self.debug: 362 print >>sys.stderr, "Forwarded parts..." 363 print message 364 elif self.messenger.local_delivery(): 365 self.messenger.sendmail([get_address(self.user)], message.as_string()) 366 367 # Unhandled messages are delivered as they are. 368 369 if not handled: 370 if self.debug: 371 print >>sys.stderr, "Unhandled parts..." 372 print msg 373 elif self.messenger.local_delivery(): 374 self.messenger.sendmail([get_address(self.user)], msg.as_string()) 375 376 def can_provide_freebusy(self, handlers): 377 378 "Test for any free/busy information produced by 'handlers'." 379 380 fbhandler = handlers.get("VFREEBUSY") 381 if fbhandler: 382 fbmethods = fbhandler.get_outgoing_methods() 383 return not "REPLY" in fbmethods and not "PUBLISH" in fbmethods 384 else: 385 return False 386 387 # Standard arguments used by imip-agent programs. 388 389 def parse_args(args, extra_argdefs=None): 390 391 """ 392 Interpret the given program arguments 'args'. Any 'extra_argdefs' define a 393 mapping from option arguments to (option name, starting value) tuples to be 394 considered in addition to (or as replacements for) the default definitions. 395 """ 396 397 argdefs = { 398 "-d" : ("debug", False), 399 "-j" : ("journal_dir", None), 400 "-l" : ("lmtp", None), 401 "-L" : ("local_smtp", False), 402 "-o" : ("original_recipients", []), 403 "-p" : ("preferences_dir", None), 404 "-P" : ("publishing_dir", None), 405 "-s" : ("senders", []), 406 "-S" : ("store_dir", None), 407 "-T" : ("store_type", None), 408 } 409 410 argdefs.update(extra_argdefs) 411 412 l = [] 413 option = None 414 415 for arg in args: 416 417 # Set any selected option value. 418 419 if option and argdefs.has_key(option): 420 name, value = argdefs[option] 421 argdefs[option] = name, arg 422 option = None 423 424 # Where recognised, obtain the option name and value. 425 426 elif argdefs.has_key(arg): 427 name, value = argdefs[arg] 428 429 # For boolean options, invert the current value. 430 431 if isinstance(value, bool): 432 argdefs[arg] = name, not value 433 434 # For list options, switch to the given list and collect arguments. 435 436 elif isinstance(value, list): 437 l = value 438 439 # Otherwise, select the option. 440 441 else: 442 option = arg 443 444 # Where unrecognised, collect the argument in the current list. 445 446 else: 447 l.append(arg) 448 449 # Return a mapping from option names to values. 450 451 return dict(argdefs.values()) 452 453 def show_help(progname): 454 print >>sys.stderr, help_text % progname 455 456 help_text = """\ 457 Usage: %s [ -o <recipient> ... ] [-s <sender> ... ] [ -l <socket> | -L ] \\ 458 [ -T <store type ] \\ 459 [ -S <store directory> ] [ -P <publishing directory> ] \\ 460 [ -p <preferences directory> ] [ -j <journal directory> ] \\ 461 [ -d ] [ --show-config ] 462 463 Address options: 464 465 -o Indicate the original recipients of the message, overriding any found in 466 the message headers 467 -s Indicate the senders of the message, overriding any found in the message 468 headers 469 470 Delivery options: 471 472 -l The socket filename for LMTP communication with a mailbox solution, 473 selecting the LMTP delivery method 474 -L Selects the local SMTP delivery method, requiring a suitable mail system 475 configuration 476 477 (Where a program needs to deliver messages, one of the above options must be 478 specified.) 479 480 Configuration options (overriding configured defaults): 481 482 -j Indicates the location of quota-related journal information 483 -P Indicates the location of published free/busy resources 484 -p Indicates the location of user preference directories 485 -S Indicates the location of the calendar data store containing user storage 486 directories 487 -T Indicates the store and journal type (the configured value if omitted) 488 489 Output options: 490 491 -d Run in debug mode, producing informative output describing the behaviour 492 of the program, displaying responses on standard output instead of sending 493 messages 494 495 Diagnostic options: 496 497 --show-config Show the configuration with the specified options and exit 498 without performing any actions 499 """ 500 501 # vim: tabstop=4 expandtab shiftwidth=4