← Back to team overview

launchpad-reviewers team mailing list archive

lp:~allenap/launchpad/bug-mail-case-sensitive-domain-bug-48464-refactor into lp:launchpad

 

Gavin Panella has proposed merging lp:~allenap/launchpad/bug-mail-case-sensitive-domain-bug-48464-refactor into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #48464 incoming mail processor needs to be less case sensitive
  https://bugs.launchpad.net/bugs/48464
  #305856 Spurious/intermittent test failure in answers/doc/emailinterface.txt
  https://bugs.launchpad.net/bugs/305856


This moves the handlers in canonical.launchpad.mail.handlers (and their tests) out into their own parts of the lp tree, and moves the MailHandlers class into lp.services.mail. This branch also re-enables the Answers' emailinterface test (bug 305856) and corrects a copy-and-paste error in test_helpers that meant the c.l.mail.helpers doctest was not ever being run.

-- 
https://code.launchpad.net/~allenap/launchpad/bug-mail-case-sensitive-domain-bug-48464-refactor/+merge/43536
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/bug-mail-case-sensitive-domain-bug-48464-refactor into lp:launchpad.
=== modified file 'lib/canonical/launchpad/ftests/test_system_documentation.py'
--- lib/canonical/launchpad/ftests/test_system_documentation.py	2010-11-06 12:50:22 +0000
+++ lib/canonical/launchpad/ftests/test_system_documentation.py	2010-12-13 16:28:18 +0000
@@ -280,12 +280,7 @@
         setSecurityPolicy(cls._old_policy)
 
     doctests = [
-        # XXX gary 2008-12-06 bug=305856: Spurious test failure
-        # discovered on buildbot, build 40.  Note that, to completely
-        # disable the test from running, the filename has been changed
-        # to emailinterface.txt.disabled, so when this test is
-        # reinstated it will be need to be changed back.
-        # '../../../lp/answers/doc/emailinterface.txt',
+        '../../../lp/answers/tests/emailinterface.txt',
         '../../../lp/bugs/tests/bugs-emailinterface.txt',
         '../../../lp/bugs/doc/bugs-email-affects-path.txt',
         '../doc/emailauthentication.txt',

=== modified file 'lib/canonical/launchpad/mail/tests/test_helpers.py'
--- lib/canonical/launchpad/mail/tests/test_helpers.py	2010-10-12 01:11:41 +0000
+++ lib/canonical/launchpad/mail/tests/test_helpers.py	2010-12-13 16:28:18 +0000
@@ -223,6 +223,6 @@
 
 
 def test_suite():
-    suite = DocTestSuite('canonical.launchpad.mail.handlers')
+    suite = DocTestSuite('canonical.launchpad.mail.helpers')
     suite.addTests(unittest.TestLoader().loadTestsFromName(__name__))
     return suite

=== added directory 'lib/lp/answers/mail'
=== added file 'lib/lp/answers/mail/__init__.py'
--- lib/lp/answers/mail/__init__.py	1970-01-01 00:00:00 +0000
+++ lib/lp/answers/mail/__init__.py	2010-12-13 16:28:18 +0000
@@ -0,0 +1,5 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = []

=== added file 'lib/lp/answers/mail/handler.py'
--- lib/lp/answers/mail/handler.py	1970-01-01 00:00:00 +0000
+++ lib/lp/answers/mail/handler.py	2010-12-13 16:28:18 +0000
@@ -0,0 +1,95 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Handle incoming Answers email."""
+
+__metaclass__ = type
+__all__ = [
+    "AnswerTrackerHandler",
+    ]
+
+import re
+
+from zope.component import getUtility
+from zope.interface import implements
+
+from canonical.launchpad.interfaces.mail import IMailHandler
+from canonical.launchpad.interfaces.message import IMessageSet
+from canonical.launchpad.webapp.interfaces import ILaunchBag
+from lp.answers.interfaces.questioncollection import IQuestionSet
+from lp.answers.interfaces.questionenums import QuestionStatus
+
+
+class AnswerTrackerHandler:
+    """Handles emails sent to the Answer Tracker."""
+
+    implements(IMailHandler)
+
+    allow_unknown_users = False
+
+    # XXX flacoste 2007-04-23: The 'ticket' part is there for backward
+    # compatibility with the old notification address. We probably want to
+    # remove it in the future.
+    _question_address = re.compile(r'^(ticket|question)(?P<id>\d+)@.*')
+
+    def process(self, signed_msg, to_addr, filealias=None, log=None):
+        """See IMailHandler."""
+        match = self._question_address.match(to_addr)
+        if not match:
+            return False
+
+        question_id = int(match.group('id'))
+        question = getUtility(IQuestionSet).get(question_id)
+        if question is None:
+            # No such question, don't process the email.
+            return False
+
+        messageset = getUtility(IMessageSet)
+        message = messageset.fromEmail(
+            signed_msg.parsed_string,
+            owner=getUtility(ILaunchBag).user,
+            filealias=filealias,
+            parsed_message=signed_msg)
+
+        if message.owner == question.owner:
+            self.processOwnerMessage(question, message)
+        else:
+            self.processUserMessage(question, message)
+        return True
+
+    def processOwnerMessage(self, question, message):
+        """Choose the right workflow action for a message coming from
+        the question owner.
+
+        When the question status is OPEN or NEEDINFO,
+        the message is a GIVEINFO action; when the status is ANSWERED
+        or EXPIRED, we interpret the message as a reopenening request;
+        otherwise it's a comment.
+        """
+        if question.status in [
+            QuestionStatus.OPEN, QuestionStatus.NEEDSINFO]:
+            question.giveInfo(message)
+        elif question.status in [
+            QuestionStatus.ANSWERED, QuestionStatus.EXPIRED]:
+            question.reopen(message)
+        else:
+            question.addComment(message.owner, message)
+
+    def processUserMessage(self, question, message):
+        """Choose the right workflow action for a message coming from a user
+        that is not the question owner.
+
+        When the question status is OPEN, NEEDSINFO, or ANSWERED, we interpret
+        the message as containing an answer. (If it was really a request for
+        more information, the owner will still be able to answer it while
+        reopening the request.)
+
+        In the other status, the message is a comment without status change.
+        """
+        if question.status in [
+            QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
+        QuestionStatus.ANSWERED]:
+            question.giveAnswer(message.owner, message)
+        else:
+            # In the other states, only a comment can be added.
+            question.addComment(message.owner, message)

=== renamed file 'lib/lp/answers/doc/emailinterface.txt.disabled' => 'lib/lp/answers/tests/emailinterface.txt'
--- lib/lp/answers/doc/emailinterface.txt.disabled	2010-10-18 22:24:59 +0000
+++ lib/lp/answers/tests/emailinterface.txt	2010-12-13 16:28:18 +0000
@@ -28,7 +28,7 @@
     >>> now = now_generator(datetime.now(UTC) - timedelta(hours=24))
 
     # Define a helper function to send email to the Answer Tracker handler.
-    >>> from canonical.launchpad.mail.handlers import AnswerTrackerHandler
+    >>> from lp.answers.mail.handler import AnswerTrackerHandler
     >>> from email.Utils import formatdate, make_msgid, mktime_tz
     >>> from canonical.launchpad.mail import signed_message_from_string
     >>> handler = AnswerTrackerHandler()
@@ -393,7 +393,7 @@
 config.answertracker.email_domain to the AnswerTrackerHandler.
 
     >>> raw_msg = """From: test@xxxxxxxxxxxxx
-    ... X-Original-To: question1@xxxxxxxxxxxxxxxxxxxxx
+    ... X-Launchpad-Original-To: question1@xxxxxxxxxxxxxxxxxxxxx
     ... Subject: A new comment
     ... Message-Id: <comment1@localhost>
     ... Date: Mon, 02 Jan 2006 15:42:07 -0000
@@ -408,7 +408,7 @@
     ...     'test@xxxxxxxxxxxxx', ['question1@xxxxxxxxxxxxxxxxxxxxx'],
     ...     raw_msg))
 
