imip-agent

Annotated imiptools/__init__.py

523:b9c05d30449f
2015-05-15 Paul Boddie Support the cancellation of previously unseparated recurrences.
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@146 6
Copyright (C) 2014, 2015 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@213 23
from imiptools.content import handle_itip_part
paul@223 24
from imiptools.data import get_addresses, get_uri, make_freebusy, to_part
paul@223 25
from imiptools.dates import get_timestamp
paul@83 26
from imiptools.mail import Messenger
paul@178 27
from imiptools.profile import Preferences
paul@223 28
import imip_store
paul@49 29
import sys
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@82 54
    def __init__(self, handlers, messenger=None):
paul@49 55
        self.handlers = handlers
paul@82 56
        self.messenger = messenger or Messenger()
paul@60 57
        self.lmtp_socket = None
paul@49 58
paul@515 59
    def process(self, f, original_recipients, outgoing_only):
paul@49 60
paul@49 61
        """
paul@49 62
        Process content from the stream 'f' accompanied by the given
paul@515 63
        'original_recipients'.
paul@49 64
        """
paul@49 65
paul@49 66
        msg = message_from_file(f)
paul@178 67
        senders = get_addresses(msg.get_all("Reply-To") or msg.get_all("From") or [])
paul@49 68
paul@49 69
        # Handle messages with iTIP parts.
paul@438 70
        # Typically, the details of recipients are of interest in handling
paul@438 71
        # messages.
paul@49 72
paul@438 73
        if not outgoing_only:
paul@438 74
            original_recipients = original_recipients or get_addresses(get_all_values(msg, "To") or [])
paul@438 75
            for recipient in original_recipients:
paul@438 76
                self.process_for_recipient(msg, recipient, senders, outgoing_only)
paul@438 77
paul@438 78
        # However, outgoing messages do not usually presume anything about the
paul@438 79
        # eventual recipients.
paul@438 80
paul@438 81
        else:
paul@438 82
            self.process_for_recipient(msg, None, senders, outgoing_only)
paul@179 83
paul@179 84
    def process_for_recipient(self, msg, recipient, senders, outgoing_only):
paul@179 85
paul@179 86
        """
paul@179 87
        Process the given 'msg' for a single 'recipient', having the given
paul@179 88
        'senders', and with the given 'outgoing_only' status.
paul@179 89
paul@179 90
        Processing individually means that contributions to resulting messages
paul@179 91
        may be constructed according to individual preferences.
paul@179 92
        """
paul@179 93
paul@228 94
        handlers = dict([(name, cls(senders, recipient, self.messenger)) for name, cls in self.handlers])
paul@60 95
        handled = False
paul@49 96
paul@49 97
        for part in msg.walk():
paul@49 98
            if part.get_content_type() in itip_content_types and \
paul@228 99
               part.get_param("method"):
paul@49 100
paul@228 101
                handle_itip_part(part, handlers)
paul@60 102
                handled = True
paul@49 103
paul@96 104
        # When processing outgoing messages, no replies or deliveries are
paul@96 105
        # performed.
paul@96 106
paul@96 107
        if outgoing_only:
paul@96 108
            return
paul@96 109
paul@228 110
        # Get responses from the handlers.
paul@228 111
paul@228 112
        all_responses = []
paul@228 113
        for handler in handlers.values():
paul@228 114
            all_responses += handler.get_results()
paul@228 115
paul@178 116
        # Pack any returned parts into messages.
paul@49 117
paul@60 118
        if all_responses:
paul@215 119
            outgoing_parts = {}
paul@60 120
            forwarded_parts = []
paul@60 121
paul@215 122
            for outgoing_recipients, part in all_responses:
paul@215 123
                if outgoing_recipients:
paul@215 124
                    for outgoing_recipient in outgoing_recipients:
paul@215 125
                        if not outgoing_parts.has_key(outgoing_recipient):
paul@215 126
                            outgoing_parts[outgoing_recipient] = []
paul@215 127
                        outgoing_parts[outgoing_recipient].append(part)
paul@60 128
                else:
paul@60 129
                    forwarded_parts.append(part)
