Skip to content

Commit 8a70a2d

Browse files
harjothkharaclaude
andcommitted
gh-49680: Don't rewrite CR/LF in imaplib APPEND literals
IMAP4.append() rewrote bare CR or LF bytes in the message to CRLF before sending it. An IMAP literal is a counted, opaque octet sequence, so rewriting its bytes corrupts payloads that legitimately contain bare CR/LF and changes the byte count the caller intends to send. Send the message verbatim. _command() still owns the protocol framing (advertising the {N} octet count, the continuation handshake, and the trailing CRLF); append() now owns only the payload bytes. Callers that relied on the normalization -- e.g. EmailMessage.as_bytes(), which uses bare LF under the default policy -- must now supply CRLF themselves (for example via email.policy.SMTP), as required by RFC 3501. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 11f032f commit 8a70a2d

4 files changed

Lines changed: 110 additions & 18 deletions

File tree

Doc/library/imaplib.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,11 +199,21 @@ An :class:`IMAP4` instance has the following methods:
199199

200200
Append *message* to named mailbox.
201201

202+
*message* is sent verbatim, without any line-ending conversion.
203+
:rfc:`3501` requires messages to use CRLF line endings, so callers must
204+
ensure this themselves; for example, serialize :mod:`email` messages using
205+
:class:`email.policy.SMTP` (which emits CRLF) rather than the default
206+
policy.
207+
202208
*flags* may be ``None`` or a string of IMAP flag tokens. Multiple
203209
flags are separated by spaces, for example ``r'\Seen \Answered'``.
204210
If *flags* is not already enclosed in parentheses, parentheses are
205211
added automatically.
206212

213+
.. versionchanged:: 3.16
214+
*message* is now sent verbatim. Previously, bare CR or LF characters
215+
were rewritten to CRLF.
216+
207217

208218
.. method:: IMAP4.authenticate(mechanism, authobject)
209219

Lib/imaplib.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,7 @@ def append(self, mailbox, flags, date_time, message):
502502
date_time = Time2Internaldate(date_time)
503503
else:
504504
date_time = None
505-
literal = MapCRLF.sub(CRLF, message)
506-
self.literal = literal
505+
self.literal = message
507506
return self._simple_command(name, mailbox, flags, date_time)
508507

509508

Lib/test/test_imaplib.py

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from test.support import socket_helper
33

44
from contextlib import contextmanager
5+
from email.message import EmailMessage
56
import imaplib
67
import os.path
78
import socketserver
@@ -27,6 +28,25 @@
2728
CAFILE = os.path.join(os.path.dirname(__file__) or os.curdir, "certdata", "pycacert.pem")
2829

2930

31+
def _read_append_literal(handler, args):
32+
literal = args[-1]
33+
trailer = b'\r\n'
34+
if literal.startswith('('):
35+
literal = literal[1:]
36+
trailer = b')\r\n'
37+
if literal.startswith('~'):
38+
literal = literal[1:]
39+
match = re.fullmatch(r'\{(\d+)\}', literal)
40+
if match is None:
41+
raise AssertionError(f"unexpected APPEND literal marker: {args[-1]!r}")
42+
literal_size = int(match.group(1))
43+
44+
handler._send_textline('+')
45+
payload = handler.rfile.read(literal_size)
46+
received_trailer = handler.rfile.read(len(trailer))
47+
return payload, received_trailer
48+
49+
3050
class TestImaplib(unittest.TestCase):
3151

3252
def test_Internaldate2tuple(self):
@@ -371,12 +391,9 @@ def cmd_AUTHENTICATE(self, tag, args):
371391
self.server.response = yield
372392
self._send_tagged(tag, 'OK', 'FAKEAUTH successful')
373393
def cmd_APPEND(self, tag, args):
374-
self._send_textline('+')
375394
self.server.response = args
376-
literal = yield
377-
self.server.response.append(literal)
378-
literal = yield
379-
self.server.response.append(literal)
395+
payload, trailer = _read_append_literal(self, args)
396+
self.server.response.extend([payload, trailer])
380397
self._send_tagged(tag, 'OK', 'okay')
381398
client, server = self._setup(UTF8AppendServer)
382399
self.assertEqual(client._encoding, 'ascii')
@@ -387,14 +404,45 @@ def cmd_APPEND(self, tag, args):
387404
self.assertEqual(code, 'OK')
388405
self.assertEqual(client._encoding, 'utf-8')
389406
msg_string = 'Subject: üñí©öðé'
390-
typ, data = client.append(
391-
None, None, None, (msg_string + '\n').encode('utf-8'))
407+
msg = (msg_string + '\n').encode('utf-8')
408+
self.assertEqual(len(msg), 24)
409+
typ, data = client.append(None, None, None, msg)
392410
self.assertEqual(typ, 'OK')
393411
self.assertEqual(server.response,
394412
['INBOX', 'UTF8',
395-
'(~{25}', ('%s\r\n' % msg_string).encode('utf-8'),
413+
'(~{24}', msg,
396414
b')\r\n' ])
397415