-    >>> from canonical.launchpad.mail.incoming import handleMail
+    >>> from lp.services.mail.incoming import handleMail
     >>> handleMail()
 
     >>> question_one = getUtility(IQuestionSet).get(1)
@@ -421,7 +421,7 @@
 to the old ticket<ID>@support.launchpad.net address:
 
     >>> raw_msg = """From: test@xxxxxxxxxxxxx
-    ... X-Original-To: ticket11@xxxxxxxxxxxxxxxxxxxxx
+    ... X-Launchpad-Original-To: ticket11@xxxxxxxxxxxxxxxxxxxxx
     ... Subject: Another comment
     ... Message-Id: <comment2@localhost>
     ... Date: Mon, 23 Apr 2007 16:00:00 -0000

=== modified file 'lib/lp/blueprints/doc/spec-mail-exploder.txt'
--- lib/lp/blueprints/doc/spec-mail-exploder.txt	2010-10-18 22:24:59 +0000
+++ lib/lp/blueprints/doc/spec-mail-exploder.txt	2010-12-13 16:28:18 +0000
@@ -78,7 +78,7 @@
 is SpecificationHandler.
 
     >>> from canonical.config import config
-    >>> from canonical.launchpad.mail.handlers import mail_handlers
+    >>> from lp.services.mail.handlers import mail_handlers
     >>> handler = mail_handlers.get(config.launchpad.specs_domain)
     >>> handler is not None
     True

=== added directory 'lib/lp/blueprints/mail'
=== added file 'lib/lp/blueprints/mail/__init__.py'
--- lib/lp/blueprints/mail/__init__.py	1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/mail/__init__.py	2010-12-13 16:28:18 +0000
@@ -0,0 +1,5 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = []

=== added file 'lib/lp/blueprints/mail/handler.py'
--- lib/lp/blueprints/mail/handler.py	1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/mail/handler.py	2010-12-13 16:28:18 +0000
@@ -0,0 +1,108 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Handle incoming Blueprints email."""
+
+__metaclass__ = type
+__all__ = [
+    "SpecificationHandler",
+    ]
+
+import re
+from urlparse import urlunparse
+
+from zope.component import getUtility
+from zope.interface import implements
+
+from canonical.config import config
+from canonical.launchpad.interfaces.mail import IMailHandler
+from canonical.launchpad.mail.specexploder import get_spec_url_from_moin_mail
+from canonical.launchpad.webapp import urlparse
+from lp.blueprints.interfaces.specification import ISpecificationSet
+from lp.services.mail.sendmail import sendmail
+
+
+class SpecificationHandler:
+    """Handles emails sent to specs.launchpad.net."""
+
+    implements(IMailHandler)
+
+    allow_unknown_users = True
+
+    _spec_changes_address = re.compile(r'^notifications@.*')
+
+    # The list of hosts where the Ubuntu wiki is located. We could do a
+    # more general solution, but this kind of setup is unusual, and it
+    # will be mainly the Ubuntu and Launchpad wikis that will use this
+    # notification forwarder.
+    UBUNTU_WIKI_HOSTS = [
+        'wiki.ubuntu.com', 'wiki.edubuntu.org', 'wiki.kubuntu.org']
+
+    def _getSpecByURL(self, url):
+        """Returns a spec that is associated with the URL.
+
+        It takes into account that the same Ubuntu wiki is on three
+        different hosts.
+        """
+        scheme, host, path, params, query, fragment = urlparse(url)
+        if host in self.UBUNTU_WIKI_HOSTS:
+            for ubuntu_wiki_host in self.UBUNTU_WIKI_HOSTS:
+                possible_url = urlunparse(
+                    (scheme, ubuntu_wiki_host, path, params, query,
+                     fragment))
+                spec = getUtility(ISpecificationSet).getByURL(possible_url)
+                if spec is not None:
+                    return spec
+        else:
+            return getUtility(ISpecificationSet).getByURL(url)
+
+    def process(self, signed_msg, to_addr, filealias=None, log=None):
+        """See IMailHandler."""
+        match = self._spec_changes_address.match(to_addr)
+        if not match:
+            # We handle only spec-changes at the moment.
+            return False
+        our_address = "notifications@%s" % config.launchpad.specs_domain
+        # Check for emails that we sent.
+        xloop = signed_msg['X-Loop']
+        if xloop and our_address in signed_msg.get_all('X-Loop'):
+            if log and filealias:
+                log.warning(
+                    'Got back a notification we sent: %s' %
+                    filealias.http_url)
+            return True
+        # Check for emails that Launchpad sent us.
+        if signed_msg['Sender'] == config.canonical.bounce_address:
+            if log and filealias:
+                log.warning('We received an email from Launchpad: %s'
+                            % filealias.http_url)
+            return True
+        # When sending the email, the sender will be set so that it's
+        # clear that we're the one sending the email, not the original
+        # sender.
+        del signed_msg['Sender']
+
+        mail_body = signed_msg.get_payload(decode=True)
+        spec_url = get_spec_url_from_moin_mail(mail_body)
+        if spec_url is not None:
+            if log is not None:
+                log.debug('Found a spec URL: %s' % spec_url)
+            spec = self._getSpecByURL(spec_url)
+            if spec is not None:
+                if log is not None:
+                    log.debug('Found a corresponding spec: %s' % spec.name)
+                # Add an X-Loop header, in order to prevent mail loop.
+                signed_msg.add_header('X-Loop', our_address)
+                notification_addresses = spec.notificationRecipientAddresses()
+                if log is not None:
+                    log.debug(
+                        'Sending notification to: %s' %
+                            ', '.join(notification_addresses))
+                sendmail(signed_msg, to_addrs=notification_addresses)
+
+            elif log is not None:
+                log.debug(
+                    "Didn't find a corresponding spec for %s" % spec_url)
+        elif log is not None:
+            log.debug("Didn't find a specification URL")
+        return True

