From feb001e17fb012f8008b8cc488b75e9f5b75b4f8 Mon Sep 17 00:00:00 2001 From: David Hobley Date: Wed, 23 Sep 2026 08:36:14 +1000 Subject: [PATCH 1/2] fix(imap): handle mailbox names containing parentheses Two independent failures for a mailbox such as INBOX.Financial.Audit(s): ListParser, for an extended LIST response (LIST-STATUS / LIST-EXTENDED return options), found the extended-data block with indexOf('(') which matched the '(' inside the quoted mailbox name. The name was truncated (taking the closing quote with it), so the later quote-strip ate the final character and the mailbox surfaced as 'Aud' at a path that did not exist on the server. It now looks for the first '(' outside a double-quoted string. _encodeMailboxPath only quoted a path containing a space. RFC 3501 section 9 lists '(' ')' '{' among the atom-specials, so an unquoted `COPY 1:3 INBOX.Audit(s)` (or SELECT, MOVE, CREATE, ...) is rejected by servers with "BAD Invalid characters in atom". Those characters now force quoting as a space does. MockImapServer records what the client sends so tests can assert on the command text actually written. --- lib/src/imap/imap_client.dart | 6 +++ lib/src/private/imap/list_parser.dart | 24 +++++++++- test/imap/imap_client_test.dart | 65 +++++++++++++++++++++++++++ test/imap/mock_imap_server.dart | 5 +++ 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index 2d90f3ec..12320c41 100644 --- a/lib/src/imap/imap_client.dart +++ b/lib/src/imap/imap_client.dart @@ -1302,7 +1302,13 @@ class ImapClient extends ClientBase { } final pathSeparator = serverInfo.pathSeparator ?? '/'; var encodedPath = Mailbox.encode(path, pathSeparator); + // RFC 3501: '(' ')' '{' are atom-specials and may not appear in an + // unquoted atom, so a mailbox such as "Audit(s)" has to be quoted or + // the server rejects the command with "BAD Invalid characters in atom". if (encodedPath.contains(' ') || + encodedPath.contains('(') || + encodedPath.contains(')') || + encodedPath.contains('{') || (alwaysQuote && !encodedPath.startsWith('"'))) { encodedPath = '"$encodedPath"'; } diff --git a/lib/src/private/imap/list_parser.dart b/lib/src/private/imap/list_parser.dart index 414cad73..a98c75af 100644 --- a/lib/src/private/imap/list_parser.dart +++ b/lib/src/private/imap/list_parser.dart @@ -81,9 +81,14 @@ class ListParser extends ResponseParser> { // Parses extended data final boxExtendedData = >{}; if (isExtended) { - final extraInfoStartIndex = listDetails.indexOf('('); + // Only a '(' outside a double-quoted string opens the extended data. + // Mailbox names may contain parentheses (e.g. "INBOX.Audit(s)") and + // those must not be mistaken for the extended-data delimiter. + final extraInfoStartIndex = _firstUnquotedOpenParen(listDetails); final extraInfoEndIndex = listDetails.lastIndexOf(')'); - if (extraInfoEndIndex != -1 && extraInfoStartIndex < extraInfoEndIndex) { + if (extraInfoStartIndex != -1 && + extraInfoEndIndex != -1 && + extraInfoStartIndex < extraInfoEndIndex) { final extraInfo = listDetails.substring( extraInfoStartIndex + 1, extraInfoEndIndex, @@ -143,6 +148,21 @@ class ListParser extends ResponseParser> { boxes.add(box); } + /// Returns the index of the first '(' that is not inside a double-quoted + /// string, or -1 if there is none. + static int _firstUnquotedOpenParen(String s) { + var inQuote = false; + for (var i = 0; i < s.length; i++) { + if (s[i] == '"') { + inQuote = !inQuote; + } else if (s[i] == '(' && !inQuote) { + return i; + } + } + + return -1; + } + void _addFlags( int flagsStartIndex, int flagsEndIndex, diff --git a/test/imap/imap_client_test.dart b/test/imap/imap_client_test.dart index 7c712d48..6405b363 100644 --- a/test/imap/imap_client_test.dart +++ b/test/imap/imap_client_test.dart @@ -220,6 +220,53 @@ void main() { ); }); + test('ImapClient listMailboxes via LIST-STATUS preserves parentheses in ' + 'mailbox names', () async { + // Regression: with isExtended=true (LIST-STATUS return options), the + // extended-data parser did listDetails.indexOf('(') which found the '(' + // inside "INBOX.Financial.Audit(s)" and treated 's)' as extended data. + // The path was truncated (removing the closing '"' too), so the + // subsequent quote-strip ate the 'i', leaving name='Aud' and a path + // that did not exist on the server. + mockServer.response = + '* LIST (\\HasChildren \\UnMarked) "." "INBOX.Financial.Audit(s)"\r\n' + '* STATUS "INBOX.Financial.Audit(s)" (MESSAGES 0 UNSEEN 0)\r\n' + '* LIST (\\HasNoChildren \\UnMarked) "." "INBOX.Financial.Home Hardware"\r\n' + '* STATUS "INBOX.Financial.Home Hardware" (MESSAGES 40 UNSEEN 0)\r\n' + ' OK List completed (0.001 + 0.000 secs).'; + final listResponse = await client.listMailboxes( + path: '"INBOX.Financial."', + recursive: false, + returnOptions: [ + ReturnOption.status(['MESSAGES', 'UNSEEN']), + ReturnOption.children(), + ], + ); + expect(listResponse, hasLength(2)); + + final audit = listResponse[0]; + expect( + audit.name, + equals('Audit(s)'), + reason: 'name was truncated at "(" inside mailbox name', + ); + expect( + audit.path, + equals('INBOX.Financial.Audit(s)'), + reason: 'path was truncated at "(" inside mailbox name', + ); + expect(audit.hasChildren, isTrue); + + final homeHardware = listResponse[1]; + expect( + homeHardware.name, + equals('Home Hardware'), + reason: 'quoted name with space should be preserved', + ); + expect(homeHardware.path, equals('INBOX.Financial.Home Hardware')); + expect(homeHardware.messagesExists, equals(40)); + }); + test('ImapClient LSUB', () async { mockServer.response = '* LSUB (\\HasChildren \\Marked) "/" INBOX\r\n' @@ -1172,6 +1219,24 @@ void main() { ); }); + test('ImapClient copy quotes a target path containing parentheses', () async { + // '(' and ')' are atom-specials (RFC 3501 section 9), so an unquoted + // `COPY 1:3 INBOX.Financial.Audit(s)` is rejected by servers with + // "BAD Invalid characters in atom". _encodeMailboxPath used to quote a + // path only when it contained a space. + await _selectInbox(); + mockServer.response = ' OK messages copied'; + await client.copy( + MessageSequence.fromRange(1, 3), + targetMailboxPath: 'INBOX.Financial.Audit(s)', + ); + + expect( + mockServer.requests.last, + contains(' COPY 1:3 "INBOX.Financial.Audit(s)"'), + ); + }); + test('ImapClient uid copy', () async { await _selectInbox(); mockServer.response = diff --git a/test/imap/mock_imap_server.dart b/test/imap/mock_imap_server.dart index 47d56058..db3d3724 100644 --- a/test/imap/mock_imap_server.dart +++ b/test/imap/mock_imap_server.dart @@ -21,8 +21,13 @@ class MockImapServer { String? response; String? _overrideTag; + /// Everything the client has sent, one entry per received chunk, so tests + /// can assert on the command text actually written to the socket. + final requests = []; + void parseRequest(Uint8List data) { final line = String.fromCharCodes(data); + requests.add(line); // print('C: $line'); final firstSpaceIndex = line.indexOf(' '); String? tag = firstSpaceIndex == -1 From a695816e7891506dd96e102cbfc125fdde1e40ba Mon Sep 17 00:00:00 2001 From: David Hobley Date: Wed, 23 Sep 2026 08:38:56 +1000 Subject: [PATCH 2/2] feat(imap): APPEND with an explicit INTERNALDATE and from raw bytes Two additions to APPEND, both needed when copying a fetched message into another mailbox or account: appendMessage/appendMessageText gain an optional `internalDate`, sent as the command's optional date-time argument (RFC 3501 section 6.3.11) so the server records the original date as INTERNALDATE rather than the time of the append. Most servers sort and display by INTERNALDATE, so without it every copied message lands at "now". appendMessageBytes appends already-encoded MIME source as bytes. appendMessageText computes `{n}` over utf8.encode(text) and the socket writes UTF-8, which agrees for a message rendered as a String. A message fetched with BODY[] is not that: it is CHAR8 in whatever transfer encoding it was sent with, and turning it into a String to go through appendMessageText re-encodes every byte above 0x7F as two bytes on the wire, corrupting the message and desyncing the declared literal length. Command.withRawContinuation carries the literal as bytes to writeData so the declared and transmitted lengths stay identical. The shared command-line construction is factored into _buildAppendCommandText. MockImapServer records the last request so tests can assert on the command text actually sent. --- lib/src/imap/imap_client.dart | 123 +++++++++++++++++++++++++++--- lib/src/private/imap/command.dart | 50 ++++++++++++ test/imap/imap_client_test.dart | 83 ++++++++++++++++++++ 3 files changed, 245 insertions(+), 11 deletions(-) diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index 12320c41..61e1b360 100644 --- a/lib/src/imap/imap_client.dart +++ b/lib/src/imap/imap_client.dart @@ -1919,6 +1919,9 @@ class ImapClient extends ClientBase { /// When no [targetMailbox] or [targetMailboxPath] is specified, then the /// message will be appended to the currently selected mailbox. /// You can specify flags such as `\Seen` or `\Draft` in the [flags] parameter. + /// [internalDate], when given, is sent as the APPEND command's optional + /// date-time so the server records that as the message's INTERNALDATE + /// instead of the time of the append. Most servers sort and display by it. /// Specify a [responseTimeout] when a response is expected within the /// given time. /// Compare also the [appendMessageText] method. @@ -1927,12 +1930,14 @@ class ImapClient extends ClientBase { List? flags, Mailbox? targetMailbox, String? targetMailboxPath, + DateTime? internalDate, Duration? responseTimeout, }) => appendMessageText( message.renderMessage(), flags: flags, targetMailbox: targetMailbox, targetMailboxPath: targetMailboxPath, + internalDate: internalDate, responseTimeout: responseTimeout, ); @@ -1941,16 +1946,90 @@ class ImapClient extends ClientBase { /// When no [targetMailbox] or [targetMailboxPath] is specified, then the /// message will be appended to the currently selected mailbox. /// You can specify flags such as `\Seen` or `\Draft` in the [flags] parameter. + /// [internalDate], when given, is sent as the APPEND command's optional + /// date-time (RFC 3501: `[SP date-time] SP literal`) so the server records + /// that as the message's INTERNALDATE instead of the time of the append. /// Specify a [responseTimeout] when a response is expected within the /// given time. - /// Compare also the [appendMessage] method. + /// Compare also the [appendMessage] and [appendMessageBytes] methods. Future appendMessageText( String messageText, { List? flags, Mailbox? targetMailbox, String? targetMailboxPath, + DateTime? internalDate, Duration? responseTimeout, }) { + final numberOfBytes = utf8.encode(messageText).length; + final cmdText = _buildAppendCommandText( + targetMailbox, + targetMailboxPath, + flags, + internalDate, + numberOfBytes, + ); + final cmd = Command.withContinuation([ + cmdText, + messageText, + ], responseTimeout: responseTimeout); + + return sendCommand( + cmd, + GenericParser(this, _selectedMailbox), + ); + } + + /// Appends the specified MIME message given as raw [messageBytes]. + /// + /// Byte-exact counterpart to [appendMessageText]: that method's `{n}` byte + /// count and the socket's `IOSink.write` both agree on UTF-8, which is + /// right for a message rendered as a `String`. This method exists for + /// callers holding already-encoded MIME source as bytes, e.g. a message + /// fetched with `BODY[]` being copied to another account. Sending those + /// through [appendMessageText] would first turn them into a `String`, and + /// any byte outside 7-bit ASCII would come back out re-encoded as + /// multi-byte UTF-8, corrupting the message and desyncing the declared + /// literal length from what is actually sent. Here `{n}` is computed over + /// [messageBytes] directly and the same bytes reach the socket, via + /// [Command.withRawContinuation]. + /// + /// See [appendMessageText] for the other parameters. + Future appendMessageBytes( + Uint8List messageBytes, { + List? flags, + Mailbox? targetMailbox, + String? targetMailboxPath, + DateTime? internalDate, + Duration? responseTimeout, + }) { + final cmdText = _buildAppendCommandText( + targetMailbox, + targetMailboxPath, + flags, + internalDate, + messageBytes.length, + ); + final cmd = Command.withRawContinuation( + cmdText, + messageBytes, + responseTimeout: responseTimeout, + ); + + return sendCommand( + cmd, + GenericParser(this, _selectedMailbox), + ); + } + + /// Builds the APPEND command line up to and including the literal size, + /// `APPEND mailbox [(flags)] ["date-time"] {n}`. + String _buildAppendCommandText( + Mailbox? targetMailbox, + String? targetMailboxPath, + List? flags, + DateTime? internalDate, + int numberOfBytes, + ) { final path = _encodeFirstMailboxPath( targetMailbox, targetMailboxPath, @@ -1965,21 +2044,34 @@ class ImapClient extends ClientBase { ..write(flags.join(' ')) ..write(')'); } - final numberOfBytes = utf8.encode(messageText).length; + if (internalDate != null) { + buffer + ..write(' "') + ..write(_encodeAppendDateTime(internalDate)) + ..write('"'); + } buffer ..write(' {') ..write(numberOfBytes) ..write('}'); - final cmdText = buffer.toString(); - final cmd = Command.withContinuation([ - cmdText, - messageText, - ], responseTimeout: responseTimeout); - return sendCommand( - cmd, - GenericParser(this, _selectedMailbox), - ); + return buffer.toString(); + } + + static const _appendMonths = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', // + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + + /// Formats [dateTime] as RFC 3501's `date-time`, e.g. + /// `05-Jan-2026 10:00:00 +0000`, for the optional date-time argument of + /// the APPEND command. + static String _encodeAppendDateTime(DateTime dateTime) { + final d = dateTime.toUtc(); + String two(int n) => n.toString().padLeft(2, '0'); + + return '${two(d.day)}-${_appendMonths[d.month - 1]}-${d.year} ' + '${two(d.hour)}:${two(d.minute)}:${two(d.second)} +0000'; } /// Retrieves the specified meta data entry. @@ -2759,6 +2851,15 @@ class ImapClient extends ClientBase { Future onContinuationResponse(ImapResponse imapResponse) async { final cmd = _currentCommandTask?.command; if (cmd != null) { + final rawData = cmd.getRawContinuationResponse(); + if (rawData != null) { + // The literal is still terminated by CRLF, the same as the text path + // below, just appended to the raw bytes instead of a String so that + // nothing here re-encodes them. + await writeData(Uint8List.fromList([...rawData, 13, 10])); + + return; + } final response = cmd.getContinuationResponse(imapResponse); if (response != null) { await writeText(response); diff --git a/lib/src/private/imap/command.dart b/lib/src/private/imap/command.dart index 268c55ab..ce7fd069 100644 --- a/lib/src/private/imap/command.dart +++ b/lib/src/private/imap/command.dart @@ -11,6 +11,7 @@ class Command { this.commandText, { this.logText, this.parts, + this.rawContinuationData, this.writeTimeout, this.responseTimeout, }); @@ -29,6 +30,34 @@ class Command { responseTimeout: responseTimeout, ); + /// Creates a command whose continuation payload is raw bytes rather than + /// text. + /// + /// Every other continuation command sends text that the socket's default + /// UTF-8 `IOSink` encoding round-trips exactly (search terms, message + /// flags: genuine Unicode content). APPEND's literal is not that: it is + /// already-encoded MIME source (RFC 3501's `CHAR8`), and its `{n}` byte + /// count is computed over those exact bytes. Routing it through the + /// `String`-based `writeText` re-encodes any byte outside 7-bit ASCII as + /// multi-byte UTF-8 on the wire, corrupting the message and, because the + /// declared `{n}` no longer matches what is sent, desyncing the + /// connection. This constructor keeps the byte count and the transmitted + /// bytes identical by carrying the literal as bytes all the way to the + /// socket's raw `writeData` path. + Command.withRawContinuation( + String commandText, + List rawBytes, { + String? logText, + Duration? writeTimeout, + Duration? responseTimeout, + }) : this( + commandText, + rawContinuationData: rawBytes, + logText: logText, + writeTimeout: writeTimeout, + responseTimeout: responseTimeout, + ); + /// The command text final String commandText; @@ -38,9 +67,15 @@ class Command { /// The optional command parts for multiline-requests final List? parts; + /// The optional raw-bytes continuation payload, see + /// [Command.withRawContinuation]. + final List? rawContinuationData; + /// The current part index of multiline-requests int _currentPartIndex = 1; + bool _rawContinuationConsumed = false; + /// The command specific write timeout final Duration? writeTimeout; @@ -61,6 +96,21 @@ class Command { return nextPart; } + + /// The raw-bytes continuation payload, if this command carries one and it + /// has not already been sent. + List? getRawContinuationResponse() { + if (_rawContinuationConsumed) { + return null; + } + final data = rawContinuationData; + if (data == null) { + return null; + } + _rawContinuationConsumed = true; + + return data; + } } /// Contains an IMAP command task diff --git a/test/imap/imap_client_test.dart b/test/imap/imap_client_test.dart index 6405b363..7e68ff2b 100644 --- a/test/imap/imap_client_test.dart +++ b/test/imap/imap_client_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'dart:typed_data'; import 'package:enough_mail/enough_mail.dart'; import 'package:enough_mail/src/private/util/client_base.dart'; @@ -1505,6 +1506,88 @@ void main() { expect(uidResponseCode?.targetSequence.toList().first, 176); }); + test( + 'ImapClient append with internalDate sends RFC 3501 date-time', + () async { + await _selectInbox(); + final message = MessageBuilder.buildSimpleTextMessage( + const MailAddress('User Name', 'user.name@domain.com'), + [const MailAddress('Rita Recpient', 'rr@domain.com')], + 'Hey,\r\nhow are things today?', + subject: 'Appended with a date', + ); + mockServer.response = + '+ OK\r\n' + ' OK [APPENDUID 1466002016 178] Append completed.'; + final appendResponse = await client.appendMessage( + message, + flags: [r'\Seen'], + internalDate: DateTime.utc(2026, 1, 5, 10, 0, 0), + ); + + expect( + appendResponse.responseCodeAppendUid?.targetSequence.toList().first, + 178, + ); + expect( + mockServer.requests.join(), + contains(' APPEND INBOX (\\Seen) "05-Jan-2026 10:00:00 +0000" {'), + ); + }, + ); + + test( + 'ImapClient append with a non-UTC internalDate is sent in UTC', + () async { + await _selectInbox(); + mockServer.response = + '+ OK\r\n' + ' OK [APPENDUID 1466002016 179] Append completed.'; + await client.appendMessageText( + 'Subject: tz\r\n\r\nbody', + internalDate: DateTime.utc(2026, 6, 30, 23, 30).toLocal(), + ); + + expect( + mockServer.requests.join(), + contains(' APPEND INBOX "30-Jun-2026 23:30:00 +0000" {'), + ); + }, + ); + + // appendMessageText sends its literal through the socket's default UTF-8 + // IOSink.write: fine for genuine text, but a byte outside 7-bit ASCII + // comes back out re-encoded as two bytes on the wire, corrupting an + // already-encoded MIME source and desyncing the `{n}` byte count declared + // in the command line from what is actually sent. appendMessageBytes + // exists for exactly that case and must reach the wire unchanged. + test('ImapClient appendMessageBytes sends raw bytes unchanged, including ' + 'a byte outside 7-bit ASCII', () async { + await _selectInbox(); + final rawBytes = Uint8List.fromList([ + ...'Subject: raw\r\n\r\n'.codeUnits, + 0xE9, // outside ASCII: would double-encode if routed through UTF-8. + 0x41, + ]); + mockServer.response = + '+ OK\r\n' + ' OK [APPENDUID 1466002016 177] Append completed.'; + final appendResponse = await client.appendMessageBytes( + rawBytes, + targetMailboxPath: 'INBOX', + ); + + expect( + appendResponse.responseCodeAppendUid?.targetSequence.toList().first, + 177, + ); + final sent = mockServer.requests.join(); + expect(sent, contains(' APPEND INBOX {${rawBytes.length}}\r\n')); + // The mock maps each received byte to one code unit, so a byte that + // had been re-encoded as UTF-8 would show up here as two characters. + expect(sent, endsWith(String.fromCharCodes([...rawBytes, 13, 10]))); + }); + test('ImapClient idle', () async { final box = await _selectInbox(); expungedMessages = [];