Commits

Ryan Ollos committed 6363cd6

Imported from svn by Bitbucket

  • Participants

Comments (0)

Files changed (83)

+*.egg-info
+*.patch
+*.pyc
+*.mo
+.stgit-*
+build/
+dist/
+patches-*/
+Founder : ixokai
+Maintainer : doki_pen@doki-pen.org
+Contributers:
+ * acamac
+ * davidf@sjsoft.com
+ * doki_pen@doki-pen.org
+ * ebray
+ * hasienda
+ * ixokai
+ * jdio
+ * leorachael
+ * martin_s
+ * mixedpuppy
+ * pipern
+ * rea
+ * rjollos
+ * robrien
+ * spcamp
+ * thomas.moschny@gmx.de
+
+If you've been left off this list and you shouldn't have been, email me.
+Copyright (c) 2008, Steven Hanson
+Copyright (c) 2009, Robert Corsaro
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+ 1. Redistributions of source code must retain the above copyright
+    notice, this list of conditions and the following disclaimer.
+ 2. Redistributions in binary form must reproduce the above copyright
+    notice, this list of conditions and the following disclaimer in
+    the documentation and/or other materials provided with the
+    distribution.
+ 3. Neither the name of the main authors nor the names of its contributors
+    may be used to endorse or promote products derived from this software
+    without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS
+OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
+GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
+IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+This software now contains voluntary contributions made by many individuals.
+For the exact contribution history, see the revision history and logs,
+available at https://trac-hacks.org/log/announcerplugin/.
+TracAnnouncer is a flexible trac notifications drop in replacement that is
+very flexible and customizable.  There is a focus on users being able to
+configure what notifications they would like and relieving the sysadmin
+from having to manage notifications.
+
+HACKING NOTES:
+
+The most confusing part of announce is that subscriptions have three
+related fields, that are not intuitive.  (sid, authenticated, address).
+There is a very good reason for this.  First, Trac users are identified
+throughout the system with the sid, authenticated pair.  Anonymous user
+are allowed to set their sid to anything that they would like via the
+advanced preferences in the preferences section of the site.  The can
+set their sid to the same sid as some authenticated user.  The way we
+tell the difference between the two identical sids is the authenticated
+flag.  There is a third type of user when we are talking about announcements.
+Users can enter any email address in some ticket fields, like CC.  These
+subscriptions are not associated with any sid.  So the sid and authenticated
+in the subscription would be None, None.  These users should be treated
+with all default configuration and permissions checked against anonymous.
+I hope this helps, because it took me a while to wrap my head around :P
+

File announcer/__init__.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2008, Stephen Hansen
+# Copyright (c) 2009, Robert Corsaro
+# 
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution.
+#
+
+__version__ = __import__('pkg_resources').get_distribution('TracAnnouncer').version

