From e9de7103b9c20ea9b024b028d2231516ff2901f4 Mon Sep 17 00:00:00 2001 From: Jiucheng Zang Date: Tue, 10 Jun 2025 18:12:07 -0400 Subject: [PATCH 1/7] enhance email content handling: allow infinite line length in encoding --- Lib/email/contentmanager.py | 11 +++++--- Lib/test/test_email/test_message.py | 28 +++++++++++++++++++ ...-06-10-18-02-29.gh-issue-135307.fXGrcK.rst | 2 ++ 3 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst diff --git a/Lib/email/contentmanager.py b/Lib/email/contentmanager.py index b4f5830beada4a..506e99f6838468 100644 --- a/Lib/email/contentmanager.py +++ b/Lib/email/contentmanager.py @@ -1,3 +1,4 @@ +import sys import binascii import email.charset import email.message @@ -142,13 +143,15 @@ def _encode_base64(data, max_line_length): def _encode_text(string, charset, cte, policy): + # max_line_length 0/None means no limit, ie: infinitely long. + maxlen = policy.max_line_length or sys.maxsize lines = string.encode(charset).splitlines() linesep = policy.linesep.encode('ascii') def embedded_body(lines): return linesep.join(lines) + linesep def normal_body(lines): return b'\n'.join(lines) + b'\n' if cte is None: # Use heuristics to decide on the "best" encoding. - if max((len(x) for x in lines), default=0) <= policy.max_line_length: + if max((len(x) for x in lines), default=0) <= maxlen: try: return '7bit', normal_body(lines).decode('ascii') except UnicodeDecodeError: @@ -157,7 +160,7 @@ def normal_body(lines): return b'\n'.join(lines) + b'\n' return '8bit', normal_body(lines).decode('ascii', 'surrogateescape') sniff = embedded_body(lines[:10]) sniff_qp = quoprimime.body_encode(sniff.decode('latin-1'), - policy.max_line_length) + maxlen) sniff_base64 = binascii.b2a_base64(sniff) # This is a little unfair to qp; it includes lineseps, base64 doesn't. if len(sniff_qp) > len(sniff_base64): @@ -172,9 +175,9 @@ def normal_body(lines): return b'\n'.join(lines) + b'\n' data = normal_body(lines).decode('ascii', 'surrogateescape') elif cte == 'quoted-printable': data = quoprimime.body_encode(normal_body(lines).decode('latin-1'), - policy.max_line_length) + maxlen) elif cte == 'base64': - data = _encode_base64(embedded_body(lines), policy.max_line_length) + data = _encode_base64(embedded_body(lines), maxlen) else: raise ValueError("Unknown content transfer encoding {}".format(cte)) return cte, data diff --git a/Lib/test/test_email/test_message.py b/Lib/test/test_email/test_message.py index 23c39775a8b2e5..614f693c991f53 100644 --- a/Lib/test/test_email/test_message.py +++ b/Lib/test/test_email/test_message.py @@ -1004,6 +1004,34 @@ def test_folding_with_long_nospace_http_policy_1(self): parsed_msg = message_from_bytes(m.as_bytes(), policy=policy.default) self.assertEqual(parsed_msg['Message-ID'], m['Message-ID']) + def test_no_wrapping_with_zero_max_line_length(self): + pol = policy.default.clone(max_line_length=0) + subj = "S" * 100 + msg = EmailMessage(policy=pol) + msg["From"] = "a@ex.com" + msg["To"] = "b@ex.com" + msg["Subject"] = subj + + raw = msg.as_bytes() + self.assertNotIn(b"\r\n ", raw, "Found fold indicator; wrapping not disabled") + + parsed = message_from_bytes(raw, policy=policy.default) + self.assertEqual(parsed["Subject"], subj) + + def test_no_wrapping_with_none_max_line_length(self): + pol = policy.default.clone(max_line_length=None) + subj = "S" * 100 + body = "B" * 100 + msg = EmailMessage(policy=pol) + msg["From"] = "a@ex.com" + msg["To"] = "b@ex.com" + msg["Subject"] = subj + msg.set_content(body) + + parsed = message_from_bytes(msg.as_bytes(), policy=policy.default) + self.assertEqual(parsed["Subject"], subj) + self.assertEqual(parsed.get_body().get_content().rstrip('\n'), body) + def test_invalid_header_names(self): invalid_headers = [ ('Invalid Header', 'contains space'), diff --git a/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst b/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst new file mode 100644 index 00000000000000..0f66b4e1d5b98a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst @@ -0,0 +1,2 @@ +:mod:`email`: Ensure policy accepts unlimited line lengths by +treating 0 or :const:`None` as :data:`sys.maxsize` From 0a5f37921223070bf4c24835f2d1c0208c4a284d Mon Sep 17 00:00:00 2001 From: Jiucheng Zang Date: Tue, 10 Jun 2025 18:20:32 -0400 Subject: [PATCH 2/7] Fix lint --- .../next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst b/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst index 0f66b4e1d5b98a..d4c963b7749a83 100644 --- a/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst +++ b/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst @@ -1,2 +1,2 @@ -:mod:`email`: Ensure policy accepts unlimited line lengths by +:mod:`email`: Ensure policy accepts unlimited line lengths by treating 0 or :const:`None` as :data:`sys.maxsize` From d0dc3c08aa3e3e991990d279d6cca03aa2067b18 Mon Sep 17 00:00:00 2001 From: Jiucheng Zang Date: Tue, 10 Jun 2025 19:50:12 -0400 Subject: [PATCH 3/7] fix: correct formatting in email test cases and NEWS entry --- Lib/test/test_email/test_message.py | 4 ++-- .../Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_email/test_message.py b/Lib/test/test_email/test_message.py index 614f693c991f53..6f7fb5f727e688 100644 --- a/Lib/test/test_email/test_message.py +++ b/Lib/test/test_email/test_message.py @@ -1009,7 +1009,7 @@ def test_no_wrapping_with_zero_max_line_length(self): subj = "S" * 100 msg = EmailMessage(policy=pol) msg["From"] = "a@ex.com" - msg["To"] = "b@ex.com" + msg["To"] = "b@ex.com" msg["Subject"] = subj raw = msg.as_bytes() @@ -1024,7 +1024,7 @@ def test_no_wrapping_with_none_max_line_length(self): body = "B" * 100 msg = EmailMessage(policy=pol) msg["From"] = "a@ex.com" - msg["To"] = "b@ex.com" + msg["To"] = "b@ex.com" msg["Subject"] = subj msg.set_content(body) diff --git a/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst b/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst index d4c963b7749a83..2eed86d88d202b 100644 --- a/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst +++ b/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst @@ -1,2 +1,2 @@ :mod:`email`: Ensure policy accepts unlimited line lengths by -treating 0 or :const:`None` as :data:`sys.maxsize` +treating 0 or :const:`None` as :data:`sys.maxsize`. From 77ea7983d7704a7e6ab2ef90b2cdd28c47fbf6f2 Mon Sep 17 00:00:00 2001 From: Jiucheng Zang Date: Thu, 19 Jun 2025 10:47:29 -0400 Subject: [PATCH 4/7] Update NEWS entry, test case enhance, lint code --- Lib/email/contentmanager.py | 5 +- Lib/test/test_email/test_message.py | 46 ++++++++----------- ...-06-10-18-02-29.gh-issue-135307.fXGrcK.rst | 2 +- 3 files changed, 22 insertions(+), 31 deletions(-) diff --git a/Lib/email/contentmanager.py b/Lib/email/contentmanager.py index 506e99f6838468..3b50b6b0f48a85 100644 --- a/Lib/email/contentmanager.py +++ b/Lib/email/contentmanager.py @@ -1,9 +1,9 @@ -import sys import binascii import email.charset import email.message import email.errors from email import quoprimime +import sys class ContentManager: @@ -159,8 +159,7 @@ def normal_body(lines): return b'\n'.join(lines) + b'\n' if policy.cte_type == '8bit': return '8bit', normal_body(lines).decode('ascii', 'surrogateescape') sniff = embedded_body(lines[:10]) - sniff_qp = quoprimime.body_encode(sniff.decode('latin-1'), - maxlen) + sniff_qp = quoprimime.body_encode(sniff.decode('latin-1'), maxlen) sniff_base64 = binascii.b2a_base64(sniff) # This is a little unfair to qp; it includes lineseps, base64 doesn't. if len(sniff_qp) > len(sniff_base64): diff --git a/Lib/test/test_email/test_message.py b/Lib/test/test_email/test_message.py index 6f7fb5f727e688..d5ace9c0beba6a 100644 --- a/Lib/test/test_email/test_message.py +++ b/Lib/test/test_email/test_message.py @@ -1004,33 +1004,25 @@ def test_folding_with_long_nospace_http_policy_1(self): parsed_msg = message_from_bytes(m.as_bytes(), policy=policy.default) self.assertEqual(parsed_msg['Message-ID'], m['Message-ID']) - def test_no_wrapping_with_zero_max_line_length(self): - pol = policy.default.clone(max_line_length=0) - subj = "S" * 100 - msg = EmailMessage(policy=pol) - msg["From"] = "a@ex.com" - msg["To"] = "b@ex.com" - msg["Subject"] = subj - - raw = msg.as_bytes() - self.assertNotIn(b"\r\n ", raw, "Found fold indicator; wrapping not disabled") - - parsed = message_from_bytes(raw, policy=policy.default) - self.assertEqual(parsed["Subject"], subj) - - def test_no_wrapping_with_none_max_line_length(self): - pol = policy.default.clone(max_line_length=None) - subj = "S" * 100 - body = "B" * 100 - msg = EmailMessage(policy=pol) - msg["From"] = "a@ex.com" - msg["To"] = "b@ex.com" - msg["Subject"] = subj - msg.set_content(body) - - parsed = message_from_bytes(msg.as_bytes(), policy=policy.default) - self.assertEqual(parsed["Subject"], subj) - self.assertEqual(parsed.get_body().get_content().rstrip('\n'), body) + def test_no_wrapping_max_line_length(self): + def do_test_no_wrapping_max_line_length(n): + pol = policy.default.clone(max_line_length=n) + subj = "S" * 100 + body = "B" * 100 + msg = EmailMessage(policy=pol) + msg["From"] = "a@ex.com" + msg["To"] = "b@ex.com" + msg["Subject"] = subj + msg.set_content(body) + + raw = msg.as_bytes() + self.assertNotIn(b"\r\n ", raw, "Found fold indicator; wrapping not disabled") + + parsed = message_from_bytes(msg.as_bytes(), policy=policy.default) + self.assertEqual(parsed["Subject"], subj) + self.assertEqual(parsed.get_body().get_content().rstrip('\n'), body) + do_test_no_wrapping_max_line_length(None) + do_test_no_wrapping_max_line_length(0) def test_invalid_header_names(self): invalid_headers = [ diff --git a/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst b/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst index 2eed86d88d202b..930b5813770c23 100644 --- a/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst +++ b/Misc/NEWS.d/next/Library/2025-06-10-18-02-29.gh-issue-135307.fXGrcK.rst @@ -1,2 +1,2 @@ :mod:`email`: Ensure policy accepts unlimited line lengths by -treating 0 or :const:`None` as :data:`sys.maxsize`. +treating falsey values as :data:`sys.maxsize`. From 52e590c1def409fd907542a45b7e9da34d895ff1 Mon Sep 17 00:00:00 2001 From: Jiucheng Zang Date: Thu, 19 Jun 2025 13:08:34 -0400 Subject: [PATCH 5/7] resort import; restructure testcase --- Lib/email/contentmanager.py | 2 +- Lib/test/test_email/test_message.py | 41 ++++++++++++++++------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/Lib/email/contentmanager.py b/Lib/email/contentmanager.py index 3b50b6b0f48a85..7c65b2b95a56cc 100644 --- a/Lib/email/contentmanager.py +++ b/Lib/email/contentmanager.py @@ -2,8 +2,8 @@ import email.charset import email.message import email.errors -from email import quoprimime import sys +from email import quoprimime class ContentManager: diff --git a/Lib/test/test_email/test_message.py b/Lib/test/test_email/test_message.py index d5ace9c0beba6a..5ce48da97fa02c 100644 --- a/Lib/test/test_email/test_message.py +++ b/Lib/test/test_email/test_message.py @@ -1005,24 +1005,29 @@ def test_folding_with_long_nospace_http_policy_1(self): self.assertEqual(parsed_msg['Message-ID'], m['Message-ID']) def test_no_wrapping_max_line_length(self): - def do_test_no_wrapping_max_line_length(n): - pol = policy.default.clone(max_line_length=n) - subj = "S" * 100 - body = "B" * 100 - msg = EmailMessage(policy=pol) - msg["From"] = "a@ex.com" - msg["To"] = "b@ex.com" - msg["Subject"] = subj - msg.set_content(body) - - raw = msg.as_bytes() - self.assertNotIn(b"\r\n ", raw, "Found fold indicator; wrapping not disabled") - - parsed = message_from_bytes(msg.as_bytes(), policy=policy.default) - self.assertEqual(parsed["Subject"], subj) - self.assertEqual(parsed.get_body().get_content().rstrip('\n'), body) - do_test_no_wrapping_max_line_length(None) - do_test_no_wrapping_max_line_length(0) + # Test that falsey 'max_line_length' are converted to sys.maxsize. + for n in [0, None]: + with self.subTest(max_line_length=n): + self.do_test_no_wrapping_max_line_length(n) + + def do_test_no_wrapping_max_line_length(self, n): + pol = policy.default.clone(max_line_length=n) + subj = "S" * 100 + body = "B" * 100 + msg = EmailMessage(policy=pol) + msg["From"] = "a@ex.com" + msg["To"] = "b@ex.com" + msg["Subject"] = subj + msg.set_content(body) + + raw = msg.as_bytes() + self.assertNotIn(b"\r\n ", raw, + "Found fold indicator; wrapping not disabled") + + parsed = message_from_bytes(msg.as_bytes(), policy=policy.default) + self.assertEqual(parsed["Subject"], subj) + parsed_body = parsed.get_body().get_content().rstrip('\n') + self.assertEqual(parsed_body, body) def test_invalid_header_names(self): invalid_headers = [ From 603ff7492008512fe5950d326ef1ac3c8d97b6b4 Mon Sep 17 00:00:00 2001 From: Jiucheng Zang Date: Thu, 19 Jun 2025 13:29:12 -0400 Subject: [PATCH 6/7] tiny enhance varaible --- Lib/test/test_email/test_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_email/test_message.py b/Lib/test/test_email/test_message.py index 5ce48da97fa02c..c3ad451ceb97d3 100644 --- a/Lib/test/test_email/test_message.py +++ b/Lib/test/test_email/test_message.py @@ -1024,7 +1024,7 @@ def do_test_no_wrapping_max_line_length(self, n): self.assertNotIn(b"\r\n ", raw, "Found fold indicator; wrapping not disabled") - parsed = message_from_bytes(msg.as_bytes(), policy=policy.default) + parsed = message_from_bytes(raw, policy=policy.default) self.assertEqual(parsed["Subject"], subj) parsed_body = parsed.get_body().get_content().rstrip('\n') self.assertEqual(parsed_body, body) From 38e63813d2431e6b8d231a14669fcac5a6267866 Mon Sep 17 00:00:00 2001 From: Jiucheng Zang Date: Sat, 21 Jun 2025 17:04:53 -0400 Subject: [PATCH 7/7] Tiny update for test case for better readability --- Lib/test/test_email/test_message.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_email/test_message.py b/Lib/test/test_email/test_message.py index c3ad451ceb97d3..f1675ec6beaee5 100644 --- a/Lib/test/test_email/test_message.py +++ b/Lib/test/test_email/test_message.py @@ -1010,8 +1010,9 @@ def test_no_wrapping_max_line_length(self): with self.subTest(max_line_length=n): self.do_test_no_wrapping_max_line_length(n) - def do_test_no_wrapping_max_line_length(self, n): - pol = policy.default.clone(max_line_length=n) + def do_test_no_wrapping_max_line_length(self, falsey): + self.assertFalse(falsey) + pol = policy.default.clone(max_line_length=falsey) subj = "S" * 100 body = "B" * 100 msg = EmailMessage(policy=pol)