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
=== added file 'lib/canonical/launchpad/mail/errortemplates/old-signature.txt'
--- lib/canonical/launchpad/mail/errortemplates/old-signature.txt 1970-01-01 00:00:00 +0000
+++ lib/canonical/launchpad/mail/errortemplates/old-signature.txt 2010-08-18 18:27:52 +0000
@@ -0,0 +1,2 @@
1The message you sent included commands to modify the %(context)s, but the
2signature was (apparently) generated too far in the past or future.
03
=== modified file 'lib/canonical/launchpad/mail/handlers.py'
--- lib/canonical/launchpad/mail/handlers.py 2009-07-24 12:54:07 +0000
+++ lib/canonical/launchpad/mail/handlers.py 2010-08-18 18:27:52 +0000
@@ -27,7 +27,8 @@
27 BugEmailCommands, get_error_message)27 BugEmailCommands, get_error_message)
28from canonical.launchpad.mail.helpers import (28from canonical.launchpad.mail.helpers import (
29 ensure_not_weakly_authenticated, get_main_body, guess_bugtask,29 ensure_not_weakly_authenticated, get_main_body, guess_bugtask,
30 IncomingEmailError, parse_commands, reformat_wiki_text)30 IncomingEmailError, parse_commands, reformat_wiki_text,
31 ensure_sane_signature_timestamp)
31from lp.services.mail.sendmail import sendmail, simple_sendmail32from lp.services.mail.sendmail import sendmail, simple_sendmail
32from canonical.launchpad.mail.specexploder import get_spec_url_from_moin_mail33from canonical.launchpad.mail.specexploder import get_spec_url_from_moin_mail
33from canonical.launchpad.mailnotification import (34from canonical.launchpad.mailnotification import (
@@ -62,15 +63,19 @@
62 commands = self.getCommands(signed_msg)63 commands = self.getCommands(signed_msg)
63 user, host = to_addr.split('@')64 user, host = to_addr.split('@')
64 add_comment_to_bug = False65 add_comment_to_bug = False
66 signature = signed_msg.signature
6567
66 try:68 try:
67 if len(commands) > 0:69 if len(commands) > 0:
68 ensure_not_weakly_authenticated(signed_msg, 'bug report')70 CONTEXT = 'bug report'
71 ensure_not_weakly_authenticated(signed_msg, CONTEXT)
72 if signature is not None:
73 ensure_sane_signature_timestamp(signature, CONTEXT)
6974
70 if user.lower() == 'new':75 if user.lower() == 'new':
71 # A submit request.76 # A submit request.
72 commands.insert(0, BugEmailCommands.get('bug', ['new']))77 commands.insert(0, BugEmailCommands.get('bug', ['new']))
73 if signed_msg.signature is None:78 if signature is None:
74 raise IncomingEmailError(79 raise IncomingEmailError(
75 get_error_message('not-gpg-signed.txt'))80 get_error_message('not-gpg-signed.txt'))
76 elif user.isdigit():81 elif user.isdigit():
@@ -345,7 +350,7 @@
345 """350 """
346 if question.status in [351 if question.status in [
347 QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,352 QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
348 QuestionStatus.ANSWERED]:353 QuestionStatus.ANSWERED]:
349 question.giveAnswer(message.owner, message)354 question.giveAnswer(message.owner, message)
350 else:355 else:
351 # In the other states, only a comment can be added.356 # In the other states, only a comment can be added.
352357
=== modified file 'lib/canonical/launchpad/mail/helpers.py'
--- lib/canonical/launchpad/mail/helpers.py 2010-08-16 13:50:28 +0000
+++ lib/canonical/launchpad/mail/helpers.py 2010-08-18 18:27:52 +0000
@@ -5,6 +5,7 @@
55
6import os.path6import os.path
7import re7import re
8import time
89
9from zope.component import getUtility10from zope.component import getUtility
1011
@@ -74,7 +75,9 @@
74 <...IDistroSeriesBugTask>75 <...IDistroSeriesBugTask>
75 """76 """
76 bugtask_interfaces = [77 bugtask_interfaces = [
77 IUpstreamBugTask, IDistroBugTask, IDistroSeriesBugTask78 IUpstreamBugTask,
79 IDistroBugTask,
80 IDistroSeriesBugTask,
78 ]81 ]
79 for interface in bugtask_interfaces:82 for interface in bugtask_interfaces:
80 if interface.providedBy(bugtask):83 if interface.providedBy(bugtask):
@@ -134,6 +137,7 @@
134137
135 return text138 return text
136139
140
137def parse_commands(content, command_names):141def parse_commands(content, command_names):
138 """Extract indented commands from email body.142 """Extract indented commands from email body.
139143
@@ -220,3 +224,18 @@
220 no_key_template, import_url=import_url,224 no_key_template, import_url=import_url,
221 context=context)225 context=context)
222 raise IncomingEmailError(error_message)226 raise IncomingEmailError(error_message)
227
228
229def ensure_sane_signature_timestamp(signature, context,
230 error_template='old-signature.txt'):
231 """Ensure the signature was generated recently but not in the future."""
232 fourty_eight_hours = 48 * 60 * 60
233 ten_minutes = 10 * 60
234 now = time.time()
235 fourty_eight_hours_ago = now - fourty_eight_hours
236 ten_minutes_in_the_future = now + ten_minutes
237
238 if (signature.timestamp < fourty_eight_hours_ago
239 or signature.timestamp > ten_minutes_in_the_future):
240 error_message = get_error_message(error_template, context=context)
241 raise IncomingEmailError(error_message)
223242
=== modified file 'lib/canonical/launchpad/mail/tests/test_handlers.py'
--- lib/canonical/launchpad/mail/tests/test_handlers.py 2010-07-14 14:11:15 +0000
+++ lib/canonical/launchpad/mail/tests/test_handlers.py 2010-08-18 18:27:52 +0000
@@ -3,14 +3,18 @@
33
4__metaclass__ = type4__metaclass__ = type
55
6import email
7import time
6import unittest8import unittest
79
8from doctest import DocTestSuite10from doctest import DocTestSuite
911
12from canonical.database.sqlbase import commit
10from canonical.launchpad.mail.commands import BugEmailCommand13from canonical.launchpad.mail.commands import BugEmailCommand
11from canonical.launchpad.mail.handlers import MaloneHandler14from canonical.launchpad.mail.handlers import MaloneHandler
12from lp.testing import TestCaseWithFactory
13from canonical.testing import LaunchpadFunctionalLayer15from canonical.testing import LaunchpadFunctionalLayer
16from lp.services.mail import stub
17from lp.testing import TestCaseWithFactory, person_logged_in
1418
1519
16class TestMaloneHandler(TestCaseWithFactory):20class TestMaloneHandler(TestCaseWithFactory):
@@ -35,6 +39,88 @@
35 self.assertEqual(['foo'], commands[0].string_args)39 self.assertEqual(['foo'], commands[0].string_args)
3640
3741
42class FakeSignature:
43
44 def __init__(self, timestamp):
45 self.timestamp = timestamp
46
47
48def get_last_email():
49 from_addr, to_addrs, raw_message = stub.test_emails[-1]
50 sent_msg = email.message_from_string(raw_message)
51 error_mail, original_mail = sent_msg.get_payload()
52 # clear the emails so we don't accidentally get one from a previous test
53 return dict(
54 subject=sent_msg['Subject'],
55 body=error_mail.get_payload(decode=True))
56
57
58BAD_SIGNATURE_TIMESTAMP_MESSAGE = (
59 'The message you sent included commands to modify the bug '
60 'report, but the\nsignature was (apparently) generated too far '
61 'in the past or future.')
62
63
64class TestSignatureTimestampValidation(TestCaseWithFactory):
65 """GPG signature timestamps are checked for emails containing commands."""
66
67 layer = LaunchpadFunctionalLayer
68
69 def test_far_past_gpg_signature_timestamp(self):
70 # If an email message's GPG signature's timestamp is too far in the
71 # past and the message contains a command, the command will fail and
72 # send the user an email telling them what happened.
73
74 msg = self.factory.makeSignedMessage(body=' security no')
75 now = time.time()
76 one_week = 60 * 60 * 24 * 7
77 msg.signature = FakeSignature(timestamp=now-one_week)
78 handler = MaloneHandler()
79 with person_logged_in(self.factory.makePerson()):
80 success = handler.process(msg, msg['To'])
81 commit()
82 email = get_last_email()
83 self.assertEqual(email['subject'], 'Submit Request Failure')
84 self.assertIn(BAD_SIGNATURE_TIMESTAMP_MESSAGE, email['body'])
85
86 def test_far_future_gpg_signature_timestamp(self):
87 # If an email message's GPG signature's timestamp is too far in the
88 # future and the message contains a command, the command will fail and
89 # send the user an email telling them what happened.
90
91 msg = self.factory.makeSignedMessage(body=' security no')
92 now = time.time()
93 one_week = 60 * 60 * 24 * 7
94 msg.signature = FakeSignature(timestamp=now+one_week)
95 handler = MaloneHandler()
96 with person_logged_in(self.factory.makePerson()):
97 success = handler.process(msg, msg['To'])
98 commit()
99 email = get_last_email()
100 self.assertEqual(email['subject'], 'Submit Request Failure')
101 self.assertIn(BAD_SIGNATURE_TIMESTAMP_MESSAGE, email['body'])
102
103 def test_bad_timestamp_but_no_commands(self):
104 # If an email message's GPG signature's timestamp is too far in the
105 # future or past but it doesn't contain any commands, the email is
106 # processed anyway.
107
108 msg = self.factory.makeSignedMessage(
109 body='I really hope this bug gets fixed.')
110 now = time.time()
111 one_week = 60 * 60 * 24 * 7
112 msg.signature = FakeSignature(timestamp=now+one_week)
113 handler = MaloneHandler()
114 # Clear old emails before potentially generating more.
115 del stub.test_emails[:]
116 with person_logged_in(self.factory.makePerson()):
117 success = handler.process(msg, msg['To'])
118 commit()
119 # Since there were no commands in the poorly-timestamped message, no
120 # error emails were generated.
121 self.assertEqual(stub.test_emails, [])
122
123
38def test_suite():124def test_suite():
39 suite = unittest.TestSuite()125 suite = unittest.TestSuite()
40 suite.addTests(DocTestSuite('canonical.launchpad.mail.handlers'))126 suite.addTests(DocTestSuite('canonical.launchpad.mail.handlers'))
41127
=== modified file 'lib/canonical/launchpad/mail/tests/test_helpers.py'
--- lib/canonical/launchpad/mail/tests/test_helpers.py 2010-07-14 14:11:15 +0000
+++ lib/canonical/launchpad/mail/tests/test_helpers.py 2010-08-18 18:27:52 +0000
@@ -4,6 +4,7 @@
4__metaclass__ = type4__metaclass__ = type
55
6from doctest import DocTestSuite6from doctest import DocTestSuite
7import time
7import unittest8import unittest
89
9from zope.interface import directlyProvides, directlyProvidedBy10from zope.interface import directlyProvides, directlyProvidedBy
@@ -12,7 +13,7 @@
12 EmailProcessingError, IWeaklyAuthenticatedPrincipal)13 EmailProcessingError, IWeaklyAuthenticatedPrincipal)
13from canonical.launchpad.mail.helpers import (14from canonical.launchpad.mail.helpers import (
14 ensure_not_weakly_authenticated, get_person_or_team,15 ensure_not_weakly_authenticated, get_person_or_team,
15 IncomingEmailError, parse_commands)16 IncomingEmailError, parse_commands, ensure_sane_signature_timestamp)
16from lp.testing import login_person, TestCase, TestCaseWithFactory17from lp.testing import login_person, TestCase, TestCaseWithFactory
17from canonical.testing import DatabaseFunctionalLayer18from canonical.testing import DatabaseFunctionalLayer
18from canonical.launchpad.webapp.interaction import get_current_principal19from canonical.launchpad.webapp.interaction import get_current_principal
@@ -77,6 +78,50 @@
77 parse_commands(' command:', ['command']))78 parse_commands(' command:', ['command']))
7879
7980
81class FakeSignature:
82
83 def __init__(self, timestamp):
84 self.timestamp = timestamp
85
86
87class TestEnsureSaneSignatureTimestamp(unittest.TestCase):
88 """Tests for ensure_sane_signature_timestamp"""
89
90 def test_too_old_timestamp(self):
91 # signature timestamps shouldn't be too old
92 now = time.time()
93 one_week = 60 * 60 * 24 * 7
94 signature = FakeSignature(timestamp=now-one_week)
95 self.assertRaises(
96 IncomingEmailError, ensure_sane_signature_timestamp,
97 signature, 'bug report')
98
99 def test_future_timestamp(self):
100 # signature timestamps shouldn't be (far) in the future
101 now = time.time()
102 one_week = 60 * 60 * 24 * 7
103 signature = FakeSignature(timestamp=now+one_week)
104 self.assertRaises(
105 IncomingEmailError, ensure_sane_signature_timestamp,
106 signature, 'bug report')
107
108 def test_near_future_timestamp(self):
109 # signature timestamps in the near future are OK
110 now = time.time()
111 one_minute = 60
112 signature = FakeSignature(timestamp=now+one_minute)
113 # this should not raise an exception
114 ensure_sane_signature_timestamp(signature, 'bug report')
115
116 def test_recent_timestamp(self):
117 # signature timestamps in the recent past are OK
118 now = time.time()
119 one_hour = 60 * 60
120 signature = FakeSignature(timestamp=now-one_hour)
121 # this should not raise an exception
122 ensure_sane_signature_timestamp(signature, 'bug report')
123
124
80class TestEnsureNotWeaklyAuthenticated(TestCaseWithFactory):125class TestEnsureNotWeaklyAuthenticated(TestCaseWithFactory):
81 """Test the ensure_not_weakly_authenticated function."""126 """Test the ensure_not_weakly_authenticated function."""
82127
83128
=== modified file 'lib/canonical/launchpad/utilities/gpghandler.py'
--- lib/canonical/launchpad/utilities/gpghandler.py 2010-08-10 16:17:12 +0000
+++ lib/canonical/launchpad/utilities/gpghandler.py 2010-08-18 18:27:52 +0000
@@ -75,9 +75,9 @@
75 # automatically retrieve from the keyserver unknown key when75 # automatically retrieve from the keyserver unknown key when
76 # verifying signatures and 'no-auto-check-trustdb' avoid wasting76 # verifying signatures and 'no-auto-check-trustdb' avoid wasting
77 # time verifying the local keyring consistence.77 # time verifying the local keyring consistence.
78 conf.write ('keyserver hkp://%s\n'78 conf.write('keyserver hkp://%s\n'
79 'keyserver-options auto-key-retrieve\n'79 'keyserver-options auto-key-retrieve\n'
80 'no-auto-check-trustdb\n' % config.gpghandler.host)80 'no-auto-check-trustdb\n' % config.gpghandler.host)
81 conf.close()81 conf.close()
82 # create a local atexit handler to remove the configuration directory82 # create a local atexit handler to remove the configuration directory
83 # on normal termination.83 # on normal termination.
@@ -156,35 +156,26 @@
156 sig = StringIO(signature)156 sig = StringIO(signature)
157 # store the content157 # store the content
158 plain = StringIO(content)158 plain = StringIO(content)
159 # process it159 args = (sig, plain, None)
160 try:
161 signatures = ctx.verify(sig, plain, None)
162 except gpgme.GpgmeError, e:
163 # XXX: 2010-04-26, Salgado, bug=570244: This hack is needed
164 # for python2.5 compatibility. We should remove it when we no
165 # longer need to run on python2.5.
166 if hasattr(e, 'strerror'):
167 msg = e.strerror
168 else:
169 msg = e.message
170 raise GPGVerificationError(msg)
171 else:160 else:
172 # store clearsigned signature161 # store clearsigned signature
173 sig = StringIO(content)162 sig = StringIO(content)
174 # writeable content163 # writeable content
175 plain = StringIO()164 plain = StringIO()
176 # process it165 args = (sig, None, plain)
177 try:166
178 signatures = ctx.verify(sig, None, plain)167 # process it
179 except gpgme.GpgmeError, e:168 try:
180 # XXX: 2010-04-26, Salgado, bug=570244: This hack is needed169 signatures = ctx.verify(*args)
181 # for python2.5 compatibility. We should remove it when we no170 except gpgme.GpgmeError, e:
182 # longer need to run on python2.5.171 # XXX: 2010-04-26, Salgado, bug=570244: This hack is needed
183 if hasattr(e, 'strerror'):172 # for python2.5 compatibility. We should remove it when we no
184 msg = e.strerror173 # longer need to run on python2.5.
185 else:174 if hasattr(e, 'strerror'):
186 msg = e.message175 msg = e.strerror
187 raise GPGVerificationError(msg)176 else:
177 msg = e.message
178 raise GPGVerificationError(msg)
188179
189 # XXX jamesh 2006-01-31:180 # XXX jamesh 2006-01-31:
190 # We raise an exception if we don't get exactly one signature.181 # We raise an exception if we don't get exactly one signature.
@@ -204,7 +195,6 @@
204 'found multiple signatures')195 'found multiple signatures')
205196
206 signature = signatures[0]197 signature = signatures[0]
207
208 # signature.status == 0 means "Ok"198 # signature.status == 0 means "Ok"
209 if signature.status is not None:199 if signature.status is not None:
210 raise GPGVerificationError(signature.status.args)200 raise GPGVerificationError(signature.status.args)
@@ -295,8 +285,7 @@
295 # See more information at:285 # See more information at:
296 # http://pyme.sourceforge.net/doc/gpgme/Generating-Keys.html286 # http://pyme.sourceforge.net/doc/gpgme/Generating-Keys.html
297 result = context.genkey(287 result = context.genkey(
298 signing_only_param % {'name': name.encode('utf-8')}288 signing_only_param % {'name': name.encode('utf-8')})
299 )
300289
301 # Right, it might seem paranoid to have this many assertions,290 # Right, it might seem paranoid to have this many assertions,
302 # but we have to take key generation very seriously.291 # but we have to take key generation very seriously.
@@ -477,8 +466,8 @@
477 """See IGPGHandler"""466 """See IGPGHandler"""
478 params = {467 params = {
479 'search': '0x%s' % fingerprint,468 'search': '0x%s' % fingerprint,
480 'op': action469 'op': action,
481 }470 }
482 if public:471 if public:
483 host = config.gpghandler.public_host472 host = config.gpghandler.public_host
484 else:473 else:
485474
=== modified file 'lib/lp/bugs/tests/bugs-emailinterface.txt'
--- lib/lp/bugs/tests/bugs-emailinterface.txt 2010-08-02 17:48:13 +0000
+++ lib/lp/bugs/tests/bugs-emailinterface.txt 2010-08-18 18:27:52 +0000
@@ -1,11 +1,13 @@
1= Launchpad Bugs e-mail interface =1Launchpad Bugs e-mail interface
2===============================
23
3Launchpad's bugtracker has an e-mail interface, with which you may report new4Launchpad's bugtracker has an e-mail interface, with which you may report new
4bugs, add comments, and change the details of existing bug reports. Commands5bugs, add comments, and change the details of existing bug reports. Commands
5can be interleaved within a comment, so to distinguish them from the comment,6can be interleaved within a comment, so to distinguish them from the comment,
6they must be indented with at least one space or tab character.7they must be indented with at least one space or tab character.
78
8== Submit a new bug ==9Submit a new bug
10----------------
911
10To report a bug, you send an OpenPGP-signed e-mail message to12To report a bug, you send an OpenPGP-signed e-mail message to
11new@bugs.launchpad-domain. You must have registered your key in13new@bugs.launchpad-domain. You must have registered your key in
@@ -48,12 +50,19 @@
48signed, so that the system can verify the sender. But to avoid having50signed, so that the system can verify the sender. But to avoid having
49to sign each email, we'll create a class which fakes a signed email:51to sign each email, we'll create a class which fakes a signed email:
5052
51 >>> import email.Utils53 >>> import time
54 >>> class MockSignature(object):
55 ... def __init__(self):
56 ... self.timestamp = time.time()
57
58 >>> import email.Message
52 >>> class MockSignedMessage(email.Message.Message):59 >>> class MockSignedMessage(email.Message.Message):
60 ... def __init__(self, *args, **kws):
61 ... email.Message.Message.__init__(self, *args, **kws)
62 ... self.signature = MockSignature()
53 ... @property63 ... @property
54 ... def signedMessage(self):64 ... def signedMessage(self):
55 ... return self65 ... return self
56 ... signature = object()
5766
58And since we'll pass the email directly to the correct handler,67And since we'll pass the email directly to the correct handler,
59we'll have to authenticate the user manually:68we'll have to authenticate the user manually:
@@ -66,10 +75,15 @@
6675
67 >>> from canonical.launchpad.mail.handlers import MaloneHandler76 >>> from canonical.launchpad.mail.handlers import MaloneHandler
68 >>> handler = MaloneHandler()77 >>> handler = MaloneHandler()
69 >>> def process_email(raw_mail):78 >>> def construct_email(raw_mail):
70 ... msg = email.message_from_string(raw_mail, _class=MockSignedMessage)79 ... msg = email.message_from_string(
80 ... raw_mail, _class=MockSignedMessage)
71 ... if not msg.has_key('Message-Id'):81 ... if not msg.has_key('Message-Id'):
72 ... msg['Message-Id'] = factory.makeUniqueRFC822MsgId()82 ... msg['Message-Id'] = factory.makeUniqueRFC822MsgId()
83 ... return msg
84
85 >>> def process_email(raw_mail):
86 ... msg = construct_email(raw_mail)
73 ... handler.process(msg, msg['To'])87 ... handler.process(msg, msg['To'])
7488
75 >>> process_email(submit_mail)89 >>> process_email(submit_mail)
@@ -224,7 +238,8 @@
224 u'A folded email subject'238 u'A folded email subject'
225239
226240
227== Add a comment ==241Add a comment
242-------------
228243
229After a bug has been submitted a notification is sent out. The reply-to244After a bug has been submitted a notification is sent out. The reply-to
230address is set to the bug address, $bugid@malone-domain. We can send245address is set to the bug address, $bugid@malone-domain. We can send
@@ -263,7 +278,8 @@
263 True278 True
264279
265280
266== Edit bugs ==281Edit bugs
282---------
267283
268Sometimes you may want to simply edit a bug, without adding a comment.284Sometimes you may want to simply edit a bug, without adding a comment.
269For that you can send mails to edit@malone-domain.285For that you can send mails to edit@malone-domain.
@@ -304,7 +320,8 @@
304 Nicer summary320 Nicer summary
305321
306322
307== GPG signing and adding comments ==323GPG signing and adding comments
324-------------------------------
308325
309In order to include commands in the comment, the email has to be GPG326In order to include commands in the comment, the email has to be GPG
310signed. The key used to sign the email has to be associated with the327signed. The key used to sign the email has to be associated with the
@@ -322,7 +339,8 @@
322will provide IWeaklyAuthenticatedPrincipal. Let's mark the current339will provide IWeaklyAuthenticatedPrincipal. Let's mark the current
323principal with that.340principal with that.
324341
325 >>> from canonical.launchpad.interfaces import IWeaklyAuthenticatedPrincipal342 >>> from canonical.launchpad.interfaces import (
343 ... IWeaklyAuthenticatedPrincipal)
326 >>> from zope.interface import directlyProvides, directlyProvidedBy344 >>> from zope.interface import directlyProvides, directlyProvidedBy
327 >>> from zope.security.management import queryInteraction345 >>> from zope.security.management import queryInteraction
328 >>> participations = queryInteraction().participations346 >>> participations = queryInteraction().participations
@@ -446,13 +464,14 @@
446 ... provided_interfaces - IWeaklyAuthenticatedPrincipal)464 ... provided_interfaces - IWeaklyAuthenticatedPrincipal)
447465
448466
449== Commands == 467Commands
468--------
450469
451Now let's take a closer look at all the commands that are available for470Now let's take a closer look at all the commands that are available for
452us to play with. First we define a function to easily submit commands471us to play with. First we define a function to easily submit commands
453to edit bug 4:472to edit bug 4:
454473
455 >>> def submit_commands(bug, *commands):474 >>> def construct_command_email(bug, *commands):
456 ... edit_mail = ("From: test@canonical.com\n"475 ... edit_mail = ("From: test@canonical.com\n"
457 ... "To: edit@malone-domain\n"476 ... "To: edit@malone-domain\n"
458 ... "Date: Fri Jun 17 10:10:23 BST 2005\n"477 ... "Date: Fri Jun 17 10:10:23 BST 2005\n"
@@ -460,12 +479,20 @@
460 ... "\n"479 ... "\n"
461 ... " bug %d\n" % bug.id)480 ... " bug %d\n" % bug.id)
462 ... edit_mail += ' ' + '\n '.join(commands)481 ... edit_mail += ' ' + '\n '.join(commands)
463 ... process_email(edit_mail)482 ... return construct_email(edit_mail)
483
484 >>> def submit_command_email(msg):
485 ... handler.process(msg, msg['To'])
464 ... commit()486 ... commit()
465 ... sync(bug)487 ... sync(bug)
466488
467489 >>> def submit_commands(bug, *commands):
468=== bug $bugid ===490 ... msg = construct_command_email(bug, *commands)
491 ... submit_command_email(msg)
492
493
494bug $bugid
495~~~~~~~~~~
469496
470Switches what bug you want to edit. Example:497Switches what bug you want to edit. Example:
471498
@@ -509,7 +536,8 @@
509 ...536 ...
510537
511538
512=== summary "$summary" ===539summary "$summary"
540~~~~~~~~~~~~~~~~~~
513541
514Changes the summary of the bug. The title has to be enclosed in542Changes the summary of the bug. The title has to be enclosed in
515quotes. Example:543quotes. Example:
@@ -541,12 +569,13 @@
541 ...569 ...
542570
543571
544=== private yes|no ===572private yes|no
573~~~~~~~~~~~~~~
545574
546Changes the visibility of the bug. Example:575Changes the visibility of the bug. Example:
547576
548(We'll subscribe Sample Person to this bug before marking it private, otherwise577(We'll subscribe Sample Person to this bug before marking it private,
549permission to complete the operation will be denied.)578otherwise permission to complete the operation will be denied.)
550579
551 >>> subscription = bug_four.subscribe(bug_four.owner, bug_four.owner)580 >>> subscription = bug_four.subscribe(bug_four.owner, bug_four.owner)
552581
@@ -598,7 +627,8 @@
598 ...627 ...
599628
600629
601=== security yes|no ===630security yes|no
631~~~~~~~~~~~~~~~
602632
603Changes the security flag of the bug. Example:633Changes the security flag of the bug. Example:
604634
@@ -647,7 +677,8 @@
647 security yes677 security yes
648 ...678 ...
649679
650=== subscribe [$name|$email] ===680subscribe [$name|$email]
681~~~~~~~~~~~~~~~~~~~~~~~~
651682
652Subscribes yourself or someone else to the bug. All arguments are683Subscribes yourself or someone else to the bug. All arguments are
653optional. If you don't specify a name, the sender of the email will684optional. If you don't specify a name, the sender of the email will
@@ -688,7 +719,8 @@
688 non_existant@canonical.com719 non_existant@canonical.com
689 ...720 ...
690721
691=== unsubscribe [$name|$email] ===722unsubscribe [$name|$email]
723~~~~~~~~~~~~~~~~~~~~~~~~~~
692724
693Unsubscribes yourself or someone else from the bug. If you don't725Unsubscribes yourself or someone else from the bug. If you don't
694specify a name or email, the sender of the email will be726specify a name or email, the sender of the email will be
@@ -782,7 +814,8 @@
782 >>> submit_commands(bug_four, 'subscribe test@canonical.com')814 >>> submit_commands(bug_four, 'subscribe test@canonical.com')
783815
784816
785=== tag $tag ===817tag $tag
818~~~~~~~~
786819
787The 'tag' command assigns a tag to a bug. Using this command we will add the820The 'tag' command assigns a tag to a bug. Using this command we will add the
788tags foo and bar to the bug. Adding a single tag multiple times should821tags foo and bar to the bug. Adding a single tag multiple times should
@@ -865,7 +898,8 @@
865 with-hyphen+period.898 with-hyphen+period.
866899
867900
868=== duplicate $bug_id ===901duplicate $bug_id
902~~~~~~~~~~~~~~~~~
869903
870The 'duplicate' command marks a bug as a duplicate of another bug.904The 'duplicate' command marks a bug as a duplicate of another bug.
871905
@@ -927,11 +961,13 @@
927 ...961 ...
928962
929963
930=== cve $cve ===964cve $cve
965~~~~~~~~
931966
932The 'cve' command associates a bug with a CVE reference.967The 'cve' command associates a bug with a CVE reference.
933968
934 >>> from canonical.launchpad.interfaces import CreateBugParams, IProductSet969 >>> from canonical.launchpad.interfaces import (CreateBugParams,
970 ... IProductSet)
935 >>> def new_firefox_bug():971 >>> def new_firefox_bug():
936 ... firefox = getUtility(IProductSet).getByName('firefox')972 ... firefox = getUtility(IProductSet).getByName('firefox')
937 ... return firefox.createBug(CreateBugParams(973 ... return firefox.createBug(CreateBugParams(
@@ -962,7 +998,8 @@
962 Launchpad can't find the CVE "no-such-cve".998 Launchpad can't find the CVE "no-such-cve".
963 ...999 ...
9641000
965=== affects, assignee, status, importance, milestone ===1001affects, assignee, status, importance, milestone
1002~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
9661003
967affects $path [assignee $name|$email|nobody]1004affects $path [assignee $name|$email|nobody]
968 [status $status]1005 [status $status]
@@ -1189,11 +1226,12 @@
1189 >>> LaunchpadZopelessLayer.switchDbUser('launchpad')1226 >>> LaunchpadZopelessLayer.switchDbUser('launchpad')
1190 >>> login('foo.bar@canonical.com')1227 >>> login('foo.bar@canonical.com')
1191 >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')1228 >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
1192 >>> ubuntu.driver = getUtility(IPersonSet).getByEmail('test@canonical.com')1229 >>> ubuntu.driver = getUtility(IPersonSet).getByEmail(
1230 ... 'test@canonical.com')
1193 >>> commit()1231 >>> commit()
1194 >>> LaunchpadZopelessLayer.switchDbUser(config.processmail.dbuser)1232 >>> LaunchpadZopelessLayer.switchDbUser(config.processmail.dbuser)
1195 >>> login('test@canonical.com')1233 >>> login('test@canonical.com')
1196 >>> sync(bug)1234 >>> sync(bug)
11971235
1198Now a new bugtask for the series will be create directly.1236Now a new bugtask for the series will be create directly.
11991237
@@ -1328,7 +1366,8 @@
1328 Sample Person1366 Sample Person
13291367
13301368
1331=== Restricted bug statuses ===1369Restricted bug statuses
1370~~~~~~~~~~~~~~~~~~~~~~~
13321371
1333 >>> email_user = getUtility(ILaunchBag).user1372 >>> email_user = getUtility(ILaunchBag).user
13341373
@@ -1409,7 +1448,8 @@
1409 status foo1448 status foo
1410 ...1449 ...
1411 The 'status' command expects any of the following arguments:1450 The 'status' command expects any of the following arguments:
1412 new, incomplete, opinion, invalid, wontfix, expired, confirmed, triaged, inprogress, fixcommitted, fixreleased1451 new, incomplete, opinion, invalid, wontfix, expired, confirmed, triaged,
1452 inprogress, fixcommitted, fixreleased
1413 <BLANKLINE>1453 <BLANKLINE>
1414 For example:1454 For example:
1415 <BLANKLINE>1455 <BLANKLINE>
@@ -1435,8 +1475,8 @@
1435 importance critical1475 importance critical
1436 ...1476 ...
14371477
1438XXX mpt 20060516: "importance undecided" is a silly example, but customizing it1478XXX mpt 20060516: "importance undecided" is a silly example, but customizing
1439to a realistic value is difficult (see convertArguments in1479it to a realistic value is difficult (see convertArguments in
1440launchpad/mail/commands.py).1480launchpad/mail/commands.py).
14411481
1442Trying to use the obsolete "severity" or "priority" commands:1482Trying to use the obsolete "severity" or "priority" commands:
@@ -1451,8 +1491,8 @@
1451 Failing command:1491 Failing command:
1452 severity major1492 severity major
1453 ...1493 ...
1454 To make life a little simpler, Malone no longer has "priority" and "severity"1494 To make life a little simpler, Malone no longer has "priority" and
1455 fields. There is now an "importance" field...1495 "severity" fields. There is now an "importance" field...
1456 ...1496 ...
14571497
1458 >>> submit_commands(bug_four, 'affects firefox', 'priority low')1498 >>> submit_commands(bug_four, 'affects firefox', 'priority low')
@@ -1464,8 +1504,8 @@
1464 Failing command:1504 Failing command:
1465 priority low1505 priority low
1466 ...1506 ...
1467 To make life a little simpler, Malone no longer has "priority" and "severity"1507 To make life a little simpler, Malone no longer has "priority" and
1468 fields. There is now an "importance" field...1508 "severity" fields. There is now an "importance" field...
1469 ...1509 ...
14701510
1471Invalid assignee:1511Invalid assignee:
@@ -1486,7 +1526,8 @@
1486 >>> stub.test_emails = []1526 >>> stub.test_emails = []
14871527
14881528
1489== Multiple Commands ==1529Multiple Commands
1530-----------------
14901531
1491An email can contain multiple commands, even for different bugs.1532An email can contain multiple commands, even for different bugs.
14921533
@@ -1541,7 +1582,8 @@
1541 >>> bugtask_created_listener.unregister()1582 >>> bugtask_created_listener.unregister()
15421583
15431584
1544== Default 'affects' target ==1585Default 'affects' target
1586------------------------
15451587
1546Most of the time it's not necessary to give the 'affects' command. If1588Most of the time it's not necessary to give the 'affects' command. If
1547you omit it, the email interface tries to guess which bug task you1589you omit it, the email interface tries to guess which bug task you
@@ -1571,7 +1613,8 @@
1571the user wanted to edit. We apply the following heuristics for choosing1613the user wanted to edit. We apply the following heuristics for choosing
1572which bug task to edit:1614which bug task to edit:
15731615
1574=== The user is a bug supervisors of the upstream product ===1616The user is a bug supervisors of the upstream product
1617~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
15751618
1576 >>> login('test@canonical.com')1619 >>> login('test@canonical.com')
1577 >>> bug_one = getUtility(IBugSet).get(1)1620 >>> bug_one = getUtility(IBugSet).get(1)
@@ -1598,7 +1641,8 @@
1598 Status: New => Confirmed...1641 Status: New => Confirmed...
15991642
16001643
1601=== The user is a package bug supervisor ===1644The user is a package bug supervisor
1645~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
16021646
1603 >>> from canonical.launchpad.interfaces import (1647 >>> from canonical.launchpad.interfaces import (
1604 ... IDistributionSet, ISourcePackageNameSet)1648 ... IDistributionSet, ISourcePackageNameSet)
@@ -1638,12 +1682,14 @@
1638 ** Changed in: mozilla-firefox (Ubuntu)1682 ** Changed in: mozilla-firefox (Ubuntu)
1639 Status: New => Confirmed1683 Status: New => Confirmed
16401684
1641=== The user is a bug supervisor of a distribution ===1685The user is a bug supervisor of a distribution
1686~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
16421687
1643XXX: TBD after InitialBugContacts is implemented.1688XXX: TBD after InitialBugContacts is implemented.
1644 -- Bjorn Tillenius, 2005-11-301689 -- Bjorn Tillenius, 2005-11-30
16451690
1646=== The user is a distribution member ===1691The user is a distribution member
1692~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
16471693
1648 >>> login('foo.bar@canonical.com')1694 >>> login('foo.bar@canonical.com')
1649 >>> submit_commands(1695 >>> submit_commands(
@@ -1668,7 +1714,8 @@
1668 Status: Confirmed => New1714 Status: Confirmed => New
16691715
16701716
1671=== No matching bug task ===1717No matching bug task
1718~~~~~~~~~~~~~~~~~~~~
16721719
1673If none of the bug tasks can be chosen, an error message is sent to the1720If none of the bug tasks can be chosen, an error message is sent to the
1674user, telling him that he has to use the 'affects' command.1721user, telling him that he has to use the 'affects' command.
@@ -1696,7 +1743,8 @@
1696 ...1743 ...
16971744
16981745
1699== More About Error Handling ==1746More About Error Handling
1747-------------------------
17001748
1701If an error is encountered, an email is sent to the sender informing1749If an error is encountered, an email is sent to the sender informing
1702him about the error. Let's start with trying to submit a bug without1750him about the error. Let's start with trying to submit a bug without
@@ -1706,6 +1754,7 @@
17061754
1707 >>> from canonical.launchpad.mail import signed_message_from_string1755 >>> from canonical.launchpad.mail import signed_message_from_string
1708 >>> msg = signed_message_from_string(submit_mail)1756 >>> msg = signed_message_from_string(submit_mail)
1757 >>> import email.Utils
1709 >>> msg['Message-Id'] = email.Utils.make_msgid()1758 >>> msg['Message-Id'] = email.Utils.make_msgid()
1710 >>> handler.process(msg, msg['To'])1759 >>> handler.process(msg, msg['To'])
1711 True1760 True
@@ -1788,9 +1837,10 @@
1788 ... Subject: A bug with no affects1837 ... Subject: A bug with no affects
1789 ...1838 ...
1790 ... I'm abusing ltsp-build-client to build a diskless fat client, but dint1839 ... I'm abusing ltsp-build-client to build a diskless fat client, but dint
1791 ... of --late-packages ubuntu-desktop. The dpkg --configure step for eg. HAL1840 ... of --late-packages ubuntu-desktop. The dpkg --configure step for eg.
1792 ... will try to start the daemon and failing, due to the lack of /proc. This1841 ... HAL will try to start the daemon and failing, due to the lack of
1793 ... is just the tip of the iceberg; I'll file more bugs as I go along.1842 ... /proc. This is just the tip of the iceberg; I'll file more bugs as I
1843 ... go along.
1794 ... """1844 ... """
17951845
1796 >>> process_email(submit_mail)1846 >>> process_email(submit_mail)
@@ -1982,7 +2032,8 @@
1982 Original message body.2032 Original message body.
19832033
19842034
1985== Error handling ==2035Error handling
2036--------------
19862037
1987When creating a new task and assigning it to a team, it is possible2038When creating a new task and assigning it to a team, it is possible
1988that the team will not have a contact address. This is not generally2039that the team will not have a contact address. This is not generally
@@ -2023,7 +2074,8 @@
2023 landscape-developers2074 landscape-developers
20242075
20252076
2026== Recovering from errors ==2077Recovering from errors
2078----------------------
20272079
2028When a user sends an email with multiple commands, some of them might2080When a user sends an email with multiple commands, some of them might
2029fail (because of bad arguments, for example). Some commands, namely2081fail (because of bad arguments, for example). Some commands, namely
@@ -2139,7 +2191,8 @@
2139 or send an email to help@launchpad.net2191 or send an email to help@launchpad.net
21402192
21412193
2142== Terminating command input ==2194Terminating command input
2195-------------------------
21432196
2144To make it possible to submit emails with lines that look like commands2197To make it possible to submit emails with lines that look like commands
2145(but aren't), a 'done' statement is provided. When the email parser2198(but aren't), a 'done' statement is provided. When the email parser
@@ -2176,7 +2229,8 @@
2176 CONFIRMED2229 CONFIRMED
21772230
21782231
2179== Requesting help ==2232Requesting help
2233---------------
21802234
2181It's possible to ask for the help document for the email interface via2235It's possible to ask for the help document for the email interface via
2182email too. Just send an email to `help@bugs.launchpad.net`.2236email too. Just send an email to `help@bugs.launchpad.net`.
@@ -2231,10 +2285,11 @@
2231 See you in {{{#launchpad}}}.2285 See you in {{{#launchpad}}}.
22322286
22332287
2234== Email attachments ==2288Email attachments
2289-----------------
22352290
2236Email attachments are stored as bug attachments (provided that they 2291Email attachments are stored as bug attachments (provided that they match the
2237match the criteria described below).2292criteria described below).
22382293
2239 >>> def print_attachments(attachments):2294 >>> def print_attachments(attachments):
2240 ... if attachments.count() == 0:2295 ... if attachments.count() == 0:
@@ -2479,9 +2534,9 @@
2479 >>> process_email(submit_mail)2534 >>> process_email(submit_mail)
2480 >>> print_attachments(get_latest_added_bug().attachments)2535 >>> print_attachments(get_latest_added_bug().attachments)
2481 No attachments2536 No attachments
2482 2537
2483If an attachment has one of the content types application/applefile 2538If an attachment has one of the content types application/applefile
2484(the resource fork of a MacOS file), application/pgp-signature, 2539(the resource fork of a MacOS file), application/pgp-signature,
2485application/pkcs7-signature, application/x-pkcs7-signature,2540application/pkcs7-signature, application/x-pkcs7-signature,
2486text/x-vcard, application/ms-tnef, it is not stored.2541text/x-vcard, application/ms-tnef, it is not stored.
24872542
@@ -2504,7 +2559,7 @@
2504 ...2559 ...
2505 ... -----BEGIN PGP SIGNATURE-----2560 ... -----BEGIN PGP SIGNATURE-----
2506 ... Version: GnuPG v1.4.6 (GNU/Linux)2561 ... Version: GnuPG v1.4.6 (GNU/Linux)
2507 ... 2562 ...
2508 ... 123eetsdtdgdg43e42563 ... 123eetsdtdgdg43e4
2509 ... -----END PGP SIGNATURE-----2564 ... -----END PGP SIGNATURE-----
2510 ... --BOUNDARY2565 ... --BOUNDARY
@@ -2550,12 +2605,12 @@
2550 ... affects firefox2605 ... affects firefox
2551 ... --BOUNDARY2606 ... --BOUNDARY
2552 ... Content-type: multipart/appledouble; boundary="SUBBOUNDARY"2607 ... Content-type: multipart/appledouble; boundary="SUBBOUNDARY"
2553 ... 2608 ...
2554 ... --SUBBOUNDARY2609 ... --SUBBOUNDARY
2555 ... Content-type: application/applefile2610 ... Content-type: application/applefile
2556 ... Content-disposition: attachment; filename="sampledata"2611 ... Content-disposition: attachment; filename="sampledata"
2557 ... Content-tranfer-encoding: 7bit2612 ... Content-tranfer-encoding: 7bit
2558 ... 2613 ...
2559 ... qwert2614 ... qwert
2560 ... --SUBBOUNDARY2615 ... --SUBBOUNDARY
2561 ... Content-type: text/plain2616 ... Content-type: text/plain
@@ -2595,7 +2650,8 @@
2595 ... --BOUNDARY"""2650 ... --BOUNDARY"""
2596 >>>2651 >>>
2597 >>> process_email(submit_mail)2652 >>> process_email(submit_mail)
2598 >>> new_message = getUtility(IMessageSet).get('comment-with-attachment')[0]2653 >>> new_message = getUtility(IMessageSet).get(
2654 ... 'comment-with-attachment')[0]
2599 >>> new_message in bug_one.messages2655 >>> new_message in bug_one.messages
2600 True2656 True
2601 >>> print_attachments(new_message.bugattachments)2657 >>> print_attachments(new_message.bugattachments)
@@ -2741,7 +2797,7 @@
2741 ... Content-Transfer-Encoding: base642797 ... Content-Transfer-Encoding: base64
2742 ... X-Attachment-Id: f_fcuhv1fz02798 ... X-Attachment-Id: f_fcuhv1fz0
2743 ... Content-Disposition: attachment; filename=image.jpg2799 ... Content-Disposition: attachment; filename=image.jpg
2744 ... 2800 ...
2745 ... dGhpcyBpcyBub3QgYSByZWFsIEpQRyBmaWxl==2801 ... dGhpcyBpcyBub3QgYSByZWFsIEpQRyBmaWxl==
2746 ... --BOUNDARY"""2802 ... --BOUNDARY"""
2747 >>>2803 >>>
@@ -2773,7 +2829,7 @@
2773 ... Content-Transfer-Encoding: base642829 ... Content-Transfer-Encoding: base64
2774 ... X-Attachment-Id: f_fcuhv1fz02830 ... X-Attachment-Id: f_fcuhv1fz0
2775 ... Content-Disposition: attachment2831 ... Content-Disposition: attachment
2776 ... 2832 ...
2777 ... dGhpcyBpcyBub3QgYSByZWFsIEpQRyBmaWxl==2833 ... dGhpcyBpcyBub3QgYSByZWFsIEpQRyBmaWxl==
2778 ... --BOUNDARY2834 ... --BOUNDARY
2779 ... Content-type: text/x-diff; name="sourcefile1.diff"2835 ... Content-type: text/x-diff; name="sourcefile1.diff"
@@ -2799,7 +2855,7 @@
2799 >>> print_attachments(get_latest_added_bug().attachments)2855 >>> print_attachments(get_latest_added_bug().attachments)
2800 LibraryFileAlias ... image/jpeg; name="image.jpg" UNSPECIFIED2856 LibraryFileAlias ... image/jpeg; name="image.jpg" UNSPECIFIED
2801 this is not a real JPG file2857 this is not a real JPG file
2802 LibraryFileAlias sourcefile.diff text/x-diff; name="sourcefile1.diff" PATCH2858 LibraryFileAlias ... text/x-diff; name="sourcefile1.diff" PATCH
2803 this should be diff output.2859 this should be diff output.
28042860
28052861
@@ -2807,7 +2863,8 @@
2807 -- Bjorn Tillenius, 2005-05-202863 -- Bjorn Tillenius, 2005-05-20
28082864
28092865
2810== Reply to a comment on a remote bug ==2866Reply to a comment on a remote bug
2867----------------------------------
28112868
2812If someone uses the email interface to reply to a comment which was2869If someone uses the email interface to reply to a comment which was
2813imported into Launchpad from a remote bugtracker their reply will be2870imported into Launchpad from a remote bugtracker their reply will be