File announcer/api.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2008, Stephen Hansen
+# Copyright (c) 2009, Robert Corsaro
+# Copyright (c) 2010-2012, Steffen Hoffmann
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution.
+#
+
+import time
+
+from operator import itemgetter
+from pkg_resources import resource_filename
+
+from trac import __version__ as trac_version
+from trac.config import ExtensionOption
+from trac.core import Component, ExtensionPoint, Interface, TracError, \
+                      implements
+from trac.db import DatabaseManager
+from trac.env import IEnvironmentSetupParticipant
+
+from announcer import db_default
+
+
+class IAnnouncementProducer(Interface):
+    """Producer converts Trac events from different subsystems, into
+    AnnouncerEvents.
+    """
+
+    def realms():
+        """Returns an iterable that lists all the realms that this producer
+        is capable of producing events for.
+        """
+
+
+class IAnnouncementSubscriber(Interface):
+    """IAnnouncementSubscriber provides an interface where a Plug-In can
+    register realms and categories of subscriptions it is able to provide.
+
+    An IAnnouncementSubscriber component can use any means to determine
+    if a user is interested in hearing about a given event. More then one
+    component can handle the same realms and categories.
+
+    The subscriber must also indicate not just that a user is interested
+    in receiving a particular notice. Again, how it makes that decision is
+    entirely up to a particular implementation."""
+
+    def matches(event):
+        """Returns a list of subscriptions that match the given event.
+        Responses should be yielded as 7 part tuples as follows:
+        (distributor, sid, authenticated, address, format, priority, adverb)
+        The default installation includes email and xmpp distributors.  The
+        default installation includes formats for text/plain and text/html.
+        If an unknown format is return, it will be replaced by a default known
+        format.  Priority is used to resolve conflicting subscriptions for the
+        same user/distribution pair.  adverb is either always or never.
+        """
+
+    def description():
+        """A description of the subscription that shows up in the users
+        preferences.
+        """
+
+    def requires_authentication():
+        """Returns True or False.  If the user is required to be authenticated
+        to create the subscription, then return True.  This applies to things
+        like ticket owner subscriber, since the ticket owner can never be the
+        sid of an unauthenticated user and we have no way to lookup users by
+        email address (as of yet).
+        """
+
+
+class IAnnouncementDefaultSubscriber(Interface):
+    """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.
+    """
+
+    def default_subscriptions():
+        """Yields 5 part tuple containing (class, distributor, priority,
+        adverb).  This is used to display default subscriptions in the
+        user UI and can also be used by matches to figure out what
+        default subscriptions it should yield.
+        """
+
+
+class IAnnouncementSubscriptionFilter(Interface):
+    """IAnnouncementSubscriptionFilter provides an interface where a component
+    can filter subscribers from the final distribution list.
+    """
+
+    def filter_subscriptions(event, subscriptions):
+        """Returns a filtered iterator of subscriptions.  This method is called
+        after all get_subscriptions_for_event calls are made to allow
+        components to remove addresses from the distribution list.  This can
+        be used for things like "never notify updater" functionality.
+        """
+
+
+class IAnnouncementFormatter(Interface):
+    """Formatters are responsible for converting an event into a message
+    appropriate for a given transport.
+
+    For transports like 'aim' or 'irc', this may be a short summary of a
+    change. For 'email', it may be a plaintext or html overview of all
+    the changes and perhaps the existing state.
+
+    It's up to a formatter to determine what ends up ultimately being sent
+    to the end-user. It's capable of pulling data out of the target object
+    that wasn't changed, picking and choosing details for whatever reason.
+
+    Since a formatter must be intimately familiar with the realm that
+    originated the event, formatters are tied to specific transport + realm
+    combinations. This means there may be a proliferation of formatters as
+    options expand.
+    """
+
+    def format_styles(transport, realm):
+        """Returns an iterable of styles that this formatter supports for
+        a specified transport and realm.
+
+        Many formatters may simply return a single style and never have more;
+        that's fine. But if its useful to encapsulate code for several similar
+        styles a formatter can handle more then one. For example, 'text/plain'
+        and 'text/html' may be useful variants the same formatter handles.
+
+        Formatters retain the ability to descriminate by transport, but don't
+        need to.
+        """
+
+    def alternative_style_for(transport, realm, style):
+        """Returns an alternative style for the given style if one is
+        available.
+        """
+
+    def format(transport, realm, style, event):
+        """Converts the event into the specified style. If the transport or
+        realm passed into this method are not ones this formatter can handle,
+        it should return silently and without error.
+
+        The exact return type of this method is intentionally undefined. It
+        will be whatever the distributor that it is designed to work with
+        expects.
+        """
+
+
+class IAnnouncementDistributor(Interface):
+    """The Distributor is responsible for actually delivering an event to the
+    desired subscriptions.
+
+    A distributor should attempt to avoid blocking; using subprocesses is
+    preferred to threads.
+
+    Each distributor handles a single transport, and only one distributor
+    in the system should handle that. For example, there should not be
+    two distributors for the 'email' transport.
+    """
+
+    def transports():
+        """Returns an iter of the transport supported."""
+
+    def distribute(transport, recipients, event):
+        """This method is meant to actually distribute the event to the
+        specified recipients, over the specified transport.
+
+        If it is passed a transport it does not support, it should return
+        silently and without error.
+
+        The recipients is a list of (name, address) pairs with either (but not
+        both) being allowed to be None. If name is provided but address isn't,
+        then the distributor should defer to IAnnouncementAddressResolver
+        implementations to determine what the address should be.
+
+        If the name is None but the address is not, then the distributor
+        should rely on the address being correct and use it-- if possible.
+
+        The distributor may initiate as many transactions as are necessecary
+        to deliver a message, but should use as few as possible; for example
+        in the EmailDistributor, if all of the recipients are receiving a
+        plain text form of the message, a single message with many BCC's
+        should be used.
+
+        The distributor is responsible for determining which of the
+        IAnnouncementFormatters should get the privilege of actually turning
+        an event into content. In cases where multiple formatters are capable
+        of converting an event into a message for a given transport, a
+        user preference would be a dandy idea.
+        """
+
+
+class IAnnouncementPreferenceProvider(Interface):
+    """Represents a single 'box' in the Announcements preference panel.
+
+    Any component can always implement IPreferencePanelProvider to get
+    preferences from users, of course. However, considering there may be
+    several components related to the Announcement system, and many may
+    have different preferences for a user to set, that would clutter up
+    the preference interfac quite a bit.
+
+    The IAnnouncementPreferenceProvider allows several boxes to be
+    chained in the same panel to group the preferenecs related to the
+    Announcement System.
+
+    Implementing announcement preference boxes should be essentially
+    identical to implementing entire panels.
+    """
+
+    def get_announcement_preference_boxes(req):
+        """Accepts a request object, and returns an iterable of
+        (name, label) pairs; one for each box that the implementation
+        can generate.
+
+        If a single item is returned, be sure to 'yield' it instead of
+        returning it."""
+
+    def render_announcement_preference_box(req, box):
+        """Accepts a request object, and the name (as from the previous
+        method) of the box that should be rendered.
+
+        Returns a tuple of (template, data) with the template being a
+        filename in a directory provided by an ITemplateProvider which
+        shall be rendered into a single <div> element, when combined
+        with the data member.
+        """
+
+
+class IAnnouncementAddressResolver(Interface):
+    """Handles mapping Trac usernames to addresses for distributors to use."""
+
+    def get_address_for_name(name, authenticated):
+        """Accepts a session name, and returns an address.
+
+        This address explicitly does not always have to mean an email address,
+        nor does it have to be an address stored within the Trac system at
+        all.
+
+        Implementations of this interface are never 'detected' automatically,
+        and must instead be specifically named for a particular distributor.
+        This way, some may find email addresses (for EmailDistributor), and
+        others may find AIM screen name.
+
+        If no address for the specified name can be found, None should be
+        returned. The next resolver will be attempted in the chain.
+        """
+
+
+class AnnouncementEvent(object):
+    """AnnouncementEvent
+
+    This packages together in a single place all data related to a particular
+    event; notably the realm, category, and the target that represents the
+    initiator of the event.
+
+    In some (rare) cases, the target may be None; in cases where the message
+    is all that matters and there's no possible data you could conceivably
+    get beyond just the message.
+    """
+    def __init__(self, realm, category, target, author=""):
+        self.realm = realm
+        self.category = category
+        self.target = target
+        self.author = author
+
+    def get_basic_terms(self):
+        return (self.realm, self.category)
+
+    def get_session_terms(self, session_id):
+        return tuple()
+
+
+class IAnnouncementSubscriptionResolver(Interface):
+    """Supports new and old style of subscription resolution until new code
+    is complete."""
+
+    def subscriptions(event):
+        """Return all subscriptions as (dist, sid, auth, address, format)
+        priority 1 is highest.  adverb is 'always' or 'never'.
+        """
+
+
+class SubscriptionResolver(Component):
+    """Collect, and resolve subscriptions."""
+
+    implements(IAnnouncementSubscriptionResolver)
+
+    subscribers = ExtensionPoint(IAnnouncementSubscriber)
+
+    def subscriptions(self, event):
+        """Yields all subscriptions for a given event."""
+
+        subscriptions = []
+        for sp in self.subscribers:
+            subscriptions.extend(
+                [x for x in sp.matches(event) if x]
+            )
+
+        """
+        This logic is meant to generate a list of subscriptions for each
+        distribution method.  The important thing is, that we pick the rule
+        with the highest priority for each (sid, distribution) pair.
+        If it is "never", then the user is dropped from the list,
+        if it is "always", then the user is kept.
+        Only users highest priority rule is used and all others are skipped.
+        """
+        # sort by dist, sid, authenticated, priority
+        ordered_subs = sorted(subscriptions, key=itemgetter(1,2,3,6))
+
+        resolved_subs = []
+
+        # collect highest priority for each (sid, dist) pair
+        state = {
+            'last': None
+        }
+        for s in ordered_subs:
+            if (s[1], s[2], s[3]) == state['last']:
+                continue
+            if s[-1] == 'always':
+                self.log.debug("Adding (%s [%s]) for 'always' on rule (%s) "
+                    "for (%s)"%(s[2], s[3], s[0], s[1]))
+                resolved_subs.append(s[1:6])
+            else:
+                self.log.debug("Ignoring (%s [%s]) for 'never' on rule (%s) "
+                    "for (%s)"%(s[2], s[3], s[0], s[1]))
+
+            # if s[1] is None, then the subscription is for a raw email
+            # address that has been set in some field and we shouldn't skip
+            # the next raw email subscription.  In other words, all raw email
+            # subscriptions should be added.
+            if s[2]:
+                state['last'] = (s[1], s[2], s[3])
+
+        return resolved_subs
+
+
+_TRUE_VALUES = ('yes', 'true', 'enabled', 'on', 'aye', '1', 1, True)
+
+def istrue(value, otherwise=False):
+    return True and (value in _TRUE_VALUES) or otherwise
+
+
+# Import i18n methods.  Fallback modules maintain compatibility to Trac 0.11
+# by keeping Babel optional here.
+try:
+    from trac.util.translation import domain_functions
+    add_domain, _, N_ , tag_= \
+        domain_functions('announcer', ('add_domain', '_', 'N_', 'tag_'))
+except ImportError:
+    from  genshi.builder         import  tag as tag_
+    from  trac.util.translation  import  gettext
+    _ = gettext
+    N_ = lambda text: text
+    def add_domain(a, b, c=None):
+        pass
+
+
+class AnnouncementSystem(Component):
+    """AnnouncementSystem represents the entry-point into the announcement
+    system, and is also the central controller that handles passing notices
+    around.
+
+    An announcement begins when something-- an announcement provider--
+    constructs an AnnouncementEvent (or subclass) and calls the send method
+    on the AnnouncementSystem.
+
+    Every event is classified by two required fields-- realm and category.
+    In general, the realm corresponds to the realm of a Resource within Trac;
+    ticket, wiki, milestone, and such. This is not a requirement, however.
+    Realms can be anything distinctive-- if you specify novel realms to solve
+    a particular problem, you'll simply also have to specify subscribers and
+    formatters who are able to deal with data in those realms.
+
+    The other classifier is a category that is defined by the providers and
+    has no particular meaning; for the providers that implement the
+    I*ChangeListener interfaces, the categories will often correspond to the
+    kinds of events they receive. For tickets, they would be 'created',
+    'changed' and 'deleted'.
+
+    There is no requirement for an event to have more then realm and category
+    to classify an event, but if more is provided in a subclass that the
+    subscribers can use to pick through events, all power to you.
+    """
+
+    implements(IEnvironmentSetupParticipant)
+
+    subscribers = ExtensionPoint(IAnnouncementSubscriber)
+    subscription_filters = ExtensionPoint(IAnnouncementSubscriptionFilter)
+    subscription_resolvers = ExtensionPoint(IAnnouncementSubscriptionResolver)
+    distributors = ExtensionPoint(IAnnouncementDistributor)
+
+    resolver = ExtensionOption('announcer', 'subscription_resolvers',
+        IAnnouncementSubscriptionResolver, 'SubscriptionResolver',
+        """Comma-separated list of subscription resolver components in the
+        order they will be called.
+        """)
+
+    def __init__(self):
+        # Bind the 'announcer' catalog to the specified locale directory.
+        locale_dir = resource_filename(__name__, 'locale')
+        add_domain(self.env.path, locale_dir)
+
+    # IEnvironmentSetupParticipant methods
+
+    def environment_created(self):
+        self._upgrade_db(self.env.get_db_cnx())
+
+    def environment_needs_upgrade(self, db):
+        schema_ver = self.get_schema_version(db)
+        if schema_ver == db_default.schema_version:
+            return False
+        if schema_ver > db_default.schema_version:
+            raise TracError(_("""A newer plugin version has been installed
+                              before, but downgrading is unsupported."""))
+        self.log.info("TracAnnouncer db schema version is %d, should be %d"
+                      % (schema_ver, db_default.schema_version))
+        return True
+
+    def upgrade_environment(self, db):
+        self._upgrade_db(db)
+
+    # Internal methods
+
+    def get_schema_version(self, db=None):
+        """Return the current schema version for this plugin."""
+        db = db and db or self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("""
+            SELECT value
+              FROM system
+             WHERE name='announcer_version'
+        """)
+        row = cursor.fetchone()
+        if not (row and int(row[0]) > 5):
+            # Care for pre-announcer-1.0 installations.
+            dburi = self.config.get('trac', 'database')
+            tables = self._get_tables(dburi, cursor)
+            if 'subscription' in tables:
+                # Version > 2
+                cursor.execute("SELECT * FROM subscription_attribute")
+                columns = [col[0] for col in
+                           self._get_cursor_description(cursor)]
+                if 'authenticated' in columns:
+                    self.env.log.debug(
+                        "TracAnnouncer needs to register schema version")
+                    return 5
+                if 'realm' in columns:
+                    self.env.log.debug(
+                        "TracAnnouncer needs to change a table")
+                    return 4
+                self.env.log.debug("TracAnnouncer needs to change tables")
+                return 3
+            if 'subscriptions' in tables:
+                cursor.execute("SELECT * FROM subscriptions")
+                columns = [col[0] for col in
+                           self._get_cursor_description(cursor)]
+                if not 'format' in columns:
+                    self.env.log.debug("TracAnnouncer needs to add new tables")
+                    return 2
+                self.env.log.debug("TracAnnouncer needs to add/change tables")
+                return 1
+            # This is a new installation.
+            return 0
+        # The expected outcome for any up-to-date installation.
+        return row and int(row[0]) or 0
+
+    def _get_cursor_description(self, cursor):
+        # Cursors don't look the same across Trac versions
+        if trac_version < '0.12':
+            return cursor.description
+        else:
+            return cursor.cursor.description
+
+    def _get_tables(self, dburi, cursor):
+        """Code from TracMigratePlugin by Jun Omae (see tracmigrate.admin)."""
+        if dburi.startswith('sqlite:'):
+            sql = """
+                SELECT name
+                  FROM sqlite_master
+                 WHERE type='table'
+                   AND NOT name='sqlite_sequence'
+            """
+        elif dburi.startswith('postgres:'):
+            sql = """
+                SELECT tablename
+                  FROM pg_tables
+                 WHERE schemaname = ANY (current_schemas(false))
+            """
+        elif dburi.startswith('mysql:'):
+            sql = "SHOW TABLES"
+        else:
+            raise TracError('Unsupported database type "%s"'
+                            % dburi.split(':')[0])
+        cursor.execute(sql)
+        return sorted([row[0] for row in cursor])
+
+    def _upgrade_db(self, db):
+        """Each schema version should have its own upgrade module, named
+        upgrades/dbN.py, where 'N' is the version number (int).
+        """
+        db_mgr = DatabaseManager(self.env)
+        schema_ver = self.get_schema_version(db)
+
+        cursor = db.cursor()
+        # Is this a new installation?
+        if not schema_ver:
+            # Perform a single-step install: Create plugin schema and
+            # insert default data into the database.
+            connector = db_mgr._get_connector()[0]
+            for table in db_default.schema:
+                for stmt in connector.to_sql(table):
+                    cursor.execute(stmt)
+            for table, cols, vals in db_default.get_data(db):
+                cursor.executemany("INSERT INTO %s (%s) VALUES (%s)" % (table,
+                                   ','.join(cols),
+                                   ','.join(['%s' for c in cols])), vals)
+        else:
+            # Perform incremental upgrades.
+            for i in range(schema_ver + 1, db_default.schema_version + 1):
+                name  = 'db%i' % i
+                try:
+                    upgrades = __import__('announcer.upgrades', globals(),
+                                          locals(), [name])
+                    script = getattr(upgrades, name)
+                except AttributeError:
+                    raise TracError(_("""
+                        No upgrade module for version %(num)i (%(version)s.py)
+                        """, num=i, version=name))
+                script.do_upgrade(self.env, i, cursor)
+        cursor.execute("""
+            UPDATE system
+               SET value=%s
+             WHERE name='announcer_version'
+            """, (db_default.schema_version,))
+        self.log.info("Upgraded TracAnnouncer db schema from version %d to %d"
+                      % (schema_ver, db_default.schema_version))
+        db.commit()
+
+    # AnnouncementSystem core methods
+
+    def send(self, evt):
+        start = time.time()
+        self._real_send(evt)
+        stop = time.time()
+        self.log.debug("AnnouncementSystem sent event in %s seconds."
+                       % (round(stop - start, 2)))
+
+    def _real_send(self, evt):
+        """Accepts a single AnnouncementEvent instance (or subclass), and
+        returns nothing.
+
+        There is no way (intentionally) to determine what the
+        AnnouncementSystem did with a particular event besides looking through
+        the debug logs.
+        """
+        try:
+            subscriptions = self.resolver.subscriptions(evt)
+            for sf in self.subscription_filters:
+                subscriptions = set(
+                    sf.filter_subscriptions(evt, subscriptions)
+            )
+
+            self.log.debug(
+                "AnnouncementSystem has found the following subscriptions: " \
+                        "%s"%(', '.join(['[%s(%s) via %s]' % ((s[1] or s[3]),\
+                        s[2] and 'authenticated' or 'not authenticated',s[0])\
+                        for s in subscriptions]
+                    )
+                )
+            )
+            packages = {}
+            for transport, sid, authenticated, address, subs_format \
+                    in subscriptions:
+                if transport not in packages:
+                    packages[transport] = set()
+                packages[transport].add((sid,authenticated,address))
+            for distributor in self.distributors:
+                for transport in distributor.transports():
+                    if transport in packages:
+                        distributor.distribute(transport, packages[transport],
+                                evt)
+        except:
+            self.log.error("AnnouncementSystem failed.", exc_info=True)

