Merge lp:~benji/launchpad/bug-580035 into lp:launchpad

Proposed by Benji York
Status: Merged
Approved by: Māris Fogels
Approved revision: no longer in the source branch.
Merged at revision: 11392
Proposed branch: lp:~benji/launchpad/bug-580035
Merge into: lp:launchpad
Diff against target: 933 lines (+308/-105)
7 files modified
lib/canonical/launchpad/mail/errortemplates/old-signature.txt (+2/-0)
lib/canonical/launchpad/mail/handlers.py (+9/-4)
lib/canonical/launchpad/mail/helpers.py (+20/-1)
lib/canonical/launchpad/mail/tests/test_handlers.py (+87/-1)
lib/canonical/launchpad/mail/tests/test_helpers.py (+46/-1)
lib/canonical/launchpad/utilities/gpghandler.py (+21/-32)
lib/lp/bugs/tests/bugs-emailinterface.txt (+123/-66)
To merge this branch: bzr merge lp:~benji/launchpad/bug-580035
Reviewer Review Type Date Requested Status
Deryck Hodge (community) code Approve
Māris Fogels (community) Approve
Review via email: mp+32917@code.launchpad.net

Description of the change

Commands can be sent to bugs via GPG signed email. The commands don't contain
a nonce, so if someone were able to get ahold of an email from someone they
could resend it later (bug 580035).

This branch greatly reduces the effective window for such an attack by
rejecting any email message containing commands if the signature was generated
too long ago (more than 48 hours).

To help avoid attacks that arise from people accidentally having their clocks
set too far in the future and thus creating messages that can be reused for a
long time, the branch also rejects emails with commands that were signed more
than 10 minutes in the future.

This approach is suggested fix number 3 in the bug description.

I had a pre-implementation discussion with Gary and he had one with Francis.

The bug email tests had a natural place to add tests for this behavior. To run
the tests:

    bin/test -ct bugs-emailinterface.txt

I fixed a large amount of lint (but I don't think it pollutes the diff too
much). Here's the lint report that I made go away:

Linting changed files:
  lib/canonical/launchpad/mail/handlers.py
  lib/canonical/launchpad/mail/helpers.py
  lib/canonical/launchpad/mail/errortemplates/old-signature.txt
  lib/canonical/launchpad/utilities/gpghandler.py
  lib/lp/bugs/tests/bugs-emailinterface.txt

./lib/canonical/launchpad/mail/handlers.py
     353: W191 indentation contains tabs
     353: E101 indentation contains mixed spaces and tabs
     353: Line contains a tab character.
./lib/canonical/launchpad/mail/helpers.py
      78: E202 whitespace before ']'
     138: E302 expected 2 blank lines, found 1
./lib/canonical/launchpad/utilities/gpghandler.py
      78: E211 whitespace before '('
     288: E202 whitespace before ')'
     470: E202 whitespace before '}'
./lib/lp/bugs/tests/bugs-emailinterface.txt
      79: source exceeds 78 characters.
     341: source exceeds 78 characters.
     575: narrative exceeds 78 characters.
     967: source exceeds 78 characters.
    1226: source exceeds 78 characters.
    1230: source has bad indentation.
    1447: want exceeds 78 characters.
    1473: narrative exceeds 78 characters.
    1489: want exceeds 78 characters.
    1502: want exceeds 78 characters.
    1894: source exceeds 78 characters.
    1895: source exceeds 78 characters.
    2344: narrative has trailing whitespace.
    2583: narrative has trailing whitespace.
    2584: narrative has trailing whitespace.
    2585: narrative has trailing whitespace.
    2696: source exceeds 78 characters.
    2895: want exceeds 78 characters.

To post a comment you must log in.
Revision history for this message
Māris Fogels (mars) wrote :

Hi Benji,

Once again, these changes look good. I really like the use of named variables for time durations in ensure_sane_signature_timestamp(). It really improves the readability.

One question I did have: did you have a pre-implementation call with the bugs team about this? IIRC at the last EPIC gmb was in the process of killing all doctests in bugs. I would think the bugs team members would have asked for unit tests of this new feature.

If it turns out that the bugs team does in fact want unit tests, then I am sure you can land this as-is and land a follow-up branch soon after.

Looks good, r=mars.

Maris

review: Approve
Revision history for this message
Deryck Hodge (deryck) wrote :

Benji and I chatted on IRC. I would indeed prefer this be converted to a unit test. I would prefer it before it lands (best laid plans and all that... ;)), but I won't block on that point. Thanks for pointing that out, Maris!

Cheers,
deryck

Revision history for this message
Deryck Hodge (deryck) wrote :

