launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #02151
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')