File announcer/compat.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2012, Steffen Hoffmann
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution.
+#
+
+"""Various classes and functions to provide backwards-compatibility with
+previous versions of Python from 2.4 and Trac from 0.11 onwards.
+"""
+
+try:
+    from trac.util.datefmt  import to_utimestamp
+except ImportError:
+    # Cheap fallback for Trac 0.11 compatibility.
+    from trac.util.datefmt  import to_timestamp
+    def to_utimestamp(dt):
+        return to_timestamp(dt) * 1000000L
+
+from trac.util.text import to_unicode
+try:
+    # Method only available in Trac 0.11.3 or higher.
+    from trac.util.text import exception_to_unicode
+except:
+    def exception_to_unicode(e, traceback=False):
+        """Convert an `Exception` to an `unicode` object.
+
+        In addition to `to_unicode`, this representation of the exception
+        also contains the class name and optionally the traceback.
+        This replicates the Trac core method for backwards-compatibility.
+        """
+        message = '%s: %s' % (e.__class__.__name__, to_unicode(e))
+        if traceback:
+            from trac.util import get_last_traceback
+            traceback_only = get_last_traceback().split('\n')[:-2]
+            message = '\n%s\n%s' % (to_unicode('\n'.join(traceback_only)),
+                                    message)
+        return message

