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