The updates to make this a unit test look good to me, Benji. Thanks!

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'lib/canonical/launchpad/mail/errortemplates/old-signature.txt'
2--- lib/canonical/launchpad/mail/errortemplates/old-signature.txt 1970-01-01 00:00:00 +0000
3+++ lib/canonical/launchpad/mail/errortemplates/old-signature.txt 2010-08-18 18:27:52 +0000
4@@ -0,0 +1,2 @@
5+The message you sent included commands to modify the %(context)s, but the
6+signature was (apparently) generated too far in the past or future.
7
8=== modified file 'lib/canonical/launchpad/mail/handlers.py'
9--- lib/canonical/launchpad/mail/handlers.py 2009-07-24 12:54:07 +0000
10+++ lib/canonical/launchpad/mail/handlers.py 2010-08-18 18:27:52 +0000
11@@ -27,7 +27,8 @@
12 BugEmailCommands, get_error_message)
13 from canonical.launchpad.mail.helpers import (
14 ensure_not_weakly_authenticated, get_main_body, guess_bugtask,
15- IncomingEmailError, parse_commands, reformat_wiki_text)
16+ IncomingEmailError, parse_commands, reformat_wiki_text,
17+ ensure_sane_signature_timestamp)
18 from lp.services.mail.sendmail import sendmail, simple_sendmail
19 from canonical.launchpad.mail.specexploder import get_spec_url_from_moin_mail
20 from canonical.launchpad.mailnotification import (
21@@ -62,15 +63,19 @@
22 commands = self.getCommands(signed_msg)
23 user, host = to_addr.split('@')
24 add_comment_to_bug = False
25+ signature = signed_msg.signature
26
27 try:
28 if len(commands) > 0:
29- ensure_not_weakly_authenticated(signed_msg, 'bug report')
30+ CONTEXT = 'bug report'
31+ ensure_not_weakly_authenticated(signed_msg, CONTEXT)
32+ if signature is not None:
33+ ensure_sane_signature_timestamp(signature, CONTEXT)
34
35 if user.lower() == 'new':
36 # A submit request.
37 commands.insert(0, BugEmailCommands.get('bug', ['new']))
38- if signed_msg.signature is None:
39+ if signature is None:
40 raise IncomingEmailError(
41 get_error_message('not-gpg-signed.txt'))
42 elif user.isdigit():
43@@ -345,7 +350,7 @@
44 """
45 if question.status in [
46 QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
47- QuestionStatus.ANSWERED]:
48+ QuestionStatus.ANSWERED]:
49 question.giveAnswer(message.owner, message)
50 else:
51 # In the other states, only a comment can be added.
52
53=== modified file 'lib/canonical/launchpad/mail/helpers.py'
54--- lib/canonical/launchpad/mail/helpers.py 2010-08-16 13:50:28 +0000
55+++ lib/canonical/launchpad/mail/helpers.py 2010-08-18 18:27:52 +0000
56@@ -5,6 +5,7 @@
57
58 import os.path
59 import re
60+import time
61
62 from zope.component import getUtility
63
64@@ -74,7 +75,9 @@
65 <...IDistroSeriesBugTask>
66 """
67 bugtask_interfaces = [
68- IUpstreamBugTask, IDistroBugTask, IDistroSeriesBugTask
69+ IUpstreamBugTask,
70+ IDistroBugTask,
71+ IDistroSeriesBugTask,
72 ]
73 for interface in bugtask_interfaces:
74 if interface.providedBy(bugtask):
75@@ -134,6 +137,7 @@
76
77 return text
78
79+
80 def parse_commands(content, command_names):
81 """Extract indented commands from email body.
82
83@@ -220,3 +224,18 @@
84 no_key_template, import_url=import_url,
85 context=context)
86 raise IncomingEmailError(error_message)
87+
88+
89+def ensure_sane_signature_timestamp(signature, context,
90+ error_template='old-signature.txt'):
91+ """Ensure the signature was generated recently but not in the future."""
92+ fourty_eight_hours = 48 * 60 * 60
93+ ten_minutes = 10 * 60
94+ now = time.time()
95+ fourty_eight_hours_ago = now - fourty_eight_hours
96+ ten_minutes_in_the_future = now + ten_minutes
97+
98+ if (signature.timestamp < fourty_eight_hours_ago
99+ or signature.timestamp > ten_minutes_in_the_future):
100+ error_message = get_error_message(error_template, context=context)
101+ raise IncomingEmailError(error_message)
102
103=== modified file 'lib/canonical/launchpad/mail/tests/test_handlers.py'
104--- lib/canonical/launchpad/mail/tests/test_handlers.py 2010-07-14 14:11:15 +0000
105+++ lib/canonical/launchpad/mail/tests/test_handlers.py 2010-08-18 18:27:52 +0000
106@@ -3,14 +3,18 @@
107
108 __metaclass__ = type
109
110+import email
111+import time
112 import unittest
113
114 from doctest import DocTestSuite
115
116+from canonical.database.sqlbase import commit
117 from canonical.launchpad.mail.commands import BugEmailCommand
118 from canonical.launchpad.mail.handlers import MaloneHandler
119-from lp.testing import TestCaseWithFactory
120 from canonical.testing import LaunchpadFunctionalLayer
121+from lp.services.mail import stub
122+from lp.testing import TestCaseWithFactory, person_logged_in
123
124
125 class TestMaloneHandler(TestCaseWithFactory):
126@@ -35,6 +39,88 @@
127 self.assertEqual(['foo'], commands[0].string_args)
128
129
130+class FakeSignature:
131+
132+ def __init__(self, timestamp):
133+ self.timestamp = timestamp
134+
135+
136+def get_last_email():
137+ from_addr, to_addrs, raw_message = stub.test_emails[-1]
138+ sent_msg = email.message_from_string(raw_message)
139+ error_mail, original_mail = sent_msg.get_payload()
140+ # clear the emails so we don't accidentally get one from a previous test
141+ return dict(
142+ subject=sent_msg['Subject'],
143+ body=error_mail.get_payload(decode=True))
144+
145+
146+BAD_SIGNATURE_TIMESTAMP_MESSAGE = (
147+ 'The message you sent included commands to modify the bug '
148+ 'report, but the\nsignature was (apparently) generated too far '
149+ 'in the past or future.')
150+
151+
152+class TestSignatureTimestampValidation(TestCaseWithFactory):
153+ """GPG signature timestamps are checked for emails containing commands."""
154+
155+ layer = LaunchpadFunctionalLayer
156+
157+ def test_far_past_gpg_signature_timestamp(self):
158+ # If an email message's GPG signature's timestamp is too far in the
159+ # past and the message contains a command, the command will fail and
160+ # send the user an email telling them what happened.
161+
162+ msg = self.factory.makeSignedMessage(body=' security no')
163+ now = time.time()
164+ one_week = 60 * 60 * 24 * 7
165+ msg.signature = FakeSignature(timestamp=now-one_week)
166+ handler = MaloneHandler()
167+ with person_logged_in(self.factory.makePerson()):
168+ success = handler.process(msg, msg['To'])
169+ commit()
170+ email = get_last_email()
171+ self.assertEqual(email['subject'], 'Submit Request Failure')
172+ self.assertIn(BAD_SIGNATURE_TIMESTAMP_MESSAGE, email['body'])
173+
174+ def test_far_future_gpg_signature_timestamp(self):
175+ # If an email message's GPG signature's timestamp is too far in the
176+ # future and the message contains a command, the command will fail and
177+ # send the user an email telling them what happened.
178+
179+ msg = self.factory.makeSignedMessage(body=' security no')
180+ now = time.time()
181+ one_week = 60 * 60 * 24 * 7
182+ msg.signature = FakeSignature(timestamp=now+one_week)
183+ handler = MaloneHandler()
184+ with person_logged_in(self.factory.makePerson()):
185+ success = handler.process(msg, msg['To'])
186+ commit()
187+ email = get_last_email()
188+ self.assertEqual(email['subject'], 'Submit Request Failure')
189+ self.assertIn(BAD_SIGNATURE_TIMESTAMP_MESSAGE, email['body'])
190+
191+ def test_bad_timestamp_but_no_commands(self):
192+ # If an email message's GPG signature's timestamp is too far in the
193+ # future or past but it doesn't contain any commands, the email is
194+ # processed anyway.
195+
196+ msg = self.factory.makeSignedMessage(
197+ body='I really hope this bug gets fixed.')
198+ now = time.time()
199+ one_week = 60 * 60 * 24 * 7
200+ msg.signature = FakeSignature(timestamp=now+one_week)
201+ handler = MaloneHandler()
202+ # Clear old emails before potentially generating more.
203+ del stub.test_emails[:]
204+ with person_logged_in(self.factory.makePerson()):
205+ success = handler.process(msg, msg['To'])
206+ commit()
207+ # Since there were no commands in the poorly-timestamped message, no
208+ # error emails were generated.
209+ self.assertEqual(stub.test_emails, [])
210+
211+
212 def test_suite():
213 suite = unittest.TestSuite()
214 suite.addTests(DocTestSuite('canonical.launchpad.mail.handlers'))
215
216=== modified file 'lib/canonical/launchpad/mail/tests/test_helpers.py'
217--- lib/canonical/launchpad/mail/tests/test_helpers.py 2010-07-14 14:11:15 +0000
218+++ lib/canonical/launchpad/mail/tests/test_helpers.py 2010-08-18 18:27:52 +0000
219@@ -4,6 +4,7 @@
220 __metaclass__ = type
221
222 from doctest import DocTestSuite
223+import time
224 import unittest
225
226 from zope.interface import directlyProvides, directlyProvidedBy
227@@ -12,7 +13,7 @@
228 EmailProcessingError, IWeaklyAuthenticatedPrincipal)
229 from canonical.launchpad.mail.helpers import (
230 ensure_not_weakly_authenticated, get_person_or_team,
231- IncomingEmailError, parse_commands)
232+ IncomingEmailError, parse_commands, ensure_sane_signature_timestamp)
233 from lp.testing import login_person, TestCase, TestCaseWithFactory
234 from canonical.testing import DatabaseFunctionalLayer
235 from canonical.launchpad.webapp.interaction import get_current_principal
236@@ -77,6 +78,50 @@
237 parse_commands(' command:', ['command']))
238
239
240+class FakeSignature:
241+
242+ def __init__(self, timestamp):
243+ self.timestamp = timestamp
244+
245+
246+class TestEnsureSaneSignatureTimestamp(unittest.TestCase):
247+ """Tests for ensure_sane_signature_timestamp"""
248+
249+ def test_too_old_timestamp(self):
250+ # signature timestamps shouldn't be too old
251+ now = time.time()
252+ one_week = 60 * 60 * 24 * 7
253+ signature = FakeSignature(timestamp=now-one_week)
254+ self.assertRaises(
255+ IncomingEmailError, ensure_sane_signature_timestamp,
256+ signature, 'bug report')
257+
258+ def test_future_timestamp(self):
259+ # signature timestamps shouldn't be (far) in the future
260+ now = time.time()
261+ one_week = 60 * 60 * 24 * 7
262+ signature = FakeSignature(timestamp=now+one_week)
263+ self.assertRaises(
264+ IncomingEmailError, ensure_sane_signature_timestamp,
265+ signature, 'bug report')
266+
267+ def test_near_future_timestamp(self):
268+ # signature timestamps in the near future are OK
269+ now = time.time()
270+ one_minute = 60
271+ signature = FakeSignature(timestamp=now+one_minute)
272+ # this should not raise an exception
273+ ensure_sane_signature_timestamp(signature, 'bug report')
274+
275+ def test_recent_timestamp(self):
276+ # signature timestamps in the recent past are OK
277+ now = time.time()
278+ one_hour = 60 * 60
279+ signature = FakeSignature(timestamp=now-one_hour)
280+ # this should not raise an exception
281+ ensure_sane_signature_timestamp(signature, 'bug report')
282+
283+
284 class TestEnsureNotWeaklyAuthenticated(TestCaseWithFactory):
285 """Test the ensure_not_weakly_authenticated function."""
286
287
288=== modified file 'lib/canonical/launchpad/utilities/gpghandler.py'
289--- lib/canonical/launchpad/utilities/gpghandler.py 2010-08-10 16:17:12 +0000
290+++ lib/canonical/launchpad/utilities/gpghandler.py 2010-08-18 18:27:52 +0000
291@@ -75,9 +75,9 @@
292 # automatically retrieve from the keyserver unknown key when
293 # verifying signatures and 'no-auto-check-trustdb' avoid wasting
294 # time verifying the local keyring consistence.
295- conf.write ('keyserver hkp://%s\n'
296- 'keyserver-options auto-key-retrieve\n'
297- 'no-auto-check-trustdb\n' % config.gpghandler.host)
298+ conf.write('keyserver hkp://%s\n'
299+ 'keyserver-options auto-key-retrieve\n'
300+ 'no-auto-check-trustdb\n' % config.gpghandler.host)
301 conf.close()
302 # create a local atexit handler to remove the configuration directory
303 # on normal termination.
304@@ -156,35 +156,26 @@
305 sig = StringIO(signature)
306 # store the content
307 plain = StringIO(content)
308- # process it
309- try:
310- signatures = ctx.verify(sig, plain, None)
311- except gpgme.GpgmeError, e:
312- # XXX: 2010-04-26, Salgado, bug=570244: This hack is needed
313- # for python2.5 compatibility. We should remove it when we no
314- # longer need to run on python2.5.
315- if hasattr(e, 'strerror'):
316- msg = e.strerror
317- else:
318- msg = e.message
319- raise GPGVerificationError(msg)
320+ args = (sig, plain, None)
321 else:
322 # store clearsigned signature
323 sig = StringIO(content)
324 # writeable content
325 plain = StringIO()
326- # process it
327- try:
328- signatures = ctx.verify(sig, None, plain)
329- except gpgme.GpgmeError, e:
330- # XXX: 2010-04-26, Salgado, bug=570244: This hack is needed
331- # for python2.5 compatibility. We should remove it when we no
332- # longer need to run on python2.5.
333- if hasattr(e, 'strerror'):
334- msg = e.strerror
335- else:
336- msg = e.message
337- raise GPGVerificationError(msg)
338+ args = (sig, None, plain)
339+
340+ # process it
341+ try:
342+ signatures = ctx.verify(*args)
343+ except gpgme.GpgmeError, e:
344+ # XXX: 2010-04-26, Salgado, bug=570244: This hack is needed
345+ # for python2.5 compatibility. We should remove it when we no
346+ # longer need to run on python2.5.
347+ if hasattr(e, 'strerror'):
348+ msg = e.strerror
349+ else:
350+ msg = e.message
351+ raise GPGVerificationError(msg)
352
353 # XXX jamesh 2006-01-31:
354 # We raise an exception if we don't get exactly one signature.
355@@ -204,7 +195,6 @@
356 'found multiple signatures')
357
358 signature = signatures[0]
359-
360 # signature.status == 0 means "Ok"
361 if signature.status is not None:
362 raise GPGVerificationError(signature.status.args)
363@@ -295,8 +285,7 @@
364 # See more information at:
365 # http://pyme.sourceforge.net/doc/gpgme/Generating-Keys.html
366 result = context.genkey(
367- signing_only_param % {'name': name.encode('utf-8')}
368- )
369+ signing_only_param % {'name': name.encode('utf-8')})
370
371 # Right, it might seem paranoid to have this many assertions,
372 # but we have to take key generation very seriously.
373@@ -477,8 +466,8 @@
374 """See IGPGHandler"""
375 params = {
376 'search': '0x%s' % fingerprint,
377- 'op': action
378- }
379+ 'op': action,
380+ }
381 if public:
382 host = config.gpghandler.public_host
383 else:
384
385=== modified file 'lib/lp/bugs/tests/bugs-emailinterface.txt'
386--- lib/lp/bugs/tests/bugs-emailinterface.txt 2010-08-02 17:48:13 +0000
387+++ lib/lp/bugs/tests/bugs-emailinterface.txt 2010-08-18 18:27:52 +0000
388@@ -1,11 +1,13 @@
389-= Launchpad Bugs e-mail interface =
390+Launchpad Bugs e-mail interface
391+===============================
392
393 Launchpad's bugtracker has an e-mail interface, with which you may report new
394 bugs, add comments, and change the details of existing bug reports. Commands
395 can be interleaved within a comment, so to distinguish them from the comment,
396 they must be indented with at least one space or tab character.
397
398-== Submit a new bug ==
399+Submit a new bug
400+----------------
401
402 To report a bug, you send an OpenPGP-signed e-mail message to
403 new@bugs.launchpad-domain. You must have registered your key in
404@@ -48,12 +50,19 @@
405 signed, so that the system can verify the sender. But to avoid having
406 to sign each email, we'll create a class which fakes a signed email:
407
408- >>> import email.Utils
409+ >>> import time
410+ >>> class MockSignature(object):
411+ ... def __init__(self):
412+ ... self.timestamp = time.time()
413+
414+ >>> import email.Message
415 >>> class MockSignedMessage(email.Message.Message):
416+ ... def __init__(self, *args, **kws):
417+ ... email.Message.Message.__init__(self, *args, **kws)
418+ ... self.signature = MockSignature()
419 ... @property
420 ... def signedMessage(self):
421 ... return self
422- ... signature = object()
423
424 And since we'll pass the email directly to the correct handler,
425 we'll have to authenticate the user manually:
426@@ -66,10 +75,15 @@
427
428 >>> from canonical.launchpad.mail.handlers import MaloneHandler
429 >>> handler = MaloneHandler()
430- >>> def process_email(raw_mail):
431- ... msg = email.message_from_string(raw_mail, _class=MockSignedMessage)
432+ >>> def construct_email(raw_mail):
433+ ... msg = email.message_from_string(
434+ ... raw_mail, _class=MockSignedMessage)
435 ... if not msg.has_key('Message-Id'):
436 ... msg['Message-Id'] = factory.makeUniqueRFC822MsgId()
437+ ... return msg
438+
439+ >>> def process_email(raw_mail):
440+ ... msg = construct_email(raw_mail)
441 ... handler.process(msg, msg['To'])
442
443 >>> process_email(submit_mail)
444@@ -224,7 +238,8 @@
445 u'A folded email subject'
446
447
448-== Add a comment ==
449+Add a comment
450+-------------
451
452 After a bug has been submitted a notification is sent out. The reply-to
453 address is set to the bug address, $bugid@malone-domain. We can send
454@@ -263,7 +278,8 @@
455 True
456
457
458-== Edit bugs ==
459+Edit bugs
460+---------
461
462 Sometimes you may want to simply edit a bug, without adding a comment.
463 For that you can send mails to edit@malone-domain.
464@@ -304,7 +320,8 @@
465 Nicer summary
466
467
468-== GPG signing and adding comments ==
469+GPG signing and adding comments
470+-------------------------------
471
472 In order to include commands in the comment, the email has to be GPG
473 signed. The key used to sign the email has to be associated with the
474@@ -322,7 +339,8 @@
475 will provide IWeaklyAuthenticatedPrincipal. Let's mark the current
476 principal with that.
477
478- >>> from canonical.launchpad.interfaces import IWeaklyAuthenticatedPrincipal
479+ >>> from canonical.launchpad.interfaces import (
480+ ... IWeaklyAuthenticatedPrincipal)
481 >>> from zope.interface import directlyProvides, directlyProvidedBy
482 >>> from zope.security.management import queryInteraction
483 >>> participations = queryInteraction().participations
484@@ -446,13 +464,14 @@
485 ... provided_interfaces - IWeaklyAuthenticatedPrincipal)
486
487
488-== Commands ==
489+Commands
490+--------
491
492 Now let's take a closer look at all the commands that are available for
493 us to play with. First we define a function to easily submit commands
494 to edit bug 4:
495
496- >>> def submit_commands(bug, *commands):
497+ >>> def construct_command_email(bug, *commands):
498 ... edit_mail = ("From: test@canonical.com\n"
499 ... "To: edit@malone-domain\n"
500 ... "Date: Fri Jun 17 10:10:23 BST 2005\n"
501@@ -460,12 +479,20 @@
502 ... "\n"
503 ... " bug %d\n" % bug.id)
504 ... edit_mail += ' ' + '\n '.join(commands)
505- ... process_email(edit_mail)
506+ ... return construct_email(edit_mail)
507+
508+ >>> def submit_command_email(msg):
509+ ... handler.process(msg, msg['To'])
510 ... commit()
511 ... sync(bug)
512
513-
514-=== bug $bugid ===
515+ >>> def submit_commands(bug, *commands):
516+ ... msg = construct_command_email(bug, *commands)
517+ ... submit_command_email(msg)
518+
519+
520+bug $bugid
521+~~~~~~~~~~
522
523 Switches what bug you want to edit. Example:
524
525@@ -509,7 +536,8 @@
526 ...
527
528
529-=== summary "$summary" ===
530+summary "$summary"
531+~~~~~~~~~~~~~~~~~~
532
533 Changes the summary of the bug. The title has to be enclosed in
534 quotes. Example:
535@@ -541,12 +569,13 @@
536 ...
537
538
539-=== private yes|no ===
540+private yes|no
541+~~~~~~~~~~~~~~
542
543 Changes the visibility of the bug. Example:
544
545-(We'll subscribe Sample Person to this bug before marking it private, otherwise
546-permission to complete the operation will be denied.)
547+(We'll subscribe Sample Person to this bug before marking it private,
548+otherwise permission to complete the operation will be denied.)
549
550 >>> subscription = bug_four.subscribe(bug_four.owner, bug_four.owner)
551
552@@ -598,7 +627,8 @@
553 ...
554
555
556-=== security yes|no ===
557+security yes|no
558+~~~~~~~~~~~~~~~
559
560 Changes the security flag of the bug. Example:
561
562@@ -647,7 +677,8 @@
563 security yes
564 ...
565
566-=== subscribe [$name|$email] ===
567+subscribe [$name|$email]
568+~~~~~~~~~~~~~~~~~~~~~~~~
569
570 Subscribes yourself or someone else to the bug. All arguments are
571 optional. If you don't specify a name, the sender of the email will
572@@ -688,7 +719,8 @@
573 non_existant@canonical.com
574 ...
575
576-=== unsubscribe [$name|$email] ===
577+unsubscribe [$name|$email]
578+~~~~~~~~~~~~~~~~~~~~~~~~~~
579
580 Unsubscribes yourself or someone else from the bug. If you don't
581 specify a name or email, the sender of the email will be
582@@ -782,7 +814,8 @@
583 >>> submit_commands(bug_four, 'subscribe test@canonical.com')
584
585
586-=== tag $tag ===
587+tag $tag
588+~~~~~~~~
589
590 The 'tag' command assigns a tag to a bug. Using this command we will add the
591 tags foo and bar to the bug. Adding a single tag multiple times should
592@@ -865,7 +898,8 @@
593 with-hyphen+period.
594
595
596-=== duplicate $bug_id ===
597+duplicate $bug_id
598+~~~~~~~~~~~~~~~~~
599
600 The 'duplicate' command marks a bug as a duplicate of another bug.
601
602@@ -927,11 +961,13 @@
603 ...
604
605
606-=== cve $cve ===
607+cve $cve
608+~~~~~~~~
609
610 The 'cve' command associates a bug with a CVE reference.
611
612- >>> from canonical.launchpad.interfaces import CreateBugParams, IProductSet
613+ >>> from canonical.launchpad.interfaces import (CreateBugParams,
614+ ... IProductSet)
615 >>> def new_firefox_bug():
616 ... firefox = getUtility(IProductSet).getByName('firefox')
617 ... return firefox.createBug(CreateBugParams(
618@@ -962,7 +998,8 @@
619 Launchpad can't find the CVE "no-such-cve".
620 ...
621
622-=== affects, assignee, status, importance, milestone ===
623+affects, assignee, status, importance, milestone
624+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
625
626 affects $path [assignee $name|$email|nobody]
627 [status $status]
628@@ -1189,11 +1226,12 @@
629 >>> LaunchpadZopelessLayer.switchDbUser('launchpad')
630 >>> login('foo.bar@canonical.com')
631 >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
632- >>> ubuntu.driver = getUtility(IPersonSet).getByEmail('test@canonical.com')
633+ >>> ubuntu.driver = getUtility(IPersonSet).getByEmail(
634+ ... 'test@canonical.com')
635 >>> commit()
636 >>> LaunchpadZopelessLayer.switchDbUser(config.processmail.dbuser)
637 >>> login('test@canonical.com')
638- >>> sync(bug)
639+ >>> sync(bug)
640
641 Now a new bugtask for the series will be create directly.
642
643@@ -1328,7 +1366,8 @@
644 Sample Person
645
646
647-=== Restricted bug statuses ===
648+Restricted bug statuses
649+~~~~~~~~~~~~~~~~~~~~~~~
650
651 >>> email_user = getUtility(ILaunchBag).user
652
653@@ -1409,7 +1448,8 @@
654 status foo
655 ...
656 The 'status' command expects any of the following arguments:
657- new, incomplete, opinion, invalid, wontfix, expired, confirmed, triaged, inprogress, fixcommitted, fixreleased
658+ new, incomplete, opinion, invalid, wontfix, expired, confirmed, triaged,
659+ inprogress, fixcommitted, fixreleased
660 <BLANKLINE>
661 For example:
662 <BLANKLINE>
663@@ -1435,8 +1475,8 @@
664 importance critical
665 ...
666
667-XXX mpt 20060516: "importance undecided" is a silly example, but customizing it
668-to a realistic value is difficult (see convertArguments in
669+XXX mpt 20060516: "importance undecided" is a silly example, but customizing
670+it to a realistic value is difficult (see convertArguments in
671 launchpad/mail/commands.py).
672
673 Trying to use the obsolete "severity" or "priority" commands:
674@@ -1451,8 +1491,8 @@
675 Failing command:
676 severity major
677 ...
678- To make life a little simpler, Malone no longer has "priority" and "severity"
679- fields. There is now an "importance" field...
680+ To make life a little simpler, Malone no longer has "priority" and
681+ "severity" fields. There is now an "importance" field...
682 ...
683
684 >>> submit_commands(bug_four, 'affects firefox', 'priority low')
685@@ -1464,8 +1504,8 @@
686 Failing command:
687 priority low
688 ...
689- To make life a little simpler, Malone no longer has "priority" and "severity"
690- fields. There is now an "importance" field...
691+ To make life a little simpler, Malone no longer has "priority" and
692+ "severity" fields. There is now an "importance" field...
693 ...
694
695 Invalid assignee:
696@@ -1486,7 +1526,8 @@
697 >>> stub.test_emails = []
698
699
700-== Multiple Commands ==
701+Multiple Commands
702+-----------------
703
704 An email can contain multiple commands, even for different bugs.
705
706@@ -1541,7 +1582,8 @@
707 >>> bugtask_created_listener.unregister()
708
709
710-== Default 'affects' target ==
711+Default 'affects' target
712+------------------------
713
714 Most of the time it's not necessary to give the 'affects' command. If
715 you omit it, the email interface tries to guess which bug task you
716@@ -1571,7 +1613,8 @@
717 the user wanted to edit. We apply the following heuristics for choosing
718 which bug task to edit:
719
720-=== The user is a bug supervisors of the upstream product ===
721+The user is a bug supervisors of the upstream product
722+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
723
724 >>> login('test@canonical.com')
725 >>> bug_one = getUtility(IBugSet).get(1)
726@@ -1598,7 +1641,8 @@
727 Status: New => Confirmed...
728
729
730-=== The user is a package bug supervisor ===
731+The user is a package bug supervisor
732+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
733
734 >>> from canonical.launchpad.interfaces import (
735 ... IDistributionSet, ISourcePackageNameSet)
736@@ -1638,12 +1682,14 @@
737 ** Changed in: mozilla-firefox (Ubuntu)
738 Status: New => Confirmed
739
740-=== The user is a bug supervisor of a distribution ===
741+The user is a bug supervisor of a distribution
742+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
743
744 XXX: TBD after InitialBugContacts is implemented.
745 -- Bjorn Tillenius, 2005-11-30
746
747-=== The user is a distribution member ===
748+The user is a distribution member
749+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
750
751 >>> login('foo.bar@canonical.com')
752 >>> submit_commands(
753@@ -1668,7 +1714,8 @@
754 Status: Confirmed => New
755
756
757-=== No matching bug task ===
758+No matching bug task
759+~~~~~~~~~~~~~~~~~~~~
760
761 If none of the bug tasks can be chosen, an error message is sent to the
762 user, telling him that he has to use the 'affects' command.
763@@ -1696,7 +1743,8 @@
764 ...
765
766
767-== More About Error Handling ==
768+More About Error Handling
769+-------------------------
770
771 If an error is encountered, an email is sent to the sender informing
772 him about the error. Let's start with trying to submit a bug without
773@@ -1706,6 +1754,7 @@
774
775 >>> from canonical.launchpad.mail import signed_message_from_string
776 >>> msg = signed_message_from_string(submit_mail)
777+ >>> import email.Utils
778 >>> msg['Message-Id'] = email.Utils.make_msgid()
779 >>> handler.process(msg, msg['To'])
780 True
781@@ -1788,9 +1837,10 @@
782 ... Subject: A bug with no affects
783 ...
784 ... I'm abusing ltsp-build-client to build a diskless fat client, but dint
785- ... of --late-packages ubuntu-desktop. The dpkg --configure step for eg. HAL
786- ... will try to start the daemon and failing, due to the lack of /proc. This
787- ... is just the tip of the iceberg; I'll file more bugs as I go along.
788+ ... of --late-packages ubuntu-desktop. The dpkg --configure step for eg.
789+ ... HAL will try to start the daemon and failing, due to the lack of
790+ ... /proc. This is just the tip of the iceberg; I'll file more bugs as I
791+ ... go along.
792 ... """
793
794 >>> process_email(submit_mail)
795@@ -1982,7 +2032,8 @@
796 Original message body.
797
798
799-== Error handling ==
800+Error handling
801+--------------
802
803 When creating a new task and assigning it to a team, it is possible
804 that the team will not have a contact address. This is not generally
805@@ -2023,7 +2074,8 @@
806 landscape-developers
807
808
809-== Recovering from errors ==
810+Recovering from errors
811+----------------------
812
813 When a user sends an email with multiple commands, some of them might
814 fail (because of bad arguments, for example). Some commands, namely
815@@ -2139,7 +2191,8 @@
816 or send an email to help@launchpad.net
817
818
819-== Terminating command input ==
820+Terminating command input
821+-------------------------
822
823 To make it possible to submit emails with lines that look like commands
824 (but aren't), a 'done' statement is provided. When the email parser
825@@ -2176,7 +2229,8 @@
826 CONFIRMED
827
828
829-== Requesting help ==
830+Requesting help
831+---------------
832
833 It's possible to ask for the help document for the email interface via
834 email too. Just send an email to `help@bugs.launchpad.net`.
835@@ -2231,10 +2285,11 @@
836 See you in {{{#launchpad}}}.
837
838
839-== Email attachments ==
840+Email attachments
841+-----------------
842
843-Email attachments are stored as bug attachments (provided that they
844-match the criteria described below).
845+Email attachments are stored as bug attachments (provided that they match the
846+criteria described below).
847
848 >>> def print_attachments(attachments):
849 ... if attachments.count() == 0:
850@@ -2479,9 +2534,9 @@
851 >>> process_email(submit_mail)
852 >>> print_attachments(get_latest_added_bug().attachments)
853 No attachments
854-
855-If an attachment has one of the content types application/applefile
856-(the resource fork of a MacOS file), application/pgp-signature,
857+
858+If an attachment has one of the content types application/applefile
859+(the resource fork of a MacOS file), application/pgp-signature,
860 application/pkcs7-signature, application/x-pkcs7-signature,
861 text/x-vcard, application/ms-tnef, it is not stored.
862
863@@ -2504,7 +2559,7 @@
864 ...
865 ... -----BEGIN PGP SIGNATURE-----
866 ... Version: GnuPG v1.4.6 (GNU/Linux)
867- ...
868+ ...
869 ... 123eetsdtdgdg43e4
870 ... -----END PGP SIGNATURE-----
871 ... --BOUNDARY
872@@ -2550,12 +2605,12 @@
873 ... affects firefox
874 ... --BOUNDARY
875 ... Content-type: multipart/appledouble; boundary="SUBBOUNDARY"
876- ...
877+ ...
878 ... --SUBBOUNDARY
879 ... Content-type: application/applefile
880 ... Content-disposition: attachment; filename="sampledata"
881 ... Content-tranfer-encoding: 7bit
882- ...
883+ ...
884 ... qwert
885 ... --SUBBOUNDARY
886 ... Content-type: text/plain
887@@ -2595,7 +2650,8 @@
888 ... --BOUNDARY"""
889 >>>
890 >>> process_email(submit_mail)
891- >>> new_message = getUtility(IMessageSet).get('comment-with-attachment')[0]
892+ >>> new_message = getUtility(IMessageSet).get(
893+ ... 'comment-with-attachment')[0]
894 >>> new_message in bug_one.messages
895 True
896 >>> print_attachments(new_message.bugattachments)
897@@ -2741,7 +2797,7 @@
898 ... Content-Transfer-Encoding: base64
899 ... X-Attachment-Id: f_fcuhv1fz0
900 ... Content-Disposition: attachment; filename=image.jpg
901- ...
902+ ...
903 ... dGhpcyBpcyBub3QgYSByZWFsIEpQRyBmaWxl==
904 ... --BOUNDARY"""
905 >>>
906@@ -2773,7 +2829,7 @@
907 ... Content-Transfer-Encoding: base64
908 ... X-Attachment-Id: f_fcuhv1fz0
909 ... Content-Disposition: attachment
910- ...
911+ ...
912 ... dGhpcyBpcyBub3QgYSByZWFsIEpQRyBmaWxl==
913 ... --BOUNDARY
914 ... Content-type: text/x-diff; name="sourcefile1.diff"
915@@ -2799,7 +2855,7 @@
916 >>> print_attachments(get_latest_added_bug().attachments)
917 LibraryFileAlias ... image/jpeg; name="image.jpg" UNSPECIFIED
918 this is not a real JPG file
919- LibraryFileAlias sourcefile.diff text/x-diff; name="sourcefile1.diff" PATCH
920+ LibraryFileAlias ... text/x-diff; name="sourcefile1.diff" PATCH
921 this should be diff output.
922
923
924@@ -2807,7 +2863,8 @@
925 -- Bjorn Tillenius, 2005-05-20
926
927
928-== Reply to a comment on a remote bug ==
929+Reply to a comment on a remote bug
930+----------------------------------
931
932 If someone uses the email interface to reply to a comment which was
933 imported into Launchpad from a remote bugtracker their reply will be