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