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