Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 118 additions & 11 deletions lib/src/imap/imap_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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"';
}
Expand Down Expand Up @@ -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.
Expand All @@ -1921,12 +1930,14 @@ class ImapClient extends ClientBase {
List<String>? flags,
Mailbox? targetMailbox,
String? targetMailboxPath,
DateTime? internalDate,
Duration? responseTimeout,
}) => appendMessageText(
message.renderMessage(),
flags: flags,
targetMailbox: targetMailbox,
targetMailboxPath: targetMailboxPath,
internalDate: internalDate,
responseTimeout: responseTimeout,
);

Expand All @@ -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<GenericImapResult> appendMessageText(
String messageText, {
List<String>? 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<GenericImapResult>(
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<GenericImapResult> appendMessageBytes(
Uint8List messageBytes, {
List<String>? 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<GenericImapResult>(
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<String>? flags,
DateTime? internalDate,
int numberOfBytes,
) {
final path = _encodeFirstMailboxPath(
targetMailbox,
targetMailboxPath,
Expand All @@ -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<GenericImapResult>(
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.
Expand Down Expand Up @@ -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);
Expand Down
50 changes: 50 additions & 0 deletions lib/src/private/imap/command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Command {
this.commandText, {
this.logText,
this.parts,
this.rawContinuationData,
this.writeTimeout,
this.responseTimeout,
});
Expand All @@ -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<int> rawBytes, {
String? logText,
Duration? writeTimeout,
Duration? responseTimeout,
}) : this(
commandText,
rawContinuationData: rawBytes,
logText: logText,
writeTimeout: writeTimeout,
responseTimeout: responseTimeout,
);

/// The command text
final String commandText;

Expand All @@ -38,9 +67,15 @@ class Command {
/// The optional command parts for multiline-requests
final List<String>? parts;

/// The optional raw-bytes continuation payload, see
/// [Command.withRawContinuation].
final List<int>? rawContinuationData;

/// The current part index of multiline-requests
int _currentPartIndex = 1;

bool _rawContinuationConsumed = false;

/// The command specific write timeout
final Duration? writeTimeout;

Expand All @@ -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<int>? getRawContinuationResponse() {
if (_rawContinuationConsumed) {
return null;
}
final data = rawContinuationData;
if (data == null) {
return null;
}
_rawContinuationConsumed = true;

return data;
}
}

/// Contains an IMAP command task
Expand Down
24 changes: 22 additions & 2 deletions lib/src/private/imap/list_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,14 @@ class ListParser extends ResponseParser<List<Mailbox>> {
// Parses extended data
final boxExtendedData = <String, List<String>>{};
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,
Expand Down Expand Up @@ -143,6 +148,21 @@ class ListParser extends ResponseParser<List<Mailbox>> {
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,
Expand Down
Loading