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