paul@60 130
paul@60 131
            # Reply using any outgoing parts in a new message.
paul@60 132
paul@60 133
            if outgoing_parts:
paul@223 134
paul@223 135
                # Obtain free/busy details, if configured to do so.
paul@223 136
paul@229 137
                fb = self.can_provide_freebusy(handlers) and self.get_freebusy_for_recipient(recipient)
paul@223 138
paul@215 139
                for outgoing_recipient, parts in outgoing_parts.items():
paul@223 140
paul@223 141
                    # Bundle free/busy messages, if configured to do so.
paul@223 142
paul@223 143
                    if fb: parts.append(fb)
paul@215 144
                    message = self.messenger.make_outgoing_message(parts, [outgoing_recipient])
paul@49 145
paul@215 146
                    if "-d" in sys.argv:
paul@215 147
                        print >>sys.stderr, "Outgoing parts for %s..." % outgoing_recipient
paul@215 148
                        print message
paul@215 149
                    else:
paul@215 150
                        self.messenger.sendmail([outgoing_recipient], message.as_string())
paul@60 151
paul@178 152
            # Forward messages to their recipients either wrapping the existing
paul@178 153
            # message, accompanying it or replacing it.
paul@60 154
paul@60 155
            if forwarded_parts:
paul@178 156
paul@178 157
                # Determine whether to wrap, accompany or replace the message.
paul@178 158
paul@179 159
                preferences = Preferences(get_uri(recipient))
paul@178 160
paul@179 161
                incoming = preferences.get("incoming")
paul@60 162
paul@179 163
                if incoming == "message-only":
paul@179 164
                    messages = [msg]
paul@179 165
                else:
paul@179 166
                    summary = self.messenger.make_summary_message(msg, forwarded_parts)
paul@179 167
                    if incoming == "summary-then-message":
paul@179 168
                        messages = [summary, msg]
paul@179 169
                    elif incoming == "message-then-summary":
paul@179 170
                        messages = [msg, summary]
paul@179 171
                    elif incoming == "summary-only":
paul@179 172
                        messages = [summary]
paul@179 173
                    else: # incoming == "summary-wraps-message":
paul@179 174
                        messages = [self.messenger.wrap_message(msg, forwarded_parts)]
paul@178 175
paul@179 176
                for message in messages:
paul@179 177
                    if "-d" in sys.argv:
paul@179 178
                        print >>sys.stderr, "Forwarded parts..."
paul@179 179
                        print message
paul@179 180
                    elif self.lmtp_socket:
paul@179 181
                        self.messenger.sendmail(recipient, message.as_string(), lmtp_socket=self.lmtp_socket)
paul@60 182
paul@60 183
        # Unhandled messages are delivered as they are.
paul@60 184
paul@60 185
        if not handled:
paul@49 186
            if "-d" in sys.argv:
paul@106 187
                print >>sys.stderr, "Unhandled parts..."
paul@60 188
                print msg
paul@60 189
            elif self.lmtp_socket:
paul@179 190
                self.messenger.sendmail(recipient, msg.as_string(), lmtp_socket=self.lmtp_socket)
paul@64 191
paul@229 192
    def can_provide_freebusy(self, handlers):
paul@229 193
paul@229 194
        "Test for any free/busy information produced by 'handlers'."
paul@229 195
paul@229 196
        fbhandler = handlers.get("VFREEBUSY")
paul@229 197
        if fbhandler:
paul@229 198
            fbmethods = fbhandler.get_outgoing_methods()
paul@229 199
            return not "REPLY" in fbmethods and not "PUBLISH" in fbmethods
paul@229 200
        else:
paul@229 201
            return False
paul@229 202
paul@223 203
    def get_freebusy_for_recipient(self, recipient):
paul@223 204
paul@223 205
        """
paul@223 206
        Return a list of responses containing free/busy information for the
paul@223 207
        given 'recipient'.
paul@223 208
        """
paul@223 209
paul@223 210
        organiser = get_uri(recipient)
paul@223 211
        preferences = Preferences(organiser)
