Exemplo n.º 1
0
class EmailDistributor(Component):

    implements(IAnnouncementDistributor)

    formatters = ExtensionPoint(IAnnouncementFormatter)
    decorators = ExtensionPoint(IAnnouncementEmailDecorator)

    resolvers = OrderedExtensionsOption('announcer',
        'email_address_resolvers', IEmailAddressResolver,
        'SpecifiedEmailResolver, SessionEmailResolver, '
        'DefaultDomainEmailResolver',
        """Comma seperated list of email resolver components in the order
        they will be called.  If an email address is resolved, the remaining
        resolvers will not be called.
        """)

    email_sender = ExtensionOption('announcer', 'email_sender',
        IEmailSender, 'SmtpEmailSender',
        """Name of the component implementing `IEmailSender`.

        This component is used by the announcer system to send emails.
        Currently, `SmtpEmailSender` and `SendmailEmailSender` are provided.
        """)

    enabled = BoolOption('announcer', 'email_enabled', True,
        """Enable email notification.""")

    email_from = Option('announcer', 'email_from', 'trac@localhost',
        """Sender address to use in notification emails.""")

    from_name = Option('announcer', 'email_from_name', '',
        """Sender name to use in notification emails.""")

    reply_to = Option('announcer', 'email_replyto', 'trac@localhost',
        """Reply-To address to use in notification emails.""")

    mime_encoding = Option('announcer', 'mime_encoding', 'base64',
        """Specifies the MIME encoding scheme for emails.

        Valid options are 'base64' for Base64 encoding, 'qp' for
        Quoted-Printable, and 'none' for no encoding. Note that the no
        encoding means that non-ASCII characters in text are going to cause
        problems with notifications.
        """)

    use_public_cc = BoolOption('announcer', 'use_public_cc', 'false',
        """Recipients can see email addresses of other CC'ed recipients.

        If this option is disabled (the default), recipients are put on BCC
        """)

    # used in email decorators, but not here
    subject_prefix = Option('announcer', 'email_subject_prefix',
        '__default__',
        """Text to prepend to subject line of notification emails.

        If the setting is not defined, then the [$project_name] prefix.
        If no prefix is desired, then specifying an empty option
        will disable it.
        """)

    to_default = 'undisclosed-recipients: ;'
    to = Option('announcer', 'email_to', to_default, 'Default To: field')

    use_threaded_delivery = BoolOption('announcer', 'use_threaded_delivery',
        False,
        """Do message delivery in a separate thread.

        Enabling this will improve responsiveness for requests that end up
        with an announcement being sent over email. It requires building
        Python with threading support enabled-- which is usually the case.
        To test, start Python and type 'import threading' to see
        if it raises an error.
        """)

    default_email_format = Option('announcer', 'default_email_format',
        'text/plain',
        """The default mime type of the email notifications.

        This can be overridden on a per user basis through the announcer
        preferences panel.
        """)

    rcpt_allow_regexp = Option('announcer', 'rcpt_allow_regexp', '',
        """A whitelist pattern to match any address to before adding to
        recipients list.
        """)

    rcpt_local_regexp = Option('announcer', 'rcpt_local_regexp', '',
        """A whitelist pattern to match any address, that should be
        considered local.

        This will be evaluated only if msg encryption is set too.
        Recipients with matching email addresses will continue to
        receive unencrypted email messages.
        """)

    crypto = Option('announcer', 'email_crypto', '',
        """Enable cryptographically operation on email msg body.

        Empty string, the default for unset, disables all crypto operations.
        Valid values are:
            sign          sign msg body with given privkey
            encrypt       encrypt msg body with pubkeys of all recipients
            sign,encrypt  sign, than encrypt msg body
        """)

    # get GnuPG configuration options
    gpg_binary = Option('announcer', 'gpg_binary', 'gpg',
        """GnuPG binary name, allows for full path too.

        Value 'gpg' is same default as in python-gnupg itself.
        For usual installations location of the gpg binary is auto-detected.
        """)

    gpg_home = Option('announcer', 'gpg_home', '',
        """Directory containing keyring files.

        In case of wrong configuration missing keyring files without content
        will be created in the configured location, provided necessary
        write permssion is granted for the corresponding parent directory.
        """)

    private_key = Option('announcer', 'gpg_signing_key', None,
         """Keyid of private key (last 8 chars or more) used for signing.

         If unset, a private key will be selected from keyring automagicly.
         The password must be available i.e. provided by running gpg-agent
         or empty (bad security). On failing to unlock the private key,
         msg body will get emptied.
         """)

    def __init__(self):
        self.enigma = None
        self.delivery_queue = None
        self._init_pref_encoding()

    def get_delivery_queue(self):
        if not self.delivery_queue:
            self.delivery_queue = Queue.Queue()
            thread = DeliveryThread(self.delivery_queue, self.send)
            thread.start()
        return self.delivery_queue

    # IAnnouncementDistributor methods

    def transports(self):
        yield 'email'

    def formats(self, transport, realm):
        """Find valid formats for transport and realm."""
        formats = {}
        for f in self.formatters:
            for style in f.styles(transport, realm):
                formats[style] = f
        self.log.debug("EmailDistributor has found the following formats "
                       "capable of handling '%s' of '%s': %s",
                       transport, realm, ', '.join(formats.keys()))
        if not formats:
            self.log.error("EmailDistributor is unable to continue without "
                           "supporting formatters.")
        return formats

    def distribute(self, transport, recipients, event):
        found = False
        for supported_transport in self.transports():
            if supported_transport == transport:
                found = True
        if not self.enabled or not found:
            self.log.debug("EmailDistributor email_enabled set to false")
            return
        formats = self.formats(transport, event.realm)
        if not formats:
            self.log.error("EmailDistributor No formats found for %s %s",
                           transport, event.realm)
            return
        msgdict = {}
        msgdict_encrypt = {}
        msg_pubkey_ids = []
        # compile pattern before use for better performance
        rcpt_allow_re = re.compile(self.rcpt_allow_regexp)
        rcpt_local_re = re.compile(self.rcpt_local_regexp)

        if self.crypto != '':
            self.log.debug("EmailDistributor attempts crypto operation.")
            self.enigma = CryptoTxt(self.gpg_binary, self.gpg_home)

        for name, authed, address in recipients:
            fmt = name and \
                  self._get_preferred_format(event.realm, name, authed) or \
                  self._get_default_format()
            old_fmt = fmt
            if fmt not in formats:
                self.log.debug("EmailDistributor format %s not available "
                               "for %s %s, looking for an alternative",
                               fmt, transport, event.realm)
                # If the fmt is not available for this realm, then try to find
                # an alternative
                fmt = None
                for f in formats.values():
                    fmt = f.alternative_style_for(
                        transport, event.realm, old_fmt)
                    if fmt:
                        break
            if not fmt:
                self.log.error("EmailDistributor was unable to find a "
                               "formatter for format %s", old_fmt)
                continue
            resolver = None
            if name and not address:
                # figure out what the addr should be if it's not defined
                for resolver in self.resolvers:
                    address = resolver.get_address_for_session(name, authed)
                    if address:
                        break
            if address:
                self.log.debug("EmailDistributor found the address '%s' "
                               "for '%s (%s)' via: %s", address, name,
                               authed and 'authenticated' or
                               'not authenticated',
                               resolver.__class__.__name__)

                # ok, we found an addr, add the message
                # but wait, check for allowed rcpt first, if set
                if rcpt_allow_re.search(address) is not None:
                    # check for local recipients now
                    local_match = rcpt_local_re.search(address)
                    if self.crypto in ['encrypt', 'sign,encrypt'] and \
                            local_match is None:
                        # search available public keys for matching UID
                        pubkey_ids = self.enigma.get_pubkey_ids(address)
                        if pubkey_ids > 0:
                            msgdict_encrypt.setdefault(fmt, set())\
                                .add((name, authed, address))
                            msg_pubkey_ids[len(msg_pubkey_ids):] = pubkey_ids
                            self.log.debug("EmailDistributor got pubkeys "
                                           "for %s: %s", address, pubkey_ids)
                        else:
                            self.log.debug("EmailDistributor dropped %s "
                                           "after missing pubkey with "
                                           "corresponding address %s in any "
                                           "UID", name, address)
                    else:
                        msgdict.setdefault(fmt, set())\
                            .add((name, authed, address))
                        if local_match is not None:
                            self.log.debug("EmailDistributor expected local "
                                           "delivery for %s to: %s", name,
                                           address)
                else:
                    self.log.debug("EmailDistributor dropped %s for not "
                                   "matching allowed recipient pattern %s",
                                   address, self.rcpt_allow_regexp)
            else:
                self.log.debug("EmailDistributor was unable to find an "
                               "address for: %s (%s)", name, authed and
                               'authenticated' or 'not authenticated')
        for k, v in msgdict.items():
            if not v or not formats.get(k):
                continue
            fmt = formats[k]
            self.log.debug("EmailDistributor is sending event as '%s' to: "
                           "%s", fmt, ', '.join(x[2] for x in v))
            self._do_send(transport, event, k, v, fmt)
        for k, v in msgdict_encrypt.items():
            if not v or not formats.get(k):
                continue
            fmt = formats[k]
            self.log.debug("EmailDistributor is sending encrypted info on "
                           "event as '%s' to: %s", fmt,
                           ', '.join(x[2] for x in v))
            self._do_send(transport, event, k, v, formats[k], msg_pubkey_ids)

    def _get_default_format(self):
        return self.default_email_format

    def _get_preferred_format(self, realm, sid, authenticated):
        if authenticated is None:
            authenticated = 0
        # Format is unified for all subscriptions of a user.
        result = Subscription.find_by_sid_and_distributor(
            self.env, sid, authenticated, 'email')
        if result:
            chosen = result[0]['format']
            self.log.debug("EmailDistributor determined the preferred format"
                           " for '%s (%s)' is: %s", sid, authenticated and
                           'authenticated' or 'not authenticated', chosen)
            return chosen
        else:
            return self._get_default_format()

    def _init_pref_encoding(self):
        self._charset = Charset()
        self._charset.input_charset = 'utf-8'
        pref = self.mime_encoding.lower()
        if pref == 'base64':
            self._charset.header_encoding = BASE64
            self._charset.body_encoding = BASE64
            self._charset.output_charset = 'utf-8'
            self._charset.input_codec = 'utf-8'
            self._charset.output_codec = 'utf-8'
        elif pref in ['qp', 'quoted-printable']:
            self._charset.header_encoding = QP
            self._charset.body_encoding = QP
            self._charset.output_charset = 'utf-8'
            self._charset.input_codec = 'utf-8'
            self._charset.output_codec = 'utf-8'
        elif pref == 'none':
            self._charset.header_encoding = None
            self._charset.body_encoding = None
            self._charset.input_codec = None
            self._charset.output_charset = 'ascii'
        else:
            raise TracError(_("Invalid email encoding setting: %(pref)s",
                              pref=pref))

    def _message_id(self, realm):
        """Generate a predictable, but sufficiently unique message ID."""
        modtime = time.time()
        rand = random.randint(0, 32000)
        s = '%s.%d.%d.%s' % (self.env.project_url,
                             modtime, rand,
                             realm.encode('ascii', 'ignore'))
        dig = hashlib.md5(s).hexdigest()
        host = self.email_from[self.email_from.find('@') + 1:]
        msgid = '<%03d.%s@%s>' % (len(s), dig, host)
        return msgid

    def _filter_recipients(self, rcpt):
        return rcpt

    def _do_send(self, transport, event, format, recipients, formatter,
                 pubkey_ids=None):
        pubkey_ids = pubkey_ids or []
        # Prepare sender for use in IEmailSender component and message header.
        from_header = formataddr(
            (self.from_name and self.from_name or self.env.project_name,
             self.email_from)
        )
        headers = dict()
        headers['Message-ID'] = self._message_id(event.realm)
        headers['Date'] = formatdate()
        headers['From'] = from_header
        headers['Reply-To'] = self.reply_to

        recip_adds = [x[2] for x in recipients if x]

        if self.use_public_cc:
            headers['Cc'] = ', '.join(recip_adds)
        else:
            # Use localized Bcc: hint for default To: content.
            if self.to == self.to_default:
                headers['To'] = _("undisclosed-recipients: ;")
            else:
                headers['To'] = '"%s"' % self.to
                if self.to:
                    recip_adds += [self.to]
        if not recip_adds:
            self.log.debug("EmailDistributor stopped (no recipients).")
            return
        self.log.debug("All email recipients: %s", recip_adds)

        root_message = MIMEMultipart('related')

        # Write header data into message object.
        for k, v in headers.iteritems():
            set_header(root_message, k, v)

        output = formatter.format(transport, event.realm, format, event)

        # DEVEL: Currently crypto operations work with format text/plain only.
        alternate_output = None
        alternate_style = []
        if self.crypto != '' and pubkey_ids:
            if self.crypto == 'sign':
                output = self.enigma.sign(output, self.private_key)
            elif self.crypto == 'encrypt':
                output = self.enigma.encrypt(output, pubkey_ids)
            elif self.crypto == 'sign,encrypt':
                output = self.enigma.sign_encrypt(output, pubkey_ids,
                                                  self.private_key)
            self.log.debug(output)
            self.log.debug("EmailDistributor crypto operation successful.")
        else:
            alternate_style = formatter.alternative_style_for(
                transport,
                event.realm,
                format
            )
            if alternate_style:
                alternate_output = formatter.format(
                    transport,
                    event.realm,
                    alternate_style,
                    event
                )

        # Sanity check for suitable encoding setting.
        if not self._charset.body_encoding:
            try:
                output.encode('ascii')
            except UnicodeDecodeError:
                raise TracError(_("Ticket contains non-ASCII chars. Please "
                                  "change encoding setting"))

        root_message.preamble = "This is a multi-part message in MIME format."
        if alternate_output:
            parent_message = MIMEMultipart('alternative')
            root_message.attach(parent_message)

            alt_msg_format = 'html' in alternate_style and 'html' or 'plain'
            if isinstance(alternate_output, unicode):
                alternate_output = alternate_output.encode('utf-8')
            msg_text = MIMEText(alternate_output, alt_msg_format)
            msg_text.set_charset(self._charset)
            parent_message.attach(msg_text)
        else:
            parent_message = root_message

        msg_format = 'html' in format and 'html' or 'plain'
        if isinstance(output, unicode):
            output = output.encode('utf-8')
        msg_text = MIMEText(output, msg_format)
        del msg_text['Content-Transfer-Encoding']
        msg_text.set_charset(self._charset)
        # According to RFC 2046, the last part of a multipart message is best
        #   and preferred.
        parent_message.attach(msg_text)

        # DEVEL: Decorators can interfere with crypto operation here. Fix it.
        decorators = self._get_decorators()
        if decorators:
            decorator = decorators.pop()
            decorator.decorate_message(event, root_message, decorators)

        package = (from_header, recip_adds, root_message.as_string())
        start = time.time()
        if self.use_threaded_delivery:
            self.get_delivery_queue().put(package)
        else:
            self.send(*package)
        stop = time.time()
        self.log.debug("EmailDistributor took %s seconds to send.",
                       round(stop - start, 2))

    def send(self, from_addr, recipients, message):
        """Send message to recipients via e-mail."""
        # Ensure the message complies with RFC2822: use CRLF line endings
        message = CRLF.join(re.split('\r?\n', message))
        self.email_sender.send(from_addr, recipients, message)

    def _get_decorators(self):
        return self.decorators[:]