=== added file 'lib/lp/bugs/mail/handler.py'
--- lib/lp/bugs/mail/handler.py	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/mail/handler.py	2010-12-13 16:28:18 +0000
@@ -0,0 +1,313 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Handle incoming Bugs email."""
+
+__metaclass__ = type
+__all__ = [
+    "MaloneHandler",
+    ]
+
+from lazr.lifecycle.event import ObjectCreatedEvent
+from lazr.lifecycle.interfaces import IObjectCreatedEvent
+from zope.component import getUtility
+from zope.event import notify
+from zope.interface import implements
+
+from canonical.database.sqlbase import rollback
+from canonical.launchpad.helpers import get_email_template
+from canonical.launchpad.interfaces.mail import (
+    EmailProcessingError,
+    IBugEditEmailCommand,
+    IBugEmailCommand,
+    IBugTaskEditEmailCommand,
+    IBugTaskEmailCommand,
+    IMailHandler,
+    )
+from canonical.launchpad.interfaces.message import IMessageSet
+from canonical.launchpad.mail.commands import (
+    BugEmailCommands,
+    get_error_message,
+    )
+from canonical.launchpad.mail.helpers import (
+    ensure_not_weakly_authenticated,
+    get_main_body,
+    guess_bugtask,
+    IncomingEmailError,
+    parse_commands,
+    reformat_wiki_text,
+    )
+from canonical.launchpad.mailnotification import (
+    MailWrapper,
+    send_process_error_notification,
+    )
+from canonical.launchpad.webapp.interfaces import ILaunchBag
+from lp.bugs.interfaces.bug import CreatedBugWithNoBugTasksError
+from lp.bugs.interfaces.bugattachment import (
+    BugAttachmentType,
+    IBugAttachmentSet,
+    )
+from lp.bugs.interfaces.bugmessage import IBugMessageSet
+from lp.services.mail.sendmail import simple_sendmail
+
+
+class MaloneHandler:
+    """Handles emails sent to Malone.
+
+    It only handles mail sent to new@... and $bugid@..., where $bugid is a
+    positive integer.
+    """
+    implements(IMailHandler)
+
+    allow_unknown_users = False
+
+    def getCommands(self, signed_msg):
+        """Returns a list of all the commands found in the email."""
+        content = get_main_body(signed_msg)
+        if content is None:
+            return []
+        return [BugEmailCommands.get(name=name, string_args=args) for
+                name, args in parse_commands(content,
+                                             BugEmailCommands.names())]
+
+    def extractAndAuthenticateCommands(self, signed_msg, to_addr):
+        """Extract commands and handle special destinations.
+
+        NB: The authentication is carried out against the current principal,
+        not directly against the message.  authenticateEmail must previously
+        have been called on this thread.
+
+        :returns: (final_result, add_comment_to_bug, commands)
+            If final_result is non-none, stop processing and return this value
+            to indicate whether the message was dealt with or not.
+            If add_comment_to_bug, add the contents to the first bug
+            selected.
+            commands is a list of bug commands.
+        """
+        CONTEXT = 'bug report'
+        commands = self.getCommands(signed_msg)
+        to_user, to_host = to_addr.split('@')
+        add_comment_to_bug = False
+        # If there are any commands, we must have strong authentication.
+        # We send a different failure message for attempts to create a new
+        # bug.
+        if to_user.lower() == 'new':
+            ensure_not_weakly_authenticated(signed_msg, CONTEXT,
+                'unauthenticated-bug-creation.txt')
+        elif len(commands) > 0:
+            ensure_not_weakly_authenticated(signed_msg, CONTEXT)
+        if to_user.lower() == 'new':
+            commands.insert(0, BugEmailCommands.get('bug', ['new']))
+        elif to_user.isdigit():
+            # A comment to a bug. We set add_comment_to_bug to True so
+            # that the comment gets added to the bug later. We don't add
+            # the comment now, since we want to let the 'bug' command
+            # handle the possible errors that can occur while getting
+            # the bug.
+            add_comment_to_bug = True
+            commands.insert(0, BugEmailCommands.get('bug', [to_user]))
+        elif to_user.lower() == 'help':
+            from_user = getUtility(ILaunchBag).user
+            if from_user is not None:
+                preferredemail = from_user.preferredemail
+                if preferredemail is not None:
+                    to_address = str(preferredemail.email)
+                    self.sendHelpEmail(to_address)
+            return True, False, None
+        elif to_user.lower() != 'edit':
+            # Indicate that we didn't handle the mail.
+            return False, False, None
+        return None, add_comment_to_bug, commands
+
+    def process(self, signed_msg, to_addr, filealias=None, log=None):
+        """See IMailHandler."""
+
+        try:
+            (final_result, add_comment_to_bug,
+                commands, ) = self.extractAndAuthenticateCommands(
+                    signed_msg, to_addr)
+            if final_result is not None:
+                return final_result
+
+            bug = None
+            bug_event = None
+            bugtask = None
+            bugtask_event = None
+
+            processing_errors = []
+            while len(commands) > 0:
+                command = commands.pop(0)
+                try:
+                    if IBugEmailCommand.providedBy(command):
+                        if bug_event is not None:
+                            try:
+                                notify(bug_event)
+                            except CreatedBugWithNoBugTasksError:
+                                rollback()
+                                raise IncomingEmailError(
+                                    get_error_message(
+                                        'no-affects-target-on-submit.txt'))
+                        if (bugtask_event is not None and
+                            not IObjectCreatedEvent.providedBy(bug_event)):
+                            notify(bugtask_event)
+                        bugtask = None
+                        bugtask_event = None
+
+                        bug, bug_event = command.execute(
+                            signed_msg, filealias)
+                        if add_comment_to_bug:
+                            messageset = getUtility(IMessageSet)
+                            message = messageset.fromEmail(
+                                signed_msg.as_string(),
+                                owner=getUtility(ILaunchBag).user,
+                                filealias=filealias,
+                                parsed_message=signed_msg,
+                                fallback_parent=bug.initial_message)
+
+                            # If the new message's parent is linked to
+                            # a bug watch we also link this message to
+                            # that bug watch.
+                            bug_message_set = getUtility(IBugMessageSet)
+                            parent_bug_message = (
+                                bug_message_set.getByBugAndMessage(
+                                    bug, message.parent))
+
+                            if (parent_bug_message is not None and
+                                parent_bug_message.bugwatch):
+                                bug_watch = parent_bug_message.bugwatch
+                            else:
+                                bug_watch = None
+
+                            bugmessage = bug.linkMessage(
+                                message, bug_watch)
+
+                            notify(ObjectCreatedEvent(bugmessage))
+                            add_comment_to_bug = False
+                        else:
+                            message = bug.initial_message
+                        self.processAttachments(bug, message, signed_msg)
+                    elif IBugTaskEmailCommand.providedBy(command):
+                        if bugtask_event is not None:
+                            if not IObjectCreatedEvent.providedBy(bug_event):
+                                notify(bugtask_event)
+                            bugtask_event = None
+                        bugtask, bugtask_event = command.execute(bug)
+                    elif IBugEditEmailCommand.providedBy(command):
+                        bug, bug_event = command.execute(bug, bug_event)
+                    elif IBugTaskEditEmailCommand.providedBy(command):
+                        if bugtask is None:
+                            if len(bug.bugtasks) == 0:
+                                rollback()
+                                raise IncomingEmailError(
+                                    get_error_message(
+                                        'no-affects-target-on-submit.txt'))
+                            bugtask = guess_bugtask(
+                                bug, getUtility(ILaunchBag).user)
+                            if bugtask is None:
+                                raise IncomingEmailError(get_error_message(
+                                    'no-default-affects.txt',
+                                    bug_id=bug.id,
+                                    nr_of_bugtasks=len(bug.bugtasks)))
+                        bugtask, bugtask_event = command.execute(
+                            bugtask, bugtask_event)
+
+                except EmailProcessingError, error:
+                    processing_errors.append((error, command))
+                    if error.stop_processing:
+                        commands = []
+                        rollback()
+                    else:
+                        continue
+
+            if len(processing_errors) > 0:
+                raise IncomingEmailError(
+                    '\n'.join(str(error) for error, command
+                              in processing_errors),
+                    [command for error, command in processing_errors])
+
+            if bug_event is not None:
+                try:
+                    notify(bug_event)
+                except CreatedBugWithNoBugTasksError:
+                    rollback()
+                    raise IncomingEmailError(
+                        get_error_message('no-affects-target-on-submit.txt'))
+            if bugtask_event is not None:
+                if not IObjectCreatedEvent.providedBy(bug_event):
+                    notify(bugtask_event)
+
+        except IncomingEmailError, error:
+            send_process_error_notification(
+                str(getUtility(ILaunchBag).user.preferredemail.email),
+                'Submit Request Failure',
+                error.message, signed_msg, error.failing_command)
+
+        return True
+
+    def sendHelpEmail(self, to_address):
+        """Send usage help to `to_address`."""
+        # Get the help text (formatted as MoinMoin markup)
+        help_text = get_email_template('help.txt')
+        help_text = reformat_wiki_text(help_text)
+        # Wrap text
+        mailwrapper = MailWrapper(width=72)
+        help_text = mailwrapper.format(help_text)
+        simple_sendmail(
+            'help@xxxxxxxxxxxxxxxxxx', to_address,
+            'Launchpad Bug Tracker Email Interface Help',
+            help_text)
+
+    # Some content types indicate that an attachment has a special
+    # purpose. The current set is based on parsing emails from
+    # one mail account and may need to be extended.
+    #
+    # Mail signatures are most likely generated by the mail client
+    # and hence contain not data that is interesting except for
+    # mail authentication.
+    #
+    # Resource forks of MacOS files are not easily represented outside
+    # MacOS; if a resource fork contains useful debugging information,
+    # the entire MacOS file should be sent encapsulated for example in
+    # MacBinary format.
+    #
+    # application/ms-tnef attachment are created by Outlook; they
+    # seem to store no more than an RTF representation of an email.
+
+    irrelevant_content_types = set((
+        'application/applefile', # the resource fork of a MacOS file
+        'application/pgp-signature',
+        'application/pkcs7-signature',
+        'application/x-pkcs7-signature',
+        'text/x-vcard',
+        'application/ms-tnef',
+        ))
+
+    def processAttachments(self, bug, message, signed_mail):
+        """Create Bugattachments for "reasonable" mail attachments.
+
+        A mail attachment is stored as a bugattachment if its
+        content type is not listed in irrelevant_content_types.
+        """
+        for chunk in message.chunks:
+            blob = chunk.blob
+            if blob is None:
+                continue
+            # Mutt (other mail clients too?) appends the filename to the
+            # content type.
+            content_type = blob.mimetype.split(';', 1)[0]
+            if content_type in self.irrelevant_content_types:
+                continue
+
+            if content_type == 'text/html' and blob.filename == 'unnamed':
+                # This is the HTML representation of the main part of
+                # an email.
+                continue
+
+            if content_type in ('text/x-diff', 'text/x-patch'):
+                attach_type = BugAttachmentType.PATCH
+            else:
+                attach_type = BugAttachmentType.UNSPECIFIED
+
+            getUtility(IBugAttachmentSet).create(
+                bug=bug, filealias=blob, attach_type=attach_type,
+                title=blob.filename, message=message, send_notifications=True)

=== added file 'lib/lp/bugs/mail/tests/test_handler.py'
--- lib/lp/bugs/mail/tests/test_handler.py	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/mail/tests/test_handler.py	2010-12-13 16:28:18 +0000
@@ -0,0 +1,169 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test MaloneHandler."""
+
+__metaclass__ = type
+
+import email
+import time
+
+import transaction
+
+from canonical.database.sqlbase import commit
+from canonical.launchpad.ftests import import_secret_test_key
+from canonical.launchpad.mail.commands import BugEmailCommand
+from canonical.testing.layers import LaunchpadFunctionalLayer
+from lp.bugs.mail.handler import MaloneHandler
+from lp.services.mail import stub
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.factory import GPGSigningContext
+
+
+class TestMaloneHandler(TestCaseWithFactory):
+    """Test that the Malone/bugs handler works."""
+
+    layer = LaunchpadFunctionalLayer
+
+    def test_getCommandsEmpty(self):
+        """getCommands returns an empty list for messages with no command."""
+        message = self.factory.makeSignedMessage()
+        handler = MaloneHandler()
+        self.assertEqual([], handler.getCommands(message))
+
+    def test_getCommandsBug(self):
+        """getCommands returns a reasonable list if commands are specified."""
+        message = self.factory.makeSignedMessage(body=' bug foo')
+        handler = MaloneHandler()
+        commands = handler.getCommands(message)
+        self.assertEqual(1, len(commands))
+        self.assertTrue(isinstance(commands[0], BugEmailCommand))
+        self.assertEqual('bug', commands[0].name)
+        self.assertEqual(['foo'], commands[0].string_args)
+
+    def test_NonGPGAuthenticatedNewBug(self):
+        """Mail authenticated other than by gpg can create bugs.
+
+        The incoming mail layer is responsible for authenticating the mail,
+        and setting the current principal to the sender of the mail, either
+        weakly or non-weakly authenticated.  At the layer of the handler,
+        which this class is testing, we shouldn't care by what mechanism we
+        decided to act on behalf of the mail sender, only that we did.
+
+        In bug 643219, Launchpad had a problem where the MaloneHandler code
+        was puncturing that abstraction and directly looking at the GPG
+        signature; this test checks it's fixed.
+        """
+        # NB SignedMessage by default isn't actually signed, it just has the
+        # capability of knowing about signing.
+        message = self.factory.makeSignedMessage(body='  affects malone\nhi!')
+        self.assertEquals(message.signature, None)
+
+        # Pretend that the mail auth has given us a logged-in user.
+        handler = MaloneHandler()
+        with person_logged_in(self.factory.makePerson()):
+            mail_handled, add_comment_to_bug, commands = \
+                handler.extractAndAuthenticateCommands(message,
+                    'new@xxxxxxxxxxxxxxxxxx')
+        self.assertEquals(mail_handled, None)
+        self.assertEquals(map(str, commands), [
+            'bug new',
+            'affects malone',
+            ])
+
+    def test_mailToHelpFromUnknownUser(self):
+        """Mail from people of no account to help@ is simply dropped.
+        """
+        message = self.factory.makeSignedMessage()
+        handler = MaloneHandler()
+        mail_handled, add_comment_to_bug, commands = \
+            handler.extractAndAuthenticateCommands(message,
+                'help@xxxxxxxxxxxxxxxxxx')
+        self.assertEquals(mail_handled, True)
+        self.assertEquals(self.getSentMail(), [])
+
+    def test_mailToHelp(self):
+        """Mail to help@ generates a help command."""
+        message = self.factory.makeSignedMessage()
+        handler = MaloneHandler()
+        with person_logged_in(self.factory.makePerson()):
+            mail_handled, add_comment_to_bug, commands = \
+                handler.extractAndAuthenticateCommands(message,
+                    'help@xxxxxxxxxxxxxxxxxx')
+        self.assertEquals(mail_handled, True)
+        self.assertEquals(len(self.getSentMail()), 1)
+        # TODO: Check the right mail was sent. -- mbp 20100923
+
+    def getSentMail(self):
+        # Sending mail is (unfortunately) a side effect of parsing the
+        # commands, and unfortunately you must commit the transaction to get
+        # them sent.
+        transaction.commit()
+        return stub.test_emails[:]
+
+
+class FakeSignature:
+
+    def __init__(self, timestamp):
+        self.timestamp = timestamp
+
+
+def get_last_email():
+    from_addr, to_addrs, raw_message = stub.test_emails[-1]
+    sent_msg = email.message_from_string(raw_message)
+    error_mail, original_mail = sent_msg.get_payload()
+    # clear the emails so we don't accidentally get one from a previous test
+    return dict(
+        subject=sent_msg['Subject'],
+        body=error_mail.get_payload(decode=True))
+
+
+BAD_SIGNATURE_TIMESTAMP_MESSAGE = (
+    'The message you sent included commands to modify the bug '
+    'report, but the\nsignature was (apparently) generated too far '
+    'in the past or future.')
+
+
+class TestSignatureTimestampValidation(TestCaseWithFactory):
+    """GPG signature timestamps are checked for emails containing commands."""
+
+    layer = LaunchpadFunctionalLayer
+
+    def test_good_signature_timestamp(self):
+        # An email message's GPG signature's timestamp checked to be sure it
+        # isn't too far in the future or past.  This test shows that a
+        # signature with a timestamp of appxoimately now will be accepted.
+        signing_context = GPGSigningContext(
+            import_secret_test_key().fingerprint, password='test')
+        msg = self.factory.makeSignedMessage(
+            body=' security no', signing_context=signing_context)
+        handler = MaloneHandler()
+        with person_logged_in(self.factory.makePerson()):
+            handler.process(msg, msg['To'])
+        commit()
+        # Since there were no commands in the poorly-timestamped message, no
+        # error emails were generated.
+        self.assertEqual(stub.test_emails, [])
+
+    def test_bad_timestamp_but_no_commands(self):
+        # If an email message's GPG signature's timestamp is too far in the
+        # future or past but it doesn't contain any commands, the email is
+        # processed anyway.
+
+        msg = self.factory.makeSignedMessage(
+            body='I really hope this bug gets fixed.')
+        now = time.time()
+        one_week = 60 * 60 * 24 * 7
+        msg.signature = FakeSignature(timestamp=now+one_week)
+        handler = MaloneHandler()
+        # Clear old emails before potentially generating more.
+        del stub.test_emails[:]
+        with person_logged_in(self.factory.makePerson()):
+            handler.process(msg, msg['To'])
+        commit()
+        # Since there were no commands in the poorly-timestamped message, no
+        # error emails were generated.
+        self.assertEqual(stub.test_emails, [])

