diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index 2d90f3ec..61e1b360 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"'; } @@ -1913,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. @@ -1921,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, ); @@ -1935,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, @@ -1959,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. @@ -2753,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/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..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'; @@ -220,6 +221,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 +1220,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 = @@ -1440,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 = []; 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