Exemplo n.º 2
0
class EmailDistributor(Component):

    implements(IAnnouncementDistributor, IAnnouncementPreferenceProvider)

    formatters = ExtensionPoint(IAnnouncementFormatter)
    producers = ExtensionPoint(IAnnouncementProducer)
    distributors = ExtensionPoint(IAnnouncementDistributor)
    # Make ordered
    decorators = ExtensionPoint(IAnnouncementEmailDecorator)

    resolvers = OrderedExtensionsOption('announcer', 'email_address_resolvers',
        IAnnouncementAddressResolver, 'SpecifiedEmailResolver, '\
        'SessionEmailResolver, DefaultDomainEmailResolver',
        """Comma seperated list of email resolver components in the order
        they will be called.  If an email address is resolved, the remaining
        resolvers will not be called.
        """)

    email_sender = ExtensionOption(
        'announcer', 'email_sender', IEmailSender, 'SmtpEmailSender',
        """Name of the component implementing `IEmailSender`.

        This component is used by the announcer system to send emails.
        Currently, `SmtpEmailSender` and `SendmailEmailSender` are provided.
        """)

    enabled = BoolOption('announcer', 'email_enabled', 'true',
                         """Enable email notification.""")

    email_from = Option('announcer', 'email_from', 'trac@localhost',
                        """Sender address to use in notification emails.""")

    from_name = Option('announcer', 'email_from_name', '',
                       """Sender name to use in notification emails.""")

    replyto = Option('announcer', 'email_replyto', 'trac@localhost',
                     """Reply-To address to use in notification emails.""")

    mime_encoding = ChoiceOption(
        'announcer', 'mime_encoding', ['base64', 'qp', 'none'],
        """Specifies the MIME encoding scheme for emails.

        Valid options are 'base64' for Base64 encoding, 'qp' for
        Quoted-Printable, and 'none' for no encoding. Note that the no encoding
        means that non-ASCII characters in text are going to cause problems
        with notifications.
        """)

    use_public_cc = BoolOption(
        'announcer', 'use_public_cc', 'false',
        """Recipients can see email addresses of other CC'ed recipients.

        If this option is disabled (the default), recipients are put on BCC
        """)

    # used in email decorators, but not here
    subject_prefix = Option(
        'announcer', 'email_subject_prefix', '__default__',
        """Text to prepend to subject line of notification emails.

        If the setting is not defined, then the [$project_name] prefix.
        If no prefix is desired, then specifying an empty option
        will disable it.
        """)

    to = Option('announcer', 'email_to', 'undisclosed-recipients: ;',
                'Default To: field')

    use_threaded_delivery = BoolOption(
        'announcer', 'use_threaded_delivery', 'false',
        """Do message delivery in a separate thread.

        Enabling this will improve responsiveness for requests that end up
        with an announcement being sent over email. It requires building
        Python with threading support enabled-- which is usually the case.
        To test, start Python and type 'import threading' to see
        if it raises an error.
        """)

    default_email_format = Option(
        'announcer', 'default_email_format', 'text/plain',
        """The default mime type of the email notifications.

        This can be overridden on a per user basis through the announcer
        preferences panel.
        """)

    set_message_id = BoolOption(
        'announcer', 'set_message_id', 'true',
        """Disable if you would prefer to let the email server handle
        message-id generation.
        """)

    rcpt_allow_regexp = Option(
        'announcer', 'rcpt_allow_regexp', '',
        """A whitelist pattern to match any address to before adding to
        recipients list.
        """)

    rcpt_local_regexp = Option(
        'announcer', 'rcpt_local_regexp', '',
        """A whitelist pattern to match any address, that should be
        considered local.

        This will be evaluated only if msg encryption is set too.
        Recipients with matching email addresses will continue to
        receive unencrypted email messages.
        """)

    crypto = Option(
        'announcer', 'email_crypto', '',
        """Enable cryptographically operation on email msg body.

        Empty string, the default for unset, disables all crypto operations.
        Valid values are:
            sign          sign msg body with given privkey
            encrypt       encrypt msg body with pubkeys of all recipients
            sign,encrypt  sign, than encrypt msg body
        """)

    # get GnuPG configuration options
    gpg_binary = Option(
        'announcer', 'gpg_binary', 'gpg',
        """GnuPG binary name, allows for full path too.

        Value 'gpg' is same default as in python-gnupg itself.
        For usual installations location of the gpg binary is auto-detected.
        """)

    gpg_home = Option(
        'announcer', 'gpg_home', '', """Directory containing keyring files.

        In case of wrong configuration missing keyring files without content
        will be created in the configured location, provided necessary
        write permssion is granted for the corresponding parent directory.
        """)

    private_key = Option(
        'announcer', 'gpg_signing_key', None,
        """Keyid of private key (last 8 chars or more) used for signing.

        If unset, a private key will be selected from keyring automagicly.
        The password must be available i.e. provided by running gpg-agent
        or empty (bad security). On failing to unlock the private key,
        msg body will get emptied.
        """)

    def __init__(self):
        self.delivery_queue = None
        self._init_pref_encoding()

    def get_delivery_queue(self):
        if not self.delivery_queue:
            self.delivery_queue = Queue.Queue()
            thread = DeliveryThread(self.delivery_queue, self.send)
            thread.start()
        return self.delivery_queue

    # IAnnouncementDistributor
    def transports(self):
        yield "email"

    def formats(self, transport, realm):
        "Find valid formats for transport and realm"
        formats = {}
        for f in self.formatters:
            for style in f.styles(transport, realm):
                formats[style] = f
        self.log.debug(
            "EmailDistributor has found the following formats capable "
            "of handling '%s' of '%s': %s" %
            (transport, realm, ', '.join(formats.keys())))
        if not formats:
            self.log.error("EmailDistributor is unable to continue " \
                    "without supporting formatters.")
        return formats

    def distribute(self, transport, recipients, event):
        found = False
        for supported_transport in self.transports():
            if supported_transport == transport:
                found = True
        if not self.enabled or not found:
            self.log.debug("EmailDistributer email_enabled set to false")
            return
        fmtdict = self.formats(transport, event.realm)
        if not fmtdict:
            self.log.error("EmailDistributer No formats found for %s %s" %
                           (transport, event.realm))
            return
        msgdict = {}
        msgdict_encrypt = {}
        msg_pubkey_ids = []
        # compile pattern before use for better performance
        RCPT_ALLOW_RE = re.compile(self.rcpt_allow_regexp)
        RCPT_LOCAL_RE = re.compile(self.rcpt_local_regexp)

        if self.crypto != '':
            self.log.debug("EmailDistributor attempts crypto operation.")
            self.enigma = CryptoTxt(self.gpg_binary, self.gpg_home)

        for name, authed, addr in recipients:
            fmt = name and \
                self._get_preferred_format(event.realm, name, authed) or \
                self._get_default_format()
            if fmt not in fmtdict:
                self.log.debug(("EmailDistributer format %s not available " +
                                "for %s %s, looking for an alternative") %
                               (fmt, transport, event.realm))
                # If the fmt is not available for this realm, then try to find
                # an alternative
                oldfmt = fmt
                fmt = None
                for f in fmtdict.values():
                    fmt = f.alternative_style_for(transport, event.realm,
                                                  oldfmt)
                    if fmt: break
            if not fmt:
                self.log.error(
                    "EmailDistributer was unable to find a formatter " +
                    "for format %s" % k)
                continue
            rslvr = None
            if name and not addr:
                # figure out what the addr should be if it's not defined
                for rslvr in self.resolvers:
                    addr = rslvr.get_address_for_name(name, authed)
                    if addr: break
            if addr:
                self.log.debug("EmailDistributor found the " \
                        "address '%s' for '%s (%s)' via: %s"%(
                        addr, name, authed and \
                        'authenticated' or 'not authenticated',
                        rslvr.__class__.__name__))

                # ok, we found an addr, add the message
                # but wait, check for allowed rcpt first, if set
                if RCPT_ALLOW_RE.search(addr) is not None:
                    # check for local recipients now
                    local_match = RCPT_LOCAL_RE.search(addr)
                    if self.crypto in ['encrypt', 'sign,encrypt'] and \
                            local_match is None:
                        # search available public keys for matching UID
                        pubkey_ids = self.enigma.get_pubkey_ids(addr)
                        if len(pubkey_ids) > 0:
                            msgdict_encrypt.setdefault(fmt, set()).add(
                                (name, authed, addr))
                            msg_pubkey_ids[len(msg_pubkey_ids):] = pubkey_ids
                            self.log.debug("EmailDistributor got pubkeys " \
                                "for %s: %s" % (addr, pubkey_ids))
                        else:
                            self.log.debug("EmailDistributor dropped %s " \
                                "after missing pubkey with corresponding " \
                                "address %s in any UID" % (name, addr))
                    else:
                        msgdict.setdefault(fmt, set()).add(
                            (name, authed, addr))
                        if local_match is not None:
                            self.log.debug("EmailDistributor expected " \
                                "local delivery for %s to: %s" % (name, addr))
                else:
                    self.log.debug("EmailDistributor dropped %s for " \
                        "not matching allowed recipient pattern %s" % \
                        (addr, self.rcpt_allow_regexp))
            else:
                self.log.debug("EmailDistributor was unable to find an " \
                        "address for: %s (%s)"%(name, authed and \
                        'authenticated' or 'not authenticated'))
        for k, v in msgdict.items():
            if not v or not fmtdict.get(k):
                continue
            self.log.debug("EmailDistributor is sending event as '%s' to: %s" %
                           (fmt, ', '.join(x[2] for x in v)))
            self._do_send(transport, event, k, v, fmtdict[k])
        for k, v in msgdict_encrypt.items():
            if not v or not fmtdict.get(k):
                continue
            self.log.debug(
                "EmailDistributor is sending encrypted info on event " \
                "as '%s' to: %s"%(fmt, ', '.join(x[2] for x in v)))
            self._do_send(transport, event, k, v, fmtdict[k], msg_pubkey_ids)

    def _get_default_format(self):
        return self.default_email_format

    def _get_preferred_format(self, realm, sid, authenticated):
        if authenticated is None:
            authenticated = 0
        db = self.env.get_db_cnx()
        cursor = db.cursor()
        cursor.execute(
            """
            SELECT value
              FROM session_attribute
             WHERE sid=%s
               AND authenticated=%s
               AND name=%s
        """, (sid, int(authenticated), 'announcer_email_format_%s' % realm))
        result = cursor.fetchone()
        if result:
            chosen = result[0]
            self.log.debug("EmailDistributor determined the preferred format" \
                    " for '%s (%s)' is: %s"%(sid, authenticated and \
                    'authenticated' or 'not authenticated', chosen))
            return chosen
        else:
            return self._get_default_format()

    def _init_pref_encoding(self):
        self._charset = Charset()
        self._charset.input_charset = 'utf-8'
        pref = self.mime_encoding.lower()
        if pref == 'base64':
            self._charset.header_encoding = BASE64
            self._charset.body_encoding = BASE64
            self._charset.output_charset = 'utf-8'
            self._charset.input_codec = 'utf-8'
            self._charset.output_codec = 'utf-8'
        elif pref in ['qp', 'quoted-printable']:
            self._charset.header_encoding = QP
            self._charset.body_encoding = QP
            self._charset.output_charset = 'utf-8'
            self._charset.input_codec = 'utf-8'
            self._charset.output_codec = 'utf-8'
        elif pref == 'none':
            self._charset.header_encoding = None
            self._charset.body_encoding = None
            self._charset.input_codec = None
            self._charset.output_charset = 'ascii'
        else:
            raise TracError(_('Invalid email encoding setting: %s' % pref))

    def _message_id(self, realm):
        """Generate an unique message ID."""
        modtime = time.time()
        s = '%s.%d.%s' % (self.env.project_url, modtime,
                          realm.encode('ascii', 'ignore'))
        dig = md5(s).hexdigest()
        host = self.email_from[self.email_from.find('@') + 1:]
        msgid = '<%03d.%s@%s>' % (len(s), dig, host)
        return msgid

    def _filter_recipients(self, rcpt):
        return rcpt

    def _do_send(self,
                 transport,
                 event,
                 format,
                 recipients,
                 formatter,
                 pubkey_ids=[]):

        output = formatter.format(transport, event.realm, format, event)

        # DEVEL: force message body plaintext style for crypto operations
        if self.crypto != '' and pubkey_ids != []:
            if self.crypto == 'sign':
                output = self.enigma.sign(output, self.private_key)
            elif self.crypto == 'encrypt':
                output = self.enigma.encrypt(output, pubkey_ids)
            elif self.crypto == 'sign,encrypt':
                output = self.enigma.sign_encrypt(output, pubkey_ids,
                                                  self.private_key)

            self.log.debug(output)
            self.log.debug(_("EmailDistributor crypto operaton successful."))
            alternate_output = None
        else:
            alternate_style = formatter.alternative_style_for(
                transport, event.realm, format)
            if alternate_style:
                alternate_output = formatter.format(transport, event.realm,
                                                    alternate_style, event)
            else:
                alternate_output = None

        # sanity check
        if not self._charset.body_encoding:
            try:
                dummy = output.encode('ascii')
            except UnicodeDecodeError:
                raise TracError(_("Ticket contains non-ASCII chars. " \
                                  "Please change encoding setting"))

        rootMessage = MIMEMultipart("related")

        headers = dict()
        if self.set_message_id:
            # A different, predictable, but still sufficiently unique
            # message ID will be generated as replacement in
            # announcer.email_decorators.generic.ThreadingEmailDecorator
            # for email threads to work.
            headers['Message-ID'] = self._message_id(event.realm)
        headers['Date'] = formatdate()
        from_header = formataddr((self.from_name
                                  or self.env.project_name, self.email_from))
        headers['From'] = from_header
        headers['To'] = '"%s"' % (self.to)
        if self.use_public_cc:
            headers['Cc'] = ', '.join([x[2] for x in recipients if x])
        headers['Reply-To'] = self.replyto
        for k, v in headers.iteritems():
            set_header(rootMessage, k, v)

        rootMessage.preamble = 'This is a multi-part message in MIME format.'
        if alternate_output:
            parentMessage = MIMEMultipart('alternative')
            rootMessage.attach(parentMessage)

            alt_msg_format = 'html' in alternate_style and 'html' or 'plain'
            msgText = MIMEText(alternate_output, alt_msg_format)
            parentMessage.attach(msgText)
        else:
            parentMessage = rootMessage

        msg_format = 'html' in format and 'html' or 'plain'
        msgText = MIMEText(output, msg_format)
        del msgText['Content-Transfer-Encoding']
        msgText.set_charset(self._charset)
        parentMessage.attach(msgText)
        decorators = self._get_decorators()
        if len(decorators) > 0:
            decorator = decorators.pop()
            decorator.decorate_message(event, rootMessage, decorators)

        recip_adds = [x[2] for x in recipients if x]
        # Append any to, cc or bccs added to the recipient list
        for field in ('To', 'Cc', 'Bcc'):
            if rootMessage[field] and \
                    len(str(rootMessage[field]).split(',')) > 0:
                for addy in str(rootMessage[field]).split(','):
                    self._add_recipient(recip_adds, addy)
        # replace with localized bcc hint
        if headers['To'] == 'undisclosed-recipients: ;':
            set_header(rootMessage, 'To', _('undisclosed-recipients: ;'))

        self.log.debug("Content of recip_adds: %s" % (recip_adds))
        package = (from_header, recip_adds, rootMessage.as_string())
        start = time.time()
        if self.use_threaded_delivery:
            self.get_delivery_queue().put(package)
        else:
            self.send(*package)
        stop = time.time()
        self.log.debug("EmailDistributor took %s seconds to send."\
                %(round(stop-start,2)))

    def send(self, from_addr, recipients, message):
        """Send message to recipients via e-mail."""
        # Ensure the message complies with RFC2822: use CRLF line endings
        message = CRLF.join(re.split("\r?\n", message))
        self.email_sender.send(from_addr, recipients, message)

    def _get_decorators(self):
        return self.decorators[:]

    def _add_recipient(self, recipients, addy):
        if addy.strip() != '"undisclosed-recipients: ;"':
            recipients.append(addy)

    # IAnnouncementDistributor
    def get_announcement_preference_boxes(self, req):
        yield "email", _("E-Mail Format")

    def render_announcement_preference_box(self, req, panel):
        supported_realms = {}
        for producer in self.producers:
            for realm in producer.realms():
                for distributor in self.distributors:
                    for transport in distributor.transports():
                        for fmtr in self.formatters:
                            for style in fmtr.styles(transport, realm):
                                if realm not in supported_realms:
                                    supported_realms[realm] = set()
                                supported_realms[realm].add(style)

        if req.method == "POST":
            for realm in supported_realms:
                opt = req.args.get('email_format_%s' % realm, False)
                if opt:
                    req.session['announcer_email_format_%s' % realm] = opt
        prefs = {}
        for realm in supported_realms:
            prefs[realm] = req.session.get('announcer_email_format_%s' % realm,
                                           None) or self._get_default_format()
        data = dict(
            realms=supported_realms,
            preferences=prefs,
        )
        return "prefs_announcer_email.html", data