416+
def test_append_preserves_message_line_endings(self):
417+
class AppendServer(SimpleIMAPHandler):
418+
def cmd_APPEND(self, tag, args):
419+
payload, trailer = _read_append_literal(self, args)
420+
self.server.responses.append(args + [payload, trailer])
421+
self._send_tagged(tag, 'OK', 'okay')
422+
423+
client, server = self._setup(AppendServer)
424+
server.responses = []
425+
typ, _ = client.login('user', 'pass')
426+
self.assertEqual(typ, 'OK')
427+
428+
msg = b"one\ntwo\rthree\r\nfour"
429+
self.assertEqual(len(msg), 19)
430+
typ, _ = client.append(None, None, None, msg)
431+
self.assertEqual(typ, 'OK')
432+
self.assertEqual(server.responses[-1],
433+
['INBOX', '{19}', msg, b'\r\n'])
434+
435+
email = EmailMessage()
436+
email['Subject'] = 'line endings'
437+
email.set_content('body line\n')
438+
msg = email.as_bytes()
439+
self.assertIn(b'\n', msg)
440+
self.assertNotIn(b'\r\n', msg)
441+
typ, _ = client.append(None, None, None, msg)
442+
self.assertEqual(typ, 'OK')
443+
self.assertEqual(server.responses[-1],
444+
['INBOX', f'{{{len(msg)}}}', msg, b'\r\n'])
445+
398446
def test_search_disallows_charset_in_utf8_mode(self):
399447
class UTF8Server(SimpleIMAPHandler):
400448
capabilities = 'AUTH ENABLE UTF8=ACCEPT'
@@ -925,12 +973,9 @@ def test_enable_UTF8_True_append(self):
925973

926974
class UTF8AppendServer(self.UTF8Server):
927975
def cmd_APPEND(self, tag, args):
928-
self._send_textline('+')
929976
self.server.response = args
930-
literal = yield
931-
self.server.response.append(literal)
932-
literal = yield
933-
self.server.response.append(literal)
977+
payload, trailer = _read_append_literal(self, args)
978+
self.server.response.extend([payload, trailer])
934979
self._send_tagged(tag, 'OK', 'okay')
935980

936981
with self.reaped_pair(UTF8AppendServer) as (server, client):
@@ -943,14 +988,47 @@ def cmd_APPEND(self, tag, args):
943988
self.assertEqual(code, 'OK')
944989
self.assertEqual(client._encoding, 'utf-8')
945990
msg_string = 'Subject: üñí©öðé'
946-
typ, data = client.append(
947-
None, None, None, (msg_string + '\n').encode('utf-8'))
991+
msg = (msg_string + '\n').encode('utf-8')
992+
self.assertEqual(len(msg), 24)
993+
typ, data = client.append(None, None, None, msg)
948994
self.assertEqual(typ, 'OK')
949995
self.assertEqual(server.response,
950996
['INBOX', 'UTF8',
951-
'(~{25}', ('%s\r\n' % msg_string).encode('utf-8'),
997+
'(~{24}', msg,
952998
b')\r\n' ])
953999

1000+
@threading_helper.reap_threads
1001+
def test_append_preserves_message_line_endings(self):
1002+
1003+
class AppendServer(SimpleIMAPHandler):
1004+
def cmd_APPEND(self, tag, args):
1005+
payload, trailer = _read_append_literal(self, args)
1006+
self.server.responses.append(args + [payload, trailer])
1007+
self._send_tagged(tag, 'OK', 'okay')
1008+
1009+
with self.reaped_pair(AppendServer) as (server, client):
1010+
server.responses = []
1011+
typ, _ = client.login('user', 'pass')
1012+
self.assertEqual(typ, 'OK')
1013+
1014+
msg = b"one\ntwo\rthree\r\nfour"
1015+
self.assertEqual(len(msg), 19)
1016+
typ, _ = client.append(None, None, None, msg)
1017+
self.assertEqual(typ, 'OK')
1018+
self.assertEqual(server.responses[-1],
1019+
['INBOX', '{19}', msg, b'\r\n'])
1020+
1021+
email = EmailMessage()
1022+
email['Subject'] = 'line endings'
1023+
email.set_content('body line\n')
1024+
msg = email.as_bytes()
1025+
self.assertIn(b'\n', msg)
1026+
self.assertNotIn(b'\r\n', msg)
1027+
typ, _ = client.append(None, None, None, msg)
1028+
self.assertEqual(typ, 'OK')
1029+
self.assertEqual(server.responses[-1],
1030+
['INBOX', f'{{{len(msg)}}}', msg, b'\r\n'])
1031+
9541032
# XXX also need a test that makes sure that the Literal and Untagged_status
9551033
# regexes uses unicode in UTF8 mode instead of the default ASCII.
9561034

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
:meth:`imaplib.IMAP4.append` now sends the message verbatim instead of
2+
rewriting bare CR or LF bytes to CRLF. Since :rfc:`3501` requires messages to
3+
use CRLF line endings, callers must now supply them themselves; for example,
4+
serialize :mod:`email` messages with :class:`email.policy.SMTP` (which emits
5+
CRLF) rather than the default policy.

0 commit comments

Comments
 (0)