imip-agent

Annotated imiptools/__init__.py

1069:37921ab84c01
2016-03-06 Paul Boddie Moved imip_store into a new imiptools.stores package as the file module.
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@1039 6
Copyright (C) 2014, 2015, 2016 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@749 23
from imiptools import config
paul@604 24
from imiptools.client import Client
paul@213 25
from imiptools.content import handle_itip_part
paul@604 26
from imiptools.data import get_address, get_addresses, get_uri
paul@83 27
from imiptools.mail import Messenger
paul@1069 28
import imiptools.stores.file
paul@991 29
import sys, os
paul@49 30
paul@49 31
# Postfix exit codes.
paul@49 32
paul@49 33
EX_TEMPFAIL     = 75
paul@49 34
paul@49 35
# Permitted iTIP content types.
paul@49 36
paul@49 37
itip_content_types = [
paul@49 38
    "text/calendar",                        # from RFC 6047
paul@49 39
    "text/x-vcalendar", "application/ics",  # other possibilities
paul@49 40
    ]
paul@49 41
paul@49 42
# Processing of incoming messages.
paul@49 43
paul@49 44
def get_all_values(msg, key):
paul@49 45
    l = []
paul@49 46
    for v in msg.get_all(key) or []:
paul@49 47
        l += [s.strip() for s in v.split(",")]
paul@49 48
    return l
paul@49 49
paul@49 50
class Processor:
paul@49 51
paul@49 52
    "The processing framework."
paul@49 53
paul@822 54
    def __init__(self, handlers, outgoing_only=False):
paul@49 55
        self.handlers = handlers
paul@822 56
        self.outgoing_only = outgoing_only
paul@607 57
        self.messenger = None
paul@60 58
        self.lmtp_socket = None
paul@563 59
        self.store_dir = None
paul@563 60
        self.publishing_dir = None
paul@1039 61
        self.journal_dir = None
paul@639 62
        self.preferences_dir = None
paul@563 63
        self.debug = False
paul@49 64
paul@569 65
    def get_store(self):
paul@1069 66
        return imiptools.stores.file.FileStore(self.store_dir)
paul@569 67
paul@569 68
    def get_publisher(self):
paul@1069 69
        return self.publishing_dir and imiptools.stores.file.FilePublisher(self.publishing_dir) or None
paul@569 70
paul@1039 71
    def get_journal(self):
paul@1069 72
        return imiptools.stores.file.FileJournal(self.journal_dir)
paul@1039 73
paul@822 74
    def process(self, f, original_recipients):
paul@49 75
paul@49 76
        """
paul@49 77
        Process content from the stream 'f' accompanied by the given
paul@515 78
        'original_recipients'.
paul@49 79
        """
paul@49 80
paul@49 81
        msg = message_from_file(f)
paul@677 82
        senders = get_addresses(get_all_values(msg, "Reply-To") or get_all_values(msg, "From") or [])
paul@49 83
paul@604 84
        messenger = self.messenger
paul@604 85
        store = self.get_store()
paul@604 86
        publisher = self.get_publisher()
paul@1039 87
        journal = self.get_journal()
paul@639 88
        preferences_dir = self.preferences_dir
paul@604 89
paul@49 90
        # Handle messages with iTIP parts.
paul@438 91
        # Typically, the details of recipients are of interest in handling
paul@438 92
        # messages.
paul@49 93
paul@822 94
        if not self.outgoing_only:
paul@438 95
            original_recipients = original_recipients or get_addresses(get_all_values(msg, "To") or [])
paul@438 96
            for recipient in original_recipients:
paul@1039 97
                Recipient(get_uri(recipient), messenger, store, publisher, journal, preferences_dir, self.handlers, self.outgoing_only, self.debug
paul@822 98
                         ).process(msg, senders)
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@832 108
            senders = [sender for sender in get_addresses(get_all_values(msg, "From") or []) if sender != config.MESSAGE_SENDER]
paul@1039 109
            Recipient(senders and senders[0] or None, messenger, store, publisher, journal, preferences_dir, self.handlers, self.outgoing_only, self.debug
paul@822 110
                     ).process(msg, senders)
paul@229 111
paul@49 112
    def process_args(self, args, stream):
paul@49 113
paul@49 114
        """
paul@49 115
        Interpret the given program arguments 'args' and process input from the
paul@49 116
        given 'stream'.
paul@49 117
        """