Exemplo n.º 3
0
class EmailDistributor(Component):

    implements(IAnnouncementDistributor, IAnnouncementPreferenceProvider)

    formatters = ExtensionPoint(IAnnouncementFormatter)
    producers = ExtensionPoint(IAnnouncementProducer)
    distributors = ExtensionPoint(IAnnouncementDistributor)
    # Make ordered
    decorators = ExtensionPoint(IAnnouncementEmailDecorator)

    resolvers = OrderedExtensionsOption('announcer', 'email_address_resolvers',
        IAnnouncementAddressResolver, 'SpecifiedEmailResolver, '\
        'SessionEmailResolver, DefaultDomainEmailResolver',
        """Comma seperated list of email resolver components in the order
        they will be called.  If an email address is resolved, the remaining
        resolvers will not be called.
        """)

    email_sender = ExtensionOption('announcer', 'email_sender',
        IEmailSender, 'SmtpEmailSender',
        """Name of the component implementing `IEmailSender`.

        This component is used by the announcer system to send emails.
        Currently, `SmtpEmailSender` and `SendmailEmailSender` are provided.
        """)

    enabled = BoolOption('announcer', 'email_enabled', 'true',
        """Enable email notification.""")

    email_from = Option('announcer', 'email_from', 'trac@localhost',
        """Sender address to use in notification emails.""")

    from_name = Option('announcer', 'email_from_name', '',
        """Sender name to use in notification emails.""")

    replyto = Option('announcer', 'email_replyto', 'trac@localhost',
        """Reply-To address to use in notification emails.""")

    mime_encoding = ChoiceOption('announcer', 'mime_encoding',
        ['base64', 'qp', 'none'],
        """Specifies the MIME encoding scheme for emails.

        Valid options are 'base64' for Base64 encoding, 'qp' for
        Quoted-Printable, and 'none' for no encoding. Note that the no encoding
        means that non-ASCII characters in text are going to cause problems
        with notifications.
        """)

    use_public_cc = BoolOption('announcer', 'use_public_cc', 'false',
        """Recipients can see email addresses of other CC'ed recipients.

        If this option is disabled (the default), recipients are put on BCC
        """)

    # used in email decorators, but not here
    subject_prefix = Option('announcer', 'email_subject_prefix',
                                 '__default__',
        """Text to prepend to subject line of notification emails.

        If the setting is not defined, then the [$project_name] prefix.
        If no prefix is desired, then specifying an empty option
        will disable it.
        """)

    to = Option('announcer', 'email_to', 'undisclosed-recipients: ;',
        'Default To: field')

    use_threaded_delivery = BoolOption('announcer', 'use_threaded_delivery',
        'false',
        """Do message delivery in a separate thread.

        Enabling this will improve responsiveness for requests that end up
        with an announcement being sent over email. It requires building
        Python with threading support enabled-- which is usually the case.
        To test, start Python and type 'import threading' to see
        if it raises an error.
        """)

    default_email_format = Option('announcer', 'default_email_format',
        'text/plain',
        """The default mime type of the email notifications.

        This can be overridden on a per user basis through the announcer
        preferences panel.
        """)

    set_message_id = BoolOption('announcer', 'set_message_id', 'true',
        """Disable if you would prefer to let the email server handle
        message-id generation.
        """)

    rcpt_allow_regexp = Option('announcer', 'rcpt_allow_regexp', '',
        """A whitelist pattern to match any address to before adding to
        recipients list.
        """)

    rcpt_local_regexp = Option('announcer', 'rcpt_local_regexp', '',
        """A whitelist pattern to match any address, that should be
        considered local.

        This will be evaluated only if msg encryption is set too.
        Recipients with matching email addresses will continue to
        receive unencrypted email messages.
        """)

    crypto = Option('announcer', 'email_crypto', '',
        """Enable cryptographically operation on email msg body.

        Empty string, the default for unset, disables all crypto operations.
        Valid values are:
            sign          sign msg body with given privkey
            encrypt       encrypt msg body with pubkeys of all recipients
            sign,encrypt  sign, than encrypt msg body
        """)

    # get GnuPG configuration options
    gpg_binary = Option('announcer', 'gpg_binary', 'gpg',
        """GnuPG binary name, allows for full path too.

        Value 'gpg' is same default as in python-gnupg itself.
        For usual installations location of the gpg binary is auto-detected.
        """)

    gpg_home = Option('announcer', 'gpg_home', '',
        """Directory containing keyring files.

        In case of wrong configuration missing keyring files without content
        will be created in the configured location, provided necessary
        write permssion is granted for the corresponding parent directory.
        """)

    private_key = Option('announcer', 'gpg_signing_key', None,
        """Keyid of private key (last 8 chars or more) used for signing.

        If unset, a private key will be selected from keyring automagicly.
        The password must be available i.e. provided by running gpg-agent
        or empty (bad security). On failing to unlock the private key,
        msg body will get emptied.
        """)


    def __init__(self):
        self.delivery_queue = None
        self._init_pref_encoding()

    def get_delivery_queue(self):
        if not self.delivery_queue:
            self.delivery_queue = Queue.Queue()
            thread = DeliveryThread(self.delivery_queue, self.send)
            thread.start()
        return self.delivery_queue

    # IAnnouncementDistributor
    def transports(self):
        yield "email"

    def formats(self, transport, realm):
        "Find valid formats for transport and realm"
        formats = {}
        for f in self.formatters:
            for style in f.styles(transport, realm):
                formats[style] = f
        self.log.debug(
            "EmailDistributor has found the following formats capable "
            "of handling '%s' of '%s': %s"%(transport, realm,
                ', '.join(formats.keys())))
        if not formats:
            self.log.error("EmailDistributor is unable to continue " \
                    "without supporting formatters.")
        return formats

    def distribute(self, transport, recipients, event):
        found = False
        for supported_transport in self.transports():
            if supported_transport == transport:
                found = True
        if not self.enabled or not found:
            self.log.debug("EmailDistributer email_enabled set to false")
            return
        fmtdict = self.formats(transport, event.realm)
        if not fmtdict:
            self.log.error(
                "EmailDistributer No formats found for %s %s"%(
                    transport, event.realm))
            return
        msgdict = {}
        msgdict_encrypt = {}
        msg_pubkey_ids = []
        # compile pattern before use for better performance
        RCPT_ALLOW_RE = re.compile(self.rcpt_allow_regexp)
        RCPT_LOCAL_RE = re.compile(self.rcpt_local_regexp)

        if self.crypto != '':
            self.log.debug("EmailDistributor attempts crypto operation.")
            self.enigma = CryptoTxt(self.gpg_binary, self.gpg_home)

        for name, authed, addr in recipients:
            fmt = name and \
                self._get_preferred_format(event.realm, name, authed) or \
                self._get_default_format()
            if fmt not in fmtdict:
                self.log.debug(("EmailDistributer format %s not available " +
                    "for %s %s, looking for an alternative")%(
                        fmt, transport, event.realm))
                # If the fmt is not available for this realm, then try to find
                # an alternative
                oldfmt = fmt
                fmt = None
                for f in fmtdict.values():
                    fmt = f.alternative_style_for(
                            transport, event.realm, oldfmt)
                    if fmt: break
            if not fmt:
                self.log.error(
                    "EmailDistributer was unable to find a formatter " +
                    "for format %s"%k
                )
                continue
            rslvr = None
            if name and not addr:
                # figure out what the addr should be if it's not defined
                for rslvr in self.resolvers:
                    addr = rslvr.get_address_for_name(name, authed)
                    if addr: break
            if addr:
                self.log.debug("EmailDistributor found the " \
                        "address '%s' for '%s (%s)' via: %s"%(
                        addr, name, authed and \
                        'authenticated' or 'not authenticated',
                        rslvr.__class__.__name__))

                # ok, we found an addr, add the message
                # but wait, check for allowed rcpt first, if set
                if RCPT_ALLOW_RE.search(addr) is not None:
                    # check for local recipients now
                    local_match = RCPT_LOCAL_RE.search(addr)
                    if self.crypto in ['encrypt', 'sign,encrypt'] and \
                            local_match is None:
                        # search available public keys for matching UID
                        pubkey_ids = self.enigma.get_pubkey_ids(addr)
                        if len(pubkey_ids) > 0:
                            msgdict_encrypt.setdefault(fmt, set()).add((name,
                                                            authed, addr))
                            msg_pubkey_ids[len(msg_pubkey_ids):] = pubkey_ids
                            self.log.debug("EmailDistributor got pubkeys " \
                                "for %s: %s" % (addr, pubkey_ids))
                        else:
                            self.log.debug("EmailDistributor dropped %s " \
                                "after missing pubkey with corresponding " \
                                "address %s in any UID" % (name, addr))
                    else:
                        msgdict.setdefault(fmt, set()).add((name, authed,
                                                            addr))
                        if local_match is not None:
                            self.log.debug("EmailDistributor expected " \
                                "local delivery for %s to: %s" % (name, addr))
                else:
                    self.log.debug("EmailDistributor dropped %s for " \
                        "not matching allowed recipient pattern %s" % \
                        (addr, self.rcpt_allow_regexp))
            else:
                self.log.debug("EmailDistributor was unable to find an " \
                        "address for: %s (%s)"%(name, authed and \
                        'authenticated' or 'not authenticated'))
        for k, v in msgdict.items():
            if not v or not fmtdict.get(k):
                continue
            self.log.debug(
                "EmailDistributor is sending event as '%s' to: %s"%(
                    fmt, ', '.join(x[2] for x in v)))
            self._do_send(transport, event, k, v, fmtdict[k])
        for k, v in msgdict_encrypt.items():
            if not v or not fmtdict.get(k):
                continue
            self.log.debug(
                "EmailDistributor is sending encrypted info on event " \
                "as '%s' to: %s"%(fmt, ', '.join(x[2] for x in v)))
            self._do_send(transport, event, k, v, fmtdict[k], msg_pubkey_ids)

    def _get_default_format(self):
        return self.default_email_format

    def _get_preferred_format(self, realm, sid, authenticated):
        if authenticated is None:
            authenticated = 0
        db = self.env.get_db_cnx()
        cursor = db.cursor()
        cursor.execute("""
            SELECT value
              FROM session_attribute
             WHERE sid=%s
               AND authenticated=%s
               AND name=%s
        """, (sid, int(authenticated), 'announcer_email_format_%s' % realm))
        result = cursor.fetchone()
        if result:
            chosen = result[0]
            self.log.debug("EmailDistributor determined the preferred format" \
                    " for '%s (%s)' is: %s"%(sid, authenticated and \
                    'authenticated' or 'not authenticated', chosen))
            return chosen
        else:
            return self._get_default_format()

    def _init_pref_encoding(self):
        self._charset = Charset()
        self._charset.input_charset = 'utf-8'
        pref = self.mime_encoding.lower()
        if pref == 'base64':
            self._charset.header_encoding = BASE64
            self._charset.body_encoding = BASE64
            self._charset.output_charset = 'utf-8'
            self._charset.input_codec = 'utf-8'
            self._charset.output_codec = 'utf-8'
        elif pref in ['qp', 'quoted-printable']:
            self._charset.header_encoding = QP
            self._charset.body_encoding = QP
            self._charset.output_charset = 'utf-8'
            self._charset.input_codec = 'utf-8'
            self._charset.output_codec = 'utf-8'
        elif pref == 'none':
            self._charset.header_encoding = None
            self._charset.body_encoding = None
            self._charset.input_codec = None
            self._charset.output_charset = 'ascii'
        else:
            raise TracError(_('Invalid email encoding setting: %s'%pref))

    def _message_id(self, realm):
        """Generate an unique message ID."""
        modtime = time.time()
        s = '%s.%d.%s' % (self.env.project_url, modtime,
                          realm.encode('ascii', 'ignore'))
        dig = md5(s).hexdigest()
        host = self.email_from[self.email_from.find('@') + 1:]
        msgid = '<%03d.%s@%s>' % (len(s), dig, host)
        return msgid

    def _filter_recipients(self, rcpt):
        return rcpt

    def _do_send(self, transport, event, format, recipients, formatter,
                 pubkey_ids=[]):

        output = formatter.format(transport, event.realm, format, event)

        # DEVEL: force message body plaintext style for crypto operations
        if self.crypto != '' and pubkey_ids != []:
            if self.crypto == 'sign':
                output = self.enigma.sign(output, self.private_key)
            elif self.crypto == 'encrypt':
                output = self.enigma.encrypt(output, pubkey_ids)
            elif self.crypto == 'sign,encrypt':
                output = self.enigma.sign_encrypt(output, pubkey_ids,
                                                     self.private_key)

            self.log.debug(output)
            self.log.debug(_("EmailDistributor crypto operaton successful."))
            alternate_output = None
        else:
            alternate_style = formatter.alternative_style_for(
                transport,
                event.realm,
                format
            )
            if alternate_style:
                alternate_output = formatter.format(
                    transport,
                    event.realm,
                    alternate_style,
                    event
                )
            else:
                alternate_output = None

        # sanity check
        if not self._charset.body_encoding:
            try:
                dummy = output.encode('ascii')
            except UnicodeDecodeError:
                raise TracError(_("Ticket contains non-ASCII chars. " \
                                  "Please change encoding setting"))

        rootMessage = MIMEMultipart("related")

        headers = dict()
        if self.set_message_id:
            # A different, predictable, but still sufficiently unique
            # message ID will be generated as replacement in
            # announcer.email_decorators.generic.ThreadingEmailDecorator
            # for email threads to work.
            headers['Message-ID'] = self._message_id(event.realm)
        headers['Date'] = formatdate()
        from_header = formataddr((
            self.from_name or self.env.project_name,
            self.email_from
        ))
        headers['From'] = from_header
        headers['To'] = '"%s"'%(self.to)
        if self.use_public_cc:
            headers['Cc'] = ', '.join([x[2] for x in recipients if x])
        headers['Reply-To'] = self.replyto
        for k, v in headers.iteritems():
            set_header(rootMessage, k, v)

        rootMessage.preamble = 'This is a multi-part message in MIME format.'
        if alternate_output:
            parentMessage = MIMEMultipart('alternative')
            rootMessage.attach(parentMessage)

            alt_msg_format = 'html' in alternate_style and 'html' or 'plain'
            msgText = MIMEText(alternate_output, alt_msg_format)
            parentMessage.attach(msgText)
        else:
            parentMessage = rootMessage

        msg_format = 'html' in format and 'html' or 'plain'
        msgText = MIMEText(output, msg_format)
        del msgText['Content-Transfer-Encoding']
        msgText.set_charset(self._charset)
        parentMessage.attach(msgText)
        decorators = self._get_decorators()
        if len(decorators) > 0:
            decorator = decorators.pop()
            decorator.decorate_message(event, rootMessage, decorators)

        recip_adds = [x[2] for x in recipients if x]
        # Append any to, cc or bccs added to the recipient list
        for field in ('To', 'Cc', 'Bcc'):
            if rootMessage[field] and \
                    len(str(rootMessage[field]).split(',')) > 0:
                for addy in str(rootMessage[field]).split(','):
                    self._add_recipient(recip_adds, addy)
        # replace with localized bcc hint
        if headers['To'] == 'undisclosed-recipients: ;':
            set_header(rootMessage, 'To', _('undisclosed-recipients: ;'))

        self.log.debug("Content of recip_adds: %s" %(recip_adds))
        package = (from_header, recip_adds, rootMessage.as_string())
        start = time.time()
        if self.use_threaded_delivery:
            self.get_delivery_queue().put(package)
        else:
            self.send(*package)
        stop = time.time()
        self.log.debug("EmailDistributor took %s seconds to send."\
                %(round(stop-start,2)))

    def send(self, from_addr, recipients, message):
        """Send message to recipients via e-mail."""
        # Ensure the message complies with RFC2822: use CRLF line endings
        message = CRLF.join(re.split("\r?\n", message))
        self.email_sender.send(from_addr, recipients, message)

    def _get_decorators(self):
        return self.decorators[:]

    def _add_recipient(self, recipients, addy):
        if addy.strip() != '"undisclosed-recipients: ;"':
            recipients.append(addy)

    # IAnnouncementDistributor
    def get_announcement_preference_boxes(self, req):
        yield "email", _("E-Mail Format")

    def render_announcement_preference_box(self, req, panel):
        supported_realms = {}
        for producer in self.producers:
            for realm in producer.realms():
                for distributor in self.distributors:
                    for transport in distributor.transports():
                        for fmtr in self.formatters:
                            for style in fmtr.styles(transport, realm):
                                if realm not in supported_realms:
                                    supported_realms[realm] = set()
                                supported_realms[realm].add(style)

        if req.method == "POST":
            for realm in supported_realms:
                opt = req.args.get('email_format_%s'%realm, False)
                if opt:
                    req.session['announcer_email_format_%s'%realm] = opt
        prefs = {}
        for realm in supported_realms:
            prefs[realm] = req.session.get('announcer_email_format_%s'%realm,
                                           None) or self._get_default_format()
        data = dict(
            realms = supported_realms,
            preferences = prefs,
        )
        return "prefs_announcer_email.html", data