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