diff --git a/Lib/email/_header_value_parser.py b/Lib/email/_header_value_parser.py index 792072ab9f6128..98512702504677 100644 --- a/Lib/email/_header_value_parser.py +++ b/Lib/email/_header_value_parser.py @@ -227,6 +227,12 @@ class Token(TokenList): token_type = 'token' encode_as_ew = False + @property + def stripped_value(self): + for token in self: + if token.token_type == 'ttext': + return token.value + class EncodedWord(TokenList): token_type = 'encoded-word' @@ -734,7 +740,7 @@ def stripped_value(self): if token.token_type == 'cfws': token = self[1] if token.token_type.endswith( - ('quoted-string', 'attribute', 'extended-attribute')): + ('quoted-string', 'attribute', 'extended-attribute', 'token')): return token.stripped_value return self.value @@ -2458,8 +2464,8 @@ def get_section(value): return section, value -def get_value(value): - """ quoted-string / attribute +def get_value(value, *, extended=False): + """ quoted-string / token / extended-attribute """ v = Value() @@ -2473,8 +2479,10 @@ def get_value(value): "only {}".format(leader)) if value[0] == '"': token, value = get_quoted_string(value) - else: + elif extended: token, value = get_extended_attribute(value) + else: + token, value = get_token(value) if leader is not None: token[:0] = [leader] v.append(token) @@ -2560,7 +2568,13 @@ def get_parameter(value): if value and value[0] == "'": token = None else: - token, value = get_value(value) + # Preserve legacy recovery for unencoded *0 continuations using the + # charset'language'value form. Unsectioned parameters are MIME tokens. + parse_as_extended = ( + param.extended or + (param.sectioned and param.section_number == 0) + ) + token, value = get_value(value, extended=parse_as_extended) if not param.extended or param.section_number > 0: if not value or value[0] != "'": appendto.append(token) @@ -2614,7 +2628,7 @@ def get_parameter(value): v.append(token) token = v else: - token, value = get_value(value) + token, value = get_value(value, extended=True) appendto.append(token) if remainder is not None: assert not value, value diff --git a/Lib/test/test_email/test_email.py b/Lib/test/test_email/test_email.py index e40c82bba9af42..c0077f20074141 100644 --- a/Lib/test/test_email/test_email.py +++ b/Lib/test/test_email/test_email.py @@ -169,6 +169,14 @@ def test_get_filename(self): subpart = msg.get_payload(1) eq(subpart.get_filename(), 'dingusfish.gif') + def test_get_filename_with_unquoted_apostrophe(self): + msg = email.message_from_string( + "Content-Disposition: attachment; filename=O'Brien.pdf\n", + policy=email.policy.default, + ) + self.assertEqual(msg.get_filename(), "O'Brien.pdf") + self.assertDefectsEqual(msg['content-disposition'].defects, []) + def test_get_filename_with_name_parameter(self): eq = self.assertEqual @@ -1924,6 +1932,23 @@ def test_hierarchy(self): self.assertFalse(m0.is_multipart()) self.assertFalse(m1.is_multipart()) + def test_unquoted_apostrophe_in_boundary(self): + msg = email.message_from_string("""\ +Content-Type: multipart/mixed; boundary=abc'def + +--abc'def +Content-Type: text/plain + +body +--abc'def-- +""", policy=email.policy.default) + self.assertEqual(msg.get_boundary(), "abc'def") + self.assertTrue(msg.is_multipart()) + self.assertEqual(len(msg.get_payload()), 1) + self.assertEqual(msg.get_payload(0).get_payload(), 'body') + self.assertDefectsEqual(msg['content-type'].defects, []) + self.assertDefectsEqual(msg.defects, []) + def test_empty_multipart_idempotent(self): text = """\ Content-Type: multipart/mixed; boundary="BOUNDARY" diff --git a/Lib/test/test_email/test_headerregistry.py b/Lib/test/test_email/test_headerregistry.py index c118fd5e20d048..73d7ace9321416 100644 --- a/Lib/test/test_email/test_headerregistry.py +++ b/Lib/test/test_email/test_headerregistry.py @@ -422,6 +422,26 @@ def content_type_as_value(self, 'text/plain; title="foo"', ), + 'unquoted_param_value_with_apostrophe_and_asterisk': ( + "text/plain; title=O'Brien*notes.txt", + 'text/plain', + 'text', + 'plain', + {'title': "O'Brien*notes.txt"}, + [], + 'text/plain; title="O\'Brien*notes.txt"', + ), + + 'unquoted_boundary_with_apostrophe': ( + "multipart/mixed; boundary=abc'def", + 'multipart/mixed', + 'multipart', + 'mixed', + {'boundary': "abc'def"}, + [], + 'multipart/mixed; boundary="abc\'def"', + ), + 'param_value_with_tspecials': ( 'text/plain; title="(bar)foo blue"', 'text/plain', @@ -934,6 +954,15 @@ def content_disp_as_value(self, ' filename*=utf-8\'\'Schulbesuchsbest%C3%A4ttigung.pdf\n'), ), + 'unquoted_filename_with_apostrophe': ( + "attachment; filename=O'Brien.pdf", + 'attachment', + {'filename': "O'Brien.pdf"}, + [], + 'attachment; filename="O\'Brien.pdf"', + 'Content-Disposition: attachment; filename="O\'Brien.pdf"\n', + ), + 'parameter_value_with_fws_between_tokens': ( 'attachment; filename="File =?utf-8?q?Name?= With Spaces.pdf"', 'attachment', diff --git a/Misc/NEWS.d/next/Library/2026-07-17-21-43-09.gh-issue-153823.RMjgKC.rst b/Misc/NEWS.d/next/Library/2026-07-17-21-43-09.gh-issue-153823.RMjgKC.rst new file mode 100644 index 00000000000000..2a0fb2d95a42e7 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-17-21-43-09.gh-issue-153823.RMjgKC.rst @@ -0,0 +1,3 @@ +Fix :data:`email.policy.default` to parse apostrophes in unquoted MIME +parameter values as valid token characters. This restores attachment +filename and multipart boundary handling consistent with ``compat32``.