Skip to content

Commit 02b7877

Browse files
committed
[FIX] base: avoid bpo-35805 corrupting Message-Id
Python 3 before 3.8 has a bug that causes the email.policy classes to incorrectly fold and RFC2047-encode "identification fields" in email messages. This mainly applies to Message-Id, References, and In-Reply-To fields. We are impacted by this bug since odoo#35929 where we switched to using the "modern" email.message API. RFC2047 section 5 clearly states that those headers/fields are not to be encoded, and that would violate RFC5322. Further, such a folded Message-Id is considered non-RFC-conformant by popular MTAs (GMail, Outlook), which will then generate *another* Message-Id field, causing the original threading information to be lost. Replies to such a modified message will reference the new, unknown Message-Id, and won't be attached to the original thread. The solution we adopt here is to monkey-patch the SMTP policies to special-case those identification fields and deactivate the automatic folding, until the bug is properly and fully fixed in the standard lib. Some considerations taken into account for this patch: - `email.policy.SMTP` is being monkey-patched globally to make sure we fix all possible places where Messages are being encoded/folded - the fix is **not** made version-specific, considering that even in Python 3.8 the official bugfix only applies to Message-Id, but still fails to protect other identification fields, like *References* and *In-Reply-To*. The author specifically noted that shortcoming [2]. The fix wouldn't break anything on Python 3.8 anyway. - the `noFoldPolicy` trick for preventing folding is done with no max line length at all. RFC5322, section 2.1.1 states [3] that the maximum length is 998 due to legacy implementations, but there is no provision to wrap identification fields that are longer than that. Wrapping at 998 chars would corrupt the header anyway. We'll just count on the fact that we don't usually need 1k+ chars in those headers. The invalid folding/encoding in action on Python 3.6 (in Python 3.8 only the second header gets folded): ```py >>> msg = email.message.EmailMessage(policy=email.policy.SMTP) >>> msg['Message-Id'] = '<929227342217024.1596730490.324691772460938-example-30661-some.reference@test-123.example.com>' >>> msg['In-Reply-To'] = '<92922734221723.1596730568.324691772460444-another-30661-parent.reference@test-123.example.com>' >>> print(msg.as_string()) Message-Id: =?utf-8?q?=3C929227342217024=2E1596730490=2E324691772460938-exam?= =?utf-8?q?ple-30661-some=2Ereference=40test-123=2Eexample=2Ecom=3E?= In-Reply-To: =?utf-8?q?=3C92922734221723=2E1596730568=2E324691772460444-anot?= =?utf-8?q?her-30661-parent=2Ereference=40test-123=2Eexample=2Ecom=3E?= ``` and the expected result after the fix: ```py >>> msg = email.message.EmailMessage(policy=email.policy.SMTP) >>> msg['Message-Id'] = '<929227342217024.1596730490.324691772460938-example-30661-some.reference@test-123.example.com>' >>> msg['In-Reply-To'] = '<92922734221723.1596730568.324691772460444-another-30661-parent.reference@test-123.example.com>' >>> print(msg.as_string()) Message-Id: <929227342217024.1596730490.324691772460938-example-30661-some.reference@test-123.example.com> In-Reply-To: <92922734221723.1596730568.324691772460444-another-30661-parent.reference@test-123.example.com> ``` [1] bpo-35805: https://bugs.python.org/issue35805 [2] python/cpython#13397 (comment) [3] https://tools.ietf.org/html/rfc5322#section-2.1.1 closes odoo#55655 X-original-commit: 6726e9a Signed-off-by: Olivier Dony (odo) <[email protected]>
1 parent 1683c7c commit 02b7877

File tree

2 files changed

+30
-11
lines changed

2 files changed

+30
-11
lines changed

odoo/addons/base/models/ir_mail_server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ def _print_debug(self, *args):
3333
_logger.debug(' '.join(str(a) for a in args))
3434
smtplib.SMTP._print_debug = _print_debug
3535

