diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 4a714652ecf5d2..db17f6b79a7c50 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -202,7 +202,7 @@ upper bound (``'3:*'``). An :class:`IMAP4` instance has the following methods: -.. method:: IMAP4.append(mailbox, flags, date_time, message) +.. method:: IMAP4.append(mailbox, flags, date_time, message, *, translate_line_endings=True) Append *message* to named mailbox. @@ -211,6 +211,17 @@ An :class:`IMAP4` instance has the following methods: If *flags* is not already enclosed in parentheses, parentheses are added automatically. + If *translate_line_endings* is true (the default), + line endings in *message* are translated to CRLF. + Pass ``False`` to send the message literal exactly as given, + which is required to preserve messages that contain bare CR or LF. + In that case *message* must already use CRLF line endings as required + by :rfc:`3501`; for example, serialize :mod:`email` messages using + :class:`email.policy.SMTP`. + + .. versionchanged:: next + Added the *translate_line_endings* parameter. + .. method:: IMAP4.authenticate(mechanism, authobject) diff --git a/Lib/imaplib.py b/Lib/imaplib.py index ca2ae40726b2d1..239218bc96aeb4 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -487,12 +487,17 @@ def response(self, code): # IMAP4 commands - def append(self, mailbox, flags, date_time, message): + def append(self, mailbox, flags, date_time, message, *, + translate_line_endings=True): """Append message to named mailbox. (typ, [data]) = .append(mailbox, flags, date_time, message) All args except 'message' can be None. + + If 'translate_line_endings' is true (the default), line endings in + 'message' are translated to CRLF. Pass false to send the message + literal exactly as given. """ name = 'APPEND' if not mailbox: @@ -506,8 +511,9 @@ 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 + if translate_line_endings: + message = MapCRLF.sub(CRLF, message) + 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 e797a625014603..3a3f62ef1ea6ab 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 @@ -9,6 +10,7 @@ import calendar import threading import re +import select import socket from test.support import verbose, run_with_tz, run_with_locale, cpython_only @@ -89,6 +91,18 @@ class Handler(SimpleIMAPHandler): return Handler +def _read_literal(handler, marker): + # Read one literal, a raw octet sequence, by its count from the marker + # ('{N}', or '(~{N}' in UTF8 mode). + size = int(re.search(r'\{(\d+)\}', marker).group(1)) + # The client must wait for the continuation, so nothing should be readable. + if select.select([handler.connection], [], [], 0)[0]: + raise AssertionError('client sent the literal before the ' + 'continuation request') + handler._send_textline('+') + return handler.rfile.read(size) + + class TestImaplib(unittest.TestCase): def test_Internaldate2tuple(self): @@ -474,10 +488,8 @@ 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) + self.server.response.append(_read_literal(self, args[-1])) literal = yield self.server.response.append(literal) self._send_tagged(tag, 'OK', 'okay') @@ -737,6 +749,33 @@ def test_login(self): self.assertEqual(data[0], b'LOGIN completed') self.assertEqual(client.state, 'AUTH') + def test_append_translate_line_endings(self): + # By default line endings are normalized to CRLF; False sends the + # literal exactly (gh-49680). + class AppendHandler(SimpleIMAPHandler): + def cmd_APPEND(self, tag, args): + self.server.response = _read_literal(self, args[-1]) + yield # read the trailer line + self._send_tagged(tag, 'OK', 'APPEND completed') + client, server = self._setup(AppendHandler) + client.login('user', 'pass') + message = b'a\rb\nc\r\nd' + client.append('INBOX', None, None, message) + self.assertEqual(server.response, b'a\r\nb\r\nc\r\nd') + client.append('INBOX', None, None, message, + translate_line_endings=False) + self.assertEqual(server.response, message) + + # An email message uses bare LF by default; False sends it verbatim. + message = EmailMessage() + message['Subject'] = 'line endings' + message.set_content('body line\n') + message = message.as_bytes() + self.assertNotIn(b'\r\n', message) + client.append('INBOX', None, None, message, + translate_line_endings=False) + self.assertEqual(server.response, message) + def test_login_capabilities(self): # A server may advertise new capabilities after login (as an # untagged CAPABILITY response); imaplib must refresh its cached @@ -1673,10 +1712,8 @@ 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) + self.server.response.append(_read_literal(self, args[-1])) literal = yield self.server.response.append(literal) self._send_tagged(tag, 'OK', 'okay') diff --git a/Misc/NEWS.d/next/Library/2026-07-01-14-00-00.gh-issue-49680.Lm5rQv.rst b/Misc/NEWS.d/next/Library/2026-07-01-14-00-00.gh-issue-49680.Lm5rQv.rst new file mode 100644 index 00000000000000..6371df69e69077 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-01-14-00-00.gh-issue-49680.Lm5rQv.rst @@ -0,0 +1,4 @@ +Add the *translate_line_endings* parameter to :meth:`imaplib.IMAP4.append`. +By default line endings in the message are translated to CRLF, as before; +passing ``False`` sends the message literal exactly as given, preserving +bare CR or LF octets.