File announcer/db_default.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2009, Robert Corsaro
+# Copyright (c) 2012, Steffen Hoffmann
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution.
+#
+
+from trac.db import Table, Column, Index
+
+schema_version = 6
+
+## Database schema
+#
+
+"""The 'subscriptions' db table has been dropped in favor of the new
+subscriber interface, that uses two other tables.
+
+TODO: We still need to create an upgrade script, that will port subscriptions
+from 'subscriptions' and 'session_attribute' db tables to 'subscription' and
+'subscription_attribute'.
+"""
+
+schema = [
+    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')
+    ],
+    Table('subscription_attribute', key='id')[
+        Column('id', auto_increment=True),
+        Column('sid'),
+        Column('authenticated', type='int'),
+        Column('class'),
+        Column('realm'),
+        Column('target')
+    ]
+]
+
+## Default database values
+#
+
+# (table, (column1, column2), ((row1col1, row1col2), (row2col1, row2col2)))
+def get_data(db):
+    return (('system',
+              ('name', 'value'),
+                (('announcer_version', str(schema_version)),)),)

File announcer/distributors/__init__.py

Empty file added.

File announcer/distributors/mail.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2008, Stephen Hansen
+# Copyright (c) 2009, Robert Corsaro
+# Copyright (c) 2010,2012 Steffen Hoffmann
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution.
+#
+
+# TODO: pick format based on subscription.  For now users will use the same
+#       format for all announcements, but in the future we can make this more
+#       flexible, since it's in the subscription table.
+
+import Queue
+import random
+import re
+import smtplib
+import sys
+import threading
+import time
+
+from email.Charset import Charset, QP, BASE64
+from email.MIMEMultipart import MIMEMultipart
+from email.MIMEText import MIMEText
+from email.Utils import formatdate, formataddr
+try:
+    from email.header import Header
+except:
+    from email.Header import Header
+from subprocess import Popen, PIPE
+
+from trac.config import BoolOption, ExtensionOption, IntOption, Option, \
+                        OrderedExtensionsOption
+from trac.core import *
+from trac.util import get_pkginfo, md5
+from trac.util.compat import set, sorted
+from trac.util.datefmt import to_timestamp
+from trac.util.text import CRLF, to_unicode
+
+from announcer.api import AnnouncementSystem
+from announcer.api import IAnnouncementAddressResolver
+from announcer.api import IAnnouncementDistributor
+from announcer.api import IAnnouncementFormatter
+from announcer.api import IAnnouncementPreferenceProvider
+from announcer.api import IAnnouncementProducer
+from announcer.api import _
+from announcer.model import Subscription
+from announcer.util.mail import set_header
+from announcer.util.mail_crypto import CryptoTxt
+
+
+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 IAnnouncementEmailDecorator(Interface):
+    def decorate_message(event, message, decorators):
+        """Manipulate the message before it is sent on it's way.  The callee
+        should call the next decorator by popping decorators and calling the
+        popped decorator.  If decorators is empty, don't worry about it.
+        """
+
+
+class EmailDistributor(Component):
+
+    implements(IAnnouncementDistributor)
+
+    formatters = ExtensionPoint(IAnnouncementFormatter)
+    # 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.""")
+
+    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.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
+        # 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: %s'%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 = 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=[]):
+
+        # 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)
+
+        rootMessage = MIMEMultipart("related")
+        # TODO: Is this good? (from jabber branch)
+        #rootMessage.set_charset(self._charset)
+
+        # Write header data into message object.
+        for k, v in headers.iteritems():
+            set_header(rootMessage, k, v)
+
+        output = formatter.format(transport, event.realm, format, event)
+
+        # DEVEL: Currently crypto operations work with format text/plain only.
+        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.")
+            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 for suitable encoding setting.
+        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.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)
+            msgText.set_charset(self._charset)
+            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)
+        # According to RFC 2046, the last part of a multipart message is best
+        #   and preferred.
+        parentMessage.attach(msgText)
+
+        # DEVEL: Decorators can interfere with crypto operation here. Fix it.
+        decorators = self._get_decorators()
+        if len(decorators) > 0:
+            decorator = decorators.pop()
+            decorator.decorate_message(event, rootMessage, decorators)
+
+        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[:]
+
+
+class SmtpEmailSender(Component):
+    """E-mail sender connecting to an SMTP server."""
+
+    implements(IEmailSender)
+
+    server = Option('smtp', 'server', 'localhost',
+        """SMTP server hostname to use for email notifications.""")
+
+    timeout = IntOption('smtp', 'timeout', 10,
+        """SMTP server connection timeout. (requires python-2.6)""")
+
+    port = IntOption('smtp', 'port', 25,
+        """SMTP server port to use for email notification.""")
+
+    user = Option('smtp', 'user', '',
+        """Username for SMTP server.""")
+
+    password = Option('smtp', 'password', '',
+        """Password for SMTP server.""")
+
+    use_tls = BoolOption('smtp', 'use_tls', 'false',
+        """Use SSL/TLS to send notifications over SMTP.""")
+
+    use_ssl = BoolOption('smtp', 'use_ssl', 'false',
+        """Use ssl for smtp connection.""")
+
+    debuglevel = IntOption('smtp', 'debuglevel', 0,
+        """Set to 1 for useful smtp debugging on stdout.""")
+
+
+    def send(self, from_addr, recipients, message):
+        # use defaults to make sure connect() is called in the constructor
+        smtpclass = smtplib.SMTP
+        if self.use_ssl:
+            smtpclass = smtplib.SMTP_SSL
+
+        args = {
+            'host': self.server,
+            'port': self.port
+        }
+        # timeout isn't supported until python 2.6
+        vparts = sys.version_info[0:2]
+        if vparts[0] >= 2 and vparts[1] >= 6:
+            args['timeout'] = self.timeout
+
+        smtp = smtpclass(**args)
+        smtp.set_debuglevel(self.debuglevel)
+        if self.use_tls:
+            smtp.ehlo()
+            if not smtp.esmtp_features.has_key('starttls'):
+                raise TracError(_("TLS enabled but server does not support " \
+                        "TLS"))
+            smtp.starttls()
+            smtp.ehlo()
+        if self.user:
+            smtp.login(
+                self.user.encode('utf-8'),
+                self.password.encode('utf-8')
+            )
+        smtp.sendmail(from_addr, recipients, message)
+        if self.use_tls or self.use_ssl:
+            # avoid false failure detection when the server closes
+            # the SMTP connection with TLS/SSL enabled
+            import socket
+            try:
+                smtp.quit()
+            except socket.sslerror:
+                pass
+        else:
+            smtp.quit()
+
+
+class SendmailEmailSender(Component):
+    """E-mail sender using a locally-installed sendmail program."""
+
+    implements(IEmailSender)
+
+    sendmail_path = Option('sendmail', 'sendmail_path', 'sendmail',
+        """Path to the sendmail executable.
+
+        The sendmail program must accept the `-i` and `-f` options.
+        """)
+
+    def send(self, from_addr, recipients, message):
+        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" % ' '.join(cmdline))
+        try:
+            child = Popen(cmdline, bufsize=-1, stdin=PIPE, stdout=PIPE,
+                          stderr=PIPE)
+            (out, err) = child.communicate(message)
+            if child.returncode or err:
+                raise Exception("Sendmail failed with (%s, %s), command: '%s'"
+                                % (child.returncode, err.strip(), cmdline))
+        except OSError, e:
+            self.log.error("Failed to run sendmail[%s] with error %s"%\
+                    (self.sendmail_path, e))
+
+
+class DeliveryThread(threading.Thread):
+    def __init__(self, queue, sender):
+        threading.Thread.__init__(self)
+        self._sender = sender
+        self._queue = queue
+        self.setDaemon(True)
+
+    def run(self):
+        while 1:
+            sendfrom, recipients, message = self._queue.get()
+            self._sender(sendfrom, recipients, message)
+

