imip-agent

Annotated imiptools/__init__.py

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