paul@49 118
paul@49 119
        # Obtain the different kinds of recipients plus sender address.
paul@49 120
paul@49 121
        original_recipients = []
paul@49 122
        recipients = []
paul@49 123
        senders = []
paul@60 124
        lmtp = []
paul@563 125
        store_dir = []
paul@563 126
        publishing_dir = []
paul@639 127
        preferences_dir = []
paul@1039 128
        journal_dir = []
paul@666 129
        local_smtp = False
paul@49 130
paul@49 131
        l = []
paul@49 132
paul@49 133
        for arg in args:
paul@49 134
paul@49 135
            # Switch to collecting recipients.
paul@49 136
paul@822 137
            if arg == "-o":
paul@49 138
                l = original_recipients
paul@49 139
paul@49 140
            # Switch to collecting senders.
paul@49 141
paul@49 142
            elif arg == "-s":
paul@49 143
                l = senders
paul@49 144
paul@60 145
            # Switch to getting the LMTP socket.
paul@60 146
paul@60 147
            elif arg == "-l":
paul@60 148
                l = lmtp
paul@60 149
paul@666 150
            # Detect sending to local users via SMTP.
paul@666 151
paul@666 152
            elif arg == "-L":
paul@666 153
                local_smtp = True
paul@666 154
paul@563 155
            # Switch to getting the store directory.
paul@563 156
paul@563 157
            elif arg == "-S":
paul@563 158
                l = store_dir
paul@563 159
paul@563 160
            # Switch to getting the publishing directory.
paul@563 161
paul@563 162
            elif arg == "-P":
paul@563 163
                l = publishing_dir
paul@563 164
paul@639 165
            # Switch to getting the preferences directory.
paul@639 166
paul@639 167
            elif arg == "-p":
paul@639 168
                l = preferences_dir
paul@639 169
paul@1039 170
            # Switch to getting the journal directory.
paul@1039 171
paul@1039 172
            elif arg == "-j":
paul@1039 173
                l = journal_dir
paul@1039 174
paul@49 175
            # Ignore debugging options.
paul@49 176
paul@49 177
            elif arg == "-d":
paul@563 178
                self.debug = True
paul@49 179
            else:
paul@49 180
                l.append(arg)
paul@49 181
paul@666 182
        self.messenger = Messenger(lmtp_socket=lmtp and lmtp[0] or None, local_smtp=local_smtp, sender=senders and senders[0] or None)
paul@563 183
        self.store_dir = store_dir and store_dir[0] or None
paul@563 184
        self.publishing_dir = publishing_dir and publishing_dir[0] or None
paul@639 185
        self.preferences_dir = preferences_dir and preferences_dir[0] or None
paul@1039 186
        self.journal_dir = journal_dir and journal_dir[0] or None
paul@822 187
        self.process(stream, original_recipients)
paul@49 188
paul@49 189
    def __call__(self):
paul@49 190
paul@49 191
        """
paul@49 192
        Obtain arguments from the command line to initialise the processor
paul@49 193
        before invoking it.
paul@49 194
        """
paul@49 195
paul@49 196
        args = sys.argv[1:]
paul@49 197
paul@991 198
        if "--help" in args:
paul@991 199
            print >>sys.stderr, """\
paul@991 200
Usage: %s [ -o <recipient> ... ] [-s <sender> ... ] [ -l <socket> | -L ] \\
paul@991 201
         [ -S <store directory> ] [ -P <publishing directory> ] \\
paul@1039 202
         [ -p <preferences directory> ] [ -j <journal directory> ] [ -d ]
paul@991 203
paul@991 204
Address options:
paul@991 205
paul@991 206
-o  Indicate the original recipients of the message, overriding any found in
paul@991 207
    the message headers
paul@991 208
-s  Indicate the senders of the message, overriding any found in the message
paul@991 209
    headers
paul@991 210
paul@991 211
Delivery options:
paul@991 212
paul@991 213
-l  The socket filename for LMTP communication with a mailbox solution,
paul@991 214
    selecting the LMTP delivery method
paul@991 215
-L  Selects the local SMTP delivery method, requiring a suitable mail system
paul@991 216
    configuration
paul@991 217
paul@991 218
(Where a program needs to deliver messages, one of the above options must be
paul@991 219
specified.)
paul@991 220
paul@991 221
Configuration options:
paul@991 222
paul@1039 223
-j  Indicates the location of quota-related journal information
paul@1039 224
-P  Indicates the location of published free/busy resources
paul@1039 225
-p  Indicates the location of user preference directories
paul@991 226
-S  Indicates the location of the calendar data store containing user storage
paul@991 227
    directories
paul@991 228
paul@991 229
Output options:
paul@991 230
paul@991 231
-d  Run in debug mode, producing informative output describing the behaviour
paul@991 232
    of the program
paul@991 233
""" % os.path.split(sys.argv[0])[-1]
paul@991 234
        elif "-d" in args:
paul@49 235
            self.process_args(args, sys.stdin)
paul@49 236
        else:
paul@49 237
            try:
paul@49 238
                self.process_args(args, sys.stdin)
paul@49 239
            except SystemExit, value:
paul@49 240
                sys.exit(value)
paul@49 241
            except Exception, exc:
paul@60 242
                if "-v" in args:
paul@60 243
                    raise
paul@49 244
                type, value, tb = sys.exc_info()
paul@310 245
                while tb.tb_next:
paul@310 246
                    tb = tb.tb_next
paul@310 247
                f = tb.tb_frame
paul@310 248
                co = f and f.f_code
paul@310 249
                filename = co and co.co_filename
paul@310 250
                print >>sys.stderr, "Exception %s at %d in %s" % (exc, tb.tb_lineno, filename)
paul@82 251
                #import traceback
paul@82 252
                #traceback.print_exc(file=open("/tmp/mail.log", "a"))
paul@49 253
                sys.exit(EX_TEMPFAIL)
paul@49 254
        sys.exit(0)
paul@49 255
paul@604 256
class Recipient(Client):
paul@604 257
paul@604 258
    "A processor acting as a client on behalf of a recipient."
paul@604 259
paul@1039 260
    def __init__(self, user, messenger, store, publisher, journal, preferences_dir,
paul@1039 261
                 handlers, outgoing_only, debug):
paul@604 262
paul@604 263
        """
paul@604 264
        Initialise the recipient with the given 'user' identity, 'messenger',
paul@1039 265
        'store', 'publisher', 'journal', 'preferences_dir', 'handlers',
paul@1039 266
        'outgoing_only' and 'debug' status.
paul@604 267
        """
paul@604 268
paul@1039 269
        Client.__init__(self, user, messenger, store, publisher, journal, preferences_dir)
paul@607 270
        self.handlers = handlers
paul@822 271
        self.outgoing_only = outgoing_only
paul@607 272
        self.debug = debug
paul@604 273
paul@822 274
    def process(self, msg, senders):
paul@604 275
paul@604 276
        """
paul@604 277
        Process the given 'msg' for a single recipient, having the given
paul@822 278
        'senders'.
paul@604 279
paul@604 280
        Processing individually means that contributions to resulting messages
paul@604 281
        may be constructed according to individual preferences.
paul@604 282
        """
paul@604 283
paul@604 284
        handlers = dict([(name, cls(senders, self.user and get_address(self.user),
paul@639 285
                                    self.messenger, self.store, self.publisher,
paul@1039 286
                                    self.journal, self.preferences_dir))
paul@607 287
                         for name, cls in self.handlers])
paul@604 288
        handled = False
paul@604 289
paul@667 290
        # Check for participating recipients. Non-participating recipients will
paul@667 291
        # have their messages left as being unhandled.
paul@667 292
paul@822 293
        if self.outgoing_only or self.is_participating():
paul@667 294
paul@667 295
            # Check for returned messages.
paul@665 296
paul@665 297
            for part in msg.walk():
paul@667 298
                if part.get_content_type() == "message/delivery-status":
paul@667 299
                    break
paul@667 300
            else:
paul@667 301
                for part in msg.walk():
paul@667 302
                    if part.get_content_type() in itip_content_types and \
paul@667 303
                       part.get_param("method"):
paul@604 304
paul@667 305
                        handle_itip_part(part, handlers)
paul@667 306
                        handled = True
paul@604 307
paul@604 308
        # When processing outgoing messages, no replies or deliveries are
paul@604 309
        # performed.
paul@604 310
paul@822 311
        if self.outgoing_only:
paul@604 312
            return
paul@604 313
paul@604 314
        # Get responses from the handlers.
paul@604 315
paul@604 316
        all_responses = []
paul@604 317
        for handler in handlers.values():
paul@604 318
            all_responses += handler.get_results()
