1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
|
Incoming Mail
=============
When an email is sent to Launchpad we need to handle it somehow. This
is done by handleEmails:
>>> from lp.services.mail.incoming import handleMail
Basically what it does is to open the Launchpad mail box, and for each
message it:
* Authenticates the sender
* Finds the correct mail handler
* Lets the handler process the message
* Deletes the message from the mail box
-------------
Mail Handlers
-------------
A mail handler is a utility which knows how to handle mail sent to a
specific domain. It is registered as a named utility providing
IMailHandler. The name of the utility is the domain that's handled.
Let's create some utilities which keep track of which mails they
handle, and register them for some domains:
>>> from zope.interface import implements
>>> from canonical.launchpad.interfaces.mail import IMailHandler
>>> class MockHandler:
... implements(IMailHandler)
... def __init__(self, allow_unknown_users=False):
... self.allow_unknown_users = allow_unknown_users
... self.handledMails = []
... def process(self, mail, to_addr, filealias):
... self.handledMails.append(mail['Message-Id'])
... return True
>>> 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)
>>> mail_handlers.add('bar.com', bar_handler)
Now we send a few test mails to foo.com, bar.com, and baz.com:
>>> from canonical.database.sqlbase import commit
>>> from canonical.launchpad.mail.ftests import read_test_message
>>> from canonical.testing.layers import LaunchpadZopelessLayer
>>> from canonical.launchpad.mail import sendmail as original_sendmail
For these examples, we don't want the Precedence header added. Domains
are treated without regard to case: for incoming mail, foo.com and
FOO.COM are treated equivalently.
>>> def sendmail(msg, to_addrs=None):
... return original_sendmail(msg, to_addrs=to_addrs, bulk=False)
>>> LaunchpadZopelessLayer.switchDbUser('launchpad')
>>> msgids = {'foo.com': [], 'bar.com': [], 'baz.com': []}
>>> for domain in ('foo.com', 'bar.com', 'FOO.COM', 'baz.com'):
... msg = read_test_message('signed_detached.txt')
... msg.replace_header('To', '123@%s' % domain)
... msgids[domain.lower()].append("<%s>" % sendmail(msg))
handleMail will check the timestamp on signed messages, but the signatures
on our testdata are old, and in these tests we don't care to be told.
>>> def accept_any_timestamp(timestamp, context_message):
... pass
Since the User gets authenticated using OpenPGP signatures we have to
import the keys before handleMail is called.
>>> from canonical.config import config
>>> from canonical.launchpad.ftests import import_public_test_keys
>>> import_public_test_keys()
>>> commit()
>>> LaunchpadZopelessLayer.switchDbUser(config.processmail.dbuser)
>>> zopeless_transaction = LaunchpadZopelessLayer.txn
>>> handleMailForTest = lambda: handleMail(
... zopeless_transaction,
... signature_timestamp_checker=accept_any_timestamp)
We temporarily override the error mails' From address, so that they will
pass through the authentication stage:
>>> bugmail_error_from_address = """
... [malone]
... bugmail_error_from_address: foo.bar@canonical.com
... """
>>> config.push('bugmail_error_from_address', bugmail_error_from_address)
The test mails are now in Launchpad's mail box, so now we can call
handleMail, so that every mail gets handled by the correct handler. (We
see warnings about missing `X-Launchpad-Original-To`_ headers, which are
discussed below; this output merely shows that we emit warnings when the
header is missing.)
>>> handleMailForTest()
WARNING:process-mail:No X-Launchpad-Original-To header was present ...
WARNING:process-mail:No X-Launchpad-Original-To header was present ...
WARNING:process-mail:No X-Launchpad-Original-To header was present ...
WARNING:process-mail:No X-Launchpad-Original-To header was present ...
Now we can see that each handler handled the emails sent to its domain:
>>> set(foo_handler.handledMails) ^ set(msgids['foo.com'])
set([])
>>> set(bar_handler.handledMails) ^ set(msgids['bar.com'])
set([])
--------------
Unhandled Mail
--------------
So, what happened to the message that got sent to baz.com? Since there
wasn't a handler registered for that domain, an OOPS was recorded with
a link to the original message.
>>> from lp.services.mail import stub
>>> print stub.test_emails[-1][2]
Content-Type: multipart/mixed...
...
To: Sample Person <test@canonical.com>
...
Sorry, something went wrong when Launchpad tried processing your mail.
We've recorded what happened, and we'll fix it as soon as possible.
Apologies for the inconvenience.
<BLANKLINE>
If this is blocking your work, please file a question at
https://answers.launchpad.net/launchpad/+addquestion
and include the error ID OOPS-... in the description.
...
From: Sample Person <test@canonical.com>
To: 123@baz.com
Subject: Signed Email
...
>>> stub.test_emails = []
---------------------------------------------
Mail from Persons not registered in Launchpad
---------------------------------------------
If a Person who isn't registered in Launchpad sends an email, we'll
most of the time reject the email:
>>> moin_change = read_test_message('moin-change.txt')
>>> moin_change['X-Launchpad-Original-To'] = '123@foo.com'
>>> msgid = "<%s>" % sendmail(moin_change)
>>> handleMailForTest()
>>> msgid not in foo_handler.handledMails
True
>>> stub.test_emails = []
However, bar_handler specifies that it can handle such emails:
>>> bar_handler.allow_unknown_users
True
So if we send the mail to bar.com, bar_handler will handle the mail:
>>> moin_change.replace_header('X-Launchpad-Original-To', '123@bar.com')
>>> msgid = "<%s>" % sendmail(moin_change)
>>> handleMailForTest()
>>> msgid in bar_handler.handledMails
True
>>> stub.test_emails = []
---------------------------------------------------------
Mail from Persons with with an inactive Launchpad account
---------------------------------------------------------
If a Person who's account is inactive sends an email, it will be
silently rejected.
>>> from zope.component import getUtility
>>> from lp.registry.interfaces.person import IPersonSet
>>> person_set = getUtility(IPersonSet)
>>> bigjools = person_set.getByEmail('launchpad@julian-edwards.com')
>>> print bigjools.account_status.name
NOACCOUNT
>>> msg = read_test_message('unsigned_inactive.txt')
>>> msgid = sendmail(msg, ['edit@malone-domain'])
>>> handleMailForTest()
>>> msgid not in foo_handler.handledMails
True
>>> msg = read_test_message('invalid_signed_inactive.txt')
>>> msgid = sendmail(msg, ['edit@malone-domain'])
>>> handleMailForTest()
>>> msgid not in foo_handler.handledMails
True
-----------------------
X-Launchpad-Original-To
-----------------------
If available, the X-Launchpad-Original-To header is used to determine to
which address the email was sent to:
>>> msg = read_test_message('signed_detached.txt')
>>> msg.replace_header('To', '123@foo.com')
>>> msg['CC'] = '123@foo.com'
>>> msg['X-Launchpad-Original-To'] = '123@bar.com'
>>> msgid = '<%s>' % sendmail (msg, ['123@bar.com'])
>>> handleMailForTest()
>>> msgid in bar_handler.handledMails
True
Only the address in X-Launchpad-Original-To header will be used. The
addresses in the To and CC headers will be ignored:
>>> msgid in foo_handler.handledMails
False
-------------------------------
OOPSes processing incoming mail
-------------------------------
If an unhandled exception occurs when we try to process an email from
a user, we record an OOPS with the exception and send it to the user.
We create a handler that is guaranteed to raise an exception when
attempting to process incoming mail.
>>> class TestOopsException(Exception):
... pass
>>> class OopsHandler:
... implements(IMailHandler)
... def process(self, mail, to_addr, filealias):
... raise TestOopsException()
>>> mail_handlers.add('oops.com', OopsHandler())
And submit an email to the handler.
>>> import email
>>> msg = email.message_from_string(
... """From: Foo Bar <foo.bar@canonical.com>
... To: launchpad@oops.com
... X-Launchpad-Original-To: launchpad@oops.com
... Subject: doesn't matter
...
... doesn't matter
... """)
>>> msgid = sendmail(msg, ['edit@malone-domain'])
>>> handleMailForTest()
ERROR:process-mail:An exception was raised inside the handler:
...
TestOopsException
An exception is raised, an OOPS is recorded, and an email is sent back
to the user, citing the OOPS ID, with the original message attached.
>>> print stub.test_emails[-1][2]
Content-Type: multipart/mixed...
...
To: Foo Bar <foo.bar@canonical.com>
...
Sorry, something went wrong when Launchpad tried processing your mail.
We've recorded what happened, and we'll fix it as soon as possible.
Apologies for the inconvenience.
<BLANKLINE>
If this is blocking your work, please file a question at
https://answers.launchpad.net/launchpad/+addquestion
and include the error ID OOPS-...TEMAIL... in the description.
...
From: Foo Bar <foo.bar@canonical.com>
To: launchpad@oops.com
X-Launchpad-Original-To: launchpad@oops.com
Subject: doesn't matter
...
>>> stub.test_emails = []
Unauthorized exceptions, which are ignored for the purpose of OOPS
reporting in the web interface, are not ignored in the email interface.
>>> from twisted.cred.error import Unauthorized
>>> class UnauthorizedOopsHandler:
... implements(IMailHandler)
... def process(self, mail, to_addr, filealias):
... raise Unauthorized()
>>> mail_handlers.add('unauthorized.com', UnauthorizedOopsHandler())
>>> msg = email.message_from_string(
... """From: Foo Bar <foo.bar@canonical.com>
... To: launchpad@unauthorized.com
... X-Launchpad-Original-To: launchpad@unauthorized.com
... Subject: doesn't matter
...
... doesn't matter
... """)
>>> msgid = sendmail(msg, ['edit@malone-domain'])
>>> handleMailForTest()
ERROR:process-mail:An exception was raised inside the handler:
...
Unauthorized
>>> print stub.test_emails[-1][2]
Content-Type: multipart/mixed...
...
To: Foo Bar <foo.bar@canonical.com>
...
Sorry, something went wrong when Launchpad tried processing your mail.
We've recorded what happened, and we'll fix it as soon as possible.
Apologies for the inconvenience.
<BLANKLINE>
If this is blocking your work, please file a question at
https://answers.launchpad.net/launchpad/+addquestion
and include the error ID OOPS-...TEMAIL... in the description.
...
From: Foo Bar <foo.bar@canonical.com>
To: launchpad@unauthorized.com
X-Launchpad-Original-To: launchpad@unauthorized.com
Subject: doesn't matter
...
>>> stub.test_emails = []
-------------
DB exceptions
-------------
If something goes wrongs in the handler, a DB exception can be raised,
leaving the database in a bad state. If that happens a traceback should
be printed, and the mail should be deleted from the queue.
Let's create and register a handler which raises a SQL error:
>>> from canonical.database.sqlbase import cursor
>>> class DBExceptionRaiser:
... implements(IMailHandler)
... def process(self, mail, to_addr, filealias):
... cur = cursor()
... cur.execute('SELECT 1/0')
>>> mail_handlers.add('except.com', DBExceptionRaiser())
Now we send a mail to the handler, which will cause an exception:
>>> exception_raiser = email.message_from_string(
... """From: Foo Bar <foo.bar@canonical.com>
... To: something@except.com
... X-Launchpad-Original-To: something@except.com
... Subject: Raise an exception
...
... This part is not important.
... """)
>>> msgid = sendmail(exception_raiser, ['something@exception.com'])
We send another mail as well, in order to make sure that it gets
processed as well:
>>> msg = read_test_message('signed_detached.txt')
>>> msg.replace_header('To', '123@foo.com')
>>> msgid = '<%s>' % sendmail(msg)
If we call handleMail(), we'll see some useful error messages printed
out:
>>> handleMailForTest()
ERROR:...:An exception was raised inside the handler: http://...
Traceback (most recent call last):
...
DataError: division by zero
<BLANKLINE>
WARNING...
The second mail we sent got handled despite the exception:
>>> msgid in foo_handler.handledMails
True
There is only one mail left in the mail box - the one sent back to
the user reporting the error:
>>> len(stub.test_emails)
1
---------------------
Librarian not running
---------------------
If for some reason the Librarian isn't up and running, we shouldn't
lose any emails. All that should happen is that an error should get
logged.
>>> from canonical.testing.layers import LibrarianLayer
>>> LibrarianLayer.hide()
>>> msg = read_test_message('signed_detached.txt')
>>> msg.replace_header('To', '123@foo.com')
>>> msgid = '<%s>' % sendmail(msg)
>>> len(stub.test_emails)
2
>>> handleMailForTest()
ERROR:...:Upload to Librarian failed...
...
UploadFailed: ...Connection refused...
>>> len(stub.test_emails)
2
>>> LibrarianLayer.reveal()
>>> stub.test_emails = []
----------------
Handling bounces
----------------
Some broken mailers might not respect the Errors-To and Return-Path
headers, send error messages back to the address, from which the email
was sent. To prevent mail loops, we try to detect such errors, and
simply drop the emails.
Emails with an empty Return-Path header should be dropped:
>>> stub.test_emails = []
>>> msg = read_test_message('signed_detached.txt')
>>> msg.replace_header('To', '123@foo.com')
>>> msg['Return-Path'] = '<>'
>>> msgid = '<%s>' % sendmail(msg)
>>> handleMailForTest()
>>> msgid in foo_handler.handledMails
False
Since this happens way too often, as we seem to get more spam than
legitimate email, an email is not sent about it to the errors-list.
>>> len(stub.test_emails)
0
If the content type is multipart/report, it's most likely a DSN
(RFC 3464), so those get dropped as well. Normally a DSN should have an
empty Return-Path, but there are some broken mailers out there.
>>> msg = read_test_message('signed_inline.txt')
>>> msg.replace_header('To', '123@foo.com')
>>> msg['Return-Path'] = '<not@empty.com>'
>>> msg['Content-Type'] = (
... 'multipart/report; report-type=delivery-status;'
... ' boundary="boundary"')
>>> msgid = '<%s>' % sendmail(msg)
>>> handleMailForTest()
>>> msgid in foo_handler.handledMails
False
>>> len(stub.test_emails)
0
Email with the Precedence header are probably from an auto-responder or
another robot. We also drop those.
>>> msg = read_test_message('signed_inline.txt')
>>> msg.replace_header('To', '123@foo.com')
>>> msg['Return-Path'] = '<not@empty.com>'
>>> msg['Precedence'] = 'bulk'
>>> msgid = '<%s>' % sendmail(msg)
>>> handleMailForTest()
>>> msgid in foo_handler.handledMails
False
>>> len(stub.test_emails)
0
.. Doctest cleanup
>>> config_data = config.pop('bugmail_error_from_address')
>>> mail_handlers.add('foo.com', None)
>>> mail_handlers.add('bar.com', None)
>>> mail_handlers.add('except.com', None)
|