paul@223 212
paul@292 213
        organiser_attr = self.messenger and {"SENT-BY" : get_uri(self.messenger.sender)} or {}
paul@292 214
paul@223 215
        if preferences.get("freebusy_sharing") == "share" and \
paul@223 216
           preferences.get("freebusy_bundling") == "always":
paul@223 217
paul@223 218
            # Invent a unique identifier.
paul@223 219
paul@223 220
            utcnow = get_timestamp()
paul@223 221
            uid = "imip-agent-%s-%s" % (utcnow, recipient)
paul@223 222
paul@223 223
            freebusy = imip_store.FileStore().get_freebusy(organiser)
paul@292 224
            return to_part("PUBLISH", [make_freebusy(freebusy, uid, organiser, organiser_attr)])
paul@223 225
paul@229 226
        return None
paul@229 227
paul@49 228
    def process_args(self, args, stream):
paul@49 229
paul@49 230
        """
paul@49 231
        Interpret the given program arguments 'args' and process input from the
paul@49 232
        given 'stream'.
paul@49 233
        """
paul@49 234
paul@49 235
        # Obtain the different kinds of recipients plus sender address.
paul@49 236
paul@49 237
        original_recipients = []
paul@49 238
        recipients = []
paul@49 239
        senders = []
paul@60 240
        lmtp = []
paul@96 241
        outgoing_only = False
paul@49 242
paul@49 243
        l = []
paul@49 244
paul@49 245
        for arg in args:
paul@49 246
paul@96 247
            # Detect outgoing processing mode.
paul@96 248
paul@96 249
            if arg == "-O":
paul@96 250
                outgoing_only = True
paul@96 251
paul@49 252
            # Switch to collecting recipients.
paul@49 253
paul@49 254
            if arg == "-o":
paul@49 255
                l = original_recipients
paul@49 256
paul@49 257
            # Switch to collecting senders.
paul@49 258
paul@49 259
            elif arg == "-s":
paul@49 260
                l = senders
paul@49 261
paul@60 262
            # Switch to getting the LMTP socket.
paul@60 263
paul@60 264
            elif arg == "-l":
paul@60 265
                l = lmtp
paul@60 266
paul@49 267
            # Ignore debugging options.
paul@49 268
paul@49 269
            elif arg == "-d":
paul@49 270
                pass
paul@49 271
            else:
paul@49 272
                l.append(arg)
paul@49 273
paul@82 274
        self.messenger.sender = senders and senders[0] or self.messenger.sender
paul@60 275
        self.lmtp_socket = lmtp and lmtp[0] or None
paul@515 276
        self.process(stream, original_recipients, outgoing_only)
paul@49 277
paul@49 278
    def __call__(self):
paul@49 279
paul@49 280
        """
paul@49 281
        Obtain arguments from the command line to initialise the processor
paul@49 282
        before invoking it.
paul@49 283
        """
paul@49 284
paul@49 285
        args = sys.argv[1:]
paul@49 286
paul@49 287
        if "-d" in args:
paul@49 288
            self.process_args(args, sys.stdin)
paul@49 289
        else:
paul@49 290
            try:
paul@49 291
                self.process_args(args, sys.stdin)
paul@49 292
            except SystemExit, value:
paul@49 293
                sys.exit(value)
paul@49 294
            except Exception, exc:
paul@60 295
                if "-v" in args:
paul@60 296
                    raise
paul@49 297
                type, value, tb = sys.exc_info()
paul@310 298
                while tb.tb_next:
paul@310 299
                    tb = tb.tb_next
paul@310 300
                f = tb.tb_frame
paul@310 301
                co = f and f.f_code
paul@310 302
                filename = co and co.co_filename
paul@310 303
                print >>sys.stderr, "Exception %s at %d in %s" % (exc, tb.tb_lineno, filename)
paul@82 304
                #import traceback
paul@82 305
                #traceback.print_exc(file=open("/tmp/mail.log", "a"))
paul@49 306
                sys.exit(EX_TEMPFAIL)
paul@49 307
        sys.exit(0)
paul@49 308
paul@49 309
# vim: tabstop=4 expandtab shiftwidth=4