Commits

Jun Omae  committed c3d3106 Merge

merged advanced-notification-preferences

  • Participants
  • Parent commits 5049a41, 822b61a

Comments (0)

Files changed (20)

  * Michele Cella
  * Sergey S. Chernov              sergeych@tancher.com
  * Felix Colins                   felix@keyghost.com
+ * Robert Corsaro
  * Simon Cross                    hodgestar@gmail.com
  * Wesley Crucius                 wcrucius@sandc.com
  * Wolfram Diestel                diestel@steloj.de
  * Tim Hatch                      trac@timhatch.com
  * Oren Held                      oren@held.org.il
  * Mikko Hellsing
+ * Steffen Hoffmann
  * Michael Hope                   michael.hope@hamjet.co.nz
  * Laurens Holst                  laurens@grauw.nl
  * David Huang                    khym@azeotrope.org
         trac.mimeview.pygments = trac.mimeview.pygments[Pygments]
         trac.mimeview.rst = trac.mimeview.rst[reST]
         trac.mimeview.txtl = trac.mimeview.txtl[Textile]
+        trac.notification.api = trac.notification.api
+        trac.notification.compat = trac.notification.compat
+        trac.notification.mail = trac.notification.mail
+        trac.notification.prefs = trac.notification.prefs
         trac.prefs = trac.prefs.web_ui
         trac.search = trac.search.web_ui
         trac.ticket.admin = trac.ticket.admin

File trac/db_default.py

 from trac.db import Table, Column, Index
 
 # Database version identifier. Used for automatic upgrades.
