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