diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index df2468f7124e6d..1c64c8f665611b 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -199,11 +199,21 @@ An :class:`IMAP4` instance has the following methods: Append *message* to named mailbox. + *message* is sent verbatim, without any line-ending conversion. + :rfc:`3501` requires messages to use CRLF line endings, so callers must + ensure this themselves; for example, serialize :mod:`email` messages using + :class:`email.policy.SMTP` (which emits CRLF) rather than the default + policy. + *flags* may be ``None`` or a string of IMAP flag tokens. Multiple flags are separated by spaces, for example ``r'\Seen \Answered'``. If *flags* is not already enclosed in parentheses, parentheses are added automatically. + .. versionchanged:: 3.16 + *message* is now sent verbatim. Previously, bare CR or LF characters + were rewritten to CRLF. + .. method:: IMAP4.authenticate(mechanism, authobject) diff --git a/Lib/imaplib.py b/Lib/imaplib.py index 497b5a60cecb08..af9a0c26501d3c 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -502,8 +502,7 @@ def append(self, mailbox, flags, date_time, message): date_time = Time2Internaldate(date_time) else: date_time = None - literal = MapCRLF.sub(CRLF, message) - self.literal = literal + self.literal = message return self._simple_command(name, mailbox, flags, date_time) diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index fb256fb7cbcd34..756039062844a2 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -2,6 +2,7 @@ from test.support import socket_helper from contextlib import contextmanager +from email.message import EmailMessage import imaplib import os.path import socketserver @@ -27,6 +28,25 @@ CAFILE = os.path.join(os.path.dirname(__file__) or os.curdir, "certdata", "pycacert.pem") +def _read_append_literal(handler, args): + literal = args[-1] + trailer = b'\r\n' + if literal.startswith('('): + literal = literal[1:] + trailer = b')\r\n' + if literal.startswith('~'): + literal = literal[1:] + match = re.fullmatch(r'\{(\d+)\}', literal) + if match is None: + raise AssertionError(f"unexpected APPEND literal marker: {args[-1]!r}") + literal_size = int(match.group(1)) + + handler._send_textline('+') + payload = handler.rfile.read(literal_size) + received_trailer = handler.rfile.read(len(trailer)) + return payload, received_trailer + + class TestImaplib(unittest.TestCase): def test_Internaldate2tuple(self): @@ -371,12 +391,9 @@ def cmd_AUTHENTICATE(self, tag, args): self.server.response = yield self._send_tagged(tag, 'OK', 'FAKEAUTH successful') def cmd_APPEND(self, tag, args): - self._send_textline('+') self.server.response = args - literal = yield - self.server.response.append(literal) - literal = yield - self.server.response.append(literal) + payload, trailer = _read_append_literal(self, args) + self.server.response.extend([payload, trailer]) self._send_tagged(tag, 'OK', 'okay') client, server = self._setup(UTF8AppendServer) self.assertEqual(client._encoding, 'ascii') @@ -387,14 +404,45 @@ def cmd_APPEND(self, tag, args): self.assertEqual(code, 'OK') self.assertEqual(client._encoding, 'utf-8') msg_string = 'Subject: üñí©öðé' - typ, data = client.append( - None, None, None, (msg_string + '\n').encode('utf-8')) + msg = (msg_string + '\n').encode('utf-8') + self.assertEqual(len(msg), 24) + typ, data = client.append(None, None, None, msg) self.assertEqual(typ, 'OK') self.assertEqual(server.response, ['INBOX', 'UTF8', - '(~{25}', ('%s\r\n' % msg_string).encode('utf-8'), + '(~{24}', msg, b')\r\n' ]) + def test_append_preserves_message_line_endings(self): + class AppendServer(SimpleIMAPHandler): + def cmd_APPEND(self, tag, args): + payload, trailer = _read_append_literal(self, args) + self.server.responses.append(args + [payload, trailer]) + self._send_tagged(tag, 'OK', 'okay') + + client, server = self._setup(AppendServer) + server.responses = [] + typ, _ = client.login('user', 'pass') + self.assertEqual(typ, 'OK') + + msg = b"one\ntwo\rthree\r\nfour" + self.assertEqual(len(msg), 19) + typ, _ = client.append(None, None, None, msg) + self.assertEqual(typ, 'OK') + self.assertEqual(server.responses[-1], + ['INBOX', '{19}', msg, b'\r\n']) + + email = EmailMessage() + email['Subject'] = 'line endings' + email.set_content('body line\n') + msg = email.as_bytes() + self.assertIn(b'\n', msg) + self.assertNotIn(b'\r\n', msg) + typ, _ = client.append(None, None, None, msg) + self.assertEqual(typ, 'OK') + self.assertEqual(server.responses[-1], + ['INBOX', f'{{{len(msg)}}}', msg, b'\r\n']) + def test_search_disallows_charset_in_utf8_mode(self): class UTF8Server(SimpleIMAPHandler): capabilities = 'AUTH ENABLE UTF8=ACCEPT' @@ -925,12 +973,9 @@ def test_enable_UTF8_True_append(self): class UTF8AppendServer(self.UTF8Server): def cmd_APPEND(self, tag, args): - self._send_textline('+') self.server.response = args - literal = yield - self.server.response.append(literal) - literal = yield - self.server.response.append(literal) + payload, trailer = _read_append_literal(self, args) + self.server.response.extend([payload, trailer]) self._send_tagged(tag, 'OK', 'okay') with self.reaped_pair(UTF8AppendServer) as (server, client): @@ -943,14 +988,47 @@ def cmd_APPEND(self, tag, args): self.assertEqual(code, 'OK') self.assertEqual(client._encoding, 'utf-8') msg_string = 'Subject: üñí©öðé' - typ, data = client.append( - None, None, None, (msg_string + '\n').encode('utf-8')) + msg = (msg_string + '\n').encode('utf-8') + self.assertEqual(len(msg), 24) + typ, data = client.append(None, None, None, msg) self.assertEqual(typ, 'OK') self.assertEqual(server.response, ['INBOX', 'UTF8', - '(~{25}', ('%s\r\n' % msg_string).encode('utf-8'), + '(~{24}', msg, b')\r\n' ]) + @threading_helper.reap_threads + def test_append_preserves_message_line_endings(self): + + class AppendServer(SimpleIMAPHandler): + def cmd_APPEND(self, tag, args): + payload, trailer = _read_append_literal(self, args) + self.server.responses.append(args + [payload, trailer]) + self._send_tagged(tag, 'OK', 'okay') + + with self.reaped_pair(AppendServer) as (server, client): + server.responses = [] + typ, _ = client.login('user', 'pass') + self.assertEqual(typ, 'OK') + + msg = b"one\ntwo\rthree\r\nfour" + self.assertEqual(len(msg), 19) + typ, _ = client.append(None, None, None, msg) + self.assertEqual(typ, 'OK') + self.assertEqual(server.responses[-1], + ['INBOX', '{19}', msg, b'\r\n']) + + email = EmailMessage() + email['Subject'] = 'line endings' + email.set_content('body line\n') + msg = email.as_bytes() + self.assertIn(b'\n', msg) + self.assertNotIn(b'\r\n', msg) + typ, _ = client.append(None, None, None, msg) + self.assertEqual(typ, 'OK') + self.assertEqual(server.responses[-1], + ['INBOX', f'{{{len(msg)}}}', msg, b'\r\n']) + # XXX also need a test that makes sure that the Literal and Untagged_status # regexes uses unicode in UTF8 mode instead of the default ASCII. diff --git a/Misc/ACKS b/Misc/ACKS index ee68d91f13c431..bbf76ec6c0ea15 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -975,6 +975,7 @@ Robert Kern Jim Kerr Magnus Kessler Lawrence Kesteloot +Harjoth Khara Garvit Khatri Vivek Khera Dhiru Kholia diff --git a/Misc/NEWS.d/next/Library/2026-06-16-12-00-00.gh-issue-49680.7aac3b.rst b/Misc/NEWS.d/next/Library/2026-06-16-12-00-00.gh-issue-49680.7aac3b.rst new file mode 100644 index 00000000000000..df53f41f90dc77 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-16-12-00-00.gh-issue-49680.7aac3b.rst @@ -0,0 +1,5 @@ +:meth:`imaplib.IMAP4.append` now sends the message verbatim instead of +rewriting bare CR or LF bytes to CRLF. Since :rfc:`3501` requires messages to +use CRLF line endings, callers must now supply them themselves; for example, +serialize :mod:`email` messages with :class:`email.policy.SMTP` (which emits +CRLF) rather than the default policy.