36+
# Python 3: workaround for bpo-35805, only partially fixed in Python 3.8.
37+
RFC5322_IDENTIFICATION_HEADERS = {'message-id', 'in-reply-to', 'references', 'resent-msg-id'}
38+
_noFoldPolicy = email.policy.SMTP.clone(max_line_length=None)
39+
class IdentificationFieldsNoFoldPolicy(email.policy.EmailPolicy):
40+
# Override _fold() to avoid folding identification fields, excluded by RFC2047 section 5
41+
# These are particularly important to preserve, as MTAs will often rewrite non-conformant
42+
# Message-ID headers, causing a loss of thread information (replies are lost)
43+
def _fold(self, name, value, *args, **kwargs):
44+
if name.lower() in RFC5322_IDENTIFICATION_HEADERS:
45+
return _noFoldPolicy._fold(name, value, *args, **kwargs)
46+
return super()._fold(name, value, *args, **kwargs)
47+
48+
# Global monkey-patch for our preferred SMTP policy, preserving the non-default linesep
49+
email.policy.SMTP = IdentificationFieldsNoFoldPolicy(linesep=email.policy.SMTP.linesep)
50+
3651
# Python 2: replace smtplib's stderr
3752
class WriteToLogger(object):
3853
def write(self, s):

odoo/addons/base/tests/test_mail.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -397,38 +397,42 @@ def test_default_email_from(self, *args):
397397

398398
class TestEmailMessage(TransactionCase):
399399
def test_as_string(self):
400-
"""Ensure all email sent are bpo-34424 free"""
400+
"""Ensure all email sent are bpo-34424 and bpo-35805 free"""
401+
402+
message_truth = (
403+
r'From: .+? <joe@example\.com>\r\n'
404+
r'To: .+? <joe@example\.com>\r\n'
405+
r'Message-Id: <[0-9a-z.-]+@[0-9a-z.-]+>\r\n'
406+
r'References: (<[0-9a-z.-]+@[0-9a-z.-]+>\s*)+\r\n'
407+
r'\r\n'
408+
)
401409

402410
class FakeSMTP:
403411
"""SMTP stub"""
404412
def __init__(this):
405413
this.email_sent = False
406414

415+
# Python 3 before 3.7.4
407416
def sendmail(this, smtp_from, smtp_to_list, message_str,
408417
mail_options=(), rcpt_options=()):
409418
this.email_sent = True
410-
message_truth = (
411-
r'From: .+? <joe@example\.com>\r\n'
412-
r'To: .+? <joe@example\.com>\r\n'
413-
r'\r\n'
414-
)
415419
self.assertRegex(message_str, message_truth)
416420

421+
# Python 3.7.4+
417422
def send_message(this, message, smtp_from, smtp_to_list,
418423
mail_options=(), rcpt_options=()):
419424
message_str = message.as_string()
420425
this.email_sent = True
421-
message_truth = (
422-
r'From: .+? <joe@example\.com>\r\n'
423-
r'To: .+? <joe@example\.com>\r\n'
424-
r'\r\n'
425-
)
426426
self.assertRegex(message_str, message_truth)
427427

428428
msg = email.message.EmailMessage(policy=email.policy.SMTP)
429429
msg['From'] = '"Joé Doe" <[email protected]>'
430430
msg['To'] = '"Joé Doe" <[email protected]>'
431431

432+
# Message-Id & References fields longer than 77 chars (bpo-35805)
433+
msg['Message-Id'] = '<929227342217024.1596730490.324691772460938-example-30661-some.reference@test-123.example.com>'
434+
msg['References'] = '<345227342212345.1596730777.324691772483620-example-30453-other.reference@test-123.example.com>'
435+
432436
smtp = FakeSMTP()
433437
self.patch(threading.currentThread(), 'testing', False)
434438
self.env['ir.mail_server'].send_email(msg, smtp_session=smtp)

0 commit comments

Comments
 (0)