File announcer/distributors/xmppd.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2010, Robert Corsaro
+# Copyright (c) 2012, Steffen Hoffmann
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution.
+#
+
+import Queue
+import time
+
+from threading import Thread
+from xmpp import Client
+from xmpp.protocol import Message, JID
+
+from trac.config import Option, BoolOption, IntOption, OrderedExtensionsOption
+from trac.core import *
+from trac.util.compat import set
+
+from announcer.api import IAnnouncementDistributor
+from announcer.api import IAnnouncementPreferenceProvider
+from announcer.api import IAnnouncementAddressResolver
+from announcer.api import IAnnouncementFormatter
+from announcer.api import IAnnouncementProducer
+from announcer.resolvers import SpecifiedXmppResolver
+from announcer.util.settings import SubscriptionSetting
+
+
+class XmppDistributor(Component):
+    """Distribute announcements to XMPP clients."""
+
+    implements(IAnnouncementDistributor)
+
+    formatters = ExtensionPoint(IAnnouncementFormatter)
+
+    resolvers = OrderedExtensionsOption('announcer', 'xmpp_resolvers',
+        IAnnouncementAddressResolver, 'SpecifiedXmppResolver',
+        """Comma seperated list of xmpp resolver components in the order
+        they will be called.  If an xmpp address is resolved, the remaining
+        resolvers will no be called.
+        """)
+
+    default_format = Option('announcer', 'default_xmpp_format',
+            'text/plain',
+            """Default format for xmpp messages.""")
+
+    server = Option('xmpp', 'server', None,
+        """XMPP server hostname to use for jabber notifications.""")
+
+    port = IntOption('xmpp', 'port', 5222,
+        """XMPP server port to use for jabber notification.""")
+
+    user = Option('xmpp', 'user', 'trac@localhost',
+        """Sender address to use in xmpp message.""")
+
+    resource = Option('xmpp', 'resource', 'TracAnnouncerPlugin',
+        """Sender resource to use in xmpp message.""")
+
+    password = Option('xmpp', 'password', None,
+        """Password for XMPP server.""")
+
+    use_threaded_delivery = BoolOption('announcer', 'use_threaded_delivery',
+            False,
+            """If true, the actual delivery of the message will occur
+            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.
+            """)
+
+    def __init__(self):
+        self.connections = {}
+        self.delivery_queue = None
+        self.xmpp_format_setting = SubscriptionSetting(self.env, 'xmpp_format',
+                self.default_format)
+
+    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 "xmpp"
+
+    def distribute(self, transport, recipients, event):
+        self.log.info('XmppDistributor called')
+        if transport != 'xmpp':
+            return
+        fmtdict = self._formats(transport, event.realm)
+        if not fmtdict:
+            self.log.error(
+                "XmppDistributor No formats found for %s %s"%(
+                    transport, event.realm))
+            return
+        msgdict = {}
+        for name, authed, addr in recipients:
+            fmt = name and \
+                self._get_preferred_format(name, event.realm)
+            if fmt not in fmtdict:
+                self.log.debug(("XmppDistributor 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(
+                    "XmppDistributor was unable to find a formatter " +
+                    "for format %s"%k
+                )
+                continue
+            # TODO:  This won't work with multiple distributors
+            #rslvr = None
+            # 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
+            rslvr = SpecifiedXmppResolver(self.env)
+            addr = rslvr.get_address_for_name(name, authed)
+            if addr:
+                self.log.debug("XmppDistributor 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
+                msgdict.setdefault(fmt, set()).add((name, authed, addr))
+            else:
+                self.log.debug("XmppDistributor 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(
+                "XmppDistributor is sending event as '%s' to: %s"%(
+                    fmt, ', '.join(x[2] for x in v)))
+            self._do_send(transport, event, k, v, fmtdict[k])
+
+    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(
+            "XmppDistributor has found the following formats capable "
+            "of handling '%s' of '%s': %s"%(transport, realm,
+                ', '.join(formats.keys())))
+        if not formats:
+            self.log.error("XmppDistributor is unable to continue " \
+                    "without supporting formatters.")
+        return formats
+
+    def _get_preferred_format(self, sid, realm=None):
+        if realm:
+            name = 'xmpp_format_%s'%realm
+        else:
+            name = 'xmpp_format'
+        setting = SubscriptionSetting(self.env, name,
+                self.xmpp_format_setting.default)
+        return self.xmpp_format_setting.get_user_setting(sid)[0]
+
+    def _do_send(self, transport, event, format, recipients, formatter):
+        message = formatter.format(transport, event.realm, format, event)
+
+        package = (recipients, message)
+
+        start = time.time()
+        if self.use_threaded_delivery:
+            self.get_delivery_queue().put(package)
+        else:
+            self.send(*package)
+        stop = time.time()
+        self.log.debug("XmppDistributor took %s seconds to send."\
+                %(round(stop-start,2)))
+
+    def send(self, recipients, message):
+        """Send message to recipients via xmpp."""
+        jid = JID(self.user)
+        if self.server:
+            server = self.server
+        else:
+            server = jid.getDomain()
+        cl = Client(server, port=self.port, debug=[])
+        if not cl.connect():
+            raise IOError("Couldn't connect to xmpp server %s"%server)
+        if not cl.auth(jid.getNode(), self.password,
+                resource=self.resource):
+            cl.Connection.disconnect()
+            raise IOError("Xmpp auth erro using %s to %s"%(jid, server))
+        default_domain = jid.getDomain()
+        for recip in recipients:
+            cl.send(Message(recip[2], message))
+
+
+class XmppPreferencePanel(Component):
+    implements(IAnnouncementPreferenceProvider)
+
+    formatters = ExtensionPoint(IAnnouncementFormatter)
+    producers = ExtensionPoint(IAnnouncementProducer)
+    distributors = ExtensionPoint(IAnnouncementDistributor)
+
+    def get_announcement_preference_boxes(self, req):
+        yield "xmpp", "XMPP Formats"
+
+    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)
+
+        settings = {}
+        for realm in supported_realms:
+            name = 'xmpp_format_%s'%realm
+            settings[realm] = SubscriptionSetting(self.env, name,
+                XmppDistributor(self.env).xmpp_format_setting.default)
+        if req.method == "POST":
+            for realm, setting in settings.items():
+                name = 'xmpp_format_%s'%realm
+                setting.set_user_setting(req.session, req.args.get(name),
+                    save=False)
+            req.session.save()
+        prefs = {}
+        for realm, setting in settings.items():
+            prefs[realm] = setting.get_user_setting(req.session.sid)[0]
+        data = dict(
+            realms = supported_realms,
+            preferences = prefs,
+        )
+        return "prefs_announcer_xmpp.html", data
+
+
+class DeliveryThread(Thread):
+
+    def __init__(self, queue, sender):
+        Thread.__init__(self)
+        self._sender = sender
+        self._queue = queue
+        self.setDaemon(True)
+
+    def run(self):
+        while 1:
+            sendfrom, recipients, message = self._queue.get()
+            self._sender(sendfrom, recipients, message)
+

File announcer/email_decorators.py

+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution.
+#
+
+import re
+
+from email.utils import parseaddr
+from genshi.template import NewTextTemplate, TemplateError
+
+from trac import __version__ as trac_version
+from trac.config import ListOption, Option
+from trac.core import Component, implements
+from trac.util.text import to_unicode
+
+from announcer import __version__ as announcer_version
+from announcer.distributors.mail import IAnnouncementEmailDecorator
+from announcer.util.mail import msgid, next_decorator, set_header, uid_encode
+
+"""Email decorators have the chance to modify emails or their headers, before
+the email distributor sends them out.
+"""
+
+class ThreadingEmailDecorator(Component):
+    """Add Message-ID, In-Reply-To and References message headers for resources.
+    All message ids are derived from the properties of the ticket so that they
+    can be regenerated later.
+    """
+
+    implements(IAnnouncementEmailDecorator)
+
+    supported_realms = ListOption('announcer', 'email_threaded_realms',
+        'ticket,wiki',
+        doc="""These are realms with announcements that should be threaded
+        emails.  In order for email threads to work, the announcer
+        system needs to give the email recreatable Message-IDs based
+        on the resources in the realm.  The resources must have a unique
+        and immutable id, name or str() representation in it's realm
+        """)
+
+    def decorate_message(self, event, message, decorates=None):
+        """
+        Added headers to the outgoing email to track it's relationship
+        with a ticket.
+
+        References, In-Reply-To and Message-ID are just so email clients can
+        make sense of the threads.
+        """
+        if to_unicode(event.realm) in self.supported_realms:
+            uid = uid_encode(self.env.abs_href(), event.realm, event.target)
+            email_from = self.config.get('announcer', 'email_from', 'localhost')
+            _, email_addr = parseaddr(email_from)
+            host = re.sub('^.+@', '', email_addr)
+            mymsgid = msgid(uid, host)
+            if event.category == 'created':
+                set_header(message, 'Message-ID', mymsgid)
+            else:
+                set_header(message, 'In-Reply-To', mymsgid)
+                set_header(message, 'References', mymsgid)
+
+        return next_decorator(event, message, decorates)
+
+
+class StaticEmailDecorator(Component):
+    """The static ticket decorator implements a policy to -always- send an
+    email to a certain address.
+
+    Controlled via the always_cc and always_bcc option in the announcer section
+    of the trac.ini.  If no subscribers are found, then even if always_cc and
+    always_bcc addresses are specified, no announcement will be sent.  Since
+    these fields are added after announcers subscription system, filters such
+    as never_announce and never_notify author won't work with these addresses.
+
+    These settings are considered dangerous if you are using the verify email
+    or reset password features of the accountmanager plugin.
+    """
+
+    # FIXME: mark that emails as 'private' in AcctMgr and eval that mark here
+
+    implements(IAnnouncementEmailDecorator)
+
+    always_cc = Option("announcer", "email_always_cc", None,
+        """Email addresses specified here will always
+        be cc'd on all announcements.  This setting is dangerous if
+        accountmanager is present.
+        """)
+
+    always_bcc = Option("announcer", "email_always_bcc", None,
+        """Email addresses specified here will always
+        be bcc'd on all announcements.  This setting is dangerous if
+        accountmanager is present.
+        """)
+
+    def decorate_message(self, event, message, decorates=None):
+        for k, v in {'Cc': self.always_cc, 'Bcc': self.always_bcc}.items():
+            if v:
+                self.log.debug("StaticEmailDecorator added '%s' "
+                        "because of rule: email_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)
+        return next_decorator(event, message, decorates)
+
+
+class AnnouncerEmailDecorator(Component):
+    """Add some boring headers that should be set."""
+
+    implements(IAnnouncementEmailDecorator)
+
+    def decorate_message(self, event, message, decorators):
+        mailer = 'AnnouncerPlugin v%s on Trac v%s' % (
+            announcer_version,
+            trac_version
+        )
+        set_header(message, 'Auto-Submitted', 'auto-generated')
+        set_header(message, 'Precedence', 'bulk')
+        set_header(message, 'X-Announcer-Version', announcer_version)
+        set_header(message, 'X-Mailer', mailer)
+        set_header(message, 'X-Trac-Announcement-Realm', event.realm)
+        set_header(message, 'X-Trac-Project', self.env.project_name)
+        set_header(message, 'X-Trac-Version', trac_version)
+
+        return next_decorator(event, message, decorators)
+
+
+class TicketSubjectEmailDecorator(Component):
+    """Formats ticket announcement subject headers based on the
+    ticket_email_subject configuration.
+    """
+
+    implements(IAnnouncementEmailDecorator)
+
+    ticket_email_subject = Option('announcer', 'ticket_email_subject',
+            "Ticket #${ticket.id}: ${ticket['summary']} " \
+                    "{% if action %}[${action}]{% end %}",
+            """Format string for ticket email subject.  This is
+               a mini genshi template that is passed the ticket
+               event and action objects.""")
+
+    def decorate_message(self, event, message, decorates=None):
+        if event.realm == 'ticket':
+            if 'status' in event.changes:
+                action = 'Status -> %s' % (event.target['status'])
+            template = NewTextTemplate(
+                self.ticket_email_subject.encode('utf8'))
+            # Create a fallback for invalid custom Genshi template in option.
+            default_template = NewTextTemplate(
+                Option.registry[('announcer', 'ticket_email_subject')
+                    ].default.encode('utf8'))
+            try:
+                subject = template.generate(
+                    ticket=event.target,
+                    event=event,
+                    action=event.category
+                ).render('text', encoding=None)
+            except TemplateError:
+                # Use fallback template.
+                subject = default_template.generate(
+                    ticket=event.target,
+                    event=event,
+                    action=event.category
+                ).render('text', encoding=None)
+
+            prefix = self.config.get('announcer', 'email_subject_prefix')
+            if prefix == '__default__':
+                prefix = '[%s] ' % self.env.project_name
+            if prefix:
+                subject = "%s%s" % (prefix, subject)
+            if event.category != 'created':
+                subject = 'Re: %s' % subject
+            set_header(message, 'Subject', subject)
+
+        return next_decorator(event, message, decorates)
+
+
+class TicketAddlHeaderEmailDecorator(Component):
+    """Adds X-Announcement-(id,priority and severity) headers to ticket
+    emails.  This is useful for automated handling of incoming emails or
+    customized filtering.
+    """
+
+    implements(IAnnouncementEmailDecorator)
+
+    def decorate_message(self, event, message, decorates=None):
+        if event.realm == 'ticket':
+            for k in ('id', 'priority', 'severity'):
+                name = 'X-Announcement-%s'%k.capitalize()
+                set_header(message, name, event.target[k])
+
+        return next_decorator(event, message, decorates)
+
+
+class WikiSubjectEmailDecorator(Component):
+    """Formats wiki announcement subject headers based on the
+    wiki_email_subject configuration.
+    """
+
+    implements(IAnnouncementEmailDecorator)
+
+    wiki_email_subject = Option('announcer', 'wiki_email_subject',
+            "Page: ${page.name} ${action}",
+            """Format string for the wiki email subject.  This is a
+               mini genshi template and it is passed the page, event
+               and action objects.""")
+
+    def decorate_message(self, event, message, decorates=None):
+        if event.realm == 'wiki':
+            template = NewTextTemplate(self.wiki_email_subject.encode('utf8'))
+            subject = template.generate(
+                page=event.target,
+                event=event,
+                action=event.category
+            ).render('text', encoding=None)
+
+            prefix = self.config.get('announcer', 'email_subject_prefix')
+            if prefix == '__default__':
+                prefix = '[%s] ' % self.env.project_name
+            if prefix:
+                subject = "%s%s"%(prefix, subject)
+            if event.category != 'created':
+                subject = 'Re: %s'%subject
+            set_header(message, 'Subject', subject)
+
+        return next_decorator(event, message, decorates)

File announcer/filters.py

+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2009, Robert Corsaro
+# Copyright (c) 2010-2012, Steffen Hoffmann
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution.
+#
+
+"""Filters can remove subscriptions after they are collected.
+
+This is commonly done based on access restrictions for Trac realm and
+resource ID, that the event is referring to (alias 'event target').
+In some contexts like AccountManagerPlugin account change notifications
+(realm 'acct_mgr') an `IAnnouncementSubscriptionFilter` implementation is
+essential for meaningful operation
+(see announcer.opt.acct_mgr.announce.AccountManagerAnnouncement).
+
+Only subscriptions, that pass all filters, can trigger a distributor to emit a
+notification about an event for shipment via one of its associated transports.
+"""
+
+from trac.core import Component, implements
+from trac.config import ListOption
+from trac.perm import PermissionCache
+
+from announcer.api import IAnnouncementSubscriptionFilter
+from announcer.api import _, N_
+from announcer.util import get_target_id
+
+
+class DefaultPermissionFilter(Component):
+    """Simple view permission enforcement for common Trac realms.
+
+    It checks, that each subscription has ${REALM}_VIEW permission for the
+    corresponding event target, before the subscription is allowed to
+    propagate to distributors.
+    """
+    implements(IAnnouncementSubscriptionFilter)
+
+    exception_realms = ListOption(
+            'announcer', 'filter_exception_realms', 'acct_mgr', doc=N_(
+            """The PermissionFilter will filter announcements, for which the
+            user doesn't have ${REALM}_VIEW permission.  If there is some
+            realm that doesn't use a permission called ${REALM}_VIEW, then
+            you should add it to this list and create a custom filter to
+            enforce it's permissions.  Be careful, or permissions could be
+            bypassed using the AnnouncerPlugin.
+            """))
+