=== modified file 'lib/lp/bugs/tests/bugs-emailinterface.txt'
--- lib/lp/bugs/tests/bugs-emailinterface.txt	2010-11-25 04:42:51 +0000
+++ lib/lp/bugs/tests/bugs-emailinterface.txt	2010-12-13 16:28:18 +0000
@@ -70,7 +70,7 @@
 Now if we pass the message to the Malone handler, we can see that the
 bug got submitted correctly:
 
-    >>> from canonical.launchpad.mail.handlers import MaloneHandler
+    >>> from lp.bugs.mail.handler import MaloneHandler
     >>> handler = MaloneHandler()
     >>> def construct_email(raw_mail):
     ...     msg = email.message_from_string(
@@ -2298,7 +2298,7 @@
 The help text is taken from the Launchpad help wiki as raw text, and
 transformed to be a bit more readable as a plain text document.
 
-    >>> from canonical.launchpad.mail.handlers import reformat_wiki_text
+    >>> from canonical.launchpad.mail.helpers import reformat_wiki_text
     >>> wiki_text = """
     ... = Sample Wiki Text =
     ... # A comment line

=== modified file 'lib/lp/code/mail/tests/test_codehandler.py'
--- lib/lp/code/mail/tests/test_codehandler.py	2010-12-01 11:26:57 +0000
+++ lib/lp/code/mail/tests/test_codehandler.py	2010-12-13 16:28:18 +0000
@@ -27,7 +27,6 @@
     EmailProcessingError,
     IWeaklyAuthenticatedPrincipal,
     )
-from canonical.launchpad.mail.handlers import mail_handlers
 from canonical.launchpad.webapp.authorization import LaunchpadSecurityPolicy
 from canonical.launchpad.webapp.interaction import (
     get_current_principal,
@@ -69,6 +68,7 @@
 from lp.codehosting.vfs import get_lp_server
 from lp.registry.interfaces.person import IPersonSet
 from lp.services.job.runner import JobRunner
+from lp.services.mail.handlers import mail_handlers
 from lp.services.osutils import override_environ
 from lp.testing import (
     login,

=== modified file 'lib/lp/registry/doc/message-holds.txt'
--- lib/lp/registry/doc/message-holds.txt	2010-10-26 02:18:08 +0000
+++ lib/lp/registry/doc/message-holds.txt	2010-12-13 16:28:18 +0000
@@ -483,7 +483,7 @@
     ...         return self
     ...     signature = object()
 
-    >>> from canonical.launchpad.mail.handlers import MaloneHandler
+    >>> from lp.bugs.mail.handler import MaloneHandler
     >>> from email import message_from_string
     >>> malone_msg = message_from_string(message_text, _class=SignedMessage)
 

=== renamed file 'lib/canonical/launchpad/mail/handlers.py' => 'lib/lp/services/mail/handlers.py'
--- lib/canonical/launchpad/mail/handlers.py	2010-11-25 04:42:51 +0000
+++ lib/lp/services/mail/handlers.py	2010-12-13 16:28:18 +0000
@@ -3,483 +3,11 @@
 
 __metaclass__ = type
 
-import re
-from urlparse import urlunparse
-
-from lazr.lifecycle.event import ObjectCreatedEvent
-from lazr.lifecycle.interfaces import IObjectCreatedEvent
-from zope.component import getUtility
-from zope.event import notify
-from zope.interface import implements
-
 from canonical.config import config
-from canonical.database.sqlbase import rollback
-from canonical.launchpad.helpers import get_email_template
-from canonical.launchpad.interfaces.mail import (
-    EmailProcessingError,
-    IBugEditEmailCommand,
-    IBugEmailCommand,
-    IBugTaskEditEmailCommand,
-    IBugTaskEmailCommand,
-    IMailHandler,
-    )
-from canonical.launchpad.interfaces.message import IMessageSet
-from canonical.launchpad.mail.commands import (
-    BugEmailCommands,
-    get_error_message,
-    )
-from canonical.launchpad.mail.helpers import (
-    ensure_not_weakly_authenticated,
-    get_main_body,
-    guess_bugtask,
-    IncomingEmailError,
-    parse_commands,
-    reformat_wiki_text,
-    )
-from canonical.launchpad.mail.specexploder import get_spec_url_from_moin_mail
-from canonical.launchpad.mailnotification import (
-    MailWrapper,
-    send_process_error_notification,
-    )
-from canonical.launchpad.webapp import urlparse
-from canonical.launchpad.webapp.interfaces import ILaunchBag
-from lp.answers.interfaces.questioncollection import IQuestionSet
-from lp.answers.interfaces.questionenums import QuestionStatus
-from lp.blueprints.interfaces.specification import ISpecificationSet
-from lp.bugs.interfaces.bug import CreatedBugWithNoBugTasksError
-from lp.bugs.interfaces.bugattachment import (
-    BugAttachmentType,
-    IBugAttachmentSet,
-    )
-from lp.bugs.interfaces.bugmessage import IBugMessageSet
+from lp.answers.mail.handler import AnswerTrackerHandler
+from lp.blueprints.mail.handler import SpecificationHandler
+from lp.bugs.mail.handler import MaloneHandler
 from lp.code.mail.codehandler import CodeHandler
-from lp.services.mail.sendmail import (
-    sendmail,
-    simple_sendmail,
-    )
-
-
-class MaloneHandler:
-    """Handles emails sent to Malone.
-
-    It only handles mail sent to new@... and $bugid@..., where $bugid is a
-    positive integer.
-    """
-    implements(IMailHandler)
-
-    allow_unknown_users = False
-
-    def getCommands(self, signed_msg):
-        """Returns a list of all the commands found in the email."""
-        content = get_main_body(signed_msg)
-        if content is None:
-            return []
-        return [BugEmailCommands.get(name=name, string_args=args) for
-                name, args in parse_commands(content,
-                                             BugEmailCommands.names())]
-
-    def extractAndAuthenticateCommands(self, signed_msg, to_addr):
-        """Extract commands and handle special destinations.
-
-        NB: The authentication is carried out against the current principal,
-        not directly against the message.  authenticateEmail must previously
-        have been called on this thread.
-
-        :returns: (final_result, add_comment_to_bug, commands)
-            If final_result is non-none, stop processing and return this value
-            to indicate whether the message was dealt with or not.
-            If add_comment_to_bug, add the contents to the first bug
-            selected.
-            commands is a list of bug commands.
-        """
-        CONTEXT = 'bug report'
-        commands = self.getCommands(signed_msg)
-        to_user, to_host = to_addr.split('@')
-        add_comment_to_bug = False
-        # If there are any commands, we must have strong authentication.
-        # We send a different failure message for attempts to create a new
-        # bug.
-        if to_user.lower() == 'new':
-            ensure_not_weakly_authenticated(signed_msg, CONTEXT,
-                'unauthenticated-bug-creation.txt')
-        elif len(commands) > 0:
-            ensure_not_weakly_authenticated(signed_msg, CONTEXT)
-        if to_user.lower() == 'new':
-            commands.insert(0, BugEmailCommands.get('bug', ['new']))
-        elif to_user.isdigit():
-            # A comment to a bug. We set add_comment_to_bug to True so
-            # that the comment gets added to the bug later. We don't add
-            # the comment now, since we want to let the 'bug' command
-            # handle the possible errors that can occur while getting
-            # the bug.
-            add_comment_to_bug = True
-            commands.insert(0, BugEmailCommands.get('bug', [to_user]))
-        elif to_user.lower() == 'help':
-            from_user = getUtility(ILaunchBag).user
-            if from_user is not None:
-                preferredemail = from_user.preferredemail
-                if preferredemail is not None:
-                    to_address = str(preferredemail.email)
-                    self.sendHelpEmail(to_address)
-            return True, False, None
-        elif to_user.lower() != 'edit':
-            # Indicate that we didn't handle the mail.
-            return False, False, None
-        return None, add_comment_to_bug, commands
-
-    def process(self, signed_msg, to_addr, filealias=None, log=None):
-        """See IMailHandler."""
-
-        try:
-            (final_result, add_comment_to_bug,
-                commands, ) = self.extractAndAuthenticateCommands(
-                    signed_msg, to_addr)
-            if final_result is not None:
-                return final_result
-
-            bug = None
-            bug_event = None
-            bugtask = None
-            bugtask_event = None
-
-            processing_errors = []
-            while len(commands) > 0:
-                command = commands.pop(0)
-                try:
-                    if IBugEmailCommand.providedBy(command):
-                        if bug_event is not None:
-                            try:
-                                notify(bug_event)
-                            except CreatedBugWithNoBugTasksError:
-                                rollback()
-                                raise IncomingEmailError(
-                                    get_error_message(
-                                        'no-affects-target-on-submit.txt'))
-                        if (bugtask_event is not None and
-                            not IObjectCreatedEvent.providedBy(bug_event)):
-                            notify(bugtask_event)
-                        bugtask = None
-                        bugtask_event = None
-
-                        bug, bug_event = command.execute(
-                            signed_msg, filealias)
-                        if add_comment_to_bug:
-                            messageset = getUtility(IMessageSet)
-                            message = messageset.fromEmail(
-                                signed_msg.as_string(),
-                                owner=getUtility(ILaunchBag).user,
-                                filealias=filealias,
-                                parsed_message=signed_msg,
-                                fallback_parent=bug.initial_message)
-
-                            # If the new message's parent is linked to
-                            # a bug watch we also link this message to
-                            # that bug watch.
-                            bug_message_set = getUtility(IBugMessageSet)
-                            parent_bug_message = (
-                                bug_message_set.getByBugAndMessage(
-                                    bug, message.parent))
-
-                            if (parent_bug_message is not None and
-                                parent_bug_message.bugwatch):
-                                bug_watch = parent_bug_message.bugwatch
-                            else:
-                                bug_watch = None
-
-                            bugmessage = bug.linkMessage(
-                                message, bug_watch)
-
-                            notify(ObjectCreatedEvent(bugmessage))
-                            add_comment_to_bug = False
-                        else:
-                            message = bug.initial_message
-                        self.processAttachments(bug, message, signed_msg)
-                    elif IBugTaskEmailCommand.providedBy(command):
-                        if bugtask_event is not None:
-                            if not IObjectCreatedEvent.providedBy(bug_event):
-                                notify(bugtask_event)
-                            bugtask_event = None
-                        bugtask, bugtask_event = command.execute(bug)
-                    elif IBugEditEmailCommand.providedBy(command):
-                        bug, bug_event = command.execute(bug, bug_event)
-                    elif IBugTaskEditEmailCommand.providedBy(command):
-                        if bugtask is None:
-                            if len(bug.bugtasks) == 0:
-                                rollback()
-                                raise IncomingEmailError(
-                                    get_error_message(
-                                        'no-affects-target-on-submit.txt'))
-                            bugtask = guess_bugtask(
-                                bug, getUtility(ILaunchBag).user)
-                            if bugtask is None:
-                                raise IncomingEmailError(get_error_message(
-                                    'no-default-affects.txt',
-                                    bug_id=bug.id,
-                                    nr_of_bugtasks=len(bug.bugtasks)))
-                        bugtask, bugtask_event = command.execute(
-                            bugtask, bugtask_event)
-
-                except EmailProcessingError, error:
-                    processing_errors.append((error, command))
-                    if error.stop_processing:
-                        commands = []
-                        rollback()
-                    else:
-                        continue
-
-            if len(processing_errors) > 0:
-                raise IncomingEmailError(
-                    '\n'.join(str(error) for error, command
-                              in processing_errors),
-                    [command for error, command in processing_errors])
-
-            if bug_event is not None:
-                try:
-                    notify(bug_event)
-                except CreatedBugWithNoBugTasksError:
-                    rollback()
-                    raise IncomingEmailError(
-                        get_error_message('no-affects-target-on-submit.txt'))
-            if bugtask_event is not None:
-                if not IObjectCreatedEvent.providedBy(bug_event):
-                    notify(bugtask_event)
-
-        except IncomingEmailError, error:
-            send_process_error_notification(
-                str(getUtility(ILaunchBag).user.preferredemail.email),
-                'Submit Request Failure',
-                error.message, signed_msg, error.failing_command)
-
-        return True
-
-    def sendHelpEmail(self, to_address):
-        """Send usage help to `to_address`."""
-        # Get the help text (formatted as MoinMoin markup)
-        help_text = get_email_template('help.txt')
-        help_text = reformat_wiki_text(help_text)
-        # Wrap text
-        mailwrapper = MailWrapper(width=72)
-        help_text = mailwrapper.format(help_text)
-        simple_sendmail(
-            'help@xxxxxxxxxxxxxxxxxx', to_address,
-            'Launchpad Bug Tracker Email Interface Help',
-            help_text)
-
-    # Some content types indicate that an attachment has a special
-    # purpose. The current set is based on parsing emails from
-    # one mail account and may need to be extended.
-    #
-    # Mail signatures are most likely generated by the mail client
-    # and hence contain not data that is interesting except for
-    # mail authentication.
-    #
-    # Resource forks of MacOS files are not easily represented outside
-    # MacOS; if a resource fork contains useful debugging information,
-    # the entire MacOS file should be sent encapsulated for example in
-    # MacBinary format.
-    #
-    # application/ms-tnef attachment are created by Outlook; they
-    # seem to store no more than an RTF representation of an email.
-
-    irrelevant_content_types = set((
-        'application/applefile', # the resource fork of a MacOS file
-        'application/pgp-signature',
-        'application/pkcs7-signature',
-        'application/x-pkcs7-signature',
-        'text/x-vcard',
-        'application/ms-tnef',
-        ))
-
-    def processAttachments(self, bug, message, signed_mail):
-        """Create Bugattachments for "reasonable" mail attachments.
-
-        A mail attachment is stored as a bugattachment if its
-        content type is not listed in irrelevant_content_types.
-        """
-        for chunk in message.chunks:
-            blob = chunk.blob
-            if blob is None:
-                continue
-            # Mutt (other mail clients too?) appends the filename to the
-            # content type.
-            content_type = blob.mimetype.split(';', 1)[0]
-            if content_type in self.irrelevant_content_types:
-                continue
-
-            if content_type == 'text/html' and blob.filename == 'unnamed':
-                # This is the HTML representation of the main part of
-                # an email.
-                continue
-
-            if content_type in ('text/x-diff', 'text/x-patch'):
-                attach_type = BugAttachmentType.PATCH
-            else:
-                attach_type = BugAttachmentType.UNSPECIFIED
-
-            getUtility(IBugAttachmentSet).create(
-                bug=bug, filealias=blob, attach_type=attach_type,
-                title=blob.filename, message=message, send_notifications=True)
-
-
-class AnswerTrackerHandler:
-    """Handles emails sent to the Answer Tracker."""
-
-    implements(IMailHandler)
-
-    allow_unknown_users = False
-
-    # XXX flacoste 2007-04-23: The 'ticket' part is there for backward
-    # compatibility with the old notification address. We probably want to
-    # remove it in the future.
-    _question_address = re.compile(r'^(ticket|question)(?P<id>\d+)@.*')
-
-    def process(self, signed_msg, to_addr, filealias=None, log=None):
-        """See IMailHandler."""
-        match = self._question_address.match(to_addr)
-        if not match:
-            return False
-
-        question_id = int(match.group('id'))
-        question = getUtility(IQuestionSet).get(question_id)
-        if question is None:
-            # No such question, don't process the email.
-            return False
-
-        messageset = getUtility(IMessageSet)
-        message = messageset.fromEmail(
-            signed_msg.parsed_string,
-            owner=getUtility(ILaunchBag).user,
-            filealias=filealias,
-            parsed_message=signed_msg)
-
-        if message.owner == question.owner:
-            self.processOwnerMessage(question, message)
-        else:
-            self.processUserMessage(question, message)
-        return True
-
-    def processOwnerMessage(self, question, message):
-        """Choose the right workflow action for a message coming from
-        the question owner.
-
-        When the question status is OPEN or NEEDINFO,
-        the message is a GIVEINFO action; when the status is ANSWERED
-        or EXPIRED, we interpret the message as a reopenening request;
-        otherwise it's a comment.
-        """
-        if question.status in [
-            QuestionStatus.OPEN, QuestionStatus.NEEDSINFO]:
-            question.giveInfo(message)
-        elif question.status in [
-            QuestionStatus.ANSWERED, QuestionStatus.EXPIRED]:
-            question.reopen(message)
-        else:
-            question.addComment(message.owner, message)
-
-    def processUserMessage(self, question, message):
-        """Choose the right workflow action for a message coming from a user
-        that is not the question owner.
-
-        When the question status is OPEN, NEEDSINFO, or ANSWERED, we interpret
-        the message as containing an answer. (If it was really a request for
-        more information, the owner will still be able to answer it while
-        reopening the request.)
-
-        In the other status, the message is a comment without status change.
-        """
-        if question.status in [
-            QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
-        QuestionStatus.ANSWERED]:
-            question.giveAnswer(message.owner, message)
-        else:
-            # In the other states, only a comment can be added.
-            question.addComment(message.owner, message)
-
-
-class SpecificationHandler:
-    """Handles emails sent to specs.launchpad.net."""
-
-    implements(IMailHandler)
-
-    allow_unknown_users = True
-
-    _spec_changes_address = re.compile(r'^notifications@.*')
-
-    # The list of hosts where the Ubuntu wiki is located. We could do a
-    # more general solution, but this kind of setup is unusual, and it
-    # will be mainly the Ubuntu and Launchpad wikis that will use this
-    # notification forwarder.
-    UBUNTU_WIKI_HOSTS = [
-        'wiki.ubuntu.com', 'wiki.edubuntu.org', 'wiki.kubuntu.org']
-
-    def _getSpecByURL(self, url):
-        """Returns a spec that is associated with the URL.
-
-        It takes into account that the same Ubuntu wiki is on three
-        different hosts.
-        """
-        scheme, host, path, params, query, fragment = urlparse(url)
-        if host in self.UBUNTU_WIKI_HOSTS:
-            for ubuntu_wiki_host in self.UBUNTU_WIKI_HOSTS:
-                possible_url = urlunparse(
-                    (scheme, ubuntu_wiki_host, path, params, query,
-                     fragment))
-                spec = getUtility(ISpecificationSet).getByURL(possible_url)
-                if spec is not None:
-                    return spec
-        else:
-            return getUtility(ISpecificationSet).getByURL(url)
-
-    def process(self, signed_msg, to_addr, filealias=None, log=None):
-        """See IMailHandler."""
-        match = self._spec_changes_address.match(to_addr)
-        if not match:
-            # We handle only spec-changes at the moment.
-            return False
-        our_address = "notifications@%s" % config.launchpad.specs_domain
-        # Check for emails that we sent.
-        xloop = signed_msg['X-Loop']
-        if xloop and our_address in signed_msg.get_all('X-Loop'):
-            if log and filealias:
-                log.warning(
-                    'Got back a notification we sent: %s' %
-                    filealias.http_url)
-            return True
-        # Check for emails that Launchpad sent us.
-        if signed_msg['Sender'] == config.canonical.bounce_address:
-            if log and filealias:
-                log.warning('We received an email from Launchpad: %s'
-                            % filealias.http_url)
-            return True
-        # When sending the email, the sender will be set so that it's
-        # clear that we're the one sending the email, not the original
-        # sender.
-        del signed_msg['Sender']
-
-        mail_body = signed_msg.get_payload(decode=True)
-        spec_url = get_spec_url_from_moin_mail(mail_body)
-        if spec_url is not None:
-            if log is not None:
-                log.debug('Found a spec URL: %s' % spec_url)
-            spec = self._getSpecByURL(spec_url)
-            if spec is not None:
-                if log is not None:
-                    log.debug('Found a corresponding spec: %s' % spec.name)
-                # Add an X-Loop header, in order to prevent mail loop.
-                signed_msg.add_header('X-Loop', our_address)
-                notification_addresses = spec.notificationRecipientAddresses()
-                if log is not None:
-                    log.debug(
-                        'Sending notification to: %s' %
-                            ', '.join(notification_addresses))
-                sendmail(signed_msg, to_addrs=notification_addresses)
-
-            elif log is not None:
-                log.debug(
-                    "Didn't find a corresponding spec for %s" % spec_url)
-        elif log is not None:
-            log.debug("Didn't find a specification URL")
-        return True
 
 
 class MailHandlers:

=== modified file 'lib/lp/services/mail/incoming.py'
--- lib/lp/services/mail/incoming.py	2010-12-01 19:12:00 +0000
+++ lib/lp/services/mail/incoming.py	2010-12-13 16:28:18 +0000
@@ -36,10 +36,7 @@
 from canonical.launchpad.interfaces.mail import IWeaklyAuthenticatedPrincipal
 from canonical.launchpad.interfaces.mailbox import IMailBox
 from canonical.launchpad.mail.commands import get_error_message
-from canonical.launchpad.mail.handlers import mail_handlers
-from canonical.launchpad.mail.helpers import (
-    ensure_sane_signature_timestamp,
-    )
+from canonical.launchpad.mail.helpers import ensure_sane_signature_timestamp
 from canonical.launchpad.mailnotification import (
     send_process_error_notification,
     )
@@ -54,6 +51,7 @@
 from canonical.launchpad.webapp.interfaces import IPlacelessAuthUtility
 from canonical.librarian.interfaces import UploadFailed
 from lp.registry.interfaces.person import IPerson
+from lp.services.mail.handlers import mail_handlers
 from lp.services.mail.sendmail import do_paranoid_envelope_to_validation
 from lp.services.mail.signedmessage import signed_message_from_string
 

=== modified file 'lib/lp/services/mail/tests/incomingmail.txt'
--- lib/lp/services/mail/tests/incomingmail.txt	2010-11-25 04:42:51 +0000
+++ lib/lp/services/mail/tests/incomingmail.txt	2010-12-13 16:28:18 +0000
@@ -37,7 +37,7 @@
     ...         self.handledMails.append(mail['Message-Id'])
     ...         return True
 
-    >>> from canonical.launchpad.mail.handlers import mail_handlers
+    >>> from lp.services.mail.handlers import mail_handlers
     >>> foo_handler = MockHandler()
     >>> bar_handler = MockHandler(allow_unknown_users=True)
     >>> mail_handlers.add('foo.com', foo_handler)

=== renamed file 'lib/canonical/launchpad/mail/tests/test_handlers.py' => 'lib/lp/services/mail/tests/test_handlers.py'
--- lib/canonical/launchpad/mail/tests/test_handlers.py	2010-11-25 04:42:51 +0000
+++ lib/lp/services/mail/tests/test_handlers.py	2010-12-13 16:28:18 +0000
@@ -4,172 +4,7 @@
 __metaclass__ = type
 
 from doctest import DocTestSuite
-import email
-import time
-import transaction
-import unittest
-
-from canonical.database.sqlbase import commit
-from canonical.launchpad.ftests import import_secret_test_key
-from canonical.launchpad.mail.commands import BugEmailCommand
-from canonical.launchpad.mail.handlers import MaloneHandler
-from canonical.testing.layers import LaunchpadFunctionalLayer
-from lp.services.mail import stub
-from lp.testing import (
-    person_logged_in,
-    TestCaseWithFactory,
-    )
-from lp.testing.factory import GPGSigningContext
-
-
-class TestMaloneHandler(TestCaseWithFactory):
-    """Test that the Malone/bugs handler works."""
-
-    layer = LaunchpadFunctionalLayer
-
-    def test_getCommandsEmpty(self):
-        """getCommands returns an empty list for messages with no command."""
-        message = self.factory.makeSignedMessage()
-        handler = MaloneHandler()
-        self.assertEqual([], handler.getCommands(message))
-
-    def test_getCommandsBug(self):
-        """getCommands returns a reasonable list if commands are specified."""
-        message = self.factory.makeSignedMessage(body=' bug foo')
-        handler = MaloneHandler()
-        commands = handler.getCommands(message)
-        self.assertEqual(1, len(commands))
-        self.assertTrue(isinstance(commands[0], BugEmailCommand))
-        self.assertEqual('bug', commands[0].name)
-        self.assertEqual(['foo'], commands[0].string_args)
-
-    def test_NonGPGAuthenticatedNewBug(self):
-        """Mail authenticated other than by gpg can create bugs.
-
-        The incoming mail layer is responsible for authenticating the mail,
-        and setting the current principal to the sender of the mail, either
-        weakly or non-weakly authenticated.  At the layer of the handler,
-        which this class is testing, we shouldn't care by what mechanism we
-        decided to act on behalf of the mail sender, only that we did.
-
-        In bug 643219, Launchpad had a problem where the MaloneHandler code
-        was puncturing that abstraction and directly looking at the GPG
-        signature; this test checks it's fixed.
-        """
-        # NB SignedMessage by default isn't actually signed, it just has the
-        # capability of knowing about signing.
-        message = self.factory.makeSignedMessage(body='  affects malone\nhi!')
-        self.assertEquals(message.signature, None)
-
-        # Pretend that the mail auth has given us a logged-in user.
-        handler = MaloneHandler()
-        with person_logged_in(self.factory.makePerson()):
-            mail_handled, add_comment_to_bug, commands = \
-                handler.extractAndAuthenticateCommands(message,
-                    'new@xxxxxxxxxxxxxxxxxx')
-        self.assertEquals(mail_handled, None)
-        self.assertEquals(map(str, commands), [
-            'bug new',
-            'affects malone',
-            ])
-
-    def test_mailToHelpFromUnknownUser(self):
-        """Mail from people of no account to help@ is simply dropped.
-        """
-        message = self.factory.makeSignedMessage()
-        handler = MaloneHandler()
-        mail_handled, add_comment_to_bug, commands = \
-            handler.extractAndAuthenticateCommands(message,
-                'help@xxxxxxxxxxxxxxxxxx')
-        self.assertEquals(mail_handled, True)
-        self.assertEquals(self.getSentMail(), [])
-
-    def test_mailToHelp(self):
-        """Mail to help@ generates a help command."""
-        message = self.factory.makeSignedMessage()
-        handler = MaloneHandler()
-        with person_logged_in(self.factory.makePerson()):
-            mail_handled, add_comment_to_bug, commands = \
-                handler.extractAndAuthenticateCommands(message,
-                    'help@xxxxxxxxxxxxxxxxxx')
-        self.assertEquals(mail_handled, True)
-        self.assertEquals(len(self.getSentMail()), 1)
-        # TODO: Check the right mail was sent. -- mbp 20100923
-
-    def getSentMail(self):
-        # Sending mail is (unfortunately) a side effect of parsing the
-        # commands, and unfortunately you must commit the transaction to get
-        # them sent.
-        transaction.commit()
-        return stub.test_emails[:]
-
-
-class FakeSignature:
-
-    def __init__(self, timestamp):
-        self.timestamp = timestamp
-
-
-def get_last_email():
-    from_addr, to_addrs, raw_message = stub.test_emails[-1]
-    sent_msg = email.message_from_string(raw_message)
-    error_mail, original_mail = sent_msg.get_payload()
-    # clear the emails so we don't accidentally get one from a previous test
-    return dict(
-        subject=sent_msg['Subject'],
-        body=error_mail.get_payload(decode=True))
-
-
-BAD_SIGNATURE_TIMESTAMP_MESSAGE = (
-    'The message you sent included commands to modify the bug '
-    'report, but the\nsignature was (apparently) generated too far '
-    'in the past or future.')
-
-
-class TestSignatureTimestampValidation(TestCaseWithFactory):
-    """GPG signature timestamps are checked for emails containing commands."""
-
-    layer = LaunchpadFunctionalLayer
-
-    def test_good_signature_timestamp(self):
-        # An email message's GPG signature's timestamp checked to be sure it
-        # isn't too far in the future or past.  This test shows that a
-        # signature with a timestamp of appxoimately now will be accepted.
-        signing_context = GPGSigningContext(
-            import_secret_test_key().fingerprint, password='test')
-        msg = self.factory.makeSignedMessage(
-            body=' security no', signing_context=signing_context)
-        handler = MaloneHandler()
-        with person_logged_in(self.factory.makePerson()):
-            success = handler.process(msg, msg['To'])
-        commit()
-        # Since there were no commands in the poorly-timestamped message, no
-        # error emails were generated.
-        self.assertEqual(stub.test_emails, [])
-
-    def test_bad_timestamp_but_no_commands(self):
-        # If an email message's GPG signature's timestamp is too far in the
-        # future or past but it doesn't contain any commands, the email is
-        # processed anyway.
-
-        msg = self.factory.makeSignedMessage(
-            body='I really hope this bug gets fixed.')
-        now = time.time()
-        one_week = 60 * 60 * 24 * 7
-        msg.signature = FakeSignature(timestamp=now+one_week)
-        handler = MaloneHandler()
-        # Clear old emails before potentially generating more.
-        del stub.test_emails[:]
-        with person_logged_in(self.factory.makePerson()):
-            success = handler.process(msg, msg['To'])
-        commit()
-        # Since there were no commands in the poorly-timestamped message, no
-        # error emails were generated.
-        self.assertEqual(stub.test_emails, [])
 
 
 def test_suite():
-    suite = unittest.TestSuite()
-    suite.addTests(DocTestSuite('canonical.launchpad.mail.handlers'))
-    suite.addTests(unittest.TestLoader().loadTestsFromName(__name__))
-    return suite
+    return DocTestSuite('lp.services.mail.handlers')