paul@604 319
paul@604 320
        # Pack any returned parts into messages.
paul@604 321
paul@604 322
        if all_responses:
paul@604 323
            outgoing_parts = {}
paul@604 324
            forwarded_parts = []
paul@604 325
paul@604 326
            for outgoing_recipients, part in all_responses:
paul@604 327
                if outgoing_recipients:
paul@604 328
                    for outgoing_recipient in outgoing_recipients:
paul@604 329
                        if not outgoing_parts.has_key(outgoing_recipient):
paul@604 330
                            outgoing_parts[outgoing_recipient] = []
paul@604 331
                        outgoing_parts[outgoing_recipient].append(part)
paul@604 332
                else:
paul@604 333
                    forwarded_parts.append(part)
paul@604 334
paul@604 335
            # Reply using any outgoing parts in a new message.
paul@604 336
paul@604 337
            if outgoing_parts:
paul@604 338
paul@604 339
                # Obtain free/busy details, if configured to do so.
paul@604 340
paul@604 341
                fb = self.can_provide_freebusy(handlers) and self.get_freebusy_part()
paul@604 342
paul@604 343
                for outgoing_recipient, parts in outgoing_parts.items():
paul@604 344
paul@604 345
                    # Bundle free/busy messages, if configured to do so.
paul@604 346
paul@604 347
                    if fb: parts.append(fb)
paul@604 348
                    message = self.messenger.make_outgoing_message(parts, [outgoing_recipient])
paul@604 349
paul@607 350
                    if self.debug:
paul@604 351
                        print >>sys.stderr, "Outgoing parts for %s..." % outgoing_recipient
paul@604 352
                        print message
paul@604 353
                    else:
paul@604 354
                        self.messenger.sendmail([outgoing_recipient], message.as_string())
paul@604 355
paul@604 356
            # Forward messages to their recipients either wrapping the existing
paul@604 357
            # message, accompanying it or replacing it.
paul@604 358
paul@604 359
            if forwarded_parts:
paul@604 360
paul@604 361
                # Determine whether to wrap, accompany or replace the message.
paul@604 362
paul@604 363
                prefs = self.get_preferences()
paul@749 364
                incoming = prefs.get("incoming", config.INCOMING_DEFAULT)
paul@604 365
paul@604 366
                if incoming == "message-only":
paul@604 367
                    messages = [msg]
paul@604 368
                else:
paul@604 369
                    summary = self.messenger.make_summary_message(msg, forwarded_parts)
paul@604 370
                    if incoming == "summary-then-message":
paul@604 371
                        messages = [summary, msg]
paul@604 372
                    elif incoming == "message-then-summary":
paul@604 373
                        messages = [msg, summary]
paul@604 374
                    elif incoming == "summary-only":
paul@604 375
                        messages = [summary]
paul@604 376
                    else: # incoming == "summary-wraps-message":
paul@604 377
                        messages = [self.messenger.wrap_message(msg, forwarded_parts)]
paul@604 378
paul@604 379
                for message in messages:
paul@607 380
                    if self.debug:
paul@604 381
                        print >>sys.stderr, "Forwarded parts..."
paul@604 382
                        print message
paul@607 383
                    elif self.messenger.local_delivery():
paul@666 384
                        self.messenger.sendmail([get_address(self.user)], message.as_string())
paul@604 385
paul@604 386
        # Unhandled messages are delivered as they are.
paul@604 387
paul@604 388
        if not handled:
paul@607 389
            if self.debug:
paul@604 390
                print >>sys.stderr, "Unhandled parts..."
paul@604 391
                print msg
paul@607 392
            elif self.messenger.local_delivery():
paul@666 393
                self.messenger.sendmail([get_address(self.user)], msg.as_string())
paul@604 394
paul@604 395
    def can_provide_freebusy(self, handlers):
paul@604 396
paul@604 397
        "Test for any free/busy information produced by 'handlers'."
paul@604 398
paul@604 399
        fbhandler = handlers.get("VFREEBUSY")
paul@604 400
        if fbhandler:
paul@604 401
            fbmethods = fbhandler.get_outgoing_methods()
paul@604 402
            return not "REPLY" in fbmethods and not "PUBLISH" in fbmethods
paul@604 403
        else:
paul@604 404
            return False
paul@604 405
paul@49 406
# vim: tabstop=4 expandtab shiftwidth=4