-db_version = 29
+db_version = 30
 
 def __mkreports(reports):
     """Utility function used to create report data in same syntax as the
         Column('title'),
         Column('query'),
         Column('description')],
+
+    # Notification system
+    Table('subscription', key='id')[
+        Column('id', auto_increment=True),
+        Column('time', type='int64'),
+        Column('changetime', type='int64'),
+        Column('class'),
+        Column('sid'),
+        Column('authenticated', type='int'),
+        Column('distributor'),
+        Column('format'),
+        Column('priority', type='int'),
+        Column('adverb')],
 ]
 
 

File trac/htdocs/css/prefs.css

  vertical-align: middle;
  white-space: nowrap;
 }
+
+#content.prefs div.prefs_child {
+ margin-bottom: 4em;
+ margin-top: 4em;
+}
+#content.prefs div.prefs_child  h2 {
+ background: #f7f7f7;
+ border-bottom: 1px solid #d7d7d7;
+ box-shadow: 1px 1px .5em 0 #ccc;
+ border-radius: .1em;
+ padding: .2em .4em;
+}

File trac/notification.py

-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2003-2009 Edgewall Software
-# Copyright (C) 2003-2005 Daniel Lundin <daniel@edgewall.com>
-# Copyright (C) 2005-2006 Emmanuel Blot <emmanuel.blot@free.fr>
-# All rights reserved.
-#
-# This software is licensed as described in the file COPYING, which
-# you should have received as part of this distribution. The terms
-# are also available at http://trac.edgewall.org/wiki/TracLicense.
-#
-# This software consists of voluntary contributions made by many
-# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://trac.edgewall.org/log/.
-
-import os
-import re
-import smtplib
-from subprocess import Popen, PIPE
-import time
-
-from genshi.builder import tag
-
-from trac import __version__
-from trac.config import BoolOption, ExtensionOption, IntOption, Option
-from trac.core import *
-from trac.util.compat import close_fds
-from trac.util.text import CRLF, fix_eol
-from trac.util.translation import _, deactivate, reactivate
-
-MAXHEADERLEN = 76
-EMAIL_LOOKALIKE_PATTERN = (
-        # the local part
-        r"[a-zA-Z0-9.'+_-]+" '@'
-        # the domain name part (RFC:1035)
-        '(?:[a-zA-Z0-9_-]+\.)+' # labels (but also allow '_')
-        '[a-zA-Z](?:[-a-zA-Z\d]*[a-zA-Z\d])?' # TLD
-        )
-
-
-class IEmailSender(Interface):
-    """Extension point interface for components that allow sending e-mail."""
-
-    def send(self, from_addr, recipients, message):
-        """Send message to recipients."""
-
-
-class NotificationSystem(Component):
-
-    email_sender = ExtensionOption('notification', 'email_sender',
-                                   IEmailSender, 'SmtpEmailSender',
-        """Name of the component implementing `IEmailSender`.
-
-        This component is used by the notification system to send emails.
-        Trac currently provides `SmtpEmailSender` for connecting to an SMTP
-        server, and `SendmailEmailSender` for running a `sendmail`-compatible
-        executable. (''since 0.12'')""")
-
-    smtp_enabled = BoolOption('notification', 'smtp_enabled', 'false',
-        """Enable email notification.""")
-
-    smtp_from = Option('notification', 'smtp_from', 'trac@localhost',
-        """Sender address to use in notification emails.""")
-
-    smtp_from_name = Option('notification', 'smtp_from_name', '',
-        """Sender name to use in notification emails.""")
-
-    smtp_from_author = BoolOption('notification', 'smtp_from_author', 'false',
-        """Use the action author as the sender of notification emails.
-           (''since 1.0'')""")
-
-    smtp_replyto = Option('notification', 'smtp_replyto', 'trac@localhost',
-        """Reply-To address to use in notification emails.""")
-
-    smtp_always_cc = Option('notification', 'smtp_always_cc', '',
-        """Email address(es) to always send notifications to,
-           addresses can be seen by all recipients (Cc:).""")
-
-    smtp_always_bcc = Option('notification', 'smtp_always_bcc', '',
-        """Email address(es) to always send notifications to,
-           addresses do not appear publicly (Bcc:). (''since 0.10'')""")
-
-    smtp_default_domain = Option('notification', 'smtp_default_domain', '',
-        """Default host/domain to append to address that do not specify
-           one.""")
-
-    ignore_domains = Option('notification', 'ignore_domains', '',
-        """Comma-separated list of domains that should not be considered
-           part of email addresses (for usernames with Kerberos domains).""")
-
-    admit_domains = Option('notification', 'admit_domains', '',
-        """Comma-separated list of domains that should be considered as
-        valid for email addresses (such as localdomain).""")
-
-    mime_encoding = Option('notification', 'mime_encoding', 'none',
-        """Specifies the MIME encoding scheme for emails.
-
-        Valid options are 'base64' for Base64 encoding, 'qp' for
-        Quoted-Printable, and 'none' for no encoding, in which case mails will
-        be sent as 7bit if the content is all ASCII, or 8bit otherwise.
-        (''since 0.10'')""")
-
-    use_public_cc = BoolOption('notification', '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.
-        (''since 0.10'')""")
-
-    use_short_addr = BoolOption('notification', 'use_short_addr', 'false',
-        """Permit email address without a host/domain (i.e. username only).
-
-        The SMTP server should accept those addresses, and either append
-        a FQDN or use local delivery. (''since 0.10'')""")
-
-    smtp_subject_prefix = Option('notification', 'smtp_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. (''since 0.10.1'')""")
-
-    def send_email(self, from_addr, recipients, message):
-        """Send message to recipients via e-mail."""
-        self.email_sender.send(from_addr, recipients, message)
-
-
-class SmtpEmailSender(Component):
-    """E-mail sender connecting to an SMTP server."""
-
-    implements(IEmailSender)
-
-    smtp_server = Option('notification', 'smtp_server', 'localhost',
-        """SMTP server hostname to use for email notifications.""")
-
-    smtp_port = IntOption('notification', 'smtp_port', 25,
-        """SMTP server port to use for email notification.""")
-
-    smtp_user = Option('notification', 'smtp_user', '',
-        """Username for SMTP server. (''since 0.9'')""")
-
-    smtp_password = Option('notification', 'smtp_password', '',
-        """Password for SMTP server. (''since 0.9'')""")
-
-    use_tls = BoolOption('notification', 'use_tls', 'false',
-        """Use SSL/TLS to send notifications over SMTP. (''since 0.10'')""")
-
-    def send(self, from_addr, recipients, message):
-        # Ensure the message complies with RFC2822: use CRLF line endings
-        message = fix_eol(message, CRLF)
-
-        self.log.info("Sending notification through SMTP at %s:%d to %s"
-                      % (self.smtp_server, self.smtp_port, recipients))
-        server = smtplib.SMTP(self.smtp_server, self.smtp_port)
-        # server.set_debuglevel(True)
-        if self.use_tls:
-            server.ehlo()
-            if 'starttls' not in server.esmtp_features:
-                raise TracError(_("TLS enabled but server does not support " \
-                                  "TLS"))
-            server.starttls()
-            server.ehlo()
-        if self.smtp_user:
-            server.login(self.smtp_user.encode('utf-8'),
-                         self.smtp_password.encode('utf-8'))
-        start = time.time()
-        server.sendmail(from_addr, recipients, message)
-        t = time.time() - start
-        if t > 5:
-            self.log.warning('Slow mail submission (%.2f s), '
-                             'check your mail setup' % t)
-        if self.use_tls:
-            # avoid false failure detection when the server closes
-            # the SMTP connection with TLS enabled
-            import socket
-            try:
-                server.quit()
-            except socket.sslerror:
-                pass
-        else:
-            server.quit()
-
-
-class SendmailEmailSender(Component):
-    """E-mail sender using a locally-installed sendmail program."""
-
-    implements(IEmailSender)
-
-    sendmail_path = Option('notification', 'sendmail_path', 'sendmail',
-        """Path to the sendmail executable.
-
-        The sendmail program must accept the `-i` and `-f` options.
-         (''since 0.12'')""")
-
-    def send(self, from_addr, recipients, message):
-        # Use native line endings in message
-        message = fix_eol(message, os.linesep)
-
-        self.log.info("Sending notification through sendmail at %s to %s"
-                      % (self.sendmail_path, recipients))
-        cmdline = [self.sendmail_path, "-i", "-f", from_addr]
-        cmdline.extend(recipients)
-        self.log.debug("Sendmail command line: %s" % cmdline)
-        child = Popen(cmdline, bufsize=-1, stdin=PIPE, stdout=PIPE,
-                      stderr=PIPE, close_fds=close_fds)
-        out, err = child.communicate(message)
-        if child.returncode or err:
-            raise Exception("Sendmail failed with (%s, %s), command: '%s'"
-                            % (child.returncode, err.strip(), cmdline))
-
-
-class Notify(object):
-    """Generic notification class for Trac.
-
-    Subclass this to implement different methods.
-    """
-
-    def __init__(self, env):
-        self.env = env
-        self.config = env.config
-
-        from trac.web.chrome import Chrome
-        self.template = Chrome(self.env).load_template(self.template_name,
-                                                       method='text')
-        # FIXME: actually, we would need a different
-        #        PermissionCache for each recipient
-        self.data = Chrome(self.env).populate_data(None, {'CRLF': CRLF})
-
-    def notify(self, resid):
-        (torcpts, ccrcpts) = self.get_recipients(resid)
-        self.begin_send()
-        self.send(torcpts, ccrcpts)
-        self.finish_send()
-
-    def get_recipients(self, resid):
-        """Return a pair of list of subscribers to the resource 'resid'.
-
-        First list represents the direct recipients (To:), second list
-        represents the recipients in carbon copy (Cc:).
-        """
-        raise NotImplementedError
-
-    def begin_send(self):
-        """Prepare to send messages.
-
-        Called before sending begins.
-        """
-
-    def send(self, torcpts, ccrcpts):
-        """Send message to recipients."""
-        raise NotImplementedError
-
-    def finish_send(self):
-        """Clean up after sending all messages.
-
-        Called after sending all messages.
-        """
-
-
-class NotifyEmail(Notify):
-    """Baseclass for notification by email."""
-
-    from_email = 'trac+tickets@localhost'
-    subject = ''
-    template_name = None
-    nodomaddr_re = re.compile(r'[\w\d_\.\-]+')
-    addrsep_re = re.compile(r'[;\s,]+')
-
-    def __init__(self, env):
-        Notify.__init__(self, env)
-
-        addrfmt = EMAIL_LOOKALIKE_PATTERN
-        admit_domains = self.env.config.get('notification', 'admit_domains')
-        if admit_domains:
-            pos = addrfmt.find('@')
-            domains = '|'.join([x.strip() for x in \
-                                admit_domains.replace('.','\.').split(',')])
-            addrfmt = r'%s@(?:(?:%s)|%s)' % (addrfmt[:pos], addrfmt[pos+1:],
-                                              domains)
-        self.shortaddr_re = re.compile(r'\s*(%s)\s*$' % addrfmt)
-        self.longaddr_re = re.compile(r'^\s*(.*)\s+<\s*(%s)\s*>\s*$' % addrfmt)
-        self._init_pref_encoding()
-        domains = self.env.config.get('notification', 'ignore_domains', '')
-        self._ignore_domains = [x.strip() for x in domains.lower().split(',')]
-        # Get the name and email addresses of all known users
-        self.name_map = {}
-        self.email_map = {}
-        for username, name, email in self.env.get_known_users():
-            if name:
-                self.name_map[username] = name
-            if email:
-                self.email_map[username] = email
-
-    def _init_pref_encoding(self):
-        from email.Charset import Charset, QP, BASE64, SHORTEST
-        self._charset = Charset()
-        self._charset.input_charset = 'utf-8'
-        self._charset.output_charset = 'utf-8'
-        self._charset.input_codec = 'utf-8'
-        self._charset.output_codec = 'utf-8'
-        pref = self.env.config.get('notification', 'mime_encoding').lower()
-        if pref == 'base64':
-            self._charset.header_encoding = BASE64
-            self._charset.body_encoding = BASE64
-        elif pref in ['qp', 'quoted-printable']:
-            self._charset.header_encoding = QP
-            self._charset.body_encoding = QP
-        elif pref == 'none':
-            self._charset.header_encoding = SHORTEST
-            self._charset.body_encoding = None
-        else:
-            raise TracError(_('Invalid email encoding setting: %(pref)s',
-                              pref=pref))
-
-    def notify(self, resid, subject, author=None):
-        self.subject = subject
-        config = self.config['notification']
-        if not config.getbool('smtp_enabled'):
-            return
-        from_email, from_name = '', ''
-        if author and config.getbool('smtp_from_author'):
-            from_email = self.get_smtp_address(author)
-            if from_email:
-                from_name = self.name_map.get(author, '')
-                if not from_name:
-                    mo = self.longaddr_re.search(author)
-                    if mo:
-                        from_name = mo.group(1)
-        if not from_email:
-            from_email = config.get('smtp_from')
-            from_name = config.get('smtp_from_name') or self.env.project_name
-        self.replyto_email = config.get('smtp_replyto')
-        self.from_email = from_email or self.replyto_email
-        self.from_name = from_name
-        if not self.from_email and not self.replyto_email:
-            raise TracError(tag(
-                    tag.p(_('Unable to send email due to identity crisis.')),
-                    tag.p(_('Neither %(from_)s nor %(reply_to)s are specified '
-                            'in the configuration.',
-                            from_=tag.b('notification.from'),
-                            reply_to=tag.b('notification.reply_to')))),
-                _('SMTP Notification Error'))
-
-        Notify.notify(self, resid)
-
-    def format_header(self, key, name, email=None):
-        from email.Header import Header
-        maxlength = MAXHEADERLEN-(len(key)+2)
-        # Do not sent ridiculous short headers
-        if maxlength < 10:
-            raise TracError(_("Header length is too short"))
-        try:
-            tmp = name.encode('ascii')
-            header = Header(tmp, 'ascii', maxlinelen=maxlength)
-        except UnicodeEncodeError:
-            header = Header(name, self._charset, maxlinelen=maxlength)
-        if not email:
-            return header
-        else:
-            return '"%s" <%s>' % (header, email)
-
-    def add_headers(self, msg, headers):
-        for h in headers:
-            msg[h] = self.encode_header(h, headers[h])
-
-    def get_smtp_address(self, address):
-        if not address:
-            return None
-
-        def is_email(address):
-            pos = address.find('@')
-            if pos == -1:
-                return False
-            if address[pos+1:].lower() in self._ignore_domains:
-                return False
-            return True
-
-        if address == 'anonymous':
-            return None
-        if address in self.email_map:
-            address = self.email_map[address]
-        elif not is_email(address) and NotifyEmail.nodomaddr_re.match(address):
-            if self.config.getbool('notification', 'use_short_addr'):
-                return address
-            domain = self.config.get('notification', 'smtp_default_domain')
-            if domain:
-                address = "%s@%s" % (address, domain)
-            else:
-                self.env.log.info("Email address w/o domain: %s" % address)
-                return None
-
-        mo = self.shortaddr_re.search(address)
-        if mo:
-            return mo.group(1)
-        mo = self.longaddr_re.search(address)
-        if mo:
-            return mo.group(2)
-        self.env.log.info("Invalid email address: %s" % address)
-        return None
-
-    def encode_header(self, key, value):
-        if isinstance(value, tuple):
-            return self.format_header(key, value[0], value[1])
-        mo = self.longaddr_re.match(value)
-        if mo:
-            return self.format_header(key, mo.group(1), mo.group(2))
-        return self.format_header(key, value)
-
-    def send(self, torcpts, ccrcpts, mime_headers={}):
-        from email.MIMEText import MIMEText
-        from email.Utils import formatdate
-        stream = self.template.generate(**self.data)
-        # don't translate the e-mail stream
-        t = deactivate()
-        try:
-            body = stream.render('text', encoding='utf-8')
-        finally:
-            reactivate(t)
-        public_cc = self.config.getbool('notification', 'use_public_cc')
-        headers = {}
-        headers['X-Mailer'] = 'Trac %s, by Edgewall Software' % __version__
-        headers['X-Trac-Version'] =  __version__
-        headers['X-Trac-Project'] =  self.env.project_name
-        headers['X-URL'] = self.env.project_url
-        headers['Precedence'] = 'bulk'
-        headers['Auto-Submitted'] = 'auto-generated'
-        headers['Subject'] = self.subject
-        headers['From'] = (self.from_name, self.from_email) if self.from_name \
-                          else self.from_email
-        headers['Reply-To'] = self.replyto_email
-
-        def build_addresses(rcpts):
-            """Format and remove invalid addresses"""
-            return filter(lambda x: x, \
-                          [self.get_smtp_address(addr) for addr in rcpts])
-
-        def remove_dup(rcpts, all):
-            """Remove duplicates"""
-            tmp = []
-            for rcpt in rcpts:
-                if not rcpt in all:
-                    tmp.append(rcpt)
-                    all.append(rcpt)
-            return (tmp, all)
-
-        toaddrs = build_addresses(torcpts)
-        ccaddrs = build_addresses(ccrcpts)
-        accparam = self.config.get('notification', 'smtp_always_cc')
-        accaddrs = accparam and \
-                   build_addresses(accparam.replace(',', ' ').split()) or []
-        bccparam = self.config.get('notification', 'smtp_always_bcc')
-        bccaddrs = bccparam and \
-                   build_addresses(bccparam.replace(',', ' ').split()) or []
-
-        recipients = []
-        (toaddrs, recipients) = remove_dup(toaddrs, recipients)
-        (ccaddrs, recipients) = remove_dup(ccaddrs, recipients)
-        (accaddrs, recipients) = remove_dup(accaddrs, recipients)
-        (bccaddrs, recipients) = remove_dup(bccaddrs, recipients)
-
-        # if there is not valid recipient, leave immediately
-        if len(recipients) < 1:
-            self.env.log.info('no recipient for a ticket notification')
-            return
-
-        pcc = accaddrs
-        if public_cc:
-            pcc += ccaddrs
-            if toaddrs:
-                headers['To'] = ', '.join(toaddrs)
-        if pcc:
-            headers['Cc'] = ', '.join(pcc)
-        headers['Date'] = formatdate()
-        msg = MIMEText(body, 'plain')
-        # Message class computes the wrong type from MIMEText constructor,
-        # which does not take a Charset object as initializer. Reset the
-        # encoding type to force a new, valid evaluation
-        del msg['Content-Transfer-Encoding']
-        msg.set_charset(self._charset)
-        self.add_headers(msg, headers)
-        self.add_headers(msg, mime_headers)
-        NotificationSystem(self.env).send_email(self.from_email, recipients,
-                                                msg.as_string())

File trac/notification/__init__.py

+# -*- coding: utf-8 -*-
+# 
+# Copyright (C)2013 Edgewall Software
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+
+from trac.notification.api import *
+from trac.notification.compat import *
+from trac.notification.mail import (EMAIL_LOOKALIKE_PATTERN, MAXHEADERLEN,
+                                    SmtpEmailSender, SendmailEmailSender)

File trac/notification/api.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2003-2013 Edgewall Software
+# Copyright (C) 2003-2005 Daniel Lundin <daniel@edgewall.com>
+# Copyright (C) 2005-2006 Emmanuel Blot <emmanuel.blot@free.fr>
+# Copyright (C) 2008 Stephen Hansen
+# Copyright (C) 2009 Robert Corsaro
+# Copyright (C) 2010-2012 Steffen Hoffmann
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+
+
+from operator import itemgetter
+
+from trac.core import *
+from trac.config import BoolOption, ExtensionOption, Option
+from trac.util.compat import set
+
+
+class INotificationDistributor(Interface):
+    """Deliver events over some transport (i.e. messaging protocol)."""
+
+    def transports(self):
+        """Return a list of supported transport names."""
+
+    def distribute(self, transport, recipients, event):
+        """Distribute the notification event.
+
+        :param transport: the name of a supported transport
+        :param recipients: a list of (sid, authenticated, address, format)
+                           tuples, where either `sid` or `address` can be
+                           `None`
+        :param event: a `NotificationEvent`
+        """
+
+
+class INotificationFormatter(Interface):
+    """Convert events into messages appropriate for a given transport."""
+
+    def get_supported_styles(self, transport):
+        """Return a list of supported styles.
+        
+        :param transport: the name of a transport
+        :return: a list of tuples (style, realm)
+        """
+
+    def format(self, transport, style, event):
+        """Convert the event to an appropriate message.
+
+        :param transport: the name of a transport
+        :param style: the name of a supported style
+        :return: The return type of this method depends on transport and must
+                 be compatible with the `INotificationDistributor` that
+                 handles messages for this transport.
+        """
+
+        
+class INotificationSubscriber(Interface):
+    """Subscribe to notification events."""
+
+    def matches(event):
+        """Return a list of subscriptions that match the given event.
+        
+        :param event: a `NotificationEvent`
+        :return: a list of tuples (class, distributor, sid, authenticated, 
+                 address, format, priority, adverb), where small `priority`
+                 values override larger ones and `adverb` is either
+                 'always' or 'never'.
+        """
+
+    def description():
+        """Description of the subscription shown in the preferences UI."""
+
+    def requires_authentication():
+        """Can only authenticated users subscribe?"""
+
+    def default_subscriptions():
+        """Optionally return a list of default subscriptions.
+        
+        Default subscriptions that the module will automatically generate.
+        This should only be used in reasonable situations, where users can be
+        determined by the event itself.  For instance, ticket author has a
+        default subscription that is controlled via trac.ini.  This is because
+        we can lookup the ticket author during the event and create a
+        subscription for them.  Default subscriptions should be low priority
+        so that the user can easily override them.
+        
+        :return: a list of tuples (class, distributor, priority, adverb)
+        """
+
+
+class IEmailAddressResolver(Interface):
+    """Map sessions to email addresses."""
+
+    def get_address_for_session(self, sid, authenticated):
+        """Map a session id and authenticated flag to an e-mail address.
+
+        :param sid: the session id
+        :param authenticated: 1 for authenticated sessions, 0 otherwise
+        :return: an email address or `None`
+        """
+
+
+class IEmailDecorator(Interface):
+    def decorate_message(self, event, message, charset):
+        """Manipulate the message before it is sent on it's way.
+        
+        :param event: a `NotificationEvent`
+        :param message: an `email.message.Message` to manipulate
+        :param charset: the `email.charset.Charset` to use
+        """
+
+
+class IEmailSender(Interface):
+    """Extension point interface for components that allow sending e-mail."""
+
+    def send(self, from_addr, recipients, message):
+        """Send message to recipients."""
+
+
+def get_target_id(target):
+    """Extract the resource ID from event targets.
+    
+    :param target: a resource model (e.g. `Ticket` or `WikiPage`)
+    :return: the resource ID
+    """
+    # Common Trac resource.
+    if hasattr(target, 'id'):
+        return str(target.id)
+    # Wiki page special case.
+    elif hasattr(target, 'name'):
+        return target.name
+    # Last resort: just stringify.
+    return str(target)
+
+
+class NotificationEvent(object):
+    """All data related to a particular notification event.
+    
+    :param realm: the resource realm (e.g. 'ticket' or 'wiki')
+    :param category: the kind of event that happened to the resource
+                     (e.g. 'created', 'changed' or 'deleted')
+    :param target: the resource model (e.g. Ticket or WikiPage) or `None`
+    :param time: the `datetime` when the event happened
+    """
+
+    def __init__(self, realm, category, target, time, author=""):
+        self.realm = realm
+        self.category = category
+        self.target = target
+        self.time = time
+        self.author = author
+
+
+class NotificationSystem(Component):
+
+    email_sender = ExtensionOption('notification', 'email_sender',
+                                   IEmailSender, 'SmtpEmailSender',
+        """Name of the component implementing `IEmailSender`.
+
+        This component is used by the notification system to send emails.
+        Trac currently provides `SmtpEmailSender` for connecting to an SMTP
+        server, and `SendmailEmailSender` for running a `sendmail`-compatible
+        executable. (''since 0.12'')""")
+
+    smtp_enabled = BoolOption('notification', 'smtp_enabled', 'false',
+        """Enable email notification.""")
+
+    smtp_from = Option('notification', 'smtp_from', 'trac@localhost',
+        """Sender address to use in notification emails.""")
+
+    smtp_from_name = Option('notification', 'smtp_from_name', '',
+        """Sender name to use in notification emails.""")
+
+    smtp_from_author = BoolOption('notification', 'smtp_from_author', 'false',
+        """Use the action author as the sender of notification emails.
+           (''since 1.0'')""")
+
+    smtp_replyto = Option('notification', 'smtp_replyto', 'trac@localhost',
+        """Reply-To address to use in notification emails.""")
+
+    smtp_always_cc = Option('notification', 'smtp_always_cc', '',
+        """Email address(es) to always send notifications to,
+           addresses can be seen by all recipients (Cc:).""")
+
+    smtp_always_bcc = Option('notification', 'smtp_always_bcc', '',
+        """Email address(es) to always send notifications to,
+           addresses do not appear publicly (Bcc:). (''since 0.10'')""")
+
+    smtp_default_domain = Option('notification', 'smtp_default_domain', '',
+        """Default host/domain to append to address that do not specify
+           one.""")
+
+    ignore_domains = Option('notification', 'ignore_domains', '',
+        """Comma-separated list of domains that should not be considered
+           part of email addresses (for usernames with Kerberos domains).""")
+
+    admit_domains = Option('notification', 'admit_domains', '',
+        """Comma-separated list of domains that should be considered as
+        valid for email addresses (such as localdomain).""")
+
+    mime_encoding = Option('notification', 'mime_encoding', 'none',
+        """Specifies the MIME encoding scheme for emails.
+
+        Valid options are 'base64' for Base64 encoding, 'qp' for
+        Quoted-Printable, and 'none' for no encoding, in which case mails will
+        be sent as 7bit if the content is all ASCII, or 8bit otherwise.
+        (''since 0.10'')""")
+
+    use_public_cc = BoolOption('notification', '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.
+        (''since 0.10'')""")
+
+    use_short_addr = BoolOption('notification', 'use_short_addr', 'false',
+        """Permit email address without a host/domain (i.e. username only).
+
+        The SMTP server should accept those addresses, and either append
+        a FQDN or use local delivery. (''since 0.10'')""")
+
+    smtp_subject_prefix = Option('notification', 'smtp_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. (''since 0.10.1'')""")
+
+    distributors = ExtensionPoint(INotificationDistributor)
+    subscribers = ExtensionPoint(INotificationSubscriber)
+
+    def send_email(self, from_addr, recipients, message):
+        """Send message to recipients via e-mail."""
+        self.email_sender.send(from_addr, recipients, message)
+
+    def notify(self, event):
+        """Distribute an event to all subscriptions.
+        
+        :param event: a `NotificationEvent`
+        """
+        self.distribute_event(event, self.subscriptions(event))
+        
+    def distribute_event(self, event, subscriptions):
+        """Distribute a event to all subscriptions.
+        
+        :param event: a `NotificationEvent`
+        :param subscriptions: a list of tuples (sid, authenticated, address,
+                              transport, format) where either sid or
+                              address can be `None`
+        """
+        packages = {}
+        for sid, authenticated, address, transport, format in subscriptions:
+            package = packages.setdefault(transport, set())
+            package.add((sid, authenticated, address, format))
+        for distributor in self.distributors:
+            for transport in distributor.transports():
+                if transport in packages:
+                    recipients = list(packages[transport])
+                    distributor.distribute(transport, recipients, event)
+
+    def subscriptions(self, event):
+        """Return all subscriptions for a given event.
+        
+        :return: a list of (sid, authenticated, address, transport, format)
+        """
+        subscriptions = []
+        for subscriber in self.subscribers:
+            subscriptions.extend(x for x in subscriber.matches(event) if x)
+
+        # For each (transport, sid, authenticated) combination check the
+        # subscription with the highest priority:
+        # If it is "always" keep it. If it is "never" drop it.
+        
+        # sort by (transport, sid, authenticated, priority)
+        ordered = sorted(subscriptions, key=itemgetter(1,2,3,6))
+        previous_combination = None
+        for rule, transport, sid, auth, addr, fmt, prio, adverb in ordered:
+            if (transport, sid, auth) == previous_combination:
+                continue
+            if adverb == 'always':
+                self.log.debug("Adding (%s [%s]) for 'always' on rule (%s) "
+                               "for (%s)" % (sid, auth, rule, transport))
+                yield (sid, auth, addr, transport, fmt)
+            else:
+                self.log.debug("Ignoring (%s [%s]) for 'never' on rule (%s) "
+                               "for (%s)" % (sid, auth, rule, transport))
+            # Also keep subscriptions without sid (raw email subscription)
+            if sid:
+                previous_combination = (transport, sid, auth)

File trac/notification/compat.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2003-2013 Edgewall Software
+# Copyright (C) 2003-2005 Daniel Lundin <daniel@edgewall.com>
+# Copyright (C) 2005-2006 Emmanuel Blot <emmanuel.blot@free.fr>
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+
+import re
+
+from genshi.builder import tag
+
+from trac import __version__
+from trac.core import *
+from trac.notification.api import NotificationSystem
+from trac.notification.mail import (create_charset, create_header,
+                                    create_mime_text, RecipientMatcher)
+from trac.util.text import CRLF
+from trac.util.translation import _, deactivate, reactivate
+
+
+class Notify(object):
+    """Generic notification class for Trac.
+
+    Subclass this to implement different methods.
+    """
+
+    def __init__(self, env):
+        self.env = env
+        self.config = env.config
+
+        from trac.web.chrome import Chrome
+        self.template = Chrome(self.env).load_template(self.template_name,
+                                                       method='text')
+        # FIXME: actually, we would need a different
+        #        PermissionCache for each recipient
+        self.data = Chrome(self.env).populate_data(None, {'CRLF': CRLF})
+
+    def notify(self, resid):
+        (torcpts, ccrcpts) = self.get_recipients(resid)
+        self.begin_send()
+        self.send(torcpts, ccrcpts)
+        self.finish_send()
+
+    def get_recipients(self, resid):
+        """Return a pair of list of subscribers to the resource 'resid'.
+
+        First list represents the direct recipients (To:), second list
+        represents the recipients in carbon copy (Cc:).
+        """
+        raise NotImplementedError
+
+    def begin_send(self):
+        """Prepare to send messages.
+
+        Called before sending begins.
+        """
+
+    def send(self, torcpts, ccrcpts):
+        """Send message to recipients."""
+        raise NotImplementedError
+
+    def finish_send(self):
+        """Clean up after sending all messages.
+
+        Called after sending all messages.
+        """
+
+
+class NotifyEmail(Notify):
+    """Baseclass for notification by email."""
+
+    from_email = 'trac+tickets@localhost'
+    subject = ''
+    template_name = None
+    addrsep_re = re.compile(r'[;\s,]+')
+
+    def __init__(self, env):
+        Notify.__init__(self, env)
+
+        self.recipient_matcher = RecipientMatcher(env)
+        self.shortaddr_re = self.recipient_matcher.shortaddr_re
+        self.longaddr_re = self.recipient_matcher.longaddr_re
+        self.nodomaddr_re = self.recipient_matcher.nodomaddr_re
+        self._ignore_domains = self.recipient_matcher.ignore_domains
+        self.name_map = self.recipient_matcher.name_map
+        self.email_map = self.recipient_matcher.email_map
+
+        mime_encoding = self.env.config.get('notification', 'mime_encoding')
+        self._charset = create_charset(mime_encoding)
+
+    def notify(self, resid, subject, author=None):
+        self.subject = subject
+        config = self.config['notification']
+        if not config.getbool('smtp_enabled'):
+            return
+        from_email, from_name = '', ''
+        if author and config.getbool('smtp_from_author'):
+            from_email = self.get_smtp_address(author)
+            if from_email:
+                from_name = self.name_map.get(author, '')
+                if not from_name:
+                    mo = self.longaddr_re.search(author)
+                    if mo:
+                        from_name = mo.group(1)
+        if not from_email:
+            from_email = config.get('smtp_from')
+            from_name = config.get('smtp_from_name') or self.env.project_name
+        self.replyto_email = config.get('smtp_replyto')
+        self.from_email = from_email or self.replyto_email
+        self.from_name = from_name
+        if not self.from_email and not self.replyto_email:
+            raise TracError(tag(
+                    tag.p(_('Unable to send email due to identity crisis.')),
+                    tag.p(_('Neither %(from_)s nor %(reply_to)s are specified '
+                            'in the configuration.',
+                            from_=tag.b('notification.from'),
+                            reply_to=tag.b('notification.reply_to')))),
+                _('SMTP Notification Error'))
+
+        Notify.notify(self, resid)
+
+    def format_header(self, key, name, email=None):
+        header = create_header(key, name, self._charset)
+        if not email:
+            return header
+        else:
+            return '"%s" <%s>' % (header, email)
+
+    def add_headers(self, msg, headers):
+        for h in headers:
+            msg[h] = self.encode_header(h, headers[h])
+
+    def get_smtp_address(self, address):
+        recipient = self.recipient_matcher.match_recipient(address)
+        if not recipient:
+            return None
+        return recipient[2]
+
+    def encode_header(self, key, value):
+        if isinstance(value, tuple):
+            return self.format_header(key, value[0], value[1])
+        mo = self.longaddr_re.match(value)
+        if mo:
+            return self.format_header(key, mo.group(1), mo.group(2))
+        return self.format_header(key, value)
+
+    def _format_body(self):
+        stream = self.template.generate(**self.data)
+        # don't translate the e-mail stream
+        t = deactivate()
+        try:
+            return stream.render('text', encoding='utf-8')
+        finally:
+            reactivate(t)
+        
+    def send(self, torcpts, ccrcpts, mime_headers={}):
+        from email.Utils import formatdate
+        body = self._format_body()
+        public_cc = self.config.getbool('notification', 'use_public_cc')
+        headers = {}
+        headers['X-Mailer'] = 'Trac %s, by Edgewall Software' % __version__
+        headers['X-Trac-Version'] =  __version__
+        headers['X-Trac-Project'] =  self.env.project_name
+        headers['X-URL'] = self.env.project_url
+        headers['Precedence'] = 'bulk'
+        headers['Auto-Submitted'] = 'auto-generated'
+        headers['Subject'] = self.subject
+        headers['From'] = (self.from_name, self.from_email) if self.from_name \
+                          else self.from_email
+        headers['Reply-To'] = self.replyto_email
+
+        def build_addresses(rcpts):
+            """Format and remove invalid addresses"""
+            return filter(lambda x: x, \
+                          [self.get_smtp_address(addr) for addr in rcpts])
+
+        def remove_dup(rcpts, all):
+            """Remove duplicates"""
+            tmp = []
+            for rcpt in rcpts:
+                if not rcpt in all:
+                    tmp.append(rcpt)
+                    all.append(rcpt)
+            return (tmp, all)
+
+        toaddrs = build_addresses(torcpts)
+        ccaddrs = build_addresses(ccrcpts)
+        accparam = self.config.get('notification', 'smtp_always_cc')
+        accaddrs = accparam and \
+                   build_addresses(accparam.replace(',', ' ').split()) or []
+        bccparam = self.config.get('notification', 'smtp_always_bcc')
+        bccaddrs = bccparam and \
+                   build_addresses(bccparam.replace(',', ' ').split()) or []
+
+        recipients = []
+        (toaddrs, recipients) = remove_dup(toaddrs, recipients)
+        (ccaddrs, recipients) = remove_dup(ccaddrs, recipients)
+        (accaddrs, recipients) = remove_dup(accaddrs, recipients)
+        (bccaddrs, recipients) = remove_dup(bccaddrs, recipients)
+
+        # if there is not valid recipient, leave immediately
+        if len(recipients) < 1:
+            self.env.log.info('no recipient for a ticket notification')
+            return
+
+        pcc = accaddrs
+        if public_cc:
+            pcc += ccaddrs
+            if toaddrs:
+                headers['To'] = ', '.join(toaddrs)
+        if pcc:
+            headers['Cc'] = ', '.join(pcc)
+        headers['Date'] = formatdate()
+        msg = create_mime_text(body, 'plain', self._charset)
+        self.add_headers(msg, headers)
+        self.add_headers(msg, mime_headers)
+        NotificationSystem(self.env).send_email(self.from_email, recipients,
+                                                msg.as_string())

File trac/notification/mail.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2003-2013 Edgewall Software
+# Copyright (C) 2003-2005 Daniel Lundin <daniel@edgewall.com>
+# Copyright (C) 2005-2006 Emmanuel Blot <emmanuel.blot@free.fr>
+# Copyright (C) 2008 Stephen Hansen
+# Copyright (C) 2009 Robert Corsaro
+# Copyright (C) 2010-2012 Steffen Hoffmann
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+
+from __future__ import with_statement
+
+from email.MIMEMultipart import MIMEMultipart
+from email.MIMEText import MIMEText
+from email.Utils import formatdate, formataddr, parseaddr, getaddresses
+from hashlib import md5
+import os
+import re
+import smtplib
+from subprocess import Popen, PIPE
+import time
+
+from trac import __version__
+from trac.config import BoolOption, IntOption, Option, OrderedExtensionsOption
+from trac.core import *
+from trac.notification.api import (get_target_id, IEmailAddressResolver,
+                                   IEmailDecorator, IEmailSender,
+                                   INotificationDistributor,
+                                   INotificationFormatter, NotificationSystem)
+from trac.util.compat import close_fds, set
+from trac.util.datefmt import to_utimestamp
+from trac.util.text import CRLF, fix_eol
+from trac.util.translation import _
+
+
+MAXHEADERLEN = 76
+EMAIL_LOOKALIKE_PATTERN = (
+        # the local part
+        r"[a-zA-Z0-9.'+_-]+" '@'
+        # the domain name part (RFC:1035)
+        '(?:[a-zA-Z0-9_-]+\.)+' # labels (but also allow '_')
+        '[a-zA-Z](?:[-a-zA-Z\d]*[a-zA-Z\d])?' # TLD
+        )
+
+
+def create_charset(mime_encoding):
+    """Create an appropriate email charset for the given encoding.
+
+    Valid options are 'base64' for Base64 encoding, 'qp' for
+    Quoted-Printable, and 'none' for no encoding, in which case mails will
+    be sent as 7bit if the content is all ASCII, or 8bit otherwise.
+    """
+    from email.Charset import Charset, QP, BASE64, SHORTEST
+    charset = Charset()
+    charset.input_charset = 'utf-8'
+    charset.output_charset = 'utf-8'
+    charset.input_codec = 'utf-8'
+    charset.output_codec = 'utf-8'
+    pref = mime_encoding.lower()
+    if pref == 'base64':
+        charset.header_encoding = BASE64
+        charset.body_encoding = BASE64
+    elif pref in ['qp', 'quoted-printable']:
+        charset.header_encoding = QP
+        charset.body_encoding = QP
+    elif pref == 'none':
+        charset.header_encoding = SHORTEST
+        charset.body_encoding = None
+    else:
+        raise TracError(_('Invalid email encoding setting: %(mime_encoding)s',
+                          mime_encoding=mime_encoding))
+    return charset
+
+
+def create_header(key, name, charset):
+    """Create an appropriate email Header."""
+    from email.Header import Header
+    maxlength = MAXHEADERLEN-(len(key)+2)
+    # Do not sent ridiculous short headers
+    if maxlength < 10:
+        raise TracError(_("Header length is too short"))
+    try:
+        tmp = name.encode('ascii')
+        return Header(tmp, 'ascii', maxlinelen=maxlength)
+    except UnicodeEncodeError:
+        return Header(name, charset, maxlinelen=maxlength)
+
+
+def set_header(message, key, value, charset):
+    """Create and add or replace a header."""
+    header = create_header(key, value, charset)
+    if message.has_key(key):
+        message.replace_header(key, header)
+    else:
+        message[key] = header
+
+
+def create_mime_text(body, format, charset):
+    """Create an appropriate email `MIMEText`."""
+    msg = MIMEText(body, format)
+    # Message class computes the wrong type from MIMEText constructor,
+    # which does not take a Charset object as initializer. Reset the
+    # encoding type to force a new, valid evaluation
+    del msg['Content-Transfer-Encoding']
+    msg.set_charset(charset)
+    return msg
+
+
+def create_message_id(env, targetid, from_email, time, more=''):
+    """Generate a predictable, but sufficiently unique message ID."""
+    s = '%s.%s.%d.%s' % (env.project_url.encode('utf-8'),
+                         targetid, to_utimestamp(time),
+                         more.encode('ascii', 'ignore'))
+    dig = md5(s).hexdigest()
+    host = from_email[from_email.find('@') + 1:]
+    return '<%03d.%s@%s>' % (len(s), dig, host)
+
+
+class RecipientMatcher(object):
+
+    def __init__(self, env):
+        self.env = env
+        addrfmt = EMAIL_LOOKALIKE_PATTERN
+        admit_domains = self.env.config.get('notification', 'admit_domains')
+        if admit_domains:
+            pos = addrfmt.find('@')
+            domains = '|'.join([x.strip() for x in \
+                                admit_domains.replace('.','\.').split(',')])
+            addrfmt = r'%s@(?:(?:%s)|%s)' % (addrfmt[:pos], addrfmt[pos+1:],
+                                              domains)
+        self.shortaddr_re = re.compile(r'\s*(%s)\s*$' % addrfmt)
+        self.longaddr_re = re.compile(r'^\s*(.*)\s+<\s*(%s)\s*>\s*$' % addrfmt)
+        self.nodomaddr_re = re.compile(r'[\w\d_\.\-]+')
+        domains = self.env.config.get('notification', 'ignore_domains', '')
+        self.ignore_domains = [x.strip() for x in domains.lower().split(',')]
+        # Get the name and email addresses of all known users
+        self.name_map = {}
+        self.email_map = {}
+        for username, name, email in self.env.get_known_users():
+            if name:
+                self.name_map[username] = name
+            if email:
+                self.email_map[username] = email
+
+    def match_recipient(self, address):
+        if not address:
+            return None
+
+        def is_email(address):
+            pos = address.find('@')
+            if pos == -1:
+                return False
+            if address[pos+1:].lower() in self.ignore_domains:
+                return False
+            return True
+
+        if address == 'anonymous':
+            return None
+        sid = None
+        auth = 0
+        if address in self.email_map:
+            sid = address
+            auth = 1
+            address = self.email_map[address]
+        elif not is_email(address) and self.nodomaddr_re.match(address):
+            if self.env.config.getbool('notification', 'use_short_addr'):
+                return (None, 0, address)
+            domain = self.env.config.get('notification',
+                                         'smtp_default_domain')
+            if domain:
+                address = "%s@%s" % (address, domain)
+            else:
+                self.env.log.info("Email address w/o domain: %s" % address)
+                return None
+
+        mo = self.shortaddr_re.search(address)
+        if mo:
+            return (sid, auth, mo.group(1))
+        mo = self.longaddr_re.search(address)
+        if mo:
+            return (sid, auth, mo.group(2))
+        self.env.log.info("Invalid email address: %s" % address)
+        return None
+
+
+class EmailDistributor(Component):
+    """Distributes notification events as emails."""
+
+    implements(INotificationDistributor)
+
+    formatters = ExtensionPoint(INotificationFormatter)
+    decorators = ExtensionPoint(IEmailDecorator)
+
+    resolvers = OrderedExtensionsOption('notification',
+        'email_address_resolvers', IEmailAddressResolver, 
+        '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.
+        """)
+
+    def __init__(self):
+        self._charset = create_charset(self.config.get('notification',
+                                                       'mime_encoding'))
+
+    # INotificationDistributor
+    def transports(self):
+        yield 'email'
+
+    def distribute(self, transport, recipients, event):
+        if transport != 'email':
+            return
+        if not self.config.getbool('notification', 'smtp_enabled'):
+            self.log.debug("EmailDistributor smtp_enabled set to false")
+            return
+
+        formats = {}
+        for f in self.formatters:
+            for style, realm in f.get_supported_styles(transport):
+                if realm == event.realm:
+                    formats[style] = f
+        if not formats:
+            self.log.error(
+                "EmailDistributor No formats found for %s %s" % (
+                    transport, event.realm))
+            return
+        self.log.debug(
+            "EmailDistributor has found the following formats capable "
+            "of handling '%s' of '%s': %s" % (transport, event.realm,
+                ', '.join(formats.keys())))
+
+        msgdict = {}
+        for sid, authed, addr, fmt in recipients:
+            if fmt not in formats:
+                self.log.debug(
+                    "EmailDistributor format %s not available for %s %s" % (
+                        fmt, transport, event.realm))
+                continue
+
+            if sid and not addr:
+                for resolver in self.resolvers:
+                    addr = resolver.get_address_for_session(sid, authed)
+                    if addr:
+                        self.log.debug("EmailDistributor found the "
+                            "address '%s' for '%s (%s)' via: %s" % (
+                            addr, sid, 'authenticated' if authed else
+                            'not authenticated',
+                            resolver.__class__.__name__))
+                        break
+            if addr:
+                msgdict.setdefault(fmt, set()).add(addr)
+            else:
+                self.log.debug("EmailDistributor was unable to find an "
+                        "address for: %s (%s)" % (sid,
+                        'authenticated' if authed else  'not authenticated'))
+
+        for fmt, addrs in msgdict.iteritems():
+            self.log.debug(
+                "EmailDistributor is sending event as '%s' to: %s" % (
+                    fmt, ', '.join(addrs)))
+            self._do_send(transport, event, fmt, addrs, formats[fmt])
+
+    def _do_send(self, transport, event, format, recipients, formatter):
+        output = formatter.format(transport, format, event)
+
+        config = self.config['notification']
+        smtp_from = config.get('smtp_from')
+        smtp_from_name = config.get('smtp_from_name') or self.env.project_name
+        smtp_reply_to = config.get('smtp_replyto')
+        use_public_cc = config.getbool('use_public_cc')
+
+        headers = dict()
+        headers['X-Mailer'] = 'Trac %s, by Edgewall Software' % __version__
+        headers['X-Trac-Version'] = __version__
+        headers['X-Trac-Project'] = self.env.project_name
+        headers['X-URL'] = self.env.project_url
+        headers['X-Trac-Realm'] = event.realm
+        headers['Precedence'] = 'bulk'
+        headers['Auto-Submitted'] = 'auto-generated'
+        targetid = get_target_id(event.target)
+        rootid = create_message_id(self.env, targetid, smtp_from, None,
+                                   more=event.realm)
+        if event.category == 'created':
+            headers['Message-ID'] = rootid
+        else:
+            headers['Message-ID'] = create_message_id(self.env, targetid,
+                                                      smtp_from, event.time,
+                                                      more=event.realm)
+            headers['In-Reply-To'] = rootid
+            headers['References'] = rootid
+        headers['Date'] = formatdate()
+        headers['From'] = formataddr((smtp_from_name, smtp_from))
+        headers['To'] = 'undisclosed-recipients: ;'
+        if use_public_cc:
+            headers['Cc'] = ', '.join(recipients)
+        else:
+            headers['Bcc'] = ', '.join(recipients)
+        headers['Reply-To'] = smtp_reply_to
+
+        rootMessage = create_mime_text(output, 'plain', self._charset)
+        for k, v in headers.iteritems():
+            set_header(rootMessage, k, v, self._charset)
+        for decorator in self.decorators:
+            decorator.decorate_message(event, rootMessage, self._charset)
+
+        from_name, from_addr = parseaddr(str(rootMessage['From']))
+        to_addrs = [addr for name, addr in getaddresses(str(h) for h in
+                                            (rootMessage.get_all('To', []) +
+                                             rootMessage.get_all('Cc', []) +
+                                             rootMessage.get_all('Bcc', [])))]
+        del rootMessage['Bcc']
+        NotificationSystem(self.env).send_email(from_addr, to_addrs,
+                                                rootMessage.as_string())
+
+
+class SmtpEmailSender(Component):
+    """E-mail sender connecting to an SMTP server."""
+
+    implements(IEmailSender)
+
+    smtp_server = Option('notification', 'smtp_server', 'localhost',
+        """SMTP server hostname to use for email notifications.""")
+
+    smtp_port = IntOption('notification', 'smtp_port', 25,
+        """SMTP server port to use for email notification.""")
+
+    smtp_user = Option('notification', 'smtp_user', '',
+        """Username for SMTP server. (''since 0.9'')""")
+
+    smtp_password = Option('notification', 'smtp_password', '',
+        """Password for SMTP server. (''since 0.9'')""")
+
+    use_tls = BoolOption('notification', 'use_tls', 'false',
+        """Use SSL/TLS to send notifications over SMTP. (''since 0.10'')""")
+
+    def send(self, from_addr, recipients, message):
+        # Ensure the message complies with RFC2822: use CRLF line endings
+        message = fix_eol(message, CRLF)
+
+        self.log.info("Sending notification through SMTP at %s:%d to %s"
+                      % (self.smtp_server, self.smtp_port, recipients))
+        server = smtplib.SMTP(self.smtp_server, self.smtp_port)
+        # server.set_debuglevel(True)
+        if self.use_tls:
+            server.ehlo()
+            if 'starttls' not in server.esmtp_features:
+                raise TracError(_("TLS enabled but server does not support " \
+                                  "TLS"))
+            server.starttls()
+            server.ehlo()
+        if self.smtp_user:
+            server.login(self.smtp_user.encode('utf-8'),
+                         self.smtp_password.encode('utf-8'))
+        start = time.time()
+        server.sendmail(from_addr, recipients, message)
+        t = time.time() - start
+        if t > 5:
+            self.log.warning('Slow mail submission (%.2f s), '
+                             'check your mail setup' % t)
+        if self.use_tls:
+            # avoid false failure detection when the server closes
+            # the SMTP connection with TLS enabled
+            import socket
+            try:
+                server.quit()
+            except socket.sslerror:
+                pass
+        else:
+            server.quit()
+
+
+class SendmailEmailSender(Component):
+    """E-mail sender using a locally-installed sendmail program."""
+
+    implements(IEmailSender)
+
+    sendmail_path = Option('notification', 'sendmail_path', 'sendmail',
+        """Path to the sendmail executable.
+
+        The sendmail program must accept the `-i` and `-f` options.
+         (''since 0.12'')""")
+
+    def send(self, from_addr, recipients, message):
+        # Use native line endings in message
+        message = fix_eol(message, os.linesep)
+
+        self.log.info("Sending notification through sendmail at %s to %s"
+                      % (self.sendmail_path, recipients))
+        cmdline = [self.sendmail_path, "-i", "-f", from_addr]
+        cmdline.extend(recipients)
+        self.log.debug("Sendmail command line: %s" % cmdline)
+        child = Popen(cmdline, bufsize=-1, stdin=PIPE, stdout=PIPE,
+                      stderr=PIPE, close_fds=close_fds)
+        out, err = child.communicate(message)
+        if child.returncode or err:
+            raise Exception("Sendmail failed with (%s, %s), command: '%s'"
+                            % (child.returncode, err.strip(), cmdline))
+
+
+class SessionEmailResolver(Component):
+    """Gets the email address from the user preferences / session."""
+
+    implements(IEmailAddressResolver)
+
+    def get_address_for_session(self, sid, authenticated):
+        with self.env.db_query as db:
+            cursor = db.cursor() 
+            cursor.execute(""" 
+                SELECT value 
+                  FROM session_attribute 
+                 WHERE sid=%s 
+                   AND authenticated=%s 
+                   AND name=%s 
+            """, (sid, 1 if authenticated else 0, 'email')) 
+            result = cursor.fetchone() 
+            if result: 
+                return result[0] 
+            return None
+
+
+class AlwaysEmailDecorator(Component):
+    """Implement a policy to -always- send an email to a certain address.
+
+    Controlled via the smtp_always_cc and smtp_always_bcc option in the
+    notification section of trac.ini.
+    """
+
+    implements(IEmailDecorator)
+
+    def decorate_message(self, event, message, charset):
+        always_cc = self.config.get('notification', 'smtp_always_cc')
+        always_bcc = self.config.get('notification', 'smtp_always_bcc')
+        for k, v in {'Cc': always_cc, 'Bcc': always_bcc}.items():
+            if v:
+                self.log.debug("AlwaysEmailDecorator added '%s' "
+                        "because of rule: smtp_always_%s" % (v, k.lower())),
+                if message[k] and len(str(message[k]).split(',')) > 0:
+                    recips = ', '.join([str(message[k]), v])
+                else:
+                    recips = v
+                set_header(message, k, recips, charset)

File trac/notification/model.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2012 Edgewall Software
+# Copyright (C) 2010, Robert Corsaro
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+
+from __future__ import with_statement
+
+from datetime import datetime
+from trac.util.datefmt import utc, to_utimestamp
+
+__all__ = ['Subscription']
+
+class Subscription(object):
+
+    fields = ('id', 'sid', 'authenticated', 'distributor', 'format',
+            'priority', 'adverb', 'class')
+
+    def __init__(self, env):
+        self.env = env
+        self.values = {}
+
+    def __getitem__(self, name):
+        if name not in self.fields:
+            raise KeyError(name)
+        return self.values.get(name)
+
+    def __setitem__(self, name, value):
+        if name not in self.fields:
+            raise KeyError(name)
+        self.values[name] = value
+
+    @classmethod
+    def add(cls, env, subscription):
+        """id and priority overwritten."""
+        with env.db_transaction as db:
+            priority = len(cls.find_by_sid_and_distributor(env,
+                subscription['sid'], subscription['authenticated'],
+                subscription['distributor']))+1
+            now = to_utimestamp(datetime.now(utc))
+            db("""
+            INSERT INTO subscription
+                        (time, changetime, sid, authenticated, distributor,
+                        format, priority, adverb, class)
+                 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
+            """, (now, now, subscription['sid'], int(subscription['authenticated']),
+            subscription['distributor'], subscription['format'],
+            int(priority), subscription['adverb'],
+            subscription['class']))
+
+    @classmethod
+    def delete(cls, env, rule_id):
+        with env.db_transaction as db:
+            cursor = db.cursor()
+            cursor.execute("""
+            SELECT sid, authenticated, distributor
+              FROM subscription
+             WHERE id=%s
+            """, (rule_id,))
+            sid, authenticated, distributor = cursor.fetchone()
+            cursor.execute("""
+            DELETE FROM subscription
+                  WHERE id = %s
+            """, (rule_id,))
+            i = 1
+            for s in cls.find_by_sid_and_distributor(env, sid, authenticated, distributor):
+                s['priority'] = i
+                s._update_priority()
+                i += 1
+
+    @classmethod
+    def move(cls, env, rule_id, priority):
+        with env.db_transaction as db:
+            cursor = db.cursor()
+            cursor.execute("""
+            SELECT sid, authenticated, distributor
+              FROM subscription
+             WHERE id=%s
+            """, (rule_id,))
+            sid, authenticated, distributor = cursor.fetchone()
+            if priority > len(cls.find_by_sid_and_distributor(env, sid, authenticated, distributor)):
+                return
+            i = 1
+            for s in cls.find_by_sid_and_distributor(env, sid, authenticated, distributor):
+                if int(s['id']) == int(rule_id):
+                    s['priority'] = priority
+                    s._update_priority()
+                    i -= 1
+                elif i == priority:
+                    i += 1
+                    s['priority'] = i
+                    s._update_priority()
+                else:
+                    s['priority'] = i
+                    s._update_priority()
+                i+=1
+
+    @classmethod
+    def update_format_by_distributor_and_sid(cls, env, distributor, sid, authenticated, format):
+        with env.db_transaction as db:
+            cursor = db.cursor()
+            db("""
+            UPDATE subscription
+               SET format=%s
+             WHERE distributor=%s
+               AND sid=%s
+               AND authenticated=%s
+            """, (format, distributor, sid, int(authenticated)))
+
+    @classmethod
+    def find_by_sid_and_distributor(cls, env, sid, authenticated, distributor):
+        subs = []
+
+        with env.db_query as db:
+            cursor = db.cursor()
+            cursor.execute("""
+              SELECT id, sid, authenticated, distributor,
+                     format, priority, adverb, class
+                FROM subscription
+               WHERE sid=%s
+                 AND authenticated=%s
+                 AND distributor=%s
+            ORDER BY priority
+            """, (sid,int(authenticated),distributor))
+            for i in cursor.fetchall():
+                sub = Subscription(env)
+                sub['id'] = i[0]
+                sub['sid'] = i[1]
+                sub['authenticated'] = i[2]
+                sub['distributor'] = i[3]
+                sub['format'] = i[4]
+                sub['priority'] = int(i[5])
+                sub['adverb'] = i[6]
+                sub['class'] = i[7]
+                subs.append(sub)
+
+        return subs
+
+    @classmethod
+    def find_by_sids_and_class(cls, env, uids, klass):
+        """uids should be a collection to tuples (sid, auth)"""
+        if not uids:
+            return []
+
+        subs = []
+
+        with env.db_query as db:
+            cursor = db.cursor()
+            for sid, authenticated in uids:
+                cursor.execute("""
+                    SELECT id, sid, authenticated, distributor,
+                           format, priority, adverb, class
+                      FROM subscription
+                     WHERE class=%s
+                       AND sid = %s
+                       AND authenticated = %s
+                """, (klass,sid,int(authenticated)))
+                for i in cursor.fetchall():
+                    sub = Subscription(env)
+                    sub['id'] = i[0]
+                    sub['sid'] = i[1]
+                    sub['authenticated'] = i[2]
+                    sub['distributor'] = i[3]
+                    sub['format'] = i[4]
+                    sub['priority'] = int(i[5])
+                    sub['adverb'] = i[6]
+                    sub['class'] = i[7]
+                    subs.append(sub)
+
+        return subs
+
+    @classmethod
+    def find_by_class(cls, env, klass):
+        subs = []
+
+        with env.db_query as db:
+            cursor = db.cursor()
+            cursor.execute("""
+                SELECT id, sid, authenticated, distributor,
+                       format, priority, adverb, class
+                  FROM subscription
+                 WHERE class=%s
+            """, (klass,))
+            for i in cursor.fetchall():
+                sub = Subscription(env)
+                sub['id'] = i[0]
+                sub['sid'] = i[1]
+                sub['authenticated'] = i[2]
+                sub['distributor'] = i[3]
+                sub['format'] = i[4]
+                sub['priority'] = int(i[5])
+                sub['adverb'] = i[6]
+                sub['class'] = i[7]
+                subs.append(sub)
+
+        return subs
+
+    def subscription_tuple(self):
+        return (
+            self.values['class'],
+            self.values['distributor'],
+            self.values['sid'],
+            self.values['authenticated'],
+            None,
+            self.values['format'],
+            int(self.values['priority']),
+            self.values['adverb']
+        )
+
+    def _update_priority(self):
+        with self.env.db_transaction as db:
+            cursor = db.cursor()
+            now = to_utimestamp(datetime.now(utc))
+            cursor.execute("""
+            UPDATE subscription
+               SET changetime=%s,
+                   priority=%s
+             WHERE id=%s
+            """, (now, int(self.values['priority']), self.values['id']))

File trac/notification/prefs.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2012 Edgewall Software
+# Copyright (C) 2008, Stephen Hansen
+# Copyright (C) 2009-2010, Robert Corsaro
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+
+import re
+from operator import itemgetter
+from pkg_resources import resource_filename
+
+from trac.core import Component, implements, ExtensionPoint
+from trac.notification.api import (INotificationDistributor,
+                                   INotificationFormatter,
+                                   INotificationSubscriber)
+from trac.notification.model import Subscription
+from trac.prefs.api import IPreferencePanelProvider
+from trac.web.chrome import ITemplateProvider
+from trac.util.translation import _
+
+
+class NotificationPreferences(Component):
+    implements(IPreferencePanelProvider, ITemplateProvider)
+
+    subscribers = ExtensionPoint(INotificationSubscriber)
+    distributors = ExtensionPoint(INotificationDistributor)
+    formatters = ExtensionPoint(INotificationFormatter)
+
+    def __init__(self):
+        self.post_handlers = {
+            'add-rule': self._add_rule,
+            'delete-rule': self._delete_rule,
+            'move-rule': self._move_rule,
+            'set-format': self._set_format
+        }
+
+    # IPreferencePanelProvider
+    def get_preference_panels(self, req):
+        yield ('notification', _('Notifications'))
+
+    def render_preference_panel(self, req, panel, path_info=None):
+        if req.method == 'POST':
+            action_arg = req.args.get('action', '')
+            m = re.match('^([^_]+)_(.+)', action_arg)
+            if m:
+                action, arg = m.groups()
+                handler = self.post_handlers.get(action)
+                if handler:
+                    handler(arg, req)
+            req.redirect(req.href.prefs('notification'))
+
+        data = {'rules':{}, 'subscribers':[]}
+
+        desc_map = {}
+        defaults = []
+
+        data['formatters'] = {}
+        data['selected_format'] = {}
+        data['adverbs'] = ('always', 'never')
+
+        for i in self.subscribers:
+            if not i.description():
+                continue
+            if not req.session.authenticated and i.requires_authentication():
+                continue
+            data['subscribers'].append({
+                'class': i.__class__.__name__,
+                'description': i.description()
+            })
+            desc_map[i.__class__.__name__] = i.description()
+            if hasattr(i, 'default_subscriptions'):
+                defaults.extend(i.default_subscriptions())
+
+        for distributor in self.distributors:
+            for t in distributor.transports():
+                data['rules'][t] = []
+                styles = [s for f in self.formatters
+                            for s, realm in f.get_supported_styles(t)]
+                data['formatters'][t] = styles
+                for r in Subscription.find_by_sid_and_distributor(self.env,
+                        req.session.sid, req.session.authenticated, t):
+                    if desc_map.get(r['class']):
+                        data['rules'][t].append({
+                            'id': r['id'],
+                            'adverb': r['adverb'],
+                            'description': desc_map[r['class']],
+                            'priority': r['priority']
+                        })
+                        data['selected_format'][t] = r['format']
+
+        data['default_rules'] = {}
+        for r in sorted(defaults, key=itemgetter(2)):
+            klass, dist, _, adverb = r
+            if not data['default_rules'].get(dist):
+                data['default_rules'][dist] = []
+            if desc_map.get(klass):
+                data['default_rules'][dist].append({
+                    'adverb': adverb,
+                    'description': desc_map.get(klass)
+                })
+
+        return 'prefs_notification.html', dict(data=data)
+
+    def _add_rule(self, arg, req):
+        rule = Subscription(self.env)
+        rule['sid'] = req.session.sid
+        rule['authenticated'] = 1 if req.session.authenticated else 0
+        rule['distributor'] = arg
+        rule['format'] = req.args.get('format-%s' % arg, '')
+        rule['adverb'] = req.args['new-adverb-%s' % arg]
+        rule['class'] = req.args['new-rule-%s' % arg]
+        Subscription.add(self.env, rule)
+
+    def _delete_rule(self, arg, req):
+        Subscription.delete(self.env, arg)
+
+    def _move_rule(self, arg, req):
+        (rule_id, new_priority) = arg.split('-')
+        if int(new_priority) >= 1:
+            Subscription.move(self.env, rule_id, int(new_priority))
+
+    def _set_format(self, arg, req):
+        Subscription.update_format_by_distributor_and_sid(self.env, arg,
+                req.session.sid, req.session.authenticated,
+                req.args['format-%s' % arg])
+
+    # ITemplateProvider
+    def get_htdocs_dirs(self):
+        return []
+
+    def get_templates_dirs(self):
+        resource_dir = resource_filename('trac.notification', 'templates')
+        return [resource_dir]

File trac/notification/templates/prefs_notification.html

+<!DOCTYPE html
+    PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:xi="http://www.w3.org/2001/XInclude" 
+      xmlns:py="http://genshi.edgewall.org/"
+      xmlns:i18n="http://genshi.edgewall.org/i18n">
+  <xi:include href="prefs.html" />
+  <head>
+    <title>Notifications</title>
+    <script type="text/javascript">
+      jQuery(document).ready(function($) {
+        $("#content").find("h1,h2,h3,h4,h5,h6").addAnchor("Link to this section");
+      });
+    </script>
+  </head>
+  <body>
+    <h2 id="subscriptions">Subscriptions</h2>
+    <div py:for="distributor, rules in data['rules'].iteritems()" class="ruleset">
+      <h3>${distributor}</h3>
+      <div>
+        <div class="field">
+          <form action="" method="post">
+            <table>
+              <th><label for="format-${distributor}">Format:</label></th>
+              <td>
+                <select name="format-${distributor}">
+                  <option py:for="f in data['formatters'][distributor]" value="${f}" selected="${(f == data['selected_format'][distributor]) or None}">${f}</option>
+                </select>
+              </td>
+              <td>
+                <input type="hidden" value="set-format_${distributor}" name="action"></input>
+                <input type="submit" title="Save format" value="Save"></input>
+              </td>
+            </table>
+          </form>
+          <p class="hint">Configure the format of your ${distributor} notifications.</p>
+        </div>
+        <div>
+          <label>Subscription rules:
+            <py:if test="rules">
+              <table summary="Subscription rules">
+                <tr py:for="rule in rules" class="rule">
+                  <td>
+                    <form action="" method="post">
+                      <div class="inlinebuttons">
+                        <input type="hidden" value="move-rule_${rule['id']}-${rule['priority']-1}" name="action"></input>
+                        <input type="submit" title="Move rule up" value="↑"></input>
+                      </div>
+                    </form>
+                  </td>
+                  <td>
+                    <form action="" method="post">
+                      <div class="inlinebuttons">
+                        <input type="hidden" value="move-rule_${rule['id']}-${rule['priority']+1}" name="action"></input>
+                        <input type="submit" title="Move rule down" value="↓"></input>
+                      </div>
+                    </form>
+                  </td>
+                  <td>
+                    <form action="" method="post">
+                      <div class="inlinebuttons">
+                        <input type="hidden" value="delete-rule_${rule['id']}" name="action"></input>
+                        <input type="submit" title="Delete rule" value="–"></input>
+                      </div>
+                    </form>
+                  </td>
+                  <td>
+                    ${rule['adverb']} ${rule['description']}
+                  </td>
+                </tr>
+              </table>
+            </py:if>
+            <form action="" method="post">
+              <table>
+                <tr>
+                  <td>
+                    <select name="new-adverb-${distributor}">
+                      <option py:for="a in data['adverbs']" value="${a}">${a}</option>
+                    </select>
+                  </td>
+                  <td>
+                    <select name="new-rule-${distributor}">
+                      <option py:for="s in data['subscribers']" value="${s['class']}">${s['description']}</option>
+                    </select>
+                  </td>
+                  <td>
+                    <input type="hidden" value="add-rule_${distributor}" name="action"></input>
+                    <input type="hidden" value="${data['selected_format'][distributor]}" name="format-${distributor}"></input>                    
+                    <input type="submit" title="Add rule" value="Add"></input>
+                  </td>
+                </tr>
+              </table>
+            </form>
+          </label>
+          <p class="hint">
+            Add, remove or reorder subscription rules to ${distributor} notifications.
+            Only the first matching rule is applied.
+          </p>
+          <p class="hint">  
+            Example: The rule <strong>"never notify me when I update a ticket"</strong> should be above <strong>"always notify me of any ticket changes"</strong> to get
+            notifications of any ticket changes except when you update a ticket.
+          </p>
+        </div>