From 27b4cfc04a25e8a3f5688797e0d490e2523205e7 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 10:27:27 +0200 Subject: [PATCH 01/33] fix(mail): enforce STARTTLS instead of silently falling back to clear text With SocketType.starttls or plain the IMAP and SMTP connect paths used to authenticate over the unencrypted connection whenever the server did not advertise STARTTLS, so a MITM only had to strip the capability to obtain the credentials. Both paths now issue CAPABILITY/EHLO first, refuse to authenticate unless plainNoStartTls was chosen explicitly, and discard the pre-TLS capabilities after the upgrade (RFC 3501 6.2.1, RFC 3207 4.2). The TLS upgrade now also verifies the certificate against the configured host name. Co-Authored-By: Claude Fable 5.1 --- lib/src/imap/imap_client.dart | 15 + lib/src/mail/mail_client.dart | 53 +- lib/src/private/util/client_base.dart | 725 ++++++++++++----------- lib/src/smtp/smtp_client.dart | 25 +- test/mail/mail_client_starttls_test.dart | 120 ++++ 5 files changed, 552 insertions(+), 386 deletions(-) create mode 100644 test/mail/mail_client_starttls_test.dart diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index b663865b..74bdbb21 100644 --- a/lib/src/imap/imap_client.dart +++ b/lib/src/imap/imap_client.dart @@ -181,6 +181,17 @@ class ImapServerInfo { bool isEnabled(String capabilityName) => enabledCapabilities.firstWhereOrNull((c) => c.name == capabilityName) != null; + + /// Discards all cached capability information. + /// + /// RFC 3501 section 6.2.1 requires this after a successful `STARTTLS`: + /// everything announced before the TLS upgrade traveled over an + /// unprotected connection and may have been tampered with. + void clearCapabilities() { + capabilities = null; + capabilitiesText = null; + _supportedThreadingMethods = null; + } } /// Possible flag store actions @@ -498,6 +509,10 @@ class ImapClient extends ClientBase { ); log('STARTTLS: upgrading socket to secure one...', initial: 'A'); await upgradeToSslSocket(); + // RFC 3501 section 6.2.1: the capabilities received before the upgrade + // came over an unprotected connection and must be discarded. + serverInfo.clearCapabilities(); + await capability(); return response; } diff --git a/lib/src/mail/mail_client.dart b/lib/src/mail/mail_client.dart index 99a586d0..77d3c5f4 100644 --- a/lib/src/mail/mail_client.dart +++ b/lib/src/mail/mail_client.dart @@ -2102,18 +2102,32 @@ class _IncomingImapClient extends _IncomingMailClient { isSecure: isSecure, timeout: timeout, ); - if (!isSecure) { - if (_imapClient.serverInfo.supportsStartTls && - (serverConfig.socketType != SocketType.plainNoStartTls)) { - await _imapClient.startTls(); - } else { - log( - 'Warning: connecting without encryption, ' - 'your credentials are not secure.', - ); - } - } try { + if (!isSecure) { + if (serverConfig.socketType == SocketType.plainNoStartTls) { + log( + 'Warning: connecting without encryption, ' + 'your credentials are not secure.', + ); + } else { + // the greeting does not have to announce the capabilities + if (_imapClient.serverInfo.capabilities?.isEmpty ?? true) { + await _imapClient.capability(); + } + if (!_imapClient.serverInfo.supportsStartTls) { + // never fall back to clear text silently, an attacker could + // just strip the STARTTLS capability to obtain the credentials + await _imapClient.disconnect(); + throw ImapException( + _imapClient, + 'STARTTLS is not supported by ${serverConfig.hostname}, ' + 'refusing to authenticate over an unencrypted connection. ' + 'Use SocketType.plainNoStartTls to allow this explicitly.', + ); + } + await _imapClient.startTls(); + } + } await _config.authentication.authenticate( serverConfig, imap: _imapClient, @@ -3594,14 +3608,23 @@ class _OutgoingSmtpClient extends _OutgoingMailClient { ); await _smtpClient.ehlo(); if (!isSecure) { - if (_smtpClient.serverInfo.supportsStartTls && - (config.socketType != SocketType.plainNoStartTls)) { - await _smtpClient.startTls(); - } else { + if (config.socketType == SocketType.plainNoStartTls) { _smtpClient.logApp( 'Warning: not using secure connection, ' 'your credentials are not secure.', ); + } else if (!_smtpClient.serverInfo.supportsStartTls) { + // never fall back to clear text silently, an attacker could + // just strip the STARTTLS capability to obtain the credentials + await _smtpClient.disconnect(); + throw SmtpException.message( + _smtpClient, + 'STARTTLS is not supported by ${config.hostname}, ' + 'refusing to authenticate over an unencrypted connection. ' + 'Use SocketType.plainNoStartTls to allow this explicitly.', + ); + } else { + await _smtpClient.startTls(); } } await _mailConfig.authentication.authenticate( diff --git a/lib/src/private/util/client_base.dart b/lib/src/private/util/client_base.dart index a3249edc..b98b9f2d 100644 --- a/lib/src/private/util/client_base.dart +++ b/lib/src/private/util/client_base.dart @@ -1,361 +1,364 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; - -/// Provides connection information -class ConnectionInfo { - /// Creates a new connection info - const ConnectionInfo(this.host, this.port, {required this.isSecure}); - - /// The host - final String host; - - /// The port - final int port; - - /// `true` when a secure socket is used - final bool isSecure; -} - -/// Base class for socket-based clients -abstract class ClientBase { - /// Creates a new base client - /// - /// Set [isLogEnabled] to `true` to see log output. - /// - /// Set the [logName] for adding the name to each log entry. - /// - /// [onBadCertificate] is an optional handler for unverifiable certificates. - /// The handler receives the [X509Certificate], and can inspect it and decide - /// (or let the user decide) whether to accept the connection or not. - /// The handler should return true to continue the [SecureSocket] connection. - /// - /// [securityContext] is an optional security context for mTLS - /// (mutual TLS / client certificate authentication). - /// Create a [SecurityContext] with `useCertificateChain()` and - /// `usePrivateKey()` to enable client certificate authentication. - ClientBase({ - this.isLogEnabled = false, - this.logName, - this.onBadCertificate, - this.securityContext, - }); - - /// Initial for a client log output - static const String initialClient = 'C'; - - /// Initial for a server log output - static const String initialServer = 'S'; - - /// Initial for an app log output - static const String initialApp = 'A'; - - /// The name shown in log entries to differentiate this server - String? logName; - - /// `true` when the log is enabled - bool isLogEnabled; - - late Socket _socket; - - /// `true` when it is expected that the socket is closed - bool isSocketClosingExpected = false; - - /// `true` after the user has authenticated - bool isLoggedIn = false; - - bool _isServerGreetingDone = false; - - /// Information about the connection - late ConnectionInfo connectionInfo; - late Completer _greetingsCompleter; - - bool _isConnected = false; - - /// Ist the client currently connected? - bool get isConnected => _isConnected; - - /// Handles unverifiable certificates. - /// - /// The handler receives the [X509Certificate], and can inspect it and decide - /// (or let the user decide) whether to accept the connection or not. - /// The handler should return true to continue the [SecureSocket] connection. - final bool Function(X509Certificate)? onBadCertificate; - - /// Optional [SecurityContext] for mTLS (mutual TLS). - /// - /// When set, it is passed to [SecureSocket.connect] and - /// [SecureSocket.secure] to enable client certificate authentication. - final SecurityContext? securityContext; - - /// Is called when data is received - void onDataReceived(Uint8List data); - - /// Is called after the initial connection has been established - FutureOr onConnectionEstablished( - ConnectionInfo connectionInfo, - String serverGreeting, - ); - - /// Is called when the connection encountered an error - void onConnectionError(dynamic error); - - late StreamSubscription _socketStreamSubscription; - - /// Connects to the specified server. - /// - /// Specify [isSecure] if you do not want to connect to a secure service. - /// - /// Specify [timeout] to specify a different timeout for the connection. - /// This defaults to 20 seconds. - Future connectToServer( - String host, - int port, { - bool isSecure = true, - Duration timeout = const Duration(seconds: 20), - }) async { - logApp( - 'connecting to server $host:$port - ' - 'secure: $isSecure, timeout: $timeout', - ); - connectionInfo = ConnectionInfo(host, port, isSecure: isSecure); - final socket = isSecure - ? await SecureSocket.connect( - host, - port, - onBadCertificate: onBadCertificate, - context: securityContext, - ).timeout(timeout) - : await Socket.connect(host, port).timeout(timeout); - _greetingsCompleter = Completer(); - _isServerGreetingDone = false; - connect(socket); - - return _greetingsCompleter.future; - } - - /// Starts to listen on the given [socket]. - /// - /// This is mainly useful for testing purposes, ensure to set - /// [connectionInformation] manually in this case, e.g. - /// ```dart - /// await client.connect(socket, connectionInformation: - /// ConnectionInfo(host, port, isSecure)); - /// ``` - void connect(Socket socket, {ConnectionInfo? connectionInformation}) { - if (connectionInformation != null) { - connectionInfo = connectionInformation; - _greetingsCompleter = Completer(); - } - _socket = socket; - _writeFuture = null; - // if (connectionTimeout != null) { - // final timeoutStream = socket.timeout(connectionTimeout!); - // _socketStreamSubscription = timeoutStream.listen( - // _onDataReceived, - // onDone: onConnectionDone, - // onError: _onConnectionError, - // ); - // } else { - _socketStreamSubscription = socket.listen( - _onDataReceived, - onDone: onConnectionDone, - onError: _onConnectionError, - ); - // } - _isConnected = true; - isSocketClosingExpected = false; - } - - Future _onConnectionError(Object e, StackTrace s) async { - logApp('Socket error: $e $s'); - isLoggedIn = false; - _isConnected = false; - _writeFuture = null; - if (!isSocketClosingExpected) { - isSocketClosingExpected = true; - try { - await _socketStreamSubscription.cancel(); - } catch (e, s) { - logApp('Unable to cancel stream subscription: $e $s'); - } - try { - onConnectionError(e); - } catch (e, s) { - logApp('Unable to call onConnectionError: $e, $s'); - } - } - } - - /// Upgrades the current connection to a secure socket - Future upgradeToSslSocket() async { - _socketStreamSubscription.pause(); - final secureSocket = await SecureSocket.secure( - _socket, - context: securityContext, - onBadCertificate: onBadCertificate, - ); - logApp('now using secure connection.'); - await _socketStreamSubscription.cancel(); - isSocketClosingExpected = true; - _socket.destroy(); - isSocketClosingExpected = false; - connect(secureSocket); - } - - Future _onDataReceived(Uint8List data) async { - if (_isServerGreetingDone) { - onDataReceived(data); - } else { - _isServerGreetingDone = true; - final serverGreeting = String.fromCharCodes(data); - log(serverGreeting, isClient: false); - onConnectionEstablished(connectionInfo, serverGreeting); - _greetingsCompleter.complete(connectionInfo); - } - } - - /// Informs about a closed connection - void onConnectionDone() { - logApp('Done, connection closed'); - isLoggedIn = false; - _isConnected = false; - if (!isSocketClosingExpected) { - isSocketClosingExpected = true; - onConnectionError('onDone not expected'); - } - } - - /// Disconnects from the service - Future disconnect() async { - if (_isConnected) { - isLoggedIn = false; - _isConnected = false; - isSocketClosingExpected = true; - try { - await _socketStreamSubscription.cancel(); - } catch (e) { - print('unable to cancel subscription $e'); - } - try { - await _socket.close(); - } catch (e) { - print('unable to close socket $e'); - } - } - } - - Future? _writeFuture; - - /// Writes the specified [text]. - /// - /// When the log is enabled it will either log the specified [logObject] - /// or just the [text]. - /// - /// When a [timeout] is specified and occurs, it will - /// throw a [TimeoutException] after the specified time. - Future writeText(String text, [dynamic logObject, Duration? timeout]) async { - final previousWriteFuture = _writeFuture; - if (previousWriteFuture != null) { - try { - await previousWriteFuture; - } catch (e, s) { - print( - 'Unable to await previous write ' - // ignore: unawaited_futures - 'future $previousWriteFuture: $e $s', - ); - _writeFuture = null; - rethrow; - } - } - if (isLogEnabled) { - logObject ??= text; - log(logObject); - } - _socket.write('$text\r\n'); - - final future = timeout == null - ? _socket.flush() - : _socket.flush().timeout(timeout); - _writeFuture = future; - await future; - _writeFuture = null; - } - - /// Writes the specified [data]. - /// - /// When the log is enabled it will either log the specified - /// [logObject] or just the length of the data. - Future writeData(List data, [dynamic logObject]) async { - final previousWriteFuture = _writeFuture; - if (previousWriteFuture != null) { - try { - await previousWriteFuture; - } catch (e, s) { - print('Unable to await previous write future: $e $s'); - _writeFuture = null; - } - } - if (isLogEnabled) { - logObject ??= '<${data.length} bytes>'; - log(logObject); - } - _socket.add(data); - final future = _socket.flush(); - _writeFuture = future; - await future; - _writeFuture = null; - } - - /// Logs the data from the app-side - void logApp(dynamic logObject) => log(logObject, initial: initialApp); - - /// Logs the data from the client-side - void logClient(dynamic logObject) => log(logObject, initial: initialClient); - - /// Logs the data from the server-side - void logServer(dynamic logObject) => log(logObject, initial: initialServer); - - /// Logs the data - void log(dynamic logObject, {bool isClient = true, String? initial}) { - if (isLogEnabled) { - initial ??= isClient ? initialClient : initialServer; - if (logName != null) { - print('$logName $initial: $logObject'); - } else { - print('$initial: $logObject'); - } - } - } - - void _onTimeout(Completer completer, Duration duration) { - // print( - // '$completer triggers timeout after $duration on - // $this at ${DateTime.now()}'); - completer.completeError(createClientError('timeout')); - } - - /// Subclasses need to be able to create client specific exceptions - Object createClientError(String message); -} - -/// Extends Completer instances -extension ExtensionCompleter on Completer { - /// Adds a timeout to a completer - void timeout(Duration? duration, ClientBase client) { - if (duration != null) { - Future.delayed(duration).then((value) { - if (!isCompleted) { - client._onTimeout(this, duration); - } - }); - } - } -} - -// class _QueuedText { -// final String text; -// final dynamic logObject; -// _QueuedText(this.text, this.logObject); -// } +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +/// Provides connection information +class ConnectionInfo { + /// Creates a new connection info + const ConnectionInfo(this.host, this.port, {required this.isSecure}); + + /// The host + final String host; + + /// The port + final int port; + + /// `true` when a secure socket is used + final bool isSecure; +} + +/// Base class for socket-based clients +abstract class ClientBase { + /// Creates a new base client + /// + /// Set [isLogEnabled] to `true` to see log output. + /// + /// Set the [logName] for adding the name to each log entry. + /// + /// [onBadCertificate] is an optional handler for unverifiable certificates. + /// The handler receives the [X509Certificate], and can inspect it and decide + /// (or let the user decide) whether to accept the connection or not. + /// The handler should return true to continue the [SecureSocket] connection. + /// + /// [securityContext] is an optional security context for mTLS + /// (mutual TLS / client certificate authentication). + /// Create a [SecurityContext] with `useCertificateChain()` and + /// `usePrivateKey()` to enable client certificate authentication. + ClientBase({ + this.isLogEnabled = false, + this.logName, + this.onBadCertificate, + this.securityContext, + }); + + /// Initial for a client log output + static const String initialClient = 'C'; + + /// Initial for a server log output + static const String initialServer = 'S'; + + /// Initial for an app log output + static const String initialApp = 'A'; + + /// The name shown in log entries to differentiate this server + String? logName; + + /// `true` when the log is enabled + bool isLogEnabled; + + late Socket _socket; + + /// `true` when it is expected that the socket is closed + bool isSocketClosingExpected = false; + + /// `true` after the user has authenticated + bool isLoggedIn = false; + + bool _isServerGreetingDone = false; + + /// Information about the connection + late ConnectionInfo connectionInfo; + late Completer _greetingsCompleter; + + bool _isConnected = false; + + /// Ist the client currently connected? + bool get isConnected => _isConnected; + + /// Handles unverifiable certificates. + /// + /// The handler receives the [X509Certificate], and can inspect it and decide + /// (or let the user decide) whether to accept the connection or not. + /// The handler should return true to continue the [SecureSocket] connection. + final bool Function(X509Certificate)? onBadCertificate; + + /// Optional [SecurityContext] for mTLS (mutual TLS). + /// + /// When set, it is passed to [SecureSocket.connect] and + /// [SecureSocket.secure] to enable client certificate authentication. + final SecurityContext? securityContext; + + /// Is called when data is received + void onDataReceived(Uint8List data); + + /// Is called after the initial connection has been established + FutureOr onConnectionEstablished( + ConnectionInfo connectionInfo, + String serverGreeting, + ); + + /// Is called when the connection encountered an error + void onConnectionError(dynamic error); + + late StreamSubscription _socketStreamSubscription; + + /// Connects to the specified server. + /// + /// Specify [isSecure] if you do not want to connect to a secure service. + /// + /// Specify [timeout] to specify a different timeout for the connection. + /// This defaults to 20 seconds. + Future connectToServer( + String host, + int port, { + bool isSecure = true, + Duration timeout = const Duration(seconds: 20), + }) async { + logApp( + 'connecting to server $host:$port - ' + 'secure: $isSecure, timeout: $timeout', + ); + connectionInfo = ConnectionInfo(host, port, isSecure: isSecure); + final socket = isSecure + ? await SecureSocket.connect( + host, + port, + onBadCertificate: onBadCertificate, + context: securityContext, + ).timeout(timeout) + : await Socket.connect(host, port).timeout(timeout); + _greetingsCompleter = Completer(); + _isServerGreetingDone = false; + connect(socket); + + return _greetingsCompleter.future; + } + + /// Starts to listen on the given [socket]. + /// + /// This is mainly useful for testing purposes, ensure to set + /// [connectionInformation] manually in this case, e.g. + /// ```dart + /// await client.connect(socket, connectionInformation: + /// ConnectionInfo(host, port, isSecure)); + /// ``` + void connect(Socket socket, {ConnectionInfo? connectionInformation}) { + if (connectionInformation != null) { + connectionInfo = connectionInformation; + _greetingsCompleter = Completer(); + } + _socket = socket; + _writeFuture = null; + // if (connectionTimeout != null) { + // final timeoutStream = socket.timeout(connectionTimeout!); + // _socketStreamSubscription = timeoutStream.listen( + // _onDataReceived, + // onDone: onConnectionDone, + // onError: _onConnectionError, + // ); + // } else { + _socketStreamSubscription = socket.listen( + _onDataReceived, + onDone: onConnectionDone, + onError: _onConnectionError, + ); + // } + _isConnected = true; + isSocketClosingExpected = false; + } + + Future _onConnectionError(Object e, StackTrace s) async { + logApp('Socket error: $e $s'); + isLoggedIn = false; + _isConnected = false; + _writeFuture = null; + if (!isSocketClosingExpected) { + isSocketClosingExpected = true; + try { + await _socketStreamSubscription.cancel(); + } catch (e, s) { + logApp('Unable to cancel stream subscription: $e $s'); + } + try { + onConnectionError(e); + } catch (e, s) { + logApp('Unable to call onConnectionError: $e, $s'); + } + } + } + + /// Upgrades the current connection to a secure socket + Future upgradeToSslSocket() async { + _socketStreamSubscription.pause(); + final secureSocket = await SecureSocket.secure( + _socket, + // verify the certificate against the host the user asked for, not + // against whatever the resolved socket address reports + host: connectionInfo.host, + context: securityContext, + onBadCertificate: onBadCertificate, + ); + logApp('now using secure connection.'); + await _socketStreamSubscription.cancel(); + isSocketClosingExpected = true; + _socket.destroy(); + isSocketClosingExpected = false; + connect(secureSocket); + } + + Future _onDataReceived(Uint8List data) async { + if (_isServerGreetingDone) { + onDataReceived(data); + } else { + _isServerGreetingDone = true; + final serverGreeting = String.fromCharCodes(data); + log(serverGreeting, isClient: false); + onConnectionEstablished(connectionInfo, serverGreeting); + _greetingsCompleter.complete(connectionInfo); + } + } + + /// Informs about a closed connection + void onConnectionDone() { + logApp('Done, connection closed'); + isLoggedIn = false; + _isConnected = false; + if (!isSocketClosingExpected) { + isSocketClosingExpected = true; + onConnectionError('onDone not expected'); + } + } + + /// Disconnects from the service + Future disconnect() async { + if (_isConnected) { + isLoggedIn = false; + _isConnected = false; + isSocketClosingExpected = true; + try { + await _socketStreamSubscription.cancel(); + } catch (e) { + print('unable to cancel subscription $e'); + } + try { + await _socket.close(); + } catch (e) { + print('unable to close socket $e'); + } + } + } + + Future? _writeFuture; + + /// Writes the specified [text]. + /// + /// When the log is enabled it will either log the specified [logObject] + /// or just the [text]. + /// + /// When a [timeout] is specified and occurs, it will + /// throw a [TimeoutException] after the specified time. + Future writeText(String text, [dynamic logObject, Duration? timeout]) async { + final previousWriteFuture = _writeFuture; + if (previousWriteFuture != null) { + try { + await previousWriteFuture; + } catch (e, s) { + print( + 'Unable to await previous write ' + // ignore: unawaited_futures + 'future $previousWriteFuture: $e $s', + ); + _writeFuture = null; + rethrow; + } + } + if (isLogEnabled) { + logObject ??= text; + log(logObject); + } + _socket.write('$text\r\n'); + + final future = timeout == null + ? _socket.flush() + : _socket.flush().timeout(timeout); + _writeFuture = future; + await future; + _writeFuture = null; + } + + /// Writes the specified [data]. + /// + /// When the log is enabled it will either log the specified + /// [logObject] or just the length of the data. + Future writeData(List data, [dynamic logObject]) async { + final previousWriteFuture = _writeFuture; + if (previousWriteFuture != null) { + try { + await previousWriteFuture; + } catch (e, s) { + print('Unable to await previous write future: $e $s'); + _writeFuture = null; + } + } + if (isLogEnabled) { + logObject ??= '<${data.length} bytes>'; + log(logObject); + } + _socket.add(data); + final future = _socket.flush(); + _writeFuture = future; + await future; + _writeFuture = null; + } + + /// Logs the data from the app-side + void logApp(dynamic logObject) => log(logObject, initial: initialApp); + + /// Logs the data from the client-side + void logClient(dynamic logObject) => log(logObject, initial: initialClient); + + /// Logs the data from the server-side + void logServer(dynamic logObject) => log(logObject, initial: initialServer); + + /// Logs the data + void log(dynamic logObject, {bool isClient = true, String? initial}) { + if (isLogEnabled) { + initial ??= isClient ? initialClient : initialServer; + if (logName != null) { + print('$logName $initial: $logObject'); + } else { + print('$initial: $logObject'); + } + } + } + + void _onTimeout(Completer completer, Duration duration) { + // print( + // '$completer triggers timeout after $duration on + // $this at ${DateTime.now()}'); + completer.completeError(createClientError('timeout')); + } + + /// Subclasses need to be able to create client specific exceptions + Object createClientError(String message); +} + +/// Extends Completer instances +extension ExtensionCompleter on Completer { + /// Adds a timeout to a completer + void timeout(Duration? duration, ClientBase client) { + if (duration != null) { + Future.delayed(duration).then((value) { + if (!isCompleted) { + client._onTimeout(this, duration); + } + }); + } + } +} + +// class _QueuedText { +// final String text; +// final dynamic logObject; +// _QueuedText(this.text, this.logObject); +// } diff --git a/lib/src/smtp/smtp_client.dart b/lib/src/smtp/smtp_client.dart index 452b602d..0db85196 100644 --- a/lib/src/smtp/smtp_client.dart +++ b/lib/src/smtp/smtp_client.dart @@ -174,6 +174,12 @@ class SmtpClient extends ClientBase { /// that is sent to the SMTP server. Future ehlo() async { final result = await sendCommand(SmtpEhloCommand(_clientDomain)); + // A fresh EHLO replaces everything announced before, in particular the + // capabilities received in clear text before STARTTLS (RFC 3207 4.2). + serverInfo + ..capabilities = [] + ..authMechanisms = [] + ..maxMessageSize = null; for (final line in result.responseLines) { if (line.code == 250) { serverInfo.capabilities.add(line.message); @@ -190,12 +196,9 @@ class SmtpClient extends ClientBase { if (line.message.contains('XOAUTH2')) { serverInfo.authMechanisms.add(AuthMechanism.xoauth2); } - } else { - serverInfo.capabilities.add(line.message); - if (line.message.startsWith('SIZE ')) { - final maxSizeText = line.message.substring('SIZE '.length); - serverInfo.maxMessageSize = int.tryParse(maxSizeText); - } + } else if (line.message.startsWith('SIZE ')) { + final maxSizeText = line.message.substring('SIZE '.length); + serverInfo.maxMessageSize = int.tryParse(maxSizeText); } } } @@ -212,11 +215,13 @@ class SmtpClient extends ClientBase { /// port for encrypted communication. Future startTls() async { final response = await sendCommand(SmtpStartTlsCommand()); - if (response.isOkStatus) { - log('STARTTLS: upgrading socket to secure one...', initial: 'A'); - await upgradeToSslSocket(); - await ehlo(); + if (!response.isOkStatus) { + // sendCommand already fails 4xx/5xx replies, this guards the rest + throw SmtpException(this, response); } + log('STARTTLS: upgrading socket to secure one...', initial: 'A'); + await upgradeToSslSocket(); + await ehlo(); return response; } diff --git a/test/mail/mail_client_starttls_test.dart b/test/mail/mail_client_starttls_test.dart new file mode 100644 index 00000000..2aea8d9d --- /dev/null +++ b/test/mail/mail_client_starttls_test.dart @@ -0,0 +1,120 @@ +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +/// Minimal clear-text mock servers that do NOT offer STARTTLS. +/// +/// Every command the client sends is recorded so the tests can prove that no +/// credentials were transmitted. +void main() { + late ServerSocket server; + final requests = []; + + setUp(() async { + requests.clear(); + server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + }); + + tearDown(() => server.close()); + + void serveImapWithoutStartTls() { + server.listen((socket) { + socket.write('* OK IMAP4rev1 server ready\r\n'); + socket.listen((data) { + final line = String.fromCharCodes(data); + requests.add(line); + final tag = line.split(' ').first; + if (line.contains(' CAPABILITY')) { + socket.write( + '* CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=LOGIN\r\n' + '$tag OK done\r\n', + ); + } else if (line.contains(' LOGIN ')) { + socket.write('$tag OK [CAPABILITY IMAP4rev1] logged in\r\n'); + } else { + socket.write('$tag OK done\r\n'); + } + }); + }); + } + + void serveSmtpWithoutStartTls() { + server.listen((socket) { + socket.write('220 mock ESMTP\r\n'); + socket.listen((data) { + final line = String.fromCharCodes(data); + requests.add(line); + if (line.startsWith('EHLO')) { + socket.write('250-mock\r\n250-AUTH PLAIN LOGIN\r\n250 8BITMIME\r\n'); + } else if (line.startsWith('AUTH')) { + socket.write('235 ok\r\n'); + } else { + socket.write('250 ok\r\n'); + } + }); + }); + } + + MailAccount account({ + required SocketType incoming, + required SocketType outgoing, + }) => MailAccount.fromManualSettings( + name: 'test', + email: 'user@example.com', + password: 'secret', + incomingHost: InternetAddress.loopbackIPv4.address, + incomingPort: server.port, + incomingSocketType: incoming, + outgoingHost: InternetAddress.loopbackIPv4.address, + outgoingPort: server.port, + outgoingSocketType: outgoing, + ); + + test('IMAP: refuses clear-text login when STARTTLS is not offered', () async { + serveImapWithoutStartTls(); + final client = MailClient( + account(incoming: SocketType.starttls, outgoing: SocketType.ssl), + ); + await expectLater(client.connect(), throwsA(isA())); + expect(requests.any((r) => r.contains('CAPABILITY')), isTrue); + expect(requests.any((r) => r.contains('LOGIN')), isFalse); + }); + + test('IMAP: SocketType.plain also requires STARTTLS', () async { + serveImapWithoutStartTls(); + final client = MailClient( + account(incoming: SocketType.plain, outgoing: SocketType.ssl), + ); + await expectLater(client.connect(), throwsA(isA())); + expect(requests.any((r) => r.contains('LOGIN')), isFalse); + }); + + test('IMAP: plainNoStartTls explicitly allows clear-text login', () async { + serveImapWithoutStartTls(); + final client = MailClient( + account(incoming: SocketType.plainNoStartTls, outgoing: SocketType.ssl), + ); + await client.connect(); + expect(requests.any((r) => r.contains('LOGIN')), isTrue); + await client.disconnect(); + }); + + test('SMTP: refuses clear-text AUTH when STARTTLS is not offered', () async { + serveSmtpWithoutStartTls(); + final client = MailClient( + account(incoming: SocketType.ssl, outgoing: SocketType.starttls), + ); + final message = MessageBuilder.buildSimpleTextMessage( + const MailAddress('me', 'user@example.com'), + [const MailAddress('you', 'other@example.com')], + 'hello', + ); + await expectLater( + client.sendMessage(message, appendToSent: false), + throwsA(isA()), + ); + expect(requests.any((r) => r.startsWith('EHLO')), isTrue); + expect(requests.any((r) => r.startsWith('AUTH')), isFalse); + }); +} From a9285db3aee9a387e0b110d9a096fb0f323e89f0 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 10:30:28 +0200 Subject: [PATCH 02/33] fix(smtp): apply RFC 5321 transparency to every line of the DATA payload The DATA payload was only escaped with a non-overlapping replaceAll of CRLF.CRLF, so two consecutive lines consisting of a single period ended the DATA phase early and turned the rest of the message into SMTP commands on the authenticated session. Lines like '.NET' also lost their leading period. Every line starting with a period is now dot-stuffed and the end-of-data marker is appended exactly once. Co-Authored-By: Claude Fable 5.1 --- .../smtp/commands/smtp_sendmail_command.dart | 5 +- lib/src/private/smtp/smtp_command.dart | 34 ++++++++++ lib/src/smtp/smtp_client.dart | 3 +- test/smtp/smtp_transparency_test.dart | 66 +++++++++++++++++++ 4 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 test/smtp/smtp_transparency_test.dart diff --git a/lib/src/private/smtp/commands/smtp_sendmail_command.dart b/lib/src/private/smtp/commands/smtp_sendmail_command.dart index e5f80fd2..074b4d98 100644 --- a/lib/src/private/smtp/commands/smtp_sendmail_command.dart +++ b/lib/src/private/smtp/commands/smtp_sendmail_command.dart @@ -51,10 +51,7 @@ class _SmtpSendCommand extends SmtpCommand { case _SmtpSendCommandSequence.data: _currentStep = _SmtpSendCommandSequence.done; - final data = getData(); - - // \r\n.\r\n is the data stop sequence, so 'pad' this sequence in the message data - return '${data.replaceAll('\r\n.\r\n', '\r\n..\r\n')}\r\n.'; + return applySmtpTransparency(getData()); default: return null; } diff --git a/lib/src/private/smtp/smtp_command.dart b/lib/src/private/smtp/smtp_command.dart index 200cca26..352877ec 100644 --- a/lib/src/private/smtp/smtp_command.dart +++ b/lib/src/private/smtp/smtp_command.dart @@ -42,6 +42,40 @@ class SmtpCommand { String toString() => command; } +/// Prepares [data] for transmission after the `DATA` command. +/// +/// Implements the transparency procedure of RFC 5321 section 4.5.2: every +/// line that starts with a period gets one additional period, so that the +/// only `.` the server can see is the end-of-data marker that is +/// appended here. Without it, message content such as two consecutive lines +/// consisting of a single `.` would terminate the DATA phase early and +/// everything after it would be interpreted as SMTP commands. +/// +/// The returned text ends with `.` so that the caller's trailing +/// `` completes the end-of-data marker. +String applySmtpTransparency(String data) { + final buffer = StringBuffer(); + if (data.startsWith('.')) { + buffer.write('.'); + } + var start = 0; + var index = data.indexOf('\n.'); + while (index != -1) { + buffer + ..write(data.substring(start, index + 1)) + ..write('.'); + start = index + 1; + index = data.indexOf('\n.', start); + } + buffer.write(data.substring(start)); + if (!data.endsWith('\r\n')) { + buffer.write('\r\n'); + } + buffer.write('.'); + + return buffer.toString(); +} + /// Contains command-specific data class SmtpCommandData { /// Creates a new data diff --git a/lib/src/smtp/smtp_client.dart b/lib/src/smtp/smtp_client.dart index 0db85196..88db6b6c 100644 --- a/lib/src/smtp/smtp_client.dart +++ b/lib/src/smtp/smtp_client.dart @@ -282,7 +282,8 @@ class SmtpClient extends ClientBase { /// Sends the specified message [text] [from] to the [recipients]. /// /// In contrast to the other methods the text is not modified apart from - /// the padding of `.` sequences. + /// the SMTP transparency procedure (RFC 5321 section 4.5.2), i.e. every + /// line starting with a period gets an additional leading period. /// Set [use8BitEncoding] to `true` for sending a UTF-8 encoded message body. Future sendMessageText( String text, diff --git a/test/smtp/smtp_transparency_test.dart b/test/smtp/smtp_transparency_test.dart new file mode 100644 index 00000000..4b1450a8 --- /dev/null +++ b/test/smtp/smtp_transparency_test.dart @@ -0,0 +1,66 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/smtp/commands/smtp_sendmail_command.dart'; +import 'package:enough_mail/src/private/smtp/smtp_command.dart'; +import 'package:test/test.dart'; + +/// Drives a DATA command through MAIL FROM / RCPT TO / DATA and returns the +/// payload that would be written to the socket after the `354` reply. +String transmit(SmtpCommand command) { + var next = command.nextCommand(SmtpResponse(['250 ok'])); + while (next != null && next.startsWith('RCPT TO:')) { + next = command.nextCommand(SmtpResponse(['250 ok'])); + } + expect(next, 'DATA'); + + return command.nextCommand(SmtpResponse(['354 go ahead']))!; +} + +void main() { + group('applySmtpTransparency', () { + test('stuffs every line starting with a period', () { + expect( + applySmtpTransparency('a\r\n.\r\n.\r\n.NET rocks\r\nb\r\n'), + 'a\r\n..\r\n..\r\n..NET rocks\r\nb\r\n.', + ); + }); + + test('stuffs a leading period on the very first line', () { + expect(applySmtpTransparency('.hidden\r\n'), '..hidden\r\n.'); + }); + + test('terminates data that does not end with CRLF', () { + expect(applySmtpTransparency('body'), 'body\r\n.'); + expect(applySmtpTransparency('body\r\n.'), 'body\r\n..\r\n.'); + }); + + test('does not add an empty line when data already ends with CRLF', () { + expect(applySmtpTransparency('body\r\n'), 'body\r\n.'); + }); + }); + + group('SmtpSendMailTextCommand', () { + test('body cannot terminate DATA early or inject commands', () { + const from = MailAddress(null, 'me@example.com'); + const to = MailAddress(null, 'you@example.com'); + const body = + 'Subject: hi\r\n\r\n' + 'hello\r\n.\r\n.\r\n' + 'MAIL FROM:\r\n' + 'RCPT TO:\r\n' + 'DATA\r\nspam\r\n.\r\n.\r\n'; + final command = SmtpSendMailTextCommand(body, from, [ + to.email, + ], use8BitEncoding: false); + final payload = transmit(command); + // the end-of-data marker is completed by the CRLF the client appends + final onWire = '$payload\r\n'; + final terminator = onWire.indexOf('\r\n.\r\n'); + expect( + terminator, + onWire.length - '\r\n.\r\n'.length, + reason: 'the only CRLF.CRLF must be the final terminator', + ); + expect(onWire, contains('\r\n..\r\n..\r\nMAIL FROM')); + }); + }); +} From 903a5a7d2e2e7153085caa50cf712e24bb8f0cf3 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 10:35:30 +0200 Subject: [PATCH 03/33] fix: prevent header and SMTP envelope injection through untrusted values Header values are now sanitised of CR/LF in the Header constructor, so a subject, display name, file name or custom header can no longer start another header or terminate the header section. MailAddress.encode() renders display names as RFC 5322 quoted-strings with quoted-pairs (the Q encoder now also encodes '"' and '\'), and refuses email addresses containing control characters, white space, angle brackets or list separators. The SMTP DATA and BDAT commands validate every MAIL FROM / RCPT TO address with the same rule instead of interpolating it verbatim, and a missing sender is an error instead of 'MAIL FROM:'. mailto links drop addresses that fail this check. The address parser honours quoted-pairs and indexes by UTF-16 code unit, so an escaped quote can neither hide an extra recipient nor crash on emoji. Co-Authored-By: Claude Fable 5.1 --- .../codecs/quoted_printable_mail_codec.dart | 11 +- lib/src/mail_address.dart | 62 +++++-- lib/src/message_builder.dart | 21 ++- lib/src/mime_message.dart | 22 ++- .../smtp/commands/smtp_send_bdat_command.dart | 16 +- .../smtp/commands/smtp_sendmail_command.dart | 17 +- lib/src/private/smtp/smtp_command.dart | 22 +++ lib/src/private/util/mail_address_parser.dart | 44 ++++- test/header_injection_test.dart | 162 ++++++++++++++++++ test/mail_address_parser_test.dart | 33 ++++ 10 files changed, 369 insertions(+), 41 deletions(-) create mode 100644 test/header_injection_test.dart create mode 100644 test/mail_address_parser_test.dart diff --git a/lib/src/codecs/quoted_printable_mail_codec.dart b/lib/src/codecs/quoted_printable_mail_codec.dart index 2ec20582..56620815 100644 --- a/lib/src/codecs/quoted_printable_mail_codec.dart +++ b/lib/src/codecs/quoted_printable_mail_codec.dart @@ -143,9 +143,16 @@ class QuotedPrintableMailCodec extends MailCodec { } buffer.write(qpWordHead); } - if ((rune > AsciiRunes.runeSpace && rune <= 60) || + // " and \ are encoded as well, so that an encoded word can be used + // inside a quoted-string, e.g. for personal names in addresses + if ((rune > AsciiRunes.runeSpace && + rune <= 60 && + rune != AsciiRunes.runeDoubleQuote) || (rune == 62) || - (rune > 63 && rune <= 126 && rune != AsciiRunes.runeUnderline)) { + (rune > 63 && + rune <= 126 && + rune != AsciiRunes.runeUnderline && + rune != AsciiRunes.runeBackslash)) { wordCounter++; isWordSplit = wordCounter > qpWordSize; if (!isWordSplit) { diff --git a/lib/src/mail_address.dart b/lib/src/mail_address.dart index fe49cc2b..ba7fbdbf 100644 --- a/lib/src/mail_address.dart +++ b/lib/src/mail_address.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'codecs/mail_codec.dart'; +import 'private/util/ascii_runes.dart'; import 'private/util/mail_address_parser.dart'; part 'mail_address.g.dart'; @@ -90,27 +91,62 @@ class MailAddress { return buffer.toString(); } + /// Checks that [email] contains no characters that could break out of an + /// address header or an SMTP envelope address: control characters, `<`, + /// `>`, `,`, `;` and - unless the local part is quoted - white space. + /// + /// This is not a full RFC 5322 syntax check. + static bool isSafeEmail(String email) { + final isQuotedLocalPart = email.startsWith('"') && email.contains('"@'); + for (final code in email.codeUnits) { + if (code < AsciiRunes.runeSpace || + code == 127 || + code == AsciiRunes.runeSmallerThan || + code == AsciiRunes.runeGreaterThan || + code == AsciiRunes.runeComma || + code == AsciiRunes.runeSemicolon) { + return false; + } + if (code == AsciiRunes.runeSpace && !isQuotedLocalPart) { + return false; + } + } + + return true; + } + /// Encodes this mail address /// + /// The personal name is rendered as an RFC 5322 quoted-string, escaping + /// `"` and `\`, or as an encoded word when it contains non-ASCII text. + /// Throws an [ArgumentError] when [email] contains characters that would + /// break the address header, compare [isSafeEmail]. + /// /// Compare [MailAddress.parse] to decode an address String encode() { + if (!isSafeEmail(email)) { + throw ArgumentError.value( + email, + 'email', + 'contains characters that are not allowed in an address header', + ); + } final personalName = this.personalName; if (personalName == null || hasNoPersonalName) { return email; } - final buffer = StringBuffer() - ..write('"') - ..write( - MailCodec.quotedPrintable.encodeHeader( - personalName.trim(), - fromStart: true, - ), - ) - ..write('" <') - ..write(email) - ..write('>'); - - return buffer.toString(); + final name = personalName.trim(); + final encoded = MailCodec.quotedPrintable.encodeHeader( + name, + fromStart: true, + ); + // RFC 5322 section 3.2.4: within a quoted-string the characters " and \ + // have to be escaped as quoted-pairs + final quoted = encoded == name + ? name.replaceAll(r'\', r'\\').replaceAll('"', r'\"') + : encoded; + + return '"$quoted" <$email>'; } /// Encodes this mail address into the given [buffer] diff --git a/lib/src/message_builder.dart b/lib/src/message_builder.dart index 90bc1db8..f871e108 100644 --- a/lib/src/message_builder.dart +++ b/lib/src/message_builder.dart @@ -705,6 +705,16 @@ class PartBuilder { } } +/// Parses a comma separated list of addresses from a mailto URI, ignoring +/// anything that could not be a plain email address - percent-encoded line +/// breaks or angle brackets in a link must not end up in a header or in +/// the SMTP envelope. +Iterable _parseMailtoAddresses(String value) => value + .split(',') + .map((email) => email.trim()) + .where((email) => email.isNotEmpty && MailAddress.isSafeEmail(email)) + .map((email) => MailAddress(null, email)); + /// Simplifies creating mime messages for sending or storing. class MessageBuilder extends PartBuilder { /// Creates a new message builder and populates it with the optional data. @@ -924,7 +934,7 @@ class MessageBuilder extends PartBuilder { ..transferEncoding = TransferEncoding.automatic; final to = []; for (final value in mailto.pathSegments) { - to.addAll(value.split(',').map((email) => MailAddress(null, email))); + to.addAll(_parseMailtoAddresses(value)); } final queryParameters = mailto.queryParameters; for (final key in queryParameters.keys) { @@ -937,17 +947,12 @@ class MessageBuilder extends PartBuilder { break; case 'to': if (value != null) { - to.addAll( - value.split(',').map((email) => MailAddress(null, email)), - ); + to.addAll(_parseMailtoAddresses(value)); } break; case 'cc': if (value != null) { - builder.cc = value - .split(',') - .map((email) => MailAddress(null, email)) - .toList(); + builder.cc = _parseMailtoAddresses(value).toList(); } break; case 'body': diff --git a/lib/src/mime_message.dart b/lib/src/mime_message.dart index 22ac2d71..4cf918a5 100644 --- a/lib/src/mime_message.dart +++ b/lib/src/mime_message.dart @@ -1357,8 +1357,26 @@ class MimeMessage extends MimePart { /// Encapsulates a MIME header class Header { /// Creates a new header - Header(this.name, this.value, [this.encoding = HeaderEncoding.none]) - : lowerCaseName = name.toLowerCase(); + /// + /// Line breaks in [name] and [value] are replaced by a single space, so + /// that a header value can neither start another header nor terminate the + /// header section (header injection). Folding is applied in [render]. + Header(String name, String? value, [this.encoding = HeaderEncoding.none]) + : name = sanitize(name), + value = value == null ? null : sanitize(value), + lowerCaseName = sanitize(name).toLowerCase(); + + static final _lineBreakPattern = RegExp(r'[\r\n]+[ \t]*'); + + /// Replaces every line break in [text], including the folding white space + /// that follows it, with a single space. + static String sanitize(String text) { + if (!text.contains('\r') && !text.contains('\n')) { + return text; + } + + return text.replaceAll(_lineBreakPattern, ' '); + } /// The name of the header final String name; diff --git a/lib/src/private/smtp/commands/smtp_send_bdat_command.dart b/lib/src/private/smtp/commands/smtp_send_bdat_command.dart index 22d81987..d73ac8be 100644 --- a/lib/src/private/smtp/commands/smtp_send_bdat_command.dart +++ b/lib/src/private/smtp/commands/smtp_send_bdat_command.dart @@ -12,17 +12,25 @@ enum _BdatSequence { mailFrom, rcptTo, bdat, done } class _SmtpSendBdatCommand extends SmtpCommand { _SmtpSendBdatCommand( this.getData, - this.fromEmail, - this.recipientEmails, { + String? fromEmail, + List recipientEmails, { required this.use8BitEncoding, required this.supportUnicode, - }) : super('MAIL FROM') { + }) : fromEmail = validateEnvelopeAddress(fromEmail, 'from'), + recipientEmails = [ + for (final recipient in recipientEmails) + validateEnvelopeAddress(recipient, 'recipient'), + ], + super('MAIL FROM') { + if (recipientEmails.isEmpty) { + throw ArgumentError.value(recipientEmails, 'recipients', 'no recipients'); + } final binaryData = _codec.encode(getData()); _chunks = chunkData(binaryData); } final String Function() getData; - final String? fromEmail; + final String fromEmail; final List recipientEmails; final bool use8BitEncoding; final bool supportUnicode; diff --git a/lib/src/private/smtp/commands/smtp_sendmail_command.dart b/lib/src/private/smtp/commands/smtp_sendmail_command.dart index 074b4d98..e9609828 100644 --- a/lib/src/private/smtp/commands/smtp_sendmail_command.dart +++ b/lib/src/private/smtp/commands/smtp_sendmail_command.dart @@ -6,13 +6,22 @@ enum _SmtpSendCommandSequence { mailFrom, rcptTo, data, done } class _SmtpSendCommand extends SmtpCommand { _SmtpSendCommand( this.getData, - this.fromEmail, - this.recipientEmails, { + String? fromEmail, + List recipientEmails, { required this.use8BitEncoding, - }) : super('MAIL FROM'); + }) : fromEmail = validateEnvelopeAddress(fromEmail, 'from'), + recipientEmails = [ + for (final recipient in recipientEmails) + validateEnvelopeAddress(recipient, 'recipient'), + ], + super('MAIL FROM') { + if (recipientEmails.isEmpty) { + throw ArgumentError.value(recipientEmails, 'recipients', 'no recipients'); + } + } final String Function() getData; - final String? fromEmail; + final String fromEmail; final List recipientEmails; final bool use8BitEncoding; _SmtpSendCommandSequence _currentStep = _SmtpSendCommandSequence.mailFrom; diff --git a/lib/src/private/smtp/smtp_command.dart b/lib/src/private/smtp/smtp_command.dart index 352877ec..c087c122 100644 --- a/lib/src/private/smtp/smtp_command.dart +++ b/lib/src/private/smtp/smtp_command.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import '../../mail_address.dart'; import '../../smtp/smtp_response.dart'; /// Contains a SMTP command @@ -42,6 +43,27 @@ class SmtpCommand { String toString() => command; } +/// Ensures that [email] can be embedded into `MAIL FROM:<...>` or +/// `RCPT TO:<...>` without breaking out of the angle brackets. +/// +/// Throws an [ArgumentError] when [email] is missing or contains white space, +/// control characters, angle brackets or list separators, compare +/// [MailAddress.isSafeEmail]. +String validateEnvelopeAddress(String? email, String description) { + if (email == null || email.isEmpty) { + throw ArgumentError.value(email, description, 'no email address given'); + } + if (!MailAddress.isSafeEmail(email)) { + throw ArgumentError.value( + email, + description, + 'contains characters that are not allowed in an SMTP envelope address', + ); + } + + return email; +} + /// Prepares [data] for transmission after the `DATA` command. /// /// Implements the transparency procedure of RFC 5321 section 4.5.2: every diff --git a/lib/src/private/util/mail_address_parser.dart b/lib/src/private/util/mail_address_parser.dart index 25fed181..f5fa4b11 100644 --- a/lib/src/private/util/mail_address_parser.dart +++ b/lib/src/private/util/mail_address_parser.dart @@ -72,7 +72,8 @@ class MailAddressParser { if (name.startsWith('"') && name.endsWith('"')) { name = name.substring(1, name.length - 1); } - name = name.replaceAll(r'\"', '"'); + // resolve RFC 5322 quoted-pairs such as \" and \\ + name = name.replaceAllMapped(_quotedPair, (match) => match[1]!); if (name.contains('=?')) { try { name = MailCodec.decodeHeader(name); @@ -89,19 +90,41 @@ class MailAddressParser { return addresses; } + static final _quotedPair = RegExp(r'\\(.)', dotAll: true); + + /// Checks if the character at [index] is escaped by an odd number of + /// preceding backslashes, i.e. is part of a quoted-pair. + static bool _isEscaped(String text, int index) { + var backslashes = 0; + for (var i = index - 1; i >= 0; i--) { + if (text.codeUnitAt(i) != AsciiRunes.runeBackslash) { + break; + } + backslashes++; + } + + return backslashes.isOdd; + } + + // Note: both scanners work on UTF-16 code units so that the indices match + // String.substring. All delimiters are ASCII, so surrogate pairs are + // harmless. static List _splitAddressParts(final String text) { if (text.isEmpty) { return []; } final result = []; - final runes = text.runes.toList(); var isInValue = false; var startIndex = 0; var valueEndRune = AsciiRunes.runeSpace; for (var i = 0; i < text.length; i++) { - final rune = runes[i]; + final rune = text.codeUnitAt(i); if (isInValue) { - if (rune == valueEndRune) { + if (rune == AsciiRunes.runeBackslash && + valueEndRune == AsciiRunes.runeDoubleQuote) { + // skip the escaped character of this quoted-pair + i++; + } else if (rune == valueEndRune) { isInValue = false; } } else { @@ -119,9 +142,11 @@ class MailAddressParser { } } } - if (startIndex < text.length - 1) { + if (startIndex < text.length) { final textPart = text.substring(startIndex).trim(); - result.add(textPart); + if (textPart.isNotEmpty) { + result.add(textPart); + } } return result; @@ -136,10 +161,13 @@ class MailAddressParser { var startIndex = 0; var endIndex = text.length; var valueEndRune = AsciiRunes.runeSpace; // space - final runes = text.runes.toList(); var isFoundAtRune = false; for (var i = endIndex; --i >= 0;) { - final rune = runes[i]; + final rune = text.codeUnitAt(i); + if (rune == AsciiRunes.runeDoubleQuote && _isEscaped(text, i)) { + // an escaped quote neither opens nor closes a quoted-string + continue; + } if (isInValue) { if (rune == valueEndRune) { isInValue = false; diff --git a/test/header_injection_test.dart b/test/header_injection_test.dart new file mode 100644 index 00000000..ff787017 --- /dev/null +++ b/test/header_injection_test.dart @@ -0,0 +1,162 @@ +import 'dart:typed_data'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/smtp/commands/smtp_sendmail_command.dart'; +import 'package:enough_mail/src/private/smtp/smtp_command.dart'; +import 'package:test/test.dart'; + +/// Returns the header section of the rendered [message], one header per +/// physical line, so tests can check which header names actually appear. +List headerLines(MimeMessage message) { + final rendered = message.renderMessage(); + final end = rendered.indexOf('\r\n\r\n'); + + return rendered.substring(0, end).split('\r\n'); +} + +void main() { + const from = MailAddress('Me', 'me@example.com'); + const to = MailAddress('You', 'you@example.com'); + + group('header injection', () { + test('line breaks in a subject cannot start another header', () { + final message = + (MessageBuilder() + ..from = [from] + ..to = [to] + ..subject = 'Hi\r\nReply-To: attacker@evil.example' + ..addTextPlain('Body')) + .buildMimeMessage(); + final lines = headerLines(message); + expect(lines.where((l) => l.startsWith('Reply-To:')), isEmpty); + expect(message.decodeSubject(), 'Hi Reply-To: attacker@evil.example'); + }); + + test('line breaks in a custom header value are neutralised', () { + final message = + (MessageBuilder() + ..from = [from] + ..to = [to] + ..setHeader('X-Custom', 'a\nX-Injected: yes\r\n\r\nbody') + ..addTextPlain('Body')) + .buildMimeMessage(); + final lines = headerLines(message); + expect(lines.where((l) => l.startsWith('X-Injected:')), isEmpty); + expect(message.getHeaderValue('X-Custom'), 'a X-Injected: yes body'); + }); + + test('line breaks in an attachment file name are neutralised', () { + final message = + (MessageBuilder() + ..from = [from] + ..to = [to] + ..addBinary( + Uint8List.fromList([1, 2, 3]), + MediaSubtype.applicationOctetStream.mediaType, + filename: 'a.txt\r\nX-Injected: yes', + )) + .buildMimeMessage(); + expect(message.renderMessage(), isNot(contains('\r\nX-Injected:'))); + }); + + test('quotes in a display name cannot add recipients', () { + const evil = MailAddress( + 'Bob" , "x', + 'bob@example.com', + ); + expect( + evil.encode(), + r'"Bob\" , \"x" ', + ); + final message = + (MessageBuilder() + ..from = [from] + ..to = [evil] + ..addTextPlain('Body')) + .buildMimeMessage(); + expect(message.recipientAddresses, ['bob@example.com']); + }); + + test('non-ASCII display names encode quotes and backslashes', () { + const address = MailAddress(r'Jörg "JJ" \ Jung', 'jj@example.com'); + final encoded = address.encode(); + expect(encoded, startsWith('"=?utf8?Q?')); + expect(encoded, endsWith('?=" ')); + // no raw quote or backslash inside the quoted-string: + final inner = encoded.substring(1, encoded.indexOf('" <')); + expect(inner, isNot(contains('"'))); + expect(inner, isNot(contains(r'\'))); + expect(MailCodec.decodeHeader(inner), r'Jörg "JJ" \ Jung'); + }); + + test('an email address with unsafe characters cannot be encoded', () { + expect( + () => const MailAddress(null, 'a@b>\r\nRCPT TO:').encode(), + throwsArgumentError, + ); + expect( + () => const MailAddress(null, 'a@b,attacker@evil.example').encode(), + throwsArgumentError, + ); + expect(MailAddress.isSafeEmail('"john doe"@example.com'), isTrue); + expect(MailAddress.isSafeEmail('john doe@example.com'), isFalse); + }); + }); + + group('SMTP envelope', () { + test('rejects addresses that could break out of MAIL FROM / RCPT TO', () { + expect( + () => validateEnvelopeAddress('a@b>\r\nRCPT TO:', 'r'), + throwsArgumentError, + ); + expect( + () => validateEnvelopeAddress('a@b c@d', 'r'), + throwsArgumentError, + ); + expect(() => validateEnvelopeAddress(null, 'from'), throwsArgumentError); + expect(validateEnvelopeAddress('ok@example.com', 'r'), 'ok@example.com'); + }); + + test('send command refuses unsafe recipients and missing sender', () { + expect( + () => SmtpSendMailTextCommand( + 'x', + const MailAddress(null, 'me@example.com'), + ['ok@example.com', 'a@b>\r\nRCPT TO:'], + use8BitEncoding: false, + ), + throwsArgumentError, + ); + final message = + (MessageBuilder() + ..to = [to] + ..addTextPlain('x')) + .buildMimeMessage(); + expect( + () => SmtpSendMailCommand( + message, + null, + message.recipientAddresses, + use8BitEncoding: false, + ), + throwsArgumentError, + reason: 'no From must not become MAIL FROM:', + ); + }); + + test('mailto links cannot smuggle recipients', () { + final mailto = Uri.parse( + 'mailto:victim@example.com%3E%0D%0ARCPT%20TO:%3Cattacker@evil.example' + '?cc=ok@example.com,%20also@example.com', + ); + final message = MessageBuilder.prepareMailtoBasedMessage( + mailto, + from, + ).buildMimeMessage(); + expect(message.recipientAddresses, [ + 'ok@example.com', + 'also@example.com', + ]); + }); + }); +} diff --git a/test/mail_address_parser_test.dart b/test/mail_address_parser_test.dart new file mode 100644 index 00000000..9999301a --- /dev/null +++ b/test/mail_address_parser_test.dart @@ -0,0 +1,33 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/util/mail_address_parser.dart'; +import 'package:test/test.dart'; + +void main() { + group('MailAddressParser', () { + test('resolves quoted-pairs in display names', () { + final addresses = MailAddressParser.parseEmailAddresses( + r'"Bob\" , \"x" ', + ); + expect(addresses, hasLength(1)); + expect(addresses.first.email, 'bob@example.com'); + expect(addresses.first.personalName, 'Bob" , "x'); + }); + + test('handles characters outside the BMP in display names', () { + final addresses = MailAddressParser.parseEmailAddresses( + '"😀 Bob" , 🎉 ', + ); + expect(addresses.map((a) => a.email), [ + 'bob@example.com', + 'party@example.com', + ]); + expect(addresses.first.personalName, '😀 Bob'); + }); + + test('keeps a trailing single-character part', () { + final addresses = MailAddressParser.parseEmailAddresses('a@b.c, x@y.z'); + expect(addresses.map((a) => a.email), ['a@b.c', 'x@y.z']); + expect(MailAddress.parse('a@b.c').email, 'a@b.c'); + }); + }); +} From 69cf7dd5d2ac27274f4dfbf30dbe32306b15ba21 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 10:36:09 +0200 Subject: [PATCH 04/33] fix(smtp): strip the Bcc header on the BDAT path and only in the header section The folded-Bcc fix from #294 only covered the DATA command; the BDAT command, which MailClient prefers whenever a server announces CHUNKING, still matched a single physical line and leaked every folded Bcc recipient to all other recipients. Both paths now share removeBccHeader, which matches the header name case-insensitively and never touches the body, so a body line starting with 'Bcc:' is no longer removed. Co-Authored-By: Claude Fable 5.1 --- .../smtp/commands/smtp_send_bdat_command.dart | 10 +-- .../smtp/commands/smtp_sendmail_command.dart | 12 +--- lib/src/private/smtp/smtp_command.dart | 28 ++++++++ test/smtp/remove_bcc_header_test.dart | 70 +++++++++++++++++++ 4 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 test/smtp/remove_bcc_header_test.dart diff --git a/lib/src/private/smtp/commands/smtp_send_bdat_command.dart b/lib/src/private/smtp/commands/smtp_send_bdat_command.dart index d73ac8be..49bb2904 100644 --- a/lib/src/private/smtp/commands/smtp_send_bdat_command.dart +++ b/lib/src/private/smtp/commands/smtp_send_bdat_command.dart @@ -145,10 +145,7 @@ class SmtpSendBdatMailCommand extends _SmtpSendBdatCommand { required bool use8BitEncoding, required bool supportUnicode, }) : super( - () => message.renderMessage().replaceAll( - RegExp('^Bcc:.*\r\n', multiLine: true), - '', - ), + () => removeBccHeader(message.renderMessage()), from?.email ?? message.fromEmail, recipientEmails, use8BitEncoding: use8BitEncoding, @@ -169,10 +166,7 @@ class SmtpSendBdatMailDataCommand extends _SmtpSendBdatCommand { required bool use8BitEncoding, required bool supportUnicode, }) : super( - () => data.toString().replaceAll( - RegExp('^Bcc:.*\r\n', multiLine: true), - '', - ), + () => removeBccHeader(data.toString()), from.email, recipientEmails, use8BitEncoding: use8BitEncoding, diff --git a/lib/src/private/smtp/commands/smtp_sendmail_command.dart b/lib/src/private/smtp/commands/smtp_sendmail_command.dart index e9609828..511f0408 100644 --- a/lib/src/private/smtp/commands/smtp_sendmail_command.dart +++ b/lib/src/private/smtp/commands/smtp_sendmail_command.dart @@ -79,14 +79,6 @@ class _SmtpSendCommand extends SmtpCommand { } } -/// The `Bcc` header line and every folded continuation line under it. -/// -/// `Header.render` folds a value longer than -/// `MailConventions.textLineMaxLength` onto `\r\n\t`-prefixed lines, which -/// three or four addresses already do. Matching only the first physical line -/// left the rest of the list in the DATA that every To/Cc recipient received. -final _bccHeader = RegExp(r'^Bcc:.*\r\n(?:[ \t].*\r\n)*', multiLine: true); - /// Sends a MIME message class SmtpSendMailCommand extends _SmtpSendCommand { /// Creates a new DATA command @@ -96,7 +88,7 @@ class SmtpSendMailCommand extends _SmtpSendCommand { List recipientEmails, { required bool use8BitEncoding, }) : super( - () => message.renderMessage().replaceAll(_bccHeader, ''), + () => removeBccHeader(message.renderMessage()), from?.email ?? message.fromEmail, recipientEmails, use8BitEncoding: use8BitEncoding, @@ -115,7 +107,7 @@ class SmtpSendMailDataCommand extends _SmtpSendCommand { List recipientEmails, { required bool use8BitEncoding, }) : super( - () => data.toString().replaceAll(_bccHeader, ''), + () => removeBccHeader(data.toString()), from.email, recipientEmails, use8BitEncoding: use8BitEncoding, diff --git a/lib/src/private/smtp/smtp_command.dart b/lib/src/private/smtp/smtp_command.dart index c087c122..69a276b1 100644 --- a/lib/src/private/smtp/smtp_command.dart +++ b/lib/src/private/smtp/smtp_command.dart @@ -64,6 +64,34 @@ String validateEnvelopeAddress(String? email, String description) { return email; } +final _bccHeader = RegExp( + r'^bcc:.*\r\n(?:[ \t].*\r\n)*', + multiLine: true, + caseSensitive: false, +); + +/// Removes the `Bcc` header, including its folded continuation lines, from +/// the header section of the rendered [message]. +/// +/// Only the header section is touched: a body line that happens to start +/// with `Bcc:` is content and stays untouched. The header name is matched +/// case-insensitively, as messages parsed from other sources may use a +/// different capitalisation than the `MessageBuilder` does. +String removeBccHeader(String message) { + final headerEnd = message.indexOf('\r\n\r\n'); + final headerSection = headerEnd == -1 + ? message + : message.substring(0, headerEnd + 2); + final stripped = headerSection.replaceAll(_bccHeader, ''); + if (stripped.length == headerSection.length) { + return message; + } + + return headerEnd == -1 + ? stripped + : stripped + message.substring(headerEnd + 2); +} + /// Prepares [data] for transmission after the `DATA` command. /// /// Implements the transparency procedure of RFC 5321 section 4.5.2: every diff --git a/test/smtp/remove_bcc_header_test.dart b/test/smtp/remove_bcc_header_test.dart new file mode 100644 index 00000000..735cddcd --- /dev/null +++ b/test/smtp/remove_bcc_header_test.dart @@ -0,0 +1,70 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/smtp/commands/smtp_send_bdat_command.dart'; +import 'package:enough_mail/src/private/smtp/smtp_command.dart'; +import 'package:test/test.dart'; + +void main() { + group('removeBccHeader', () { + test('removes a folded Bcc header', () { + const message = + 'To: a@example.com\r\n' + 'Bcc: "Hidden One" ,\r\n' + '\t"Hidden Two" \r\n' + 'Subject: x\r\n' + '\r\n' + 'body\r\n'; + expect( + removeBccHeader(message), + 'To: a@example.com\r\nSubject: x\r\n\r\nbody\r\n', + ); + }); + + test('is case-insensitive', () { + const message = 'bcc: h@example.com\r\nTo: a@example.com\r\n\r\nbody'; + expect(removeBccHeader(message), 'To: a@example.com\r\n\r\nbody'); + }); + + test('removes a Bcc header that is the last header', () { + const message = 'To: a@example.com\r\nBcc: h@example.com\r\n\r\nbody'; + expect(removeBccHeader(message), 'To: a@example.com\r\n\r\nbody'); + }); + + test('leaves body lines starting with Bcc: untouched', () { + const message = + 'To: a@example.com\r\n\r\n' + 'Bcc: keep this line\r\n' + ' and this indented one\r\n'; + expect(removeBccHeader(message), message); + }); + }); + + test('BDAT path strips a folded Bcc header as well', () { + final message = + (MessageBuilder() + ..from = const [MailAddress('Me', 'me@example.com')] + ..to = const [MailAddress('You', 'you@example.com')] + ..bcc = [ + for (var i = 0; i < 6; i++) + MailAddress( + 'Hidden Recipient Number $i', + 'hidden.recipient.$i@example.com', + ), + ] + ..subject = 'Hello' + ..addTextPlain('Body')) + .buildMimeMessage(); + expect(message.renderMessage(), contains('\r\n\t')); + final command = SmtpSendBdatMailCommand( + message, + null, + message.recipientAddresses, + use8BitEncoding: false, + supportUnicode: false, + ); + final data = command.getData(); + expect(data, isNot(contains('Bcc'))); + expect(data, isNot(contains('hidden.recipient'))); + expect(data, contains('To: "You" \r\n')); + expect(data, contains('Body')); + }); +} From b9153f9a56d8ad77c666992805bf4b8e6c421834 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 10:39:28 +0200 Subject: [PATCH 05/33] fix(mime): only accept multipart delimiters at the start of a line Both the text and the binary multipart splitter matched '--boundary' anywhere, so a text line containing it introduced a part that other clients and scanners do not see (RFC 2046 5.1.1 requires the preceding CRLF). Delimiters now need a line start, tolerate transport padding and reject boundaries that are only a prefix of the real one. The binary splitter no longer hides small trailing parts or drops the last part when the closing delimiter is missing, message/rfc822 parts without a header separator no longer throw, and nesting is capped at 50 levels with children skipping the redundant line-ending normalisation. Co-Authored-By: Claude Fable 5.1 --- lib/src/mime_data.dart | 891 +++++++++++++++++++---------------- test/mime_boundary_test.dart | 132 ++++++ 2 files changed, 612 insertions(+), 411 deletions(-) create mode 100644 test/mime_boundary_test.dart diff --git a/lib/src/mime_data.dart b/lib/src/mime_data.dart index 3a3dceee..f6463326 100644 --- a/lib/src/mime_data.dart +++ b/lib/src/mime_data.dart @@ -1,411 +1,480 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:collection/collection.dart' show IterableExtension; - -import 'codecs/mail_codec.dart'; -import 'mime_message.dart'; -import 'private/imap/parser_helper.dart'; -import 'private/util/ascii_runes.dart'; -import 'private/util/byte_utils.dart'; - -/// Abstracts textual or binary mime data -abstract class MimeData { - /// Creates a new mime data - /// - /// Specify if this data contains header information with [containsHeader]. - MimeData({required this.containsHeader}); - - /// Defines if this mime data includes header data - final bool containsHeader; - - /// All known headers of this mime data - List
? headersList; - - /// Returns `true` when there are children - bool get hasParts => parts?.isNotEmpty ?? false; - - /// The children of this mime data - List? parts; - - ContentTypeHeader? _contentType; - - /// The content type of this mime data - ContentTypeHeader? get contentType { - var value = _contentType; - if (value == null) { - final headerText = _getHeaderValue('content-type'); - if (headerText != null) { - value = ContentTypeHeader(headerText); - } - } - - return value; - } - - bool _isParsed = false; - ContentTypeHeader? _parsingContentTypeHeader; - - int _size = 0; - - /// Size of the entire MimePart - int get size => _size; - - int _bodySize = 0; - - /// Size of the MimePart body - int get bodySize => _bodySize; - - /// Decodes the text represented by the mime data - String decodeText( - ContentTypeHeader? contentTypeHeader, - String? contentTransferEncoding, - ); - - /// Decodes the data represented by the mime data - Uint8List decodeBinary(String? contentTransferEncoding); - - /// Decodes message/rfc822 content - MimeData? decodeMessageData(); - - /// Parses this data - void parse(ContentTypeHeader? contentTypeHeader) { - if (_isParsed && (contentTypeHeader == _parsingContentTypeHeader)) { - return; - } - _isParsed = true; - _parsingContentTypeHeader = contentTypeHeader; - _parseContent(contentTypeHeader); - } - - void _parseContent(ContentTypeHeader? contentTypeHeader); - - /// Renders this mime data. - /// - /// Optionally set [renderHeader] to `false` in case the - /// message header should be skipped. - void render(StringBuffer buffer, {bool renderHeader = true}); - - Header? _getHeader(String lowerCaseName) => - headersList?.firstWhereOrNull((h) => h.lowerCaseName == lowerCaseName); - - String? _getHeaderValue(String lowerCaseName) => - _getHeader(lowerCaseName)?.value; - - @override - String toString() { - final buffer = StringBuffer(); - render(buffer); - - return buffer.toString(); - } -} - -/// Represents textual mime data -class TextMimeData extends MimeData { - /// Creates a new text based mime data - /// - /// with the specified [text] and the [containsHeader] information. - /// - /// Line endings are automatically normalized to CRLF (`\r\n`) for - /// RFC 5322 compliance, tolerating bare LF from non-conformant MIME - /// generators (e.g. Node.js mimetext on Linux which uses `os.EOL`). - TextMimeData(String text, {required bool containsHeader}) - : text = _normalizeLineEndings(text), - super(containsHeader: containsHeader) { - _size = this.text.length; - } - - /// Normalizes bare LF to CRLF for RFC 5322 compliance. - static String _normalizeLineEndings(String text) => - text.replaceAll(RegExp(r'(?= text.length - ? '' - : text.substring(bodyStartIndex); - } - headersList = headerParseResult.headersList; - } - // ignore: parameter_assignments - contentTypeHeader ??= contentType; - } else { - bodyText = text; - } - body = bodyText; - _bodySize = body.length; - String? partsBoundary; - if (contentTypeHeader?.mediaType.isMessage ?? false) { - final headStop = body.indexOf('\r\n\r\n'); - final boundaryMatcher = RegExp(r'boundary="(.+)"'); - partsBoundary = boundaryMatcher - .firstMatch(body.substring(0, headStop)) - ?.group(1); - } else { - partsBoundary = contentTypeHeader?.boundary; - } - if (partsBoundary != null) { - parts = []; - final splitBoundary = '--$partsBoundary\r\n'; - final childParts = bodyText.split(splitBoundary); - if (!bodyText.startsWith(splitBoundary)) { - // mime-readers can ignore the preamble: - childParts.removeAt(0); - } - if (childParts.isNotEmpty) { - var lastPart = childParts.last; - final closingIndex = lastPart.lastIndexOf('--$partsBoundary--'); - if (closingIndex != -1) { - childParts.removeLast(); - lastPart = lastPart.substring(0, closingIndex); - childParts.add(lastPart); - } - for (final childPart in childParts) { - if (childPart.isNotEmpty) { - final part = TextMimeData(childPart, containsHeader: true) - ..parse(null); - parts?.add(part); - } - } - } - } - } - - @override - void render(StringBuffer buffer, {bool renderHeader = true}) { - if (!renderHeader && containsHeader) { - buffer.write(body); - } else { - buffer.write(text); - } - } - - @override - Uint8List decodeBinary(String? contentTransferEncoding) => - MailCodec.decodeBinary(body, contentTransferEncoding); - - @override - String decodeText( - ContentTypeHeader? contentTypeHeader, - String? contentTransferEncoding, - ) => MailCodec.decodeAnyText( - body, - contentTransferEncoding, - contentTypeHeader?.charset, - ); - - @override - MimeData? decodeMessageData() => TextMimeData(body, containsHeader: true); -} - -/// Represents binary mime data -class BinaryMimeData extends MimeData { - /// Creates a new binary mime data - /// - /// with the specified [data] and the [containsHeader] info. - BinaryMimeData(this.data, {required bool containsHeader}) - : super(containsHeader: containsHeader) { - _size = data.length; - } - - /// The binary data - final Uint8List data; - int? _bodyStartIndex; - late Uint8List _bodyData; - - @override - void _parseContent(ContentTypeHeader? contentTypeHeader) { - if (containsHeader) { - headersList = _parseHeader(); - } else { - _bodyStartIndex = 0; - } - final bodyStartIndex = _bodyStartIndex; - if (bodyStartIndex == null) { - _bodyData = Uint8List(0); - } else { - _bodyData = bodyStartIndex == 0 ? data : data.sublist(bodyStartIndex); - final usedContentType = contentTypeHeader ?? contentType; - String? partsBoundary; - if (usedContentType?.mediaType.isMessage ?? false) { - final headStop = '\r\n\r\n'.codeUnits; - final headStopIndex = ByteUtils.findSequence(_bodyData, headStop); - if (headStopIndex > 0) { - final matcher = 'boundary="'.codeUnits; - final boundaryPos = ByteUtils.findSequence( - Uint8List.sublistView(_bodyData, 0, headStopIndex), - matcher, - ); - if (boundaryPos > 0) { - partsBoundary = String.fromCharCodes( - _bodyData.sublist( - boundaryPos + matcher.length, - _bodyData.indexOf( - AsciiRunes.runeDoubleQuote, - boundaryPos + matcher.length + 1, - ), - ), - ); - } - // print('message/rfc822 boundary: $partsBoundary'); - } - } else { - // Generic multipart - partsBoundary = usedContentType?.boundary; - } - if (partsBoundary != null) { - // split into different parts: - parts = _splitAndParse(partsBoundary, _bodyData); - } - } - _bodySize = _bodyData.length; - } - - List _splitAndParse( - final String boundaryText, - final Uint8List bodyData, - ) { - final boundary = '--$boundaryText\r\n'.codeUnits; - final result = []; - // end is expected to be \r\n for all but the last one, where -- is expected, possibly followed by \r\n - int? startIndex; - final maxIndex = bodyData.length - (3 * boundary.length); - for (var i = 0; i < maxIndex; i++) { - var foundMatch = true; - for (var j = 0; j < boundary.length; j++) { - if (bodyData[i + j] != boundary[j]) { - foundMatch = false; - break; - } - } - if (foundMatch) { - if (startIndex == null) { - i += boundary.length; - startIndex = i; - } else { - final partData = bodyData.sublist(startIndex, i); - final part = BinaryMimeData(partData, containsHeader: true) - ..parse(null); - result.add(part); - i += boundary.length; - startIndex = i; - } - } - } - // check and add end: - if (startIndex != null) { - final endBoundary = '--$boundaryText--'.codeUnits; - for (var i = bodyData.length - endBoundary.length; i > startIndex; i--) { - var foundMatch = true; - for (var j = 0; j < endBoundary.length; j++) { - if (bodyData[i + j] != endBoundary[j]) { - foundMatch = false; - break; - } - } - if (foundMatch) { - final partData = bodyData.sublist(startIndex, i); - final part = BinaryMimeData(partData, containsHeader: true) - ..parse(null); - result.add(part); - break; - } - } - } - - return result; - } - - @override - String decodeText( - ContentTypeHeader? contentTypeHeader, - String? contentTransferEncoding, - ) => _bodyStartIndex == null - ? '' - : MailCodec.decodeAsText( - _bodyData, - contentTransferEncoding, - contentTypeHeader?.charset, - ); - - @override - Uint8List decodeBinary(String? contentTransferEncoding) { - final contentTransferEncodingLC = contentTransferEncoding?.toLowerCase(); - if (_bodyStartIndex == null || - // do not try to decode textual content: - contentTransferEncodingLC == '7bit' || - contentTransferEncodingLC == '8bit' || - contentTransferEncodingLC == 'quoted-printable') { - return _bodyData; - } - // even with a 'binary' content transfer encoding there are \r\n - // characters that need to be handled, - // so translate to text first - final dataText = utf8.decode(_bodyData); - - return MailCodec.decodeBinary(dataText, contentTransferEncodingLC); - } - - List
_parseHeader() { - final headerData = data; - // shortcut for having an empty line at the start: - if (headerData.length > 1 && - headerData[0] == AsciiRunes.runeCarriageReturn && - headerData[1] == AsciiRunes.runeLineFeed) { - _bodyStartIndex = 2; - - return []; - } - // check for first CRLF-CRLF sequence: - for (var i = 0; i < headerData.length - 4; i++) { - if (headerData[i] == AsciiRunes.runeCarriageReturn && - headerData[i + 1] == AsciiRunes.runeLineFeed && - headerData[i + 2] == AsciiRunes.runeCarriageReturn && - headerData[i + 3] == AsciiRunes.runeLineFeed) { - final headerLines = String.fromCharCodes( - headerData, - 0, - i, - ).split('\r\n'); - _bodyStartIndex = i + 4; - - return ParserHelper.parseHeaderLines(headerLines).headersList; - } - } - // the whole data is just headers: - final headerLines = String.fromCharCodes(headerData).split('\r\n'); - - return ParserHelper.parseHeaderLines(headerLines).headersList; - } - - @override - void render(StringBuffer buffer, {bool renderHeader = true}) { - if (!renderHeader && containsHeader) { - final text = String.fromCharCodes(_bodyData); - buffer.write(text); - } else { - final text = String.fromCharCodes(data); - buffer.write(text); - } - } - - @override - MimeData? decodeMessageData() => - BinaryMimeData(_bodyData, containsHeader: true); -} +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart' show IterableExtension; + +import 'codecs/mail_codec.dart'; +import 'mime_message.dart'; +import 'private/imap/parser_helper.dart'; +import 'private/util/ascii_runes.dart'; +import 'private/util/byte_utils.dart'; + +/// Abstracts textual or binary mime data +abstract class MimeData { + /// Creates a new mime data + /// + /// Specify if this data contains header information with [containsHeader]. + MimeData({required this.containsHeader}); + + /// Defines if this mime data includes header data + final bool containsHeader; + + /// All known headers of this mime data + List
? headersList; + + /// Returns `true` when there are children + bool get hasParts => parts?.isNotEmpty ?? false; + + /// The children of this mime data + List? parts; + + ContentTypeHeader? _contentType; + + /// The content type of this mime data + ContentTypeHeader? get contentType { + var value = _contentType; + if (value == null) { + final headerText = _getHeaderValue('content-type'); + if (headerText != null) { + value = ContentTypeHeader(headerText); + } + } + + return value; + } + + /// The maximum depth of nested multipart structures that is parsed. + /// + /// Deeper nested content is kept as an opaque part. Real messages rarely + /// exceed a depth of ten, so this only stops maliciously crafted messages + /// from consuming excessive stack and CPU time. + static const int maxNestingDepth = 50; + + int _nestingDepth = 0; + + bool _isParsed = false; + ContentTypeHeader? _parsingContentTypeHeader; + + int _size = 0; + + /// Size of the entire MimePart + int get size => _size; + + int _bodySize = 0; + + /// Size of the MimePart body + int get bodySize => _bodySize; + + /// Decodes the text represented by the mime data + String decodeText( + ContentTypeHeader? contentTypeHeader, + String? contentTransferEncoding, + ); + + /// Decodes the data represented by the mime data + Uint8List decodeBinary(String? contentTransferEncoding); + + /// Decodes message/rfc822 content + MimeData? decodeMessageData(); + + /// Parses this data + void parse(ContentTypeHeader? contentTypeHeader) { + if (_isParsed && (contentTypeHeader == _parsingContentTypeHeader)) { + return; + } + _isParsed = true; + _parsingContentTypeHeader = contentTypeHeader; + _parseContent(contentTypeHeader); + } + + void _parseContent(ContentTypeHeader? contentTypeHeader); + + /// Renders this mime data. + /// + /// Optionally set [renderHeader] to `false` in case the + /// message header should be skipped. + void render(StringBuffer buffer, {bool renderHeader = true}); + + Header? _getHeader(String lowerCaseName) => + headersList?.firstWhereOrNull((h) => h.lowerCaseName == lowerCaseName); + + String? _getHeaderValue(String lowerCaseName) => + _getHeader(lowerCaseName)?.value; + + @override + String toString() { + final buffer = StringBuffer(); + render(buffer); + + return buffer.toString(); + } +} + +/// Represents textual mime data +class TextMimeData extends MimeData { + /// Creates a new text based mime data + /// + /// with the specified [text] and the [containsHeader] information. + /// + /// Line endings are automatically normalized to CRLF (`\r\n`) for + /// RFC 5322 compliance, tolerating bare LF from non-conformant MIME + /// generators (e.g. Node.js mimetext on Linux which uses `os.EOL`). + TextMimeData(String text, {required bool containsHeader}) + : text = _normalizeLineEndings(text), + super(containsHeader: containsHeader) { + _size = this.text.length; + } + + /// Creates a child part from already normalized [text] at [depth]. + TextMimeData._child(this.text, int depth) : super(containsHeader: true) { + _size = text.length; + _nestingDepth = depth; + } + + /// Normalizes bare LF to CRLF for RFC 5322 compliance. + static String _normalizeLineEndings(String text) => + text.replaceAll(RegExp(r'(? splitMultipart(String body, String boundary) { + final delimiter = RegExp( + '(?:^|\\r\\n)--${RegExp.escape(boundary)}(--)?[ \\t]*(?:\\r\\n|\$)', + ); + final parts = []; + int? partStart; + for (final match in delimiter.allMatches(body)) { + if (partStart != null) { + final partEnd = body.startsWith('\r\n', match.start) + ? match.start + 2 + : match.start; + parts.add(body.substring(partStart, partEnd)); + } + if (match.group(1) != null) { + // closing delimiter, anything after it is the epilogue + return parts; + } + partStart = match.end; + } + if (partStart != null) { + // no closing delimiter: the remainder is the last part + parts.add(body.substring(partStart)); + } + + return parts; + } + + /// The text representation of the full mime data + final String text; + + /// The body of the data + late String body; + + @override + void _parseContent(ContentTypeHeader? contentTypeHeader) { + var bodyText = text; + if (containsHeader) { + if (text.startsWith('\r\n')) { + // this part has no header + bodyText = text.substring(2); + } else { + final headerParseResult = ParserHelper.parseHeader(text); + final bodyStartIndex = headerParseResult.bodyStartIndex; + if (bodyStartIndex != null) { + bodyText = bodyStartIndex >= text.length + ? '' + : text.substring(bodyStartIndex); + } + headersList = headerParseResult.headersList; + } + // ignore: parameter_assignments + contentTypeHeader ??= contentType; + } else { + bodyText = text; + } + body = bodyText; + _bodySize = body.length; + if (_nestingDepth >= MimeData.maxNestingDepth) { + return; + } + String? partsBoundary; + if (contentTypeHeader?.mediaType.isMessage ?? false) { + final headStop = body.indexOf('\r\n\r\n'); + final head = headStop == -1 ? body : body.substring(0, headStop); + partsBoundary = RegExp(r'boundary="([^"]+)"').firstMatch(head)?.group(1); + } else { + partsBoundary = contentTypeHeader?.boundary; + } + if (partsBoundary != null) { + parts = []; + for (final childPart in splitMultipart(bodyText, partsBoundary)) { + if (childPart.isNotEmpty) { + final part = TextMimeData._child(childPart, _nestingDepth + 1) + ..parse(null); + parts?.add(part); + } + } + } + } + + @override + void render(StringBuffer buffer, {bool renderHeader = true}) { + if (!renderHeader && containsHeader) { + buffer.write(body); + } else { + buffer.write(text); + } + } + + @override + Uint8List decodeBinary(String? contentTransferEncoding) => + MailCodec.decodeBinary(body, contentTransferEncoding); + + @override + String decodeText( + ContentTypeHeader? contentTypeHeader, + String? contentTransferEncoding, + ) => MailCodec.decodeAnyText( + body, + contentTransferEncoding, + contentTypeHeader?.charset, + ); + + @override + MimeData? decodeMessageData() => TextMimeData(body, containsHeader: true); +} + +/// Represents binary mime data +class BinaryMimeData extends MimeData { + /// Creates a new binary mime data + /// + /// with the specified [data] and the [containsHeader] info. + BinaryMimeData(this.data, {required bool containsHeader}) + : super(containsHeader: containsHeader) { + _size = data.length; + } + + BinaryMimeData._child(this.data, int depth) : super(containsHeader: true) { + _size = data.length; + _nestingDepth = depth; + } + + /// The binary data + final Uint8List data; + int? _bodyStartIndex; + late Uint8List _bodyData; + + @override + void _parseContent(ContentTypeHeader? contentTypeHeader) { + if (containsHeader) { + headersList = _parseHeader(); + } else { + _bodyStartIndex = 0; + } + final bodyStartIndex = _bodyStartIndex; + if (bodyStartIndex == null) { + _bodyData = Uint8List(0); + } else { + _bodyData = bodyStartIndex == 0 ? data : data.sublist(bodyStartIndex); + final usedContentType = contentTypeHeader ?? contentType; + String? partsBoundary; + if (usedContentType?.mediaType.isMessage ?? false) { + final headStop = '\r\n\r\n'.codeUnits; + final headStopIndex = ByteUtils.findSequence(_bodyData, headStop); + if (headStopIndex > 0) { + final matcher = 'boundary="'.codeUnits; + final boundaryPos = ByteUtils.findSequence( + Uint8List.sublistView(_bodyData, 0, headStopIndex), + matcher, + ); + if (boundaryPos > 0) { + final valueStart = boundaryPos + matcher.length; + // the closing quote must be within the header section + final valueEnd = _bodyData.indexOf( + AsciiRunes.runeDoubleQuote, + valueStart, + ); + if (valueEnd > valueStart && valueEnd < headStopIndex) { + partsBoundary = String.fromCharCodes( + _bodyData.sublist(valueStart, valueEnd), + ); + } + } + } + } else { + // Generic multipart + partsBoundary = usedContentType?.boundary; + } + if (partsBoundary != null && _nestingDepth < MimeData.maxNestingDepth) { + // split into different parts: + parts = _splitAndParse(partsBoundary, _bodyData); + } + } + _bodySize = _bodyData.length; + } + + static bool _matchesAt(Uint8List data, int index, List sequence) { + if (index + sequence.length > data.length) { + return false; + } + for (var j = 0; j < sequence.length; j++) { + if (data[index + j] != sequence[j]) { + return false; + } + } + + return true; + } + + /// Splits [bodyData] at its multipart delimiters, compare + /// [TextMimeData.splitMultipart] for the rules that are applied. + List _splitAndParse( + final String boundaryText, + final Uint8List bodyData, + ) { + final delimiter = '--$boundaryText'.codeUnits; + final result = []; + final length = bodyData.length; + int? partStart; + var i = 0; + while (i + delimiter.length <= length) { + final isLineStart = + i == 0 || + (i >= 2 && + bodyData[i - 1] == AsciiRunes.runeLineFeed && + bodyData[i - 2] == AsciiRunes.runeCarriageReturn); + if (!isLineStart || !_matchesAt(bodyData, i, delimiter)) { + i++; + continue; + } + var end = i + delimiter.length; + final isClosing = + end + 1 < length && + bodyData[end] == AsciiRunes.runeMinus && + bodyData[end + 1] == AsciiRunes.runeMinus; + if (isClosing) { + end += 2; + } + // skip transport padding: + while (end < length && + (bodyData[end] == AsciiRunes.runeSpace || + bodyData[end] == AsciiRunes.runeTab)) { + end++; + } + if (end < length) { + if (end + 1 < length && + bodyData[end] == AsciiRunes.runeCarriageReturn && + bodyData[end + 1] == AsciiRunes.runeLineFeed) { + end += 2; + } else if (!isClosing) { + // this is a different, longer boundary and not a delimiter + i++; + continue; + } + } + if (partStart != null) { + result.add( + BinaryMimeData._child( + bodyData.sublist(partStart, i), + _nestingDepth + 1, + )..parse(null), + ); + } + if (isClosing) { + return result; + } + partStart = end; + i = end; + } + if (partStart != null && partStart < length) { + // no closing delimiter: the remainder is the last part + result.add( + BinaryMimeData._child(bodyData.sublist(partStart), _nestingDepth + 1) + ..parse(null), + ); + } + + return result; + } + + @override + String decodeText( + ContentTypeHeader? contentTypeHeader, + String? contentTransferEncoding, + ) => _bodyStartIndex == null + ? '' + : MailCodec.decodeAsText( + _bodyData, + contentTransferEncoding, + contentTypeHeader?.charset, + ); + + @override + Uint8List decodeBinary(String? contentTransferEncoding) { + final contentTransferEncodingLC = contentTransferEncoding?.toLowerCase(); + if (_bodyStartIndex == null || + // do not try to decode textual content: + contentTransferEncodingLC == '7bit' || + contentTransferEncodingLC == '8bit' || + contentTransferEncodingLC == 'quoted-printable') { + return _bodyData; + } + // even with a 'binary' content transfer encoding there are \r\n + // characters that need to be handled, + // so translate to text first + final dataText = utf8.decode(_bodyData); + + return MailCodec.decodeBinary(dataText, contentTransferEncodingLC); + } + + List
_parseHeader() { + final headerData = data; + // shortcut for having an empty line at the start: + if (headerData.length > 1 && + headerData[0] == AsciiRunes.runeCarriageReturn && + headerData[1] == AsciiRunes.runeLineFeed) { + _bodyStartIndex = 2; + + return []; + } + // check for first CRLF-CRLF sequence: + for (var i = 0; i < headerData.length - 4; i++) { + if (headerData[i] == AsciiRunes.runeCarriageReturn && + headerData[i + 1] == AsciiRunes.runeLineFeed && + headerData[i + 2] == AsciiRunes.runeCarriageReturn && + headerData[i + 3] == AsciiRunes.runeLineFeed) { + final headerLines = String.fromCharCodes( + headerData, + 0, + i, + ).split('\r\n'); + _bodyStartIndex = i + 4; + + return ParserHelper.parseHeaderLines(headerLines).headersList; + } + } + // the whole data is just headers: + final headerLines = String.fromCharCodes(headerData).split('\r\n'); + + return ParserHelper.parseHeaderLines(headerLines).headersList; + } + + @override + void render(StringBuffer buffer, {bool renderHeader = true}) { + if (!renderHeader && containsHeader) { + final text = String.fromCharCodes(_bodyData); + buffer.write(text); + } else { + final text = String.fromCharCodes(data); + buffer.write(text); + } + } + + @override + MimeData? decodeMessageData() => + BinaryMimeData(_bodyData, containsHeader: true); +} diff --git a/test/mime_boundary_test.dart b/test/mime_boundary_test.dart new file mode 100644 index 00000000..79916421 --- /dev/null +++ b/test/mime_boundary_test.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +const _header = + 'From: a@example.com\r\n' + 'Content-Type: multipart/alternative; boundary="b"\r\n' + '\r\n'; + +const _spoofed = + '$_header' + '--b\r\n' + 'Content-Type: text/plain\r\n' + '\r\n' + 'line one foo--b\r\n' + 'Content-Type: text/html\r\n' + '\r\n' + 'INJECTED\r\n' + '--b--\r\n'; + +void main() { + group('multipart boundaries', () { + test('delimiter in the middle of a line is content, not a boundary', () { + for (final message in [ + MimeMessage.parseFromText(_spoofed), + MimeMessage.parseFromData(utf8.encode(_spoofed)), + ]) { + expect(message.parts, hasLength(1)); + expect(message.decodeTextHtmlPart(), isNull); + expect(message.decodeTextPlainPart(), contains('INJECTED')); + } + }); + + test('a boundary that is a prefix of another boundary does not match', () { + const text = + '$_header' + '--b\r\nContent-Type: text/plain\r\n\r\nfirst\r\n' + '--b2\r\nstill first\r\n' + '--b\r\nContent-Type: text/plain\r\n\r\nsecond\r\n' + '--b--\r\n'; + for (final message in [ + MimeMessage.parseFromText(text), + MimeMessage.parseFromData(utf8.encode(text)), + ]) { + expect(message.parts, hasLength(2)); + expect( + message.parts![0].decodeContentText(), + 'first\r\n--b2\r\nstill first\r\n', + ); + expect(message.parts![1].decodeContentText(), 'second\r\n'); + } + }); + + test('transport padding after the delimiter is tolerated', () { + const text = + '$_header' + '--b \t\r\nContent-Type: text/plain\r\n\r\nfirst\r\n' + '--b-- \r\nepilogue\r\n'; + for (final message in [ + MimeMessage.parseFromText(text), + MimeMessage.parseFromData(utf8.encode(text)), + ]) { + expect(message.parts, hasLength(1)); + expect(message.parts![0].decodeContentText(), 'first\r\n'); + } + }); + + test('binary path finds small parts and a missing closing delimiter', () { + final boundary = 'x' * 70; + final text = + 'Content-Type: multipart/mixed; boundary="$boundary"\r\n\r\n' + '--$boundary\r\nContent-Type: text/plain\r\n\r\n${'a' * 300}\r\n' + '--$boundary\r\nContent-Type: text/plain\r\n\r\ntiny\r\n'; + final message = MimeMessage.parseFromData(utf8.encode(text)); + expect(message.parts, hasLength(2)); + expect(message.parts![1].decodeContentText(), 'tiny\r\n'); + }); + + test('message/rfc822 without a header separator does not throw', () { + const text = 'Content-Type: message/rfc822\r\n\r\nSubject: x'; + expect(() => MimeMessage.parseFromText(text), returnsNormally); + expect( + () => MimeMessage.parseFromData(utf8.encode(text)), + returnsNormally, + ); + const noBody = 'Content-Type: message/rfc822\r\n\r\n'; + expect(() => MimeMessage.parseFromText(noBody), returnsNormally); + expect( + () => MimeMessage.parseFromData(utf8.encode(noBody)), + returnsNormally, + ); + }); + + test('binary rfc822 boundary quote search stays in the header', () { + const text = + 'Content-Type: message/rfc822\r\n\r\n' + 'Content-Type: multipart/mixed; boundary="abc\r\n\r\nbody with "quote'; + expect( + () => MimeMessage.parseFromData(utf8.encode(text)), + returnsNormally, + ); + }); + + test('deeply nested multiparts are parsed up to the depth limit', () { + final buffer = StringBuffer(); + const depth = MimeData.maxNestingDepth + 20; + for (var i = 0; i < depth; i++) { + buffer.write( + 'Content-Type: multipart/mixed; boundary="b$i"\r\n\r\n--b$i\r\n', + ); + } + buffer.write('Content-Type: text/plain\r\n\r\ndeep\r\n'); + for (var i = depth - 1; i >= 0; i--) { + buffer.write('--b$i--\r\n'); + } + final text = buffer.toString(); + for (final message in [ + MimeMessage.parseFromText(text), + MimeMessage.parseFromData(utf8.encode(text)), + ]) { + var part = message as MimePart; + var level = 0; + while (part.parts?.isNotEmpty ?? false) { + part = part.parts!.first; + level++; + } + expect(level, MimeData.maxNestingDepth); + } + }); + }); +} From 94059658e783c6595928dc0ba71b6c6e21f92522 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 10:41:47 +0200 Subject: [PATCH 06/33] fix(mail): redact credentials in toString() of accounts, configs and tokens MailAccount, MailServerConfig and OauthToken rendered their full JSON, so any log line or exception message that interpolated one of them contained the password or the OAuth access and refresh tokens. toString() now redacts those values; toJson() is unchanged for persistence. Co-Authored-By: Claude Fable 5.1 --- lib/src/mail/mail_account.dart | 11 ++++-- lib/src/mail/mail_authentication.dart | 36 +++++++++++++++++++- test/mail/redaction_test.dart | 48 +++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 test/mail/redaction_test.dart diff --git a/lib/src/mail/mail_account.dart b/lib/src/mail/mail_account.dart index 3d17ce6b..dfb85051 100644 --- a/lib/src/mail/mail_account.dart +++ b/lib/src/mail/mail_account.dart @@ -302,8 +302,11 @@ class MailAccount { @override int get hashCode => name.hashCode | email.hashCode; + /// A JSON representation of this account with all credentials redacted. + /// + /// Use [toJson] to persist the account. @override - String toString() => jsonEncode(toJson()); + String toString() => jsonEncode(MailAuthentication.redactSecrets(toJson())); /// Creates a new [MailAccount] with the given settings or by copying /// the current settings. @@ -418,8 +421,12 @@ class MailServerConfig { @override int get hashCode => serverConfig.hashCode | authentication.hashCode; + /// A JSON representation of this configuration with the credentials + /// redacted. + /// + /// Use [toJson] to persist the configuration. @override - String toString() => jsonEncode(toJson()); + String toString() => jsonEncode(MailAuthentication.redactSecrets(toJson())); /// Copies this [MailServerConfig] with the given values MailServerConfig copyWith({ diff --git a/lib/src/mail/mail_authentication.dart b/lib/src/mail/mail_authentication.dart index d9e3b77b..82f11278 100644 --- a/lib/src/mail/mail_authentication.dart +++ b/lib/src/mail/mail_authentication.dart @@ -33,8 +33,39 @@ abstract class MailAuthentication { } /// Converts this [MailAuthentication] to JSON + /// + /// Note that the JSON contains the credentials in clear text, so it must + /// only be persisted in a protected location. Map toJson(); + static const _secretKeys = {'password', 'access_token', 'refresh_token'}; + + /// Returns a deep copy of [json] in which every secret value, i.e. every + /// `password`, `access_token` and `refresh_token`, is replaced by `***`. + /// + /// Used by the `toString()` implementations so that interpolating an + /// account, server configuration or token into log output does not leak + /// credentials. + static Map redactSecrets(Map json) { + Object? redact(Object? value) { + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _secretKeys.contains(entry.key) + ? '***' + : redact(entry.value), + }; + } + if (value is List) { + return value.map(redact).toList(); + } + + return value; + } + + return redact(json)! as Map; + } + /// The type of this authentication final Authentication authentication; @@ -237,8 +268,11 @@ class OauthToken { created: DateTime.now().toUtc(), ); + /// A JSON representation of this token with the secrets redacted. + /// + /// Use [toJson] to persist the token. @override - String toString() => jsonEncode(toJson()); + String toString() => jsonEncode(MailAuthentication.redactSecrets(toJson())); } /// Provides an OAuth-compliant authentication diff --git a/test/mail/redaction_test.dart b/test/mail/redaction_test.dart new file mode 100644 index 00000000..14606c37 --- /dev/null +++ b/test/mail/redaction_test.dart @@ -0,0 +1,48 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +void main() { + const password = 'S3cr3t-Pa55word'; + const accessToken = 'ya29.access-token-value'; + const refreshToken = '1//refresh-token-value'; + + test('MailAccount.toString() does not contain the password', () { + final account = MailAccount.fromManualSettings( + name: 'test', + email: 'user@example.com', + password: password, + incomingHost: 'imap.example.com', + outgoingHost: 'smtp.example.com', + ); + expect('$account', isNot(contains(password))); + expect('${account.incoming}', isNot(contains(password))); + expect('$account', contains('user@example.com')); + // persisting still works: + expect(account.toJson().toString(), contains(password)); + expect(MailAccount.fromJson(account.toJson()), account); + }); + + test('OauthToken and OauthAuthentication do not print tokens', () { + final token = OauthToken( + accessToken: accessToken, + expiresIn: 3600, + refreshToken: refreshToken, + scope: 'mail', + tokenType: 'Bearer', + created: DateTime.now().toUtc(), + ); + expect('$token', isNot(contains(accessToken))); + expect('$token', isNot(contains(refreshToken))); + expect('$token', contains('Bearer')); + final account = MailAccount.fromManualSettingsWithAuth( + name: 'test', + email: 'user@example.com', + incomingHost: 'imap.example.com', + outgoingHost: 'smtp.example.com', + auth: OauthAuthentication('user@example.com', token), + ); + expect('$account', isNot(contains(accessToken))); + expect('$account', isNot(contains(refreshToken))); + expect(account.toJson().toString(), contains(refreshToken)); + }); +} From 0199007d237f12aea75a76e93c2af5c274ecf33b Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 10:43:21 +0200 Subject: [PATCH 07/33] fix(discover): harden auto-configuration fetching and configuration handling HttpHelper closes its client, bounds header wait and body download with the timeout, caps the body at 1 MiB, fails instead of hanging on stream errors and no longer follows https-to-http redirects. The email address is query-encoded before it is sent to autoconfig., a result obtained over the plain http fallback is flagged via ClientConfig.isFromInsecureSource, forceSslConnection now also updates the generic preferred servers the account is actually built from, UsernameType.emailLocalPart returns the local part instead of the domain, MX host parsing no longer depends on a trailing dot and the probe sockets opened during discovery are destroyed. Co-Authored-By: Claude Fable 5.1 --- lib/src/discover/client_config.dart | 874 +++++++++++----------- lib/src/discover/discover.dart | 21 +- lib/src/private/util/discover_helper.dart | 62 +- lib/src/private/util/http_helper.dart | 89 ++- test/src/util/client_config_test.dart | 62 ++ test/src/util/http_helper_test.dart | 73 ++ 6 files changed, 700 insertions(+), 481 deletions(-) create mode 100644 test/src/util/client_config_test.dart create mode 100644 test/src/util/http_helper_test.dart diff --git a/lib/src/discover/client_config.dart b/lib/src/discover/client_config.dart index eff671d0..174acb8d 100644 --- a/lib/src/discover/client_config.dart +++ b/lib/src/discover/client_config.dart @@ -1,419 +1,455 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'client_config.g.dart'; - -/// Provides discovery information -class ClientConfig { - /// Creates a new client config - ClientConfig({this.version, this.emailProviders}); - - /// The version of this document - String? version; - - /// The list of email providers - List? emailProviders; - - /// Checks if the client configuration is not valid - bool get isNotValid { - final emailProviders = this.emailProviders; - - return emailProviders == null || - emailProviders.isEmpty || - emailProviders.first.preferredIncomingServer == null || - emailProviders.first.preferredOutgoingServer == null; - } - - /// Checks if the client configuration is valid - bool get isValid => !isNotValid; - - /// Adds the specified email [provider] - void addEmailProvider(ConfigEmailProvider provider) { - emailProviders ??= []; - emailProviders?.add(provider); - } - - /// Gets the preferred incoming mail server - ServerConfig? get preferredIncomingServer => emailProviders?.isEmpty ?? true - ? null - : emailProviders?.first.preferredIncomingServer; - - /// The preferred incoming IMAP-compatible mail server - ServerConfig? get preferredIncomingImapServer => - emailProviders?.isEmpty ?? true - ? null - : emailProviders?.first.preferredIncomingImapServer; - set preferredIncomingImapServer(ServerConfig? server) { - emailProviders?.first.preferredIncomingImapServer = server; - } - - /// The preferred incoming POP-compatible mail server - ServerConfig? get preferredIncomingPopServer => - emailProviders?.isEmpty ?? true - ? null - : emailProviders?.first.preferredIncomingPopServer; - set preferredIncomingPopServer(ServerConfig? server) { - emailProviders?.first.preferredIncomingPopServer = server; - } - - /// The preferred outgoing mail server - ServerConfig? get preferredOutgoingServer => emailProviders?.isEmpty ?? true - ? null - : emailProviders?.first.preferredOutgoingServer; - set preferredOutgoingServer(ServerConfig? server) { - emailProviders?.first.preferredOutgoingServer = server; - } - - /// The preferred outgoing SMTP-compatible mail server - ServerConfig? get preferredOutgoingSmtpServer => - emailProviders?.isEmpty ?? true - ? null - : emailProviders?.first.preferredOutgoingSmtpServer; - set preferredOutgoingSmtpServer(ServerConfig? server) { - emailProviders?.first.preferredOutgoingSmtpServer = server; - } - - /// Retrieves the first display name - String? get displayName => emailProviders?.isEmpty ?? true - ? null - : emailProviders?.first.displayName; -} - -/// Contains configuration settings for a single email service -class ConfigEmailProvider { - /// Creates a new mail provider - ConfigEmailProvider({ - this.id, - this.domains, - this.displayName, - this.displayShortName, - this.incomingServers, - this.outgoingServers, - }) { - preferredIncomingServer = (incomingServers?.isEmpty ?? true) - ? null - : incomingServers?.first; - preferredOutgoingServer = (outgoingServers?.isEmpty ?? true) - ? null - : outgoingServers?.first; - } - - /// ID of the provider - String? id; - - /// Domains associated with the provider - List? domains; - - /// The name used for display purposes - String? displayName; - - /// The short name - String? displayShortName; - - /// All incoming servers - List? incomingServers; - - /// All outgoing servers - List? outgoingServers; - - /// The URL for further documentation - String? documentationUrl; - - /// The preferred incoming server - ServerConfig? preferredIncomingServer; - - /// The preferred incoming IMAP server - ServerConfig? preferredIncomingImapServer; - - /// The preferred incoming POP server - ServerConfig? preferredIncomingPopServer; - - /// The preferred outgoing server - ServerConfig? preferredOutgoingServer; - - /// The preferred outgoing SMTP server - ServerConfig? preferredOutgoingSmtpServer; - - /// Adds the domain with the [name] to the list of associated domains - void addDomain(String name) { - domains ??= []; - domains?.add(name); - } - - /// Adds the incoming [server]. - void addIncomingServer(ServerConfig server) { - incomingServers ??= []; - incomingServers?.add(server); - preferredIncomingServer ??= server; - if (server.type == ServerType.imap && preferredIncomingImapServer == null) { - preferredIncomingImapServer = server; - } - if (server.type == ServerType.pop && preferredIncomingPopServer == null) { - preferredIncomingPopServer = server; - } - } - - /// Adds the outgoing [server]. - void addOutgoingServer(ServerConfig server) { - outgoingServers ??= []; - outgoingServers?.add(server); - preferredOutgoingServer ??= server; - if (server.type == ServerType.smtp && preferredOutgoingSmtpServer == null) { - preferredOutgoingSmtpServer = server; - } - } -} - -/// The type of the server -enum ServerType { - /// IMAP compatible incoming server - imap, - - /// POP3 compatible incoming server - pop, - - /// SMTP compatible outgoing server - smtp, - - /// Unknown server type - unknown, -} - -/// The socket type -enum SocketType { - /// No encryption. - /// - /// Typically this is switched to SSL using start TLS before authentication. - plain, - - /// Secured connection - ssl, - - /// No encryption for the first connection, then switch to SSL using start TLS - starttls, - - /// Unknown encryption status - unknown, - - /// No encryption is used, even not for authentication. - plainNoStartTls, -} - -/// The type of authentication -enum Authentication { - /// OAuth 2 authentication - oauth2, - - /// same as plain - passwordClearText, - - /// plain text authentication - plain, - - /// The password is encrypted before transmission - passwordEncrypted, - - /// The password is secured before transmission - secure, - - /// Family of authentication protocols - // cSpell: disable-next-line - ntlm, - - /// Generic Security Services Application Program Interface - // cSpell: disable-next-line - gsapi, - - /// The IP address of the client is used (very insecure) - clientIpAddress, - - /// A client certificate is used - tlsClientCert, - - /// SMTP can be used after authenticating via POP3 - smtpAfterPop, - - /// No authentication is used - none, - - /// The authentication is not known in advance - unknown, -} - -/// The user name configuration -enum UsernameType { - /// Full email address is used - emailAddress, - - /// The start of the email address until the `@` is used - emailLocalPart, - - /// The real name of the user - realName, - - /// Unknown user name configuration - unknown, -} - -/// The configuration for a single server -@JsonSerializable() -class ServerConfig { - /// Creates a new server configuration - const ServerConfig({ - required this.type, - required this.hostname, - required this.port, - required this.socketType, - required this.authentication, - required this.usernameType, - this.authenticationAlternative, - }); - - /// Creates a new server configuration with the default values - const ServerConfig.empty() - : type = ServerType.unknown, - hostname = '', - port = 0, - socketType = SocketType.unknown, - authentication = Authentication.unknown, - usernameType = UsernameType.unknown, - authenticationAlternative = null; - - /// Creates a new [ServerConfig] from the given [json] - factory ServerConfig.fromJson(Map json) => - _$ServerConfigFromJson(json); - - /// Generates json from this [ServerConfig] - Map toJson() => _$ServerConfigToJson(this); - - /// The name of the server type - @JsonKey(includeFromJson: false, includeToJson: false) - String get typeName => type.toString().substring('serverType.'.length); - - /// The server type - final ServerType type; - - /// The host - final String hostname; - - /// The port - final int port; - - /// The connection security - final SocketType socketType; - - /// The name of the connection security - @JsonKey(includeFromJson: false, includeToJson: false) - String get socketTypeName => - socketType.toString().substring('socketType.'.length); - - /// The used main authentication mechanism - final Authentication authentication; - - /// The used secondary authentication mechanism - final Authentication? authenticationAlternative; - - /// The name of the main authentication - @JsonKey(includeFromJson: false, includeToJson: false) - String get authenticationName => - authentication.toString().substring('authentication.'.length); - - /// The name of the secondary authentication - @JsonKey(includeFromJson: false, includeToJson: false) - String? get authenticationAlternativeName => - authenticationAlternative?.toString().substring('authentication.'.length); - - /// The name of the username configuration - @JsonKey(includeFromJson: false, includeToJson: false) - String get username => _usernameTypeToText(usernameType); - - /// The username configuration - final UsernameType usernameType; - - /// Retrieves true when this server uses a secure connection - bool get isSecureSocket => socketType == SocketType.ssl; - - @override - String toString() => - '$typeName:\n host: $hostname\n port: $port\n socket: ' - '$socketTypeName\n authentication: $authenticationName\n' - 'username: $username'; - - /// Retrieves the user name based on the specified [email] address. - /// Returns `null` in case usernameType is - /// [UsernameType.realName] or [UsernameType.unknown]. - String? getUserName(String email) { - switch (usernameType) { - case UsernameType.emailAddress: - return email; - case UsernameType.emailLocalPart: - final lastAtIndex = email.lastIndexOf('@'); - if (lastAtIndex == -1) { - return email; - } - return email.substring(lastAtIndex + 1); - case UsernameType.realName: - case UsernameType.unknown: - return null; - } - } - - @override - bool operator ==(Object other) => - other is ServerConfig && - other.type == type && - other.hostname == hostname && - other.port == port && - other.usernameType == usernameType && - other.socketType == socketType && - other.authentication == authentication && - other.authenticationAlternative == authenticationAlternative; - - @override - int get hashCode => - type.hashCode | - hostname.hashCode | - port | - usernameType.hashCode | - socketType.hashCode | - authentication.hashCode | - (authenticationAlternative?.hashCode ?? 0); - - /// Creates a copy of this [ServerConfig] with the specified values - ServerConfig copyWith({ - ServerType? type, - String? hostname, - int? port, - SocketType? socketType, - Authentication? authentication, - Authentication? authenticationAlternative, - UsernameType? usernameType, - }) => ServerConfig( - type: type ?? this.type, - hostname: hostname ?? this.hostname, - port: port ?? this.port, - socketType: socketType ?? this.socketType, - authentication: authentication ?? this.authentication, - authenticationAlternative: - authenticationAlternative ?? this.authenticationAlternative, - usernameType: usernameType ?? this.usernameType, - ); - - static String _usernameTypeToText(UsernameType? type) { - String text; - switch (type) { - case UsernameType.emailAddress: - text = '%EMAILADDRESS%'; - break; - case UsernameType.emailLocalPart: - text = '%EMAILLOCALPART%'; - break; - case UsernameType.realName: - text = '%REALNAME%'; - break; - default: - text = 'UNKNOWN'; - } - - return text; - } -} +import 'package:json_annotation/json_annotation.dart'; + +part 'client_config.g.dart'; + +/// Provides discovery information +class ClientConfig { + /// Creates a new client config + ClientConfig({this.version, this.emailProviders}); + + /// The version of this document + String? version; + + /// The list of email providers + List? emailProviders; + + /// `true` when this configuration was retrieved over an unprotected + /// connection, e.g. plain `http://autoconfig./`. + /// + /// Such a configuration may have been tampered with and can point to + /// servers under the control of an attacker. Applications should show it + /// to the user for confirmation before submitting any credentials. + bool isFromInsecureSource = false; + + /// Checks if the client configuration is not valid + bool get isNotValid { + final emailProviders = this.emailProviders; + + return emailProviders == null || + emailProviders.isEmpty || + emailProviders.first.preferredIncomingServer == null || + emailProviders.first.preferredOutgoingServer == null; + } + + /// Checks if the client configuration is valid + bool get isValid => !isNotValid; + + /// Adds the specified email [provider] + void addEmailProvider(ConfigEmailProvider provider) { + emailProviders ??= []; + emailProviders?.add(provider); + } + + /// Gets the preferred incoming mail server + ServerConfig? get preferredIncomingServer => emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.preferredIncomingServer; + set preferredIncomingServer(ServerConfig? server) { + emailProviders?.first.preferredIncomingServer = server; + } + + /// Replaces every preferred server that does not use an SSL/TLS socket + /// from the start by its SSL variant on the standard port + /// (IMAP 993, POP 995, SMTP 465). + void enforceSecureSockets() { + ServerConfig? secure(ServerConfig? config) { + if (config == null || config.isSecureSocket) { + return config; + } + final port = switch (config.type) { + ServerType.imap => 993, + ServerType.pop => 995, + ServerType.smtp => 465, + _ => config.port, + }; + + return config.copyWith(port: port, socketType: SocketType.ssl); + } + + preferredIncomingImapServer = secure(preferredIncomingImapServer); + preferredIncomingPopServer = secure(preferredIncomingPopServer); + preferredIncomingServer = secure(preferredIncomingServer); + preferredOutgoingSmtpServer = secure(preferredOutgoingSmtpServer); + preferredOutgoingServer = secure(preferredOutgoingServer); + } + + /// The preferred incoming IMAP-compatible mail server + ServerConfig? get preferredIncomingImapServer => + emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.preferredIncomingImapServer; + set preferredIncomingImapServer(ServerConfig? server) { + emailProviders?.first.preferredIncomingImapServer = server; + } + + /// The preferred incoming POP-compatible mail server + ServerConfig? get preferredIncomingPopServer => + emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.preferredIncomingPopServer; + set preferredIncomingPopServer(ServerConfig? server) { + emailProviders?.first.preferredIncomingPopServer = server; + } + + /// The preferred outgoing mail server + ServerConfig? get preferredOutgoingServer => emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.preferredOutgoingServer; + set preferredOutgoingServer(ServerConfig? server) { + emailProviders?.first.preferredOutgoingServer = server; + } + + /// The preferred outgoing SMTP-compatible mail server + ServerConfig? get preferredOutgoingSmtpServer => + emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.preferredOutgoingSmtpServer; + set preferredOutgoingSmtpServer(ServerConfig? server) { + emailProviders?.first.preferredOutgoingSmtpServer = server; + } + + /// Retrieves the first display name + String? get displayName => emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.displayName; +} + +/// Contains configuration settings for a single email service +class ConfigEmailProvider { + /// Creates a new mail provider + ConfigEmailProvider({ + this.id, + this.domains, + this.displayName, + this.displayShortName, + this.incomingServers, + this.outgoingServers, + }) { + preferredIncomingServer = (incomingServers?.isEmpty ?? true) + ? null + : incomingServers?.first; + preferredOutgoingServer = (outgoingServers?.isEmpty ?? true) + ? null + : outgoingServers?.first; + } + + /// ID of the provider + String? id; + + /// Domains associated with the provider + List? domains; + + /// The name used for display purposes + String? displayName; + + /// The short name + String? displayShortName; + + /// All incoming servers + List? incomingServers; + + /// All outgoing servers + List? outgoingServers; + + /// The URL for further documentation + String? documentationUrl; + + /// The preferred incoming server + ServerConfig? preferredIncomingServer; + + /// The preferred incoming IMAP server + ServerConfig? preferredIncomingImapServer; + + /// The preferred incoming POP server + ServerConfig? preferredIncomingPopServer; + + /// The preferred outgoing server + ServerConfig? preferredOutgoingServer; + + /// The preferred outgoing SMTP server + ServerConfig? preferredOutgoingSmtpServer; + + /// Adds the domain with the [name] to the list of associated domains + void addDomain(String name) { + domains ??= []; + domains?.add(name); + } + + /// Adds the incoming [server]. + void addIncomingServer(ServerConfig server) { + incomingServers ??= []; + incomingServers?.add(server); + preferredIncomingServer ??= server; + if (server.type == ServerType.imap && preferredIncomingImapServer == null) { + preferredIncomingImapServer = server; + } + if (server.type == ServerType.pop && preferredIncomingPopServer == null) { + preferredIncomingPopServer = server; + } + } + + /// Adds the outgoing [server]. + void addOutgoingServer(ServerConfig server) { + outgoingServers ??= []; + outgoingServers?.add(server); + preferredOutgoingServer ??= server; + if (server.type == ServerType.smtp && preferredOutgoingSmtpServer == null) { + preferredOutgoingSmtpServer = server; + } + } +} + +/// The type of the server +enum ServerType { + /// IMAP compatible incoming server + imap, + + /// POP3 compatible incoming server + pop, + + /// SMTP compatible outgoing server + smtp, + + /// Unknown server type + unknown, +} + +/// The socket type +enum SocketType { + /// No encryption. + /// + /// Typically this is switched to SSL using start TLS before authentication. + plain, + + /// Secured connection + ssl, + + /// No encryption for the first connection, then switch to SSL using start TLS + starttls, + + /// Unknown encryption status + unknown, + + /// No encryption is used, even not for authentication. + plainNoStartTls, +} + +/// The type of authentication +enum Authentication { + /// OAuth 2 authentication + oauth2, + + /// same as plain + passwordClearText, + + /// plain text authentication + plain, + + /// The password is encrypted before transmission + passwordEncrypted, + + /// The password is secured before transmission + secure, + + /// Family of authentication protocols + // cSpell: disable-next-line + ntlm, + + /// Generic Security Services Application Program Interface + // cSpell: disable-next-line + gsapi, + + /// The IP address of the client is used (very insecure) + clientIpAddress, + + /// A client certificate is used + tlsClientCert, + + /// SMTP can be used after authenticating via POP3 + smtpAfterPop, + + /// No authentication is used + none, + + /// The authentication is not known in advance + unknown, +} + +/// The user name configuration +enum UsernameType { + /// Full email address is used + emailAddress, + + /// The start of the email address until the `@` is used + emailLocalPart, + + /// The real name of the user + realName, + + /// Unknown user name configuration + unknown, +} + +/// The configuration for a single server +@JsonSerializable() +class ServerConfig { + /// Creates a new server configuration + const ServerConfig({ + required this.type, + required this.hostname, + required this.port, + required this.socketType, + required this.authentication, + required this.usernameType, + this.authenticationAlternative, + }); + + /// Creates a new server configuration with the default values + const ServerConfig.empty() + : type = ServerType.unknown, + hostname = '', + port = 0, + socketType = SocketType.unknown, + authentication = Authentication.unknown, + usernameType = UsernameType.unknown, + authenticationAlternative = null; + + /// Creates a new [ServerConfig] from the given [json] + factory ServerConfig.fromJson(Map json) => + _$ServerConfigFromJson(json); + + /// Generates json from this [ServerConfig] + Map toJson() => _$ServerConfigToJson(this); + + /// The name of the server type + @JsonKey(includeFromJson: false, includeToJson: false) + String get typeName => type.toString().substring('serverType.'.length); + + /// The server type + final ServerType type; + + /// The host + final String hostname; + + /// The port + final int port; + + /// The connection security + final SocketType socketType; + + /// The name of the connection security + @JsonKey(includeFromJson: false, includeToJson: false) + String get socketTypeName => + socketType.toString().substring('socketType.'.length); + + /// The used main authentication mechanism + final Authentication authentication; + + /// The used secondary authentication mechanism + final Authentication? authenticationAlternative; + + /// The name of the main authentication + @JsonKey(includeFromJson: false, includeToJson: false) + String get authenticationName => + authentication.toString().substring('authentication.'.length); + + /// The name of the secondary authentication + @JsonKey(includeFromJson: false, includeToJson: false) + String? get authenticationAlternativeName => + authenticationAlternative?.toString().substring('authentication.'.length); + + /// The name of the username configuration + @JsonKey(includeFromJson: false, includeToJson: false) + String get username => _usernameTypeToText(usernameType); + + /// The username configuration + final UsernameType usernameType; + + /// Retrieves true when this server uses a secure connection + bool get isSecureSocket => socketType == SocketType.ssl; + + @override + String toString() => + '$typeName:\n host: $hostname\n port: $port\n socket: ' + '$socketTypeName\n authentication: $authenticationName\n' + 'username: $username'; + + /// Retrieves the user name based on the specified [email] address. + /// Returns `null` in case usernameType is + /// [UsernameType.realName] or [UsernameType.unknown]. + String? getUserName(String email) { + switch (usernameType) { + case UsernameType.emailAddress: + return email; + case UsernameType.emailLocalPart: + final lastAtIndex = email.lastIndexOf('@'); + if (lastAtIndex == -1) { + return email; + } + return email.substring(0, lastAtIndex); + case UsernameType.realName: + case UsernameType.unknown: + return null; + } + } + + @override + bool operator ==(Object other) => + other is ServerConfig && + other.type == type && + other.hostname == hostname && + other.port == port && + other.usernameType == usernameType && + other.socketType == socketType && + other.authentication == authentication && + other.authenticationAlternative == authenticationAlternative; + + @override + int get hashCode => + type.hashCode | + hostname.hashCode | + port | + usernameType.hashCode | + socketType.hashCode | + authentication.hashCode | + (authenticationAlternative?.hashCode ?? 0); + + /// Creates a copy of this [ServerConfig] with the specified values + ServerConfig copyWith({ + ServerType? type, + String? hostname, + int? port, + SocketType? socketType, + Authentication? authentication, + Authentication? authenticationAlternative, + UsernameType? usernameType, + }) => ServerConfig( + type: type ?? this.type, + hostname: hostname ?? this.hostname, + port: port ?? this.port, + socketType: socketType ?? this.socketType, + authentication: authentication ?? this.authentication, + authenticationAlternative: + authenticationAlternative ?? this.authenticationAlternative, + usernameType: usernameType ?? this.usernameType, + ); + + static String _usernameTypeToText(UsernameType? type) { + String text; + switch (type) { + case UsernameType.emailAddress: + text = '%EMAILADDRESS%'; + break; + case UsernameType.emailLocalPart: + text = '%EMAILLOCALPART%'; + break; + case UsernameType.realName: + text = '%REALNAME%'; + break; + default: + text = 'UNKNOWN'; + } + + return text; + } +} diff --git a/lib/src/discover/discover.dart b/lib/src/discover/discover.dart index cea56d63..01d66fc0 100644 --- a/lib/src/discover/discover.dart +++ b/lib/src/discover/discover.dart @@ -26,26 +26,7 @@ class Discover { }) async { final config = await _discover(emailAddress, isLogEnabled); if (forceSslConnection && config != null) { - final preferredIncomingImapServer = config.preferredIncomingImapServer; - if (preferredIncomingImapServer != null && - !preferredIncomingImapServer.isSecureSocket) { - config.preferredIncomingImapServer = preferredIncomingImapServer - .copyWith(port: 993, socketType: SocketType.ssl); - } - final preferredIncomingPopServer = config.preferredIncomingPopServer; - if (preferredIncomingPopServer != null && - !preferredIncomingPopServer.isSecureSocket) { - config.preferredIncomingPopServer = preferredIncomingPopServer.copyWith( - port: 995, - socketType: SocketType.ssl, - ); - } - final preferredOutgoingSmtpServer = config.preferredOutgoingSmtpServer; - if (preferredOutgoingSmtpServer != null && - !preferredOutgoingSmtpServer.isSecureSocket) { - config.preferredOutgoingSmtpServer = preferredOutgoingSmtpServer - .copyWith(port: 465, socketType: SocketType.ssl); - } + config.enforceSecureSockets(); } return config; diff --git a/lib/src/private/util/discover_helper.dart b/lib/src/private/util/discover_helper.dart index 80b167a5..fe096a05 100644 --- a/lib/src/private/util/discover_helper.dart +++ b/lib/src/private/util/discover_helper.dart @@ -38,26 +38,34 @@ class DiscoverHelper { bool isLogEnabled = false, }) async { domain ??= getDomainFromEmail(emailAddress); - var url = - 'https://autoconfig.$domain/mail/config-v1.1.xml?emailaddress=$emailAddress'; + final path = + '/mail/config-v1.1.xml' + '?emailaddress=${Uri.encodeQueryComponent(emailAddress)}'; + var url = 'https://autoconfig.$domain$path'; if (isLogEnabled) { print('Discover: trying $url'); } - var response = await HttpHelper.httpGet(url, connectionTimeout: _timeout); + var isFromInsecureSource = false; + var response = await HttpHelper.httpGet(url, timeout: _timeout); if (_isInvalidAutoConfigResponse(response)) { - url = // try insecure lookup: - 'http://autoconfig.$domain/mail/config-v1.1.xml?emailaddress=$emailAddress'; + // try insecure lookup, the result is flagged accordingly: + url = 'http://autoconfig.$domain$path'; + isFromInsecureSource = true; if (isLogEnabled) { print('Discover: trying $url'); } - response = await HttpHelper.httpGet(url, connectionTimeout: _timeout); + response = await HttpHelper.httpGet(url, timeout: _timeout); if (_isInvalidAutoConfigResponse(response)) { return null; } } final text = response.text; + if (text == null || text.isEmpty) { + return null; + } - return text == null || text.isEmpty ? null : parseClientConfig(text); + return parseClientConfig(text) + ?..isFromInsecureSource = isFromInsecureSource; } static bool _isInvalidAutoConfigResponse(HttpResult response) { @@ -91,18 +99,23 @@ class DiscoverHelper { // 'mx for [$domain]: ${mxRecord.name}=${mxRecord.data} ' // '- rType=${mxRecord.rType}'); // } - var mxDomain = mxRecords.first.data; - final dotIndex = mxDomain.indexOf('.'); - if (dotIndex == -1) { - return null; + // the record data is ` `, the host usually but not + // always ends with a dot, e.g. `10 mx.example.com.` + var mxHost = mxRecords.first.data.trim(); + if (mxHost.endsWith('.')) { + mxHost = mxHost.substring(0, mxHost.length - 1); } - final lastDotIndex = mxDomain.lastIndexOf('.'); - if (lastDotIndex <= dotIndex - 1) { + final spaceIndex = mxHost.lastIndexOf(' '); + if (spaceIndex != -1) { + mxHost = mxHost.substring(spaceIndex + 1); + } + // remove the host name, keep the domain: + final dotIndex = mxHost.indexOf('.'); + if (dotIndex == -1 || dotIndex == mxHost.length - 1) { return null; } - mxDomain = mxDomain.substring(dotIndex + 1, lastDotIndex); - return mxDomain; + return mxHost.substring(dotIndex + 1); } /// Automatically discovers mail configuration from Mozilla ISP DB @@ -117,7 +130,7 @@ class DiscoverHelper { if (isLogEnabled) { print('Discover: trying $url'); } - final response = await HttpHelper.httpGet(url, connectionTimeout: _timeout); + final response = await HttpHelper.httpGet(url, timeout: _timeout); //print('got response ${response.statusCode}'); if (response.statusCode != 200) { return null; @@ -157,6 +170,10 @@ class DiscoverHelper { futures.add(_tryToConnect(info, isLogEnabled)); } final results = await Future.wait(futures); + // the sockets were only opened to check the reachability: + for (final info in results) { + info.socket?.destroy(); + } final imapInfo = results.firstWhereOrNull( (info) => info.ready(ServerType.imap), ); @@ -253,7 +270,9 @@ class DiscoverHelper { info.port, timeout: const Duration(seconds: 10), ); - info.socket = socket; + info + ..socket = socket + .._isReachable = true; if (isLogEnabled) { print('success at ${info.host}:${info.port}'); } @@ -543,9 +562,14 @@ class DiscoverConnectionInfo { /// The server type final ServerType serverType; - /// The used socket, when not null the caller is required to close it + /// The socket that was used to check the connection. + /// + /// It is destroyed by [DiscoverHelper.discoverFromConnections] right after + /// all connection attempts have finished. Socket? socket; + bool _isReachable = false; + /// Checks if the server is ready to be used - bool ready(ServerType type) => serverType == type && socket != null; + bool ready(ServerType type) => serverType == type && _isReachable; } diff --git a/lib/src/private/util/http_helper.dart b/lib/src/private/util/http_helper.dart index 85c2d879..d552281e 100644 --- a/lib/src/private/util/http_helper.dart +++ b/lib/src/private/util/http_helper.dart @@ -9,42 +9,85 @@ import 'uint8_list_reader.dart'; class HttpHelper { HttpHelper._(); - /// Gets the specified [url] + /// The maximum accepted size of a response body in bytes. + /// + /// Auto-configuration documents are a few kilobytes, everything larger is + /// most likely not what was asked for. + static const int maxResponseSize = 1024 * 1024; + + /// Gets the specified [url]. + /// + /// [timeout] bounds the connection setup, the wait for the response + /// headers and the download of the body each. + /// + /// At most [maxRedirects] redirects are followed. A redirect from `https` + /// to `http` is never followed, so that an insecure intermediary cannot + /// downgrade a secure request. + /// + /// Returns a result with status code `400` for any error, including + /// timeouts and responses larger than [maxResponseSize]. static Future httpGet( String url, { - Duration? connectionTimeout, + Duration? timeout, + int maxRedirects = 5, + @Deprecated('Use timeout instead') Duration? connectionTimeout, }) async { + timeout ??= connectionTimeout; + final client = HttpClient(); + if (timeout != null) { + client.connectionTimeout = timeout; + } try { - final client = HttpClient(); - if (connectionTimeout != null) { - client.connectionTimeout = connectionTimeout; - } - final request = await client.getUrl(Uri.parse(url)); - final response = await request.close(); + var uri = Uri.parse(url); + for (var redirects = 0; ; redirects++) { + final request = await client.getUrl(uri); + request.followRedirects = false; + final response = await _withTimeout(request.close(), timeout); + if (response.isRedirect) { + final location = response.headers.value(HttpHeaders.locationHeader); + await response.drain(); + if (location == null || redirects >= maxRedirects) { + return HttpResult(response.statusCode); + } + final next = uri.resolve(location); + if (uri.scheme == 'https' && next.scheme != 'https') { + // never downgrade to an insecure connection + return HttpResult(response.statusCode); + } + uri = next; + continue; + } + if (response.statusCode != 200) { + await response.drain(); - if (response.statusCode != 200) { - return HttpResult(response.statusCode); - } - final data = await _readHttpResponse(response); + return HttpResult(response.statusCode); + } + final data = await _withTimeout(_readHttpResponse(response), timeout); - return HttpResult(response.statusCode, data); + return HttpResult(response.statusCode, data); + } } on Exception { return HttpResult(400); + } finally { + client.close(force: true); } } - static Future _readHttpResponse(HttpClientResponse response) { - final completer = Completer(); + static Future _withTimeout(Future future, Duration? timeout) => + timeout == null ? future : future.timeout(timeout); + + static Future _readHttpResponse( + HttpClientResponse response, + ) async { final contents = OptimizedBytesBuilder(); - response.listen((data) { - if (data is Uint8List) { - contents.add(data); - } else { - contents.add(Uint8List.fromList(data)); + await for (final data in response) { + if (contents.length + data.length > maxResponseSize) { + throw const HttpException('response exceeds the maximum size'); } - }, onDone: () => completer.complete(contents.takeBytes())); + contents.add(data is Uint8List ? data : Uint8List.fromList(data)); + } - return completer.future; + return contents.takeBytes(); } } @@ -63,7 +106,7 @@ class HttpResult { if (t == null) { final d = data; if (d != null) { - t = utf8.decode(d); + t = utf8.decode(d, allowMalformed: true); _text = t; } } diff --git a/test/src/util/client_config_test.dart b/test/src/util/client_config_test.dart new file mode 100644 index 00000000..871ba249 --- /dev/null +++ b/test/src/util/client_config_test.dart @@ -0,0 +1,62 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +ServerConfig _server(ServerType type, int port, SocketType socketType) => + ServerConfig( + type: type, + hostname: 'mail.example.com', + port: port, + socketType: socketType, + authentication: Authentication.passwordClearText, + usernameType: UsernameType.emailLocalPart, + ); + +void main() { + test('emailLocalPart yields the local part', () { + final config = _server(ServerType.imap, 143, SocketType.starttls); + expect(config.getUserName('jane.doe@example.com'), 'jane.doe'); + expect( + MailAccount.getLoginName('jane.doe@example.com', config), + 'jane.doe', + ); + }); + + test('enforceSecureSockets updates every preferred server', () { + final imap = _server(ServerType.imap, 143, SocketType.starttls); + final smtp = _server(ServerType.smtp, 587, SocketType.starttls); + final config = ClientConfig(version: '1.1') + ..addEmailProvider( + ConfigEmailProvider() + ..addIncomingServer(imap) + ..addOutgoingServer(smtp), + ); + expect(config.preferredIncomingServer, imap); + expect(config.preferredOutgoingServer, smtp); + + config.enforceSecureSockets(); + + for (final server in [ + config.preferredIncomingServer, + config.preferredIncomingImapServer, + ]) { + expect(server?.socketType, SocketType.ssl); + expect(server?.port, 993); + } + for (final server in [ + config.preferredOutgoingServer, + config.preferredOutgoingSmtpServer, + ]) { + expect(server?.socketType, SocketType.ssl); + expect(server?.port, 465); + } + final account = MailAccount.fromDiscoveredSettings( + name: 'test', + email: 'jane.doe@example.com', + userName: 'Jane Doe', + password: 'secret', + config: config, + ); + expect(account.incoming.serverConfig.socketType, SocketType.ssl); + expect(account.outgoing.serverConfig.socketType, SocketType.ssl); + }); +} diff --git a/test/src/util/http_helper_test.dart b/test/src/util/http_helper_test.dart new file mode 100644 index 00000000..138f6812 --- /dev/null +++ b/test/src/util/http_helper_test.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:enough_mail/src/private/util/http_helper.dart'; +import 'package:test/test.dart'; + +void main() { + late HttpServer server; + late String base; + + setUp(() async { + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + base = 'http://${server.address.address}:${server.port}'; + }); + + tearDown(() => server.close(force: true)); + + test('follows same-scheme redirects', () async { + server.listen((request) { + if (request.uri.path == '/start') { + request.response + ..statusCode = HttpStatus.found + ..headers.set(HttpHeaders.locationHeader, '/target') + ..close(); + } else { + request.response + ..write('') + ..close(); + } + }); + final result = await HttpHelper.httpGet('$base/start'); + expect(result.statusCode, 200); + expect(result.text, ''); + }); + + test('stops following redirects after the limit', () async { + var count = 0; + server.listen((request) { + count++; + request.response + ..statusCode = HttpStatus.found + ..headers.set(HttpHeaders.locationHeader, '/loop$count') + ..close(); + }); + final result = await HttpHelper.httpGet('$base/loop', maxRedirects: 3); + expect(result.statusCode, HttpStatus.found); + expect(result.data, isNull); + expect(count, 4); + }); + + test('rejects responses that exceed the size limit', () async { + server.listen((request) { + final chunk = List.filled(64 * 1024, 65); + for (var i = 0; i < 20; i++) { + request.response.add(chunk); + } + request.response.close(); + }); + final result = await HttpHelper.httpGet('$base/big'); + expect(result.statusCode, 400); + expect(result.data, isNull); + }); + + test('fails instead of hanging when the server stalls', () async { + server.listen((request) { + // never answer + }); + final result = await HttpHelper.httpGet( + '$base/stall', + timeout: const Duration(milliseconds: 300), + ); + expect(result.statusCode, 400); + }); +} From c59a72c18733a7e9fa5868c1ce8db64f2a52c5e1 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 10:58:25 +0200 Subject: [PATCH 08/33] fix(imap): survive malformed server responses instead of wedging the connection A parser exception used to escape the socket callback, leaving the pending command incomplete and every later command queued forever. Tagged-response parsing now fails the command with an ImapException, untagged parse errors are logged and skipped, and errors of the response reader itself fail all pending tasks and close the connection. The reader accepts only digit-only literal sizes, caps a literal at 128 MiB (configurable), adds an empty data line for {0} so the rest of the response is not mistaken for literal data, and is iterative instead of recursive. Quoted strings resolve quoted-pairs including \\, partial fetch items like BODY[1]<0> keep their range attached, the token separator before a literal is restored for LIST/STATUS parsing, message ranges are capped at 16M entries and 2^32-1, FETCH/BODYSTRUCTURE parsing uses tryParse and bounds checks, and completed tasks are removed from the task map. Co-Authored-By: Claude Fable 5.1 --- lib/src/imap/imap_client.dart | 38 +- lib/src/imap/message_sequence.dart | 18 + lib/src/private/imap/capability_parser.dart | 2 +- lib/src/private/imap/fetch_parser.dart | 1531 +++++++++-------- lib/src/private/imap/imap_response.dart | 40 +- lib/src/private/imap/imap_response_line.dart | 30 +- .../private/imap/imap_response_reader.dart | 140 +- lib/src/private/imap/parser_helper.dart | 7 +- test/imap/imap_robustness_test.dart | 222 +++ 9 files changed, 1187 insertions(+), 841 deletions(-) create mode 100644 test/imap/imap_robustness_test.dart diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index 74bdbb21..64f65ac3 100644 --- a/lib/src/imap/imap_client.dart +++ b/lib/src/imap/imap_client.dart @@ -315,7 +315,15 @@ class ImapClient extends ClientBase { @override void onDataReceived(Uint8List data) { - _imapResponseReader.onData(data); + try { + _imapResponseReader.onData(data); + } catch (e, s) { + // the response stream cannot be interpreted anymore, e.g. because the + // server announced a literal above the size limit: + logApp('Protocol error, closing connection: $e $s'); + _completePendingTasksWithError(ImapException(this, 'protocol error: $e')); + unawaited(disconnect()); + } } @override @@ -2875,13 +2883,27 @@ class ImapClient extends ClientBase { final spaceIndex = line.indexOf(' '); if (spaceIndex != -1) { final commandId = line.substring(0, spaceIndex); - final task = _tasks[commandId]; + final task = _tasks.remove(commandId); if (task != null) { if (task == _currentCommandTask) { _currentCommandTask = null; } imapResponse.parseText = line.substring(spaceIndex + 1); - final response = task.parse(imapResponse); + final Response response; + try { + response = task.parse(imapResponse); + } catch (e, s) { + // a parser must never leave the caller hanging: fail the command + logApp('Unable to parse response for ${task.command.logText}: $e $s'); + if (!task.completer.isCompleted) { + task.completer.completeError( + ImapException(this, 'unable to parse response: $e', details: e), + s, + ); + } + + return; + } try { if (!task.completer.isCompleted) { if (response.isOkStatus) { @@ -2913,8 +2935,14 @@ class ImapClient extends ClientBase { /// Handles an untagged response from the server void onUntaggedResponse(ImapResponse imapResponse) { final task = _currentCommandTask; - if (task == null || !task.parseUntaggedResponse(imapResponse)) { - log('untagged not handled: [$imapResponse] by task $task'); + try { + if (task == null || !task.parseUntaggedResponse(imapResponse)) { + log('untagged not handled: [$imapResponse] by task $task'); + } + } catch (e, s) { + // malformed untagged data must not abort the processing of the + // remaining server responses + logApp('Unable to parse untagged response [$imapResponse]: $e $s'); } } diff --git a/lib/src/imap/message_sequence.dart b/lib/src/imap/message_sequence.dart index 3a128abd..8870ee15 100644 --- a/lib/src/imap/message_sequence.dart +++ b/lib/src/imap/message_sequence.dart @@ -277,9 +277,27 @@ class MessageSequence { _text = null; } + /// The highest possible message sequence number or UID (RFC 3501 + /// `nz-number`, an unsigned 32 bit integer). + static const int maxId = 4294967295; + + /// The maximum number of IDs that a single range is expanded to. + /// + /// A (malicious) server could otherwise send e.g. `VANISHED 1:4294967295` + /// and make the client allocate gigabytes of memory. + static const int maxRangeLength = 16 * 1024 * 1024; + /// Adds all messages between [start] and [end] inclusive. void addRange(int start, int end) { // start:end + if (start > maxId || end > maxId) { + throw InvalidArgumentException('range $start:$end exceeds $maxId'); + } + if ((start - end).abs() >= maxRangeLength) { + throw InvalidArgumentException( + 'range $start:$end spans more than $maxRangeLength messages', + ); + } if (start == end) { add(start); diff --git a/lib/src/private/imap/capability_parser.dart b/lib/src/private/imap/capability_parser.dart index 8009391c..1b845a1b 100644 --- a/lib/src/private/imap/capability_parser.dart +++ b/lib/src/private/imap/capability_parser.dart @@ -61,7 +61,7 @@ class CapabilityParser extends ResponseParser> { int startIndex, ImapServerInfo info, ) { - final closeIndex = details.lastIndexOf(']'); + final closeIndex = details.indexOf(']', startIndex); String capText; capText = closeIndex == -1 ? details.substring(startIndex) diff --git a/lib/src/private/imap/fetch_parser.dart b/lib/src/private/imap/fetch_parser.dart index 1da60efb..1760ecb4 100644 --- a/lib/src/private/imap/fetch_parser.dart +++ b/lib/src/private/imap/fetch_parser.dart @@ -1,758 +1,773 @@ -import 'dart:typed_data'; - -import '../../codecs/date_codec.dart'; -import '../../codecs/mail_codec.dart'; -import '../../imap/message_sequence.dart'; -import '../../imap/response.dart'; -import '../../mail_address.dart'; -import '../../media_type.dart'; -import '../../mime_data.dart'; -import '../../mime_message.dart'; -import 'imap_response.dart'; -import 'parser_helper.dart'; -import 'response_parser.dart'; - -/// Parses FETCH IMAP responses -class FetchParser extends ResponseParser { - /// Creates a new parser - FetchParser({required this.isUidFetch}); - - final List _messages = []; - - /// The most recent message that has been parsed - MimeMessage? lastParsedMessage; - - /// The most recent VANISHED response - MessageSequence? vanishedMessages; - - /// The modified sequence if defined in the FETCH response - MessageSequence? modifiedSequence; - - /// Is the FETCH request based on UIDs instead of sequence-IDs? - final bool isUidFetch; - - @override - FetchImapResult? parse( - ImapResponse imapResponse, - Response response, - ) { - final text = imapResponse.parseText; - final modifiedIndex = text.indexOf('[MODIFIED '); - if (modifiedIndex != -1) { - final modifiedEntries = ParserHelper.parseListIntEntries( - text, - modifiedIndex + '[MODIFIED '.length, - ']', - ',', - ); - if (modifiedEntries != null) { - modifiedSequence = MessageSequence.fromIds( - modifiedEntries, - isUid: isUidFetch, - ); - } - } - final vanishedMessages = this.vanishedMessages; - if (response.isOkStatus || - _messages.isNotEmpty || - (vanishedMessages != null && vanishedMessages.isNotEmpty)) { - return FetchImapResult( - _messages, - vanishedMessages, - modifiedSequence: modifiedSequence, - ); - } - - return null; - } - - @override - bool parseUntagged( - ImapResponse imapResponse, - Response? response, - ) { - final firstLine = imapResponse.first.line; - if (firstLine == null) { - return false; - } - final fetchIndex = firstLine.indexOf(' FETCH '); - lastParsedMessage = null; - if (fetchIndex != -1) { - // eg "* 2389 FETCH (...)" - final sequenceId = parseInt(firstLine, 2, ' '); - MimeMessage message; - if (_messages.isNotEmpty && _messages.last.sequenceId == sequenceId) { - message = _messages.last; - } else { - message = MimeMessage()..sequenceId = sequenceId; - _messages.add(message); - } - lastParsedMessage = message; - final iterator = imapResponse.iterate(); - for (final value in iterator.values) { - if (value.value == 'FETCH') { - _parseFetch(message, value, imapResponse); - } - } - - return true; - } else if (firstLine.startsWith('* VANISHED (EARLIER) ')) { - final parseText = imapResponse.parseText; - - final messageSequenceText = parseText.startsWith('*') - ? parseText.substring('* VANISHED (EARLIER) '.length) - : parseText.substring('VANISHED (EARLIER) '.length); - vanishedMessages = MessageSequence.parse( - messageSequenceText, - isUidSequence: true, - ); - - return true; - } - - return super.parseUntagged(imapResponse, response); - } - - void _parseFetch( - MimeMessage message, - ImapValue fetchValue, - ImapResponse imapResponse, - ) { - final children = fetchValue.children ?? []; - for (var i = 0; i < children.length; i++) { - final child = children[i]; - final hasNext = i < children.length - 1; - switch (child.value) { - case 'UID': - if (hasNext) { - message.uid = int.parse(children[i + 1].value ?? '-1'); - i++; - } - break; - case 'MODSEQ': - if (hasNext && (children[i + 1].children?.length == 1)) { - message.modSequence = int.tryParse( - children[i + 1].children?[0].value ?? '', - ); - i++; - } - break; - case 'FLAGS': - message.flags = List.from( - child.children?.map((flag) => flag.value) ?? [], - ); - break; - case 'INTERNALDATE': - if (hasNext) { - message.internalDate = children[i + 1].value; - i++; - } - break; - case 'RFC822.SIZE': - if (hasNext) { - message.size = int.parse(children[i + 1].value ?? '-1'); - i++; - } - break; - case 'ENVELOPE': - _parseEnvelope(message, child); - break; - case 'BODY': - _parseBody(message, child); - break; - case 'BODYSTRUCTURE': - _parseBodyStructure(message, child); - break; - case 'BODY[HEADER]': - case 'RFC822.HEADER': - if (hasNext) { - i++; - _parseBodyHeader(message, children[i]); - } - break; - case 'BODY[TEXT]': - case 'RFC822.TEXT': - if (hasNext) { - i++; - _parseBodyText(message, children[i]); - } - break; - case 'BODY[]': - case 'RFC822': - if (hasNext) { - i++; - _parseBodyFull(message, children[i]); - } - break; - case 'X-GM-THRID': - if (hasNext) { - message.xGmThrid = int.tryParse(children[i + 1].value ?? ''); - i++; - } - break; - case 'X-GM-MSGID': - if (hasNext) { - message.xGmMsgid = int.tryParse(children[i + 1].value ?? ''); - i++; - } - break; - case 'X-GM-LABELS': - if (hasNext) { - final node = children[i + 1]; - final labels = []; - - final labelChildren = node.children; - if (labelChildren != null && labelChildren.isNotEmpty) { - for (final c in labelChildren) { - final v = c.valueOrDataText; - if (v != null && v.isNotEmpty) { - labels.add(v); - } - } - } else { - // Fallback if parser provides a single token - final v = node.valueOrDataText; - if (v != null && v.isNotEmpty) { - labels.add(v); - } - } - - message.xGmLabels = labels; - i++; - } - break; - - default: - final value = child.value; - if (hasNext && - value != null && - value.startsWith('BODY[') && - value.endsWith(']')) { - i++; - _parseBodyPart(message, value, children[i]); - } else { - print( - 'fetch: encountered unexpected/unsupported element ' - '${child.value} at $i in ${imapResponse.parseText}', - ); - } - } - } - } - - /// Parse a body part - /// - /// parses elements starting with `BODY[`, excluding `BODY[]` and - /// `BODY[HEADER]` which are handled separately - /// e.g. `BODY[0]` or `BODY[HEADER.FIELDS (REFERENCES)]` - void _parseBodyPart( - MimeMessage message, - String bodyPartDefinition, - ImapValue imapValue, - ) { - // this matches - // BODY[HEADER.FIELDS (name1,name2)], as well as - // BODY[HEADER.FIELDS.NOT (name1,name2)] - if (bodyPartDefinition.startsWith('BODY[HEADER.FIELDS')) { - _parseBodyHeader(message, imapValue); - } else { - const startIndex = 'BODY['.length; - final endIndex = bodyPartDefinition.length - 1; - final fetchId = bodyPartDefinition.substring(startIndex, endIndex); - final part = MimePart(); - final value = imapValue.value; - final data = imapValue.data; - if (value != null) { - part.mimeData = TextMimeData(value, containsHeader: false); - } else if (data != null) { - part.mimeData = BinaryMimeData(data, containsHeader: false); - } - part.parse(); - //print('$fetchId: results in [${imapValue.value}]'); - message.setPart(fetchId.replaceFirst('.HEADER', ''), part); - } - } - - void _parseBodyFull(MimeMessage message, ImapValue bodyValue) { - final data = bodyValue.data; - final value = bodyValue.value; - if (data != null) { - // Normalise line endings to CRLF before parsing. Some IMAP servers - // preserve the original message's bare-LF line endings; the MIME parser - // only recognises \r\n\r\n as the header/body separator, so without - // normalisation the body is silently discarded. - // - // Done on the *bytes*. Decoding to a String first needs a charset, and - // the only one that can be assumed here is wrong: a message declares its - // charset per part, in headers this has not parsed yet. Running the - // literal through `Utf8Decoder(allowMalformed: true)` — which is what - // this did — turns every byte of a windows-1252 or latin-1 body into - // U+FFFD before the part's own `charset=` is ever read, so `Teší ma` - // arrived as `Te ma` and no later decode could recover it. - message.mimeData = BinaryMimeData( - _normaliseLineEndings(data), - containsHeader: true, - ); - } else if (value != null) { - message.mimeData = TextMimeData(value, containsHeader: true); - } - // ensure all headers are set: - message.parse(); - } - - static const int _cr = 13; - static const int _lf = 10; - - /// Gives every bare LF in [data] the CR the MIME parser expects, leaving - /// every other byte exactly as the server sent it. - /// - /// Returns [data] itself when there is nothing to do, which is the common - /// case — a conforming server sends CRLF already, and this runs over the - /// whole of every message body fetched. - static Uint8List _normaliseLineEndings(Uint8List data) { - var bareLineFeeds = 0; - for (var i = 0; i < data.length; i++) { - if (_isBareLineFeed(data, i)) { - bareLineFeeds++; - } - } - if (bareLineFeeds == 0) { - return data; - } - - final out = Uint8List(data.length + bareLineFeeds); - var index = 0; - for (var i = 0; i < data.length; i++) { - if (_isBareLineFeed(data, i)) { - out[index++] = _cr; - } - out[index++] = data[i]; - } - - return out; - } - - static bool _isBareLineFeed(Uint8List data, int i) => - data[i] == _lf && (i == 0 || data[i - 1] != _cr); - - HeaderParseResult _parseBodyHeader( - MimeMessage message, - ImapValue headerValue, - ) { - //print('Parsing BODY[HEADER]\n[${headerValue.value}]'); - final headerParseResult = ParserHelper.parseHeader( - headerValue.valueOrDataText ?? '', - ); - message.headers = headerParseResult.headersList; - - return headerParseResult; - } - - void _parseBodyText(MimeMessage message, ImapValue textValue) { - //print('Parsing BODY[TEXT]\n[${textValue.value}]'); - final data = textValue.data; - message.mimeData = data != null - ? BinaryMimeData(data, containsHeader: false) - : TextMimeData(textValue.value ?? '', containsHeader: false); - } - - /// Also compare: - /// * http://sgerwk.altervista.org/imapbodystructure.html - /// * https://tools.ietf.org/html/rfc3501#section-7.4.2 - /// * http://hea-www.cfa.harvard.edu/~fine/opinions/IMAPsucks.html - void _parseBodyRecursive(BodyPart body, ImapValue bodyValue) { - // print('_parseBodyRecursive from $bodyValue'); - var isMultipartSubtypeSet = false; - var multipartChildIndex = -1; - final children = bodyValue.children ?? []; - if (children.length >= 7 && children[0].children == null) { - // this is a direct type: - final parsed = _parseBodyStructureFrom(children); - body - ..bodyRaw = parsed.bodyRaw - ..contentDisposition = parsed.contentDisposition - ..contentType = parsed.contentType - ..description = parsed.description - ..encoding = parsed.encoding - ..envelope = parsed.envelope - ..cid = parsed.cid - ..numberOfLines = parsed.numberOfLines - ..size = parsed.size; - - return; - } - for (var childIndex = 0; childIndex < children.length; childIndex++) { - final child = children[childIndex]; - final grandchildren = child.children; - if (child.value == null && - grandchildren != null && - grandchildren.isNotEmpty && - grandchildren.first.value == null) { - // this is a nested structure - final part = BodyPart(); - body.addPart(part); - _parseBodyRecursive(part, child); - } else if (!isMultipartSubtypeSet && - grandchildren != null && - grandchildren.length >= 7) { - // TODO just counting cannot be a big enough indicator, - // compare for example - // ""mixed" ("charset" "utf8" "boundary" "cs2da2ss7EsqRfMsG")" - // this is a structure value - final structures = grandchildren; - final part = _parseBodyStructureFrom(structures); - body.addPart(part); - } else if (!isMultipartSubtypeSet) { - // this is the type: - isMultipartSubtypeSet = true; - multipartChildIndex = childIndex; - body.contentType = ContentTypeHeader( - 'multipart/${child.value?.toLowerCase()}', - ); - } else if (childIndex == multipartChildIndex + 1 && - grandchildren != null && - grandchildren.length > 1) { - final parameters = grandchildren; - for (var i = 0; i < parameters.length; i += 2) { - body.contentType?.setParameter( - parameters[i].value ?? '', - parameters[i + 1].valueOrDataText ?? '', - ); - } - } - } - } - - BodyPart _parseBodyStructureFrom(List structures) { - final size = int.tryParse(structures[6].value ?? ''); - final mediaType = MediaType.fromText( - '${structures[0].value}/${structures[1].value}', - ); - final part = BodyPart() - ..cid = _checkForNil(structures[3].value) - ..description = _checkForNil(structures[4].value) - ..encoding = _checkForNil(structures[5].value)?.toLowerCase() - ..size = size - ..contentType = ContentTypeHeader.from(mediaType); - final contentTypeParameters = structures[2].children; - if (contentTypeParameters != null && contentTypeParameters.length > 1) { - for (var i = 0; i < contentTypeParameters.length; i += 2) { - final name = contentTypeParameters[i].value; - final value = contentTypeParameters[i + 1].valueOrDataText; - // print('content-type: $name=$value'); - if (name != null && value != null) { - part.contentType?.setParameter(name, value); - } - } - } - var startIndex = 7; - if (mediaType.isText && - structures.length > 7 && - structures[7].value != null) { - part.numberOfLines = int.tryParse(structures[7].value ?? ''); - startIndex = 8; - } else if (mediaType.isMessage && - mediaType.sub == MediaSubtype.messageRfc822) { - // [7] - // A body type of type MESSAGE and subtype RFC822 contains, - // immediately after the basic fields, the envelope structure, - // body structure, and size in text lines of the encapsulated - // message. - if (structures.length > 9) { - part.envelope = _parseEnvelope(null, structures[7]); - final child = BodyPart(); - part.addPart(child); - _parseBodyRecursive(child, structures[8]); - part.numberOfLines = int.tryParse(structures[9].value ?? ''); - } - startIndex += 3; - } - if ((structures.length > startIndex + 1) && - (structures[startIndex + 1].children?.isNotEmpty ?? false)) { - // read content disposition - // example: [attachment, [filename, testImage.jpg, - // modification-date, Fri, 27 Jan 2017 16:34:4 +0100, size, 13390]] - final parts = structures[startIndex + 1].children ?? []; - if (parts[0].value != null) { - final contentDisposition = ContentDispositionHeader( - parts[0].value?.toLowerCase() ?? '', - ); - final parameters = parts[1].children; - if (parameters != null && parameters.length > 1) { - for (var i = 0; i < parameters.length; i += 2) { - final name = parameters[i].value; - final value = parameters[i + 1].valueOrDataText; - if (name != null && value != null) { - // print('content-disposition: $name=$value'); - contentDisposition.setParameter(name, value); - } - } - } - part.contentDisposition = contentDisposition; - } else { - print('Unable to parse content disposition from:'); - print(parts); - } - } - - return part; - } - - void _parseBody(MimeMessage message, ImapValue bodyValue) { - // A parenthesized list that describes the [MIME-IMB] body - // structure of a message. This is computed by the server by - // parsing the [MIME-IMB] header fields, defaulting various fields - // as necessary. - - // For example, a simple text message of 48 lines and 2279 octets - // can have a body structure of: ("TEXT" "PLAIN" ("CHARSET" - // "US-ASCII") NIL NIL "7BIT" 2279 48) - - // Multiple parts are indicated by parenthesis nesting. Instead - // of a body type as the first element of the parenthesized list, - // there is a sequence of one or more nested body structures. The - // second element of the parenthesized list is the multipart - // subtype (mixed, digest, parallel, alternative, etc.). - - // For example, a two part message consisting of a text and a - // BASE64-encoded text attachment can have a body structure of: - // (("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 1152 - // 23)("TEXT" "PLAIN" ("CHARSET" "US-ASCII" "NAME" "cc.diff") - // "<960723163407.20117h@cac.washington.edu>" "Compiler diff" - // "BASE64" 4554 73) "MIXED") - - // [0]body type - // A string giving the content media type name as defined in - // [MIME-IMB]. - - // [1]body subtype - // A string giving the content subtype name as defined in - // [MIME-IMB]. - - // [2] body parameter parenthesized list - // A parenthesized list of attribute/value pairs [e.g., ("foo" - // "bar" "baz" "rag") where "bar" is the value of "foo" and - // "rag" is the value of "baz"] as defined in [MIME-IMB]. - - // [3]body id - // A string giving the content id as defined in [MIME-IMB]. - - // [4]body description - // A string giving the content description as defined in - // [MIME-IMB]. - - // [5]body encoding - // A string giving the content transfer encoding as defined in - // [MIME-IMB]. - - // [6]body size - // A number giving the size of the body in octets. Note that - // this size is the size in its transfer encoding and not the - // resulting size after any decoding. - - // [7] - // A body type of type MESSAGE and subtype RFC822 contains, - // immediately after the basic fields, the envelope structure, - // body structure, and size in text lines of the encapsulated - // message. - - // A body type of type TEXT contains, immediately after the basic - // fields, the size of the body in text lines. Note that this - // size is the size in its content transfer encoding and not the - // resulting size after any decoding. - - // Extension data follows the multipart subtype. Extension data - // is never returned with the BODY fetch, but can be returned with - // a BODYSTRUCTURE fetch. Extension data, if present, MUST be in - // the defined order. The extension data of a multipart body part - // are in the following order: - - // [7 / 8] - // body parameter parenthesized list - // A parenthesized list of attribute/value pairs [e.g., ("foo" - // "bar" "baz" "rag") where "bar" is the value of "foo", and - // "rag" is the value of "baz"] as defined in [MIME-IMB]. - - // [8 / 9] - // body disposition - // A parenthesized list, consisting of a disposition type - // string, followed by a parenthesized list of disposition - // attribute/value pairs as defined in [DISPOSITION]. - - // [9 / 10] - // body language - // A string or parenthesized list giving the body language - // value as defined in [LANGUAGE-TAGS]. - - // [10 / 11] - // body location - // A string list giving the body content URI as defined in - // [LOCATION]. - // - // - // The extension data of a non-multipart body part are in the - // following order: - - // [7 / 8] - // body MD5 - // A string giving the body MD5 value as defined in [MD5]. - // - // [8 / 9] - // body disposition - // A parenthesized list with the same content and function as - // the body disposition for a multipart body part. - - // [9 / 10] - // body language - // A string or parenthesized list giving the body language - // value as defined in [LANGUAGE-TAGS]. - - // [10 / 11] - // body location - // A string list giving the body content URI as defined in - // [LOCATION]. - //print('body: $bodyValue'); - final body = BodyPart(); - _parseBodyRecursive(body, bodyValue); - message.body = body; - } - - void _parseBodyStructure(MimeMessage message, ImapValue bodyValue) { - //print('bodystructure: $bodyValue'); - _parseBody(message, bodyValue); - } - - /// parses the envelope structure of a message - Envelope? _parseEnvelope(MimeMessage? message, ImapValue envelopeValue) { - // The fields of the envelope structure are in the following - // order: [0] date, [1]subject, [2]from, [3]sender, [4]reply-to, [5]to, - // [6]cc, [7]bcc, [8]in-reply-to, and [9]message-id. - // - // The date, subject, in-reply-to, - // and message-id fields are strings. The from, sender, reply-to, - // to, cc, and bcc fields are parenthesized lists of address - // structures. - - // If the Date, Subject, In-Reply-To, and Message-ID header lines - // are absent in the [RFC-2822] header, the corresponding member - // of the envelope is NIL; if these header lines are present but - // empty the corresponding member of the envelope is the empty - // string. - Envelope? envelope; - final children = envelopeValue.children; - //print("envelope: $children"); - if (children != null && children.length >= 10) { - final rawDate = _checkForNil(children[0].value); - final rawSubject = _checkForNil(children[1].valueOrDataText); - envelope = Envelope() - ..date = rawDate != null ? DateCodec.decodeDate(rawDate) : null - ..subject = rawSubject != null - ? MailCodec.decodeHeader(rawSubject) - : null - ..from = _parseAddressList(children[2]) - ..sender = _parseAddressListFirst(children[3]) - ..replyTo = _parseAddressList(children[4]) - ..to = _parseAddressList(children[5]) - ..cc = _parseAddressList(children[6]) - ..bcc = _parseAddressList(children[7]) - ..inReplyTo = _checkForNil(children[8].value) - ..messageId = _checkForNil(children[9].value); - if (message != null) { - message.envelope = envelope; - if (rawDate != null) { - message.addHeader('Date', rawDate); - } - if (rawSubject != null) { - message.addHeader('Subject', rawSubject); - } - message - ..addHeader('In-Reply-To', envelope.inReplyTo) - ..addHeader('Message-ID', envelope.messageId); - } - } - - return envelope; - } - - MailAddress? _parseAddressListFirst(ImapValue addressValue) { - final addresses = _parseAddressList(addressValue); - if (addresses == null || addresses.isEmpty) { - return null; - } - - return addresses.first; - } - - List? _parseAddressList(ImapValue addressValue) { - if (addressValue.value == 'NIL') { - return null; - } - final addresses = []; - final addressChildren = addressValue.children; - if (addressChildren != null) { - for (final child in addressChildren) { - final address = _parseAddress(child); - if (address != null) { - addresses.add(address); - } - } - } - - return addresses; - } - - MailAddress? _parseAddress(ImapValue addressValue) { - // An address structure is a parenthesized list that describes an - // electronic mail address. The fields of an address structure - // are in the following order: personal name, [SMTP] - // at-domain-list (source route), mailbox name, and host name. - - // [RFC-2822] group syntax is indicated by a special form of - // address structure in which the host name field is NIL. If the - // mailbox name field is also NIL, this is an end of group marker - // (semi-colon in RFC 822 syntax). If the mailbox name field is - // non-NIL, this is a start of group marker, and the mailbox name - // field holds the group name phrase. - final addressChildren = addressValue.children; - if (addressValue.value == 'NIL' || - addressChildren == null || - addressChildren.length < 4) { - return null; - } - final children = addressChildren; - final mailboxName = _checkForNil(children[2].value); - final hostName = _checkForNil(children[3].value); - if (mailboxName == null && hostName == null) { - print( - 'Warning: invalid mail address in $addressValue: ' - 'both mailboxName and hostName are null', - ); - - return null; - } - String? personalName = ''; - try { - personalName = MailCodec.decodeHeader(_checkForNil(children[0].value)); - } catch (e) { - print( - 'Warning: invalid mail address in $addressValue: ' - 'personalName is invalid: $e', - ); - } - - return MailAddress.fromEnvelope( - personalName: personalName, - //sourceRoute: _checkForNil(children[1].value), - mailboxName: mailboxName ?? '', - hostName: hostName ?? '', - ); - } - - String? _checkForNil(String? value) { - if (value == 'NIL') { - return null; - } - - return value; - } -} +import 'dart:typed_data'; + +import '../../codecs/date_codec.dart'; +import '../../codecs/mail_codec.dart'; +import '../../imap/message_sequence.dart'; +import '../../imap/response.dart'; +import '../../mail_address.dart'; +import '../../media_type.dart'; +import '../../mime_data.dart'; +import '../../mime_message.dart'; +import 'imap_response.dart'; +import 'parser_helper.dart'; +import 'response_parser.dart'; + +/// Parses FETCH IMAP responses +class FetchParser extends ResponseParser { + /// Creates a new parser + FetchParser({required this.isUidFetch}); + + final List _messages = []; + + /// The most recent message that has been parsed + MimeMessage? lastParsedMessage; + + /// The most recent VANISHED response + MessageSequence? vanishedMessages; + + /// The modified sequence if defined in the FETCH response + MessageSequence? modifiedSequence; + + /// Is the FETCH request based on UIDs instead of sequence-IDs? + final bool isUidFetch; + + @override + FetchImapResult? parse( + ImapResponse imapResponse, + Response response, + ) { + final text = imapResponse.parseText; + final modifiedIndex = text.indexOf('[MODIFIED '); + if (modifiedIndex != -1) { + final modifiedEntries = ParserHelper.parseListIntEntries( + text, + modifiedIndex + '[MODIFIED '.length, + ']', + ',', + ); + if (modifiedEntries != null) { + modifiedSequence = MessageSequence.fromIds( + modifiedEntries, + isUid: isUidFetch, + ); + } + } + final vanishedMessages = this.vanishedMessages; + if (response.isOkStatus || + _messages.isNotEmpty || + (vanishedMessages != null && vanishedMessages.isNotEmpty)) { + return FetchImapResult( + _messages, + vanishedMessages, + modifiedSequence: modifiedSequence, + ); + } + + return null; + } + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response? response, + ) { + final firstLine = imapResponse.first.line; + if (firstLine == null) { + return false; + } + final fetchIndex = firstLine.indexOf(' FETCH '); + lastParsedMessage = null; + if (fetchIndex != -1) { + // eg "* 2389 FETCH (...)" + final sequenceId = parseInt(firstLine, 2, ' '); + MimeMessage message; + if (_messages.isNotEmpty && _messages.last.sequenceId == sequenceId) { + message = _messages.last; + } else { + message = MimeMessage()..sequenceId = sequenceId; + _messages.add(message); + } + lastParsedMessage = message; + final iterator = imapResponse.iterate(); + for (final value in iterator.values) { + if (value.value == 'FETCH') { + _parseFetch(message, value, imapResponse); + } + } + + return true; + } else if (firstLine.startsWith('* VANISHED (EARLIER) ')) { + final parseText = imapResponse.parseText; + + final messageSequenceText = parseText.startsWith('*') + ? parseText.substring('* VANISHED (EARLIER) '.length) + : parseText.substring('VANISHED (EARLIER) '.length); + vanishedMessages = MessageSequence.parse( + messageSequenceText, + isUidSequence: true, + ); + + return true; + } + + return super.parseUntagged(imapResponse, response); + } + + void _parseFetch( + MimeMessage message, + ImapValue fetchValue, + ImapResponse imapResponse, + ) { + final children = fetchValue.children ?? []; + for (var i = 0; i < children.length; i++) { + final child = children[i]; + final hasNext = i < children.length - 1; + final childValue = child.value; + // a partial fetch response is named e.g. BODY[]<0> or BODY[1]<1024> + final key = childValue != null && childValue.startsWith('BODY[') + ? _removePartialRange(childValue) + : childValue; + switch (key) { + case 'UID': + if (hasNext) { + message.uid = int.tryParse(children[i + 1].value ?? ''); + i++; + } + break; + case 'MODSEQ': + if (hasNext && (children[i + 1].children?.length == 1)) { + message.modSequence = int.tryParse( + children[i + 1].children?[0].value ?? '', + ); + i++; + } + break; + case 'FLAGS': + message.flags = [ + for (final flag in child.children ?? []) + if (flag.value != null) flag.value!, + ]; + break; + case 'INTERNALDATE': + if (hasNext) { + message.internalDate = children[i + 1].value; + i++; + } + break; + case 'RFC822.SIZE': + if (hasNext) { + message.size = int.tryParse(children[i + 1].value ?? ''); + i++; + } + break; + case 'ENVELOPE': + _parseEnvelope(message, child); + break; + case 'BODY': + _parseBody(message, child); + break; + case 'BODYSTRUCTURE': + _parseBodyStructure(message, child); + break; + case 'BODY[HEADER]': + case 'RFC822.HEADER': + if (hasNext) { + i++; + _parseBodyHeader(message, children[i]); + } + break; + case 'BODY[TEXT]': + case 'RFC822.TEXT': + if (hasNext) { + i++; + _parseBodyText(message, children[i]); + } + break; + case 'BODY[]': + case 'RFC822': + if (hasNext) { + i++; + _parseBodyFull(message, children[i]); + } + break; + case 'X-GM-THRID': + if (hasNext) { + message.xGmThrid = int.tryParse(children[i + 1].value ?? ''); + i++; + } + break; + case 'X-GM-MSGID': + if (hasNext) { + message.xGmMsgid = int.tryParse(children[i + 1].value ?? ''); + i++; + } + break; + case 'X-GM-LABELS': + if (hasNext) { + final node = children[i + 1]; + final labels = []; + + final labelChildren = node.children; + if (labelChildren != null && labelChildren.isNotEmpty) { + for (final c in labelChildren) { + final v = c.valueOrDataText; + if (v != null && v.isNotEmpty) { + labels.add(v); + } + } + } else { + // Fallback if parser provides a single token + final v = node.valueOrDataText; + if (v != null && v.isNotEmpty) { + labels.add(v); + } + } + + message.xGmLabels = labels; + i++; + } + break; + + default: + if (hasNext && + key != null && + key.startsWith('BODY[') && + key.endsWith(']')) { + i++; + _parseBodyPart(message, key, children[i]); + } else { + print( + 'fetch: encountered unexpected/unsupported element ' + '${child.value} at $i in ${imapResponse.parseText}', + ); + } + } + } + } + + /// Removes the `` suffix of a partial fetch item like `BODY[1]<0>` + static String _removePartialRange(String value) { + if (!value.endsWith('>')) { + return value; + } + final index = value.lastIndexOf('<'); + + return index == -1 ? value : value.substring(0, index); + } + + /// Parse a body part + /// + /// parses elements starting with `BODY[`, excluding `BODY[]` and + /// `BODY[HEADER]` which are handled separately + /// e.g. `BODY[0]` or `BODY[HEADER.FIELDS (REFERENCES)]` + void _parseBodyPart( + MimeMessage message, + String bodyPartDefinition, + ImapValue imapValue, + ) { + // this matches + // BODY[HEADER.FIELDS (name1,name2)], as well as + // BODY[HEADER.FIELDS.NOT (name1,name2)] + if (bodyPartDefinition.startsWith('BODY[HEADER.FIELDS')) { + _parseBodyHeader(message, imapValue); + } else { + const startIndex = 'BODY['.length; + final endIndex = bodyPartDefinition.length - 1; + final fetchId = bodyPartDefinition.substring(startIndex, endIndex); + final part = MimePart(); + final value = imapValue.value; + final data = imapValue.data; + if (value != null) { + part.mimeData = TextMimeData(value, containsHeader: false); + } else if (data != null) { + part.mimeData = BinaryMimeData(data, containsHeader: false); + } + part.parse(); + //print('$fetchId: results in [${imapValue.value}]'); + message.setPart(fetchId.replaceFirst('.HEADER', ''), part); + } + } + + void _parseBodyFull(MimeMessage message, ImapValue bodyValue) { + final data = bodyValue.data; + final value = bodyValue.value; + if (data != null) { + // Normalise line endings to CRLF before parsing. Some IMAP servers + // preserve the original message's bare-LF line endings; the MIME parser + // only recognises \r\n\r\n as the header/body separator, so without + // normalisation the body is silently discarded. + // + // Done on the *bytes*. Decoding to a String first needs a charset, and + // the only one that can be assumed here is wrong: a message declares its + // charset per part, in headers this has not parsed yet. Running the + // literal through `Utf8Decoder(allowMalformed: true)` — which is what + // this did — turns every byte of a windows-1252 or latin-1 body into + // U+FFFD before the part's own `charset=` is ever read, so `Teší ma` + // arrived as `Te ma` and no later decode could recover it. + message.mimeData = BinaryMimeData( + _normaliseLineEndings(data), + containsHeader: true, + ); + } else if (value != null) { + message.mimeData = TextMimeData(value, containsHeader: true); + } + // ensure all headers are set: + message.parse(); + } + + static const int _cr = 13; + static const int _lf = 10; + + /// Gives every bare LF in [data] the CR the MIME parser expects, leaving + /// every other byte exactly as the server sent it. + /// + /// Returns [data] itself when there is nothing to do, which is the common + /// case — a conforming server sends CRLF already, and this runs over the + /// whole of every message body fetched. + static Uint8List _normaliseLineEndings(Uint8List data) { + var bareLineFeeds = 0; + for (var i = 0; i < data.length; i++) { + if (_isBareLineFeed(data, i)) { + bareLineFeeds++; + } + } + if (bareLineFeeds == 0) { + return data; + } + + final out = Uint8List(data.length + bareLineFeeds); + var index = 0; + for (var i = 0; i < data.length; i++) { + if (_isBareLineFeed(data, i)) { + out[index++] = _cr; + } + out[index++] = data[i]; + } + + return out; + } + + static bool _isBareLineFeed(Uint8List data, int i) => + data[i] == _lf && (i == 0 || data[i - 1] != _cr); + + HeaderParseResult _parseBodyHeader( + MimeMessage message, + ImapValue headerValue, + ) { + //print('Parsing BODY[HEADER]\n[${headerValue.value}]'); + final headerParseResult = ParserHelper.parseHeader( + headerValue.valueOrDataText ?? '', + ); + message.headers = headerParseResult.headersList; + + return headerParseResult; + } + + void _parseBodyText(MimeMessage message, ImapValue textValue) { + //print('Parsing BODY[TEXT]\n[${textValue.value}]'); + final data = textValue.data; + message.mimeData = data != null + ? BinaryMimeData(data, containsHeader: false) + : TextMimeData(textValue.value ?? '', containsHeader: false); + } + + /// Also compare: + /// * http://sgerwk.altervista.org/imapbodystructure.html + /// * https://tools.ietf.org/html/rfc3501#section-7.4.2 + /// * http://hea-www.cfa.harvard.edu/~fine/opinions/IMAPsucks.html + void _parseBodyRecursive(BodyPart body, ImapValue bodyValue) { + // print('_parseBodyRecursive from $bodyValue'); + var isMultipartSubtypeSet = false; + var multipartChildIndex = -1; + final children = bodyValue.children ?? []; + if (children.length >= 7 && children[0].children == null) { + // this is a direct type: + final parsed = _parseBodyStructureFrom(children); + body + ..bodyRaw = parsed.bodyRaw + ..contentDisposition = parsed.contentDisposition + ..contentType = parsed.contentType + ..description = parsed.description + ..encoding = parsed.encoding + ..envelope = parsed.envelope + ..cid = parsed.cid + ..numberOfLines = parsed.numberOfLines + ..size = parsed.size; + + return; + } + for (var childIndex = 0; childIndex < children.length; childIndex++) { + final child = children[childIndex]; + final grandchildren = child.children; + if (child.value == null && + grandchildren != null && + grandchildren.isNotEmpty && + grandchildren.first.value == null) { + // this is a nested structure + final part = BodyPart(); + body.addPart(part); + _parseBodyRecursive(part, child); + } else if (!isMultipartSubtypeSet && + grandchildren != null && + grandchildren.length >= 7) { + // TODO just counting cannot be a big enough indicator, + // compare for example + // ""mixed" ("charset" "utf8" "boundary" "cs2da2ss7EsqRfMsG")" + // this is a structure value + final structures = grandchildren; + final part = _parseBodyStructureFrom(structures); + body.addPart(part); + } else if (!isMultipartSubtypeSet) { + // this is the type: + isMultipartSubtypeSet = true; + multipartChildIndex = childIndex; + body.contentType = ContentTypeHeader( + 'multipart/${child.value?.toLowerCase()}', + ); + } else if (childIndex == multipartChildIndex + 1 && + grandchildren != null && + grandchildren.length > 1) { + final parameters = grandchildren; + for (var i = 0; i + 1 < parameters.length; i += 2) { + body.contentType?.setParameter( + parameters[i].value ?? '', + parameters[i + 1].valueOrDataText ?? '', + ); + } + } + } + } + + BodyPart _parseBodyStructureFrom(List structures) { + final size = int.tryParse(structures[6].value ?? ''); + final mediaType = MediaType.fromText( + '${structures[0].value}/${structures[1].value}', + ); + final part = BodyPart() + ..cid = _checkForNil(structures[3].value) + ..description = _checkForNil(structures[4].value) + ..encoding = _checkForNil(structures[5].value)?.toLowerCase() + ..size = size + ..contentType = ContentTypeHeader.from(mediaType); + final contentTypeParameters = structures[2].children; + if (contentTypeParameters != null && contentTypeParameters.length > 1) { + for (var i = 0; i + 1 < contentTypeParameters.length; i += 2) { + final name = contentTypeParameters[i].value; + final value = contentTypeParameters[i + 1].valueOrDataText; + // print('content-type: $name=$value'); + if (name != null && value != null) { + part.contentType?.setParameter(name, value); + } + } + } + var startIndex = 7; + if (mediaType.isText && + structures.length > 7 && + structures[7].value != null) { + part.numberOfLines = int.tryParse(structures[7].value ?? ''); + startIndex = 8; + } else if (mediaType.isMessage && + mediaType.sub == MediaSubtype.messageRfc822) { + // [7] + // A body type of type MESSAGE and subtype RFC822 contains, + // immediately after the basic fields, the envelope structure, + // body structure, and size in text lines of the encapsulated + // message. + if (structures.length > 9) { + part.envelope = _parseEnvelope(null, structures[7]); + final child = BodyPart(); + part.addPart(child); + _parseBodyRecursive(child, structures[8]); + part.numberOfLines = int.tryParse(structures[9].value ?? ''); + } + startIndex += 3; + } + if ((structures.length > startIndex + 1) && + (structures[startIndex + 1].children?.isNotEmpty ?? false)) { + // read content disposition + // example: [attachment, [filename, testImage.jpg, + // modification-date, Fri, 27 Jan 2017 16:34:4 +0100, size, 13390]] + final parts = structures[startIndex + 1].children ?? []; + if (parts.isNotEmpty && parts[0].value != null) { + final contentDisposition = ContentDispositionHeader( + parts[0].value?.toLowerCase() ?? '', + ); + final parameters = parts.length > 1 ? parts[1].children : null; + if (parameters != null && parameters.length > 1) { + for (var i = 0; i + 1 < parameters.length; i += 2) { + final name = parameters[i].value; + final value = parameters[i + 1].valueOrDataText; + if (name != null && value != null) { + // print('content-disposition: $name=$value'); + contentDisposition.setParameter(name, value); + } + } + } + part.contentDisposition = contentDisposition; + } else { + print('Unable to parse content disposition from:'); + print(parts); + } + } + + return part; + } + + void _parseBody(MimeMessage message, ImapValue bodyValue) { + // A parenthesized list that describes the [MIME-IMB] body + // structure of a message. This is computed by the server by + // parsing the [MIME-IMB] header fields, defaulting various fields + // as necessary. + + // For example, a simple text message of 48 lines and 2279 octets + // can have a body structure of: ("TEXT" "PLAIN" ("CHARSET" + // "US-ASCII") NIL NIL "7BIT" 2279 48) + + // Multiple parts are indicated by parenthesis nesting. Instead + // of a body type as the first element of the parenthesized list, + // there is a sequence of one or more nested body structures. The + // second element of the parenthesized list is the multipart + // subtype (mixed, digest, parallel, alternative, etc.). + + // For example, a two part message consisting of a text and a + // BASE64-encoded text attachment can have a body structure of: + // (("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 1152 + // 23)("TEXT" "PLAIN" ("CHARSET" "US-ASCII" "NAME" "cc.diff") + // "<960723163407.20117h@cac.washington.edu>" "Compiler diff" + // "BASE64" 4554 73) "MIXED") + + // [0]body type + // A string giving the content media type name as defined in + // [MIME-IMB]. + + // [1]body subtype + // A string giving the content subtype name as defined in + // [MIME-IMB]. + + // [2] body parameter parenthesized list + // A parenthesized list of attribute/value pairs [e.g., ("foo" + // "bar" "baz" "rag") where "bar" is the value of "foo" and + // "rag" is the value of "baz"] as defined in [MIME-IMB]. + + // [3]body id + // A string giving the content id as defined in [MIME-IMB]. + + // [4]body description + // A string giving the content description as defined in + // [MIME-IMB]. + + // [5]body encoding + // A string giving the content transfer encoding as defined in + // [MIME-IMB]. + + // [6]body size + // A number giving the size of the body in octets. Note that + // this size is the size in its transfer encoding and not the + // resulting size after any decoding. + + // [7] + // A body type of type MESSAGE and subtype RFC822 contains, + // immediately after the basic fields, the envelope structure, + // body structure, and size in text lines of the encapsulated + // message. + + // A body type of type TEXT contains, immediately after the basic + // fields, the size of the body in text lines. Note that this + // size is the size in its content transfer encoding and not the + // resulting size after any decoding. + + // Extension data follows the multipart subtype. Extension data + // is never returned with the BODY fetch, but can be returned with + // a BODYSTRUCTURE fetch. Extension data, if present, MUST be in + // the defined order. The extension data of a multipart body part + // are in the following order: + + // [7 / 8] + // body parameter parenthesized list + // A parenthesized list of attribute/value pairs [e.g., ("foo" + // "bar" "baz" "rag") where "bar" is the value of "foo", and + // "rag" is the value of "baz"] as defined in [MIME-IMB]. + + // [8 / 9] + // body disposition + // A parenthesized list, consisting of a disposition type + // string, followed by a parenthesized list of disposition + // attribute/value pairs as defined in [DISPOSITION]. + + // [9 / 10] + // body language + // A string or parenthesized list giving the body language + // value as defined in [LANGUAGE-TAGS]. + + // [10 / 11] + // body location + // A string list giving the body content URI as defined in + // [LOCATION]. + // + // + // The extension data of a non-multipart body part are in the + // following order: + + // [7 / 8] + // body MD5 + // A string giving the body MD5 value as defined in [MD5]. + // + // [8 / 9] + // body disposition + // A parenthesized list with the same content and function as + // the body disposition for a multipart body part. + + // [9 / 10] + // body language + // A string or parenthesized list giving the body language + // value as defined in [LANGUAGE-TAGS]. + + // [10 / 11] + // body location + // A string list giving the body content URI as defined in + // [LOCATION]. + //print('body: $bodyValue'); + final body = BodyPart(); + _parseBodyRecursive(body, bodyValue); + message.body = body; + } + + void _parseBodyStructure(MimeMessage message, ImapValue bodyValue) { + //print('bodystructure: $bodyValue'); + _parseBody(message, bodyValue); + } + + /// parses the envelope structure of a message + Envelope? _parseEnvelope(MimeMessage? message, ImapValue envelopeValue) { + // The fields of the envelope structure are in the following + // order: [0] date, [1]subject, [2]from, [3]sender, [4]reply-to, [5]to, + // [6]cc, [7]bcc, [8]in-reply-to, and [9]message-id. + // + // The date, subject, in-reply-to, + // and message-id fields are strings. The from, sender, reply-to, + // to, cc, and bcc fields are parenthesized lists of address + // structures. + + // If the Date, Subject, In-Reply-To, and Message-ID header lines + // are absent in the [RFC-2822] header, the corresponding member + // of the envelope is NIL; if these header lines are present but + // empty the corresponding member of the envelope is the empty + // string. + Envelope? envelope; + final children = envelopeValue.children; + //print("envelope: $children"); + if (children != null && children.length >= 10) { + final rawDate = _checkForNil(children[0].value); + final rawSubject = _checkForNil(children[1].valueOrDataText); + envelope = Envelope() + ..date = rawDate != null ? DateCodec.decodeDate(rawDate) : null + ..subject = rawSubject != null + ? MailCodec.decodeHeader(rawSubject) + : null + ..from = _parseAddressList(children[2]) + ..sender = _parseAddressListFirst(children[3]) + ..replyTo = _parseAddressList(children[4]) + ..to = _parseAddressList(children[5]) + ..cc = _parseAddressList(children[6]) + ..bcc = _parseAddressList(children[7]) + ..inReplyTo = _checkForNil(children[8].value) + ..messageId = _checkForNil(children[9].value); + if (message != null) { + message.envelope = envelope; + if (rawDate != null) { + message.addHeader('Date', rawDate); + } + if (rawSubject != null) { + message.addHeader('Subject', rawSubject); + } + message + ..addHeader('In-Reply-To', envelope.inReplyTo) + ..addHeader('Message-ID', envelope.messageId); + } + } + + return envelope; + } + + MailAddress? _parseAddressListFirst(ImapValue addressValue) { + final addresses = _parseAddressList(addressValue); + if (addresses == null || addresses.isEmpty) { + return null; + } + + return addresses.first; + } + + List? _parseAddressList(ImapValue addressValue) { + if (addressValue.value == 'NIL') { + return null; + } + final addresses = []; + final addressChildren = addressValue.children; + if (addressChildren != null) { + for (final child in addressChildren) { + final address = _parseAddress(child); + if (address != null) { + addresses.add(address); + } + } + } + + return addresses; + } + + MailAddress? _parseAddress(ImapValue addressValue) { + // An address structure is a parenthesized list that describes an + // electronic mail address. The fields of an address structure + // are in the following order: personal name, [SMTP] + // at-domain-list (source route), mailbox name, and host name. + + // [RFC-2822] group syntax is indicated by a special form of + // address structure in which the host name field is NIL. If the + // mailbox name field is also NIL, this is an end of group marker + // (semi-colon in RFC 822 syntax). If the mailbox name field is + // non-NIL, this is a start of group marker, and the mailbox name + // field holds the group name phrase. + final addressChildren = addressValue.children; + if (addressValue.value == 'NIL' || + addressChildren == null || + addressChildren.length < 4) { + return null; + } + final children = addressChildren; + final mailboxName = _checkForNil(children[2].value); + final hostName = _checkForNil(children[3].value); + if (mailboxName == null && hostName == null) { + print( + 'Warning: invalid mail address in $addressValue: ' + 'both mailboxName and hostName are null', + ); + + return null; + } + String? personalName = ''; + try { + personalName = MailCodec.decodeHeader(_checkForNil(children[0].value)); + } catch (e) { + print( + 'Warning: invalid mail address in $addressValue: ' + 'personalName is invalid: $e', + ); + } + + return MailAddress.fromEnvelope( + personalName: personalName, + //sourceRoute: _checkForNil(children[1].value), + mailboxName: mailboxName ?? '', + hostName: hostName ?? '', + ); + } + + String? _checkForNil(String? value) { + if (value == 'NIL') { + return null; + } + + return value; + } +} diff --git a/lib/src/private/imap/imap_response.dart b/lib/src/private/imap/imap_response.dart index e5199a2a..103c2336 100644 --- a/lib/src/private/imap/imap_response.dart +++ b/lib/src/private/imap/imap_response.dart @@ -26,6 +26,11 @@ class ImapResponse { } else { final buffer = StringBuffer(); for (final line in lines) { + if (line.rawData != null && buffer.isNotEmpty) { + // the space before the `{n}` literal marker was removed from the + // preceding line, restore the token separation + buffer.write(' '); + } buffer.write(line.line); } text = buffer.toString(); @@ -37,6 +42,7 @@ class ImapResponse { } set parseText(String? text) => _parseText = text; + static final _quotedPair = RegExp(r'\\(.)', dotAll: true); static const List _knownParenthesesDataItems = [ 'BODY', 'BODYSTRUCTURE', @@ -67,13 +73,25 @@ class ImapResponse { int? separatorChar; final text = line.line ?? ''; late int startIndex; - int? lastChar; final textCodeUnits = text.codeUnits; var detectedEscapeSequence = false; + var isEscaped = false; for (var charIndex = 0; charIndex < textCodeUnits.length; charIndex++) { final char = textCodeUnits[charIndex]; if (isInValue) { + if (separatorChar == AsciiRunes.runeDoubleQuote) { + // quoted-pairs such as \" and \\ within a quoted string: + if (isEscaped) { + isEscaped = false; + continue; + } + if (char == AsciiRunes.runeBackslash) { + isEscaped = true; + detectedEscapeSequence = true; + continue; + } + } if (char == AsciiRunes.runeOpeningBracket && separatorChar == AsciiRunes.runeSpace) { // this can be for example: @@ -89,16 +107,21 @@ class ImapResponse { if (separatorChar == AsciiRunes.runeClosingBracket) { // also include the closing ']' into the value: charIndex++; - } else if (separatorChar == AsciiRunes.runeDoubleQuote && - lastChar == AsciiRunes.runeBackslash) { - detectedEscapeSequence = true; - // this can happen e.g. in Subject fields within an ENVELOPE value: "hello \"sir\"" - lastChar = char; - continue; + // and the partial range of a partial fetch: BODY[1]<0> + if (charIndex < textCodeUnits.length && + textCodeUnits[charIndex] == AsciiRunes.runeSmallerThan) { + final closingIndex = text.indexOf('>', charIndex); + if (closingIndex != -1) { + charIndex = closingIndex + 1; + } + } } var valueText = text.substring(startIndex, charIndex); if (detectedEscapeSequence) { - valueText = valueText.replaceAll('\\"', '"'); + valueText = valueText.replaceAllMapped( + _quotedPair, + (match) => match[1]!, + ); detectedEscapeSequence = false; } current.addChild(ImapValue(valueText)); @@ -151,7 +174,6 @@ class ImapResponse { separatorChar = AsciiRunes.runeSpace; startIndex = charIndex; } - lastChar = char; } // for each char if (isInValue) { isInValue = false; diff --git a/lib/src/private/imap/imap_response_line.dart b/lib/src/private/imap/imap_response_line.dart index a7f62149..3d06a053 100644 --- a/lib/src/private/imap/imap_response_line.dart +++ b/lib/src/private/imap/imap_response_line.dart @@ -1,8 +1,6 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'parser_helper.dart'; - /// Contains an IMAP response line class ImapResponseLine { /// Creates a textual response line @@ -20,12 +18,16 @@ class ImapResponseLine { if (text[endIndex - 1] == '+') { endIndex--; } - literal = ParserHelper.parseIntByIndex(text, openIndex + 1, endIndex); - if (literal != null) { - if (openIndex > 0 && text[openIndex - 1] == ' ') { - openIndex--; + if (openIndex != -1) { + // RFC 3501: literal = "{" number "}" CRLF, number is digits only + final size = _parseLiteralSize(text, openIndex + 1, endIndex); + if (size != null) { + literal = size; + if (openIndex > 0 && text[openIndex - 1] == ' ') { + openIndex--; + } + _line = text.substring(0, openIndex); } - _line = text.substring(0, openIndex); } } } @@ -33,6 +35,20 @@ class ImapResponseLine { /// Creates a binary response line ImapResponseLine.raw(this.rawData) : rawLine = null; + static int? _parseLiteralSize(String text, int startIndex, int endIndex) { + if (endIndex <= startIndex) { + return null; + } + for (var i = startIndex; i < endIndex; i++) { + final code = text.codeUnitAt(i); + if (code < 48 || code > 57) { + return null; + } + } + + return int.tryParse(text.substring(startIndex, endIndex)); + } + static const Utf8Decoder _decoder = Utf8Decoder(allowMalformed: true); /// The original text line diff --git a/lib/src/private/imap/imap_response_reader.dart b/lib/src/private/imap/imap_response_reader.dart index 4118065a..f12d5647 100644 --- a/lib/src/private/imap/imap_response_reader.dart +++ b/lib/src/private/imap/imap_response_reader.dart @@ -7,81 +7,103 @@ import 'imap_response_line.dart'; /// Reads IMAP responses class ImapResponseReader { /// Creates a new imap response reader - ImapResponseReader(this.onImapResponse); + ImapResponseReader( + this.onImapResponse, { + this.maxLiteralSize = defaultMaxLiteralSize, + }); + + /// The default for [maxLiteralSize]: 128 MiB + static const int defaultMaxLiteralSize = 128 * 1024 * 1024; /// Callback for finished IMAP responses final Function(ImapResponse) onImapResponse; + + /// The maximum accepted size of a single literal in bytes. + /// + /// A malicious or broken server could otherwise announce an arbitrarily + /// large literal and make the client buffer everything that follows + /// forever. Exceeding the limit throws a [FormatException] from [onData]. + final int maxLiteralSize; + final Uint8ListReader _rawReader = Uint8ListReader(); + + /// The response that is currently being assembled ImapResponse? _currentResponse; - ImapResponseLine? _currentLine; + + /// The line whose literal data has not been received completely yet + ImapResponseLine? _awaitingLiteralFor; /// Processes the given [data] void onData(Uint8List data) { _rawReader.add(data); - // var text = String.fromCharCodes(data).replaceAll('\r\n', '\n'); - // print('onData: $text'); - final currentResponse = _currentResponse; - final currentLine = _currentLine; - if (currentResponse != null && currentLine != null) { - _checkResponse(currentResponse, currentLine); - } - if (_currentResponse == null) { - // there is currently no response awaiting its finalization - var text = _rawReader.readLine(); - while (text != null) { - final response = ImapResponse(); + _process(); + } + + void _process() { + while (true) { + final response = _currentResponse; + if (response == null) { + // there is currently no response awaiting its finalization + final text = _rawReader.readLine(); + if (text == null) { + return; + } final line = ImapResponseLine(text); - response.add(line); + final newResponse = ImapResponse()..add(line); if (line.isWithLiteral) { - _currentLine = line; - _currentResponse = response; - _checkResponse(response, line); - } else { - // this is a simple response: - onImapResponse(response); - } - if (_currentLine?.isWithLiteral ?? false) { - break; + _currentResponse = newResponse; + _awaitingLiteralFor = line; + continue; } - text = _rawReader.readLine(); + // this is a simple response: + onImapResponse(newResponse); + continue; } - } - } - - void _checkResponse(ImapResponse response, ImapResponseLine line) { - final literal = line.literal; - if (literal != null && literal > 0) { - if (_rawReader.isAvailable(literal)) { - final rawLine = ImapResponseLine.raw(_rawReader.readBytes(literal)); - response.add(rawLine); - _currentLine = rawLine; - _checkResponse(response, rawLine); + final awaiting = _awaitingLiteralFor; + if (awaiting != null) { + final literal = awaiting.literal ?? 0; + if (literal > maxLiteralSize) { + _currentResponse = null; + _awaitingLiteralFor = null; + throw FormatException( + 'literal of $literal bytes exceeds the maximum of ' + '$maxLiteralSize bytes', + ); + } + final rawData = _rawReader.readBytes(literal); + if (rawData == null) { + // wait for more data + return; + } + // an empty literal ({0}) still adds an (empty) data line, so that + // the remainder of the response is not mistaken for literal data + response.add(ImapResponseLine.raw(rawData)); + _awaitingLiteralFor = null; + continue; } - } else { - // current line has no literal + // the literal has been consumed, read the remainder of the line: final text = _rawReader.readLine(); - if (text != null) { - final textLine = ImapResponseLine(text); - // handle special case: - // the remainder of this line may consists of only a literal, - // in this case the information should be added on the previous line - if (textLine.isWithLiteral && (textLine.line?.isEmpty ?? true)) { - line.literal = textLine.literal; - } else { - if (textLine.line?.isNotEmpty ?? false) { - response.add(textLine); - } - if (!textLine.isWithLiteral) { - // this is the last line of this server response: - onImapResponse(response); - _currentResponse = null; - _currentLine = null; - } else { - _currentLine = textLine; - _checkResponse(response, textLine); - } - } + if (text == null) { + return; + } + final textLine = ImapResponseLine(text); + if (textLine.isWithLiteral && (textLine.line?.isEmpty ?? true)) { + // the remainder of this line consists of only a literal, + // in this case the information is added to the previous line + final previous = response.lines.last..literal = textLine.literal; + _awaitingLiteralFor = previous; + continue; + } + if (textLine.line?.isNotEmpty ?? false) { + response.add(textLine); + } + if (textLine.isWithLiteral) { + _awaitingLiteralFor = textLine; + continue; } + // this is the last line of this server response: + _currentResponse = null; + onImapResponse(response); } } } diff --git a/lib/src/private/imap/parser_helper.dart b/lib/src/private/imap/parser_helper.dart index 67f20bf2..dcb34a10 100644 --- a/lib/src/private/imap/parser_helper.dart +++ b/lib/src/private/imap/parser_helper.dart @@ -8,10 +8,13 @@ class ParserHelper { ParserHelper._(); /// Helper method for parsing integer values within a line [details]. + /// + /// Returns `null` when the [endCharacter] is missing or the text in + /// between is not a number. static int? parseInt(String details, int startIndex, String endCharacter) { final endIndex = details.indexOf(endCharacter, startIndex); - if (endIndex == -1) { - return -1; + if (endIndex == -1 || startIndex > details.length) { + return null; } final numericText = details.substring(startIndex, endIndex); diff --git a/test/imap/imap_robustness_test.dart b/test/imap/imap_robustness_test.dart new file mode 100644 index 00000000..b314772a --- /dev/null +++ b/test/imap/imap_robustness_test.dart @@ -0,0 +1,222 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/imap/command.dart'; +import 'package:enough_mail/src/private/imap/imap_response.dart'; +import 'package:enough_mail/src/private/imap/imap_response_line.dart'; +import 'package:enough_mail/src/private/imap/imap_response_reader.dart'; +import 'package:enough_mail/src/private/imap/response_parser.dart'; +import 'package:enough_mail/src/private/util/client_base.dart'; +import 'package:test/test.dart'; + +import '../mock_socket.dart'; +import 'mock_imap_server.dart'; + +class _ThrowingParser extends ResponseParser { + @override + String? parse(ImapResponse imapResponse, Response response) => + throw const FormatException('simulated parser bug'); +} + +late ImapClient client; +late MockImapServer mockServer; + +Uint8List _bytes(String text) => Uint8List.fromList(utf8.encode(text)); + +void main() { + setUp(() async { + client = ImapClient(); + final connection = MockConnection(); + client.connect( + connection.socketClient, + connectionInformation: const ConnectionInfo( + 'imaptest.enough.de', + 993, + isSecure: true, + ), + ); + mockServer = MockImapServer(connection.socketServer); + connection.socketServer.write( + '* OK [CAPABILITY IMAP4rev1 UIDPLUS] IMAP server ready\r\n', + ); + await Future.delayed(const Duration(milliseconds: 15)); + mockServer.response = ' OK LOGIN completed'; + await client.login('testuser', 'testpassword'); + mockServer.response = + '* LIST (\\HasNoChildren) "." "INBOX"\r\n OK LIST completed'; + await client.listMailboxes(); + mockServer.response = + '* 42 EXISTS\r\n* OK [UIDVALIDITY 1] ok\r\n OK [READ-WRITE] done'; + await client.selectMailboxByPath('INBOX'); + }); + + group('ImapResponseReader', () { + test('a line that only ends with } is not a literal', () { + final responses = []; + ImapResponseReader(responses.add).onData(_bytes('12345}\r\n')); + expect(responses, hasLength(1)); + expect(responses.first.parseText, '12345}'); + expect(ImapResponseLine('* OK {-5}').isWithLiteral, isFalse); + expect(ImapResponseLine('* OK {-5}').line, '* OK {-5}'); + expect(ImapResponseLine('* OK {12}').literal, 12); + expect(ImapResponseLine('* OK {12+}').literal, 12); + }); + + test('an empty literal does not swallow the rest of the line', () { + final responses = []; + ImapResponseReader(responses.add).onData( + _bytes('* 1 FETCH (UID 5 BODY[TEXT] {0}\r\n FLAGS (\\Seen))\r\n'), + ); + expect(responses, hasLength(1)); + final values = responses.first.iterate().values; + final fetch = values.firstWhere((v) => v.value == 'FETCH'); + expect(fetch.children?.map((v) => v.value), contains('FLAGS')); + }); + + test('rejects literals above the size limit', () { + final reader = ImapResponseReader((_) {}, maxLiteralSize: 1024); + expect( + () => reader.onData(_bytes('* 1 FETCH (BODY[] {9999999999}\r\n')), + throwsFormatException, + ); + }); + + test('many literals in one response do not overflow the stack', () { + final buffer = StringBuffer('* 1 FETCH ('); + for (var i = 0; i < 100000; i++) { + buffer.write('X {1}\r\na '); + } + buffer.write('UID 1)\r\n'); + final responses = []; + ImapResponseReader(responses.add).onData(_bytes(buffer.toString())); + expect(responses, hasLength(1)); + }); + + test('keeps token separation before a literal', () { + final responses = []; + ImapResponseReader( + responses.add, + ).onData(_bytes('* LIST (\\HasNoChildren) "." {9}\r\nINBOX.Foo\r\n')); + expect( + responses.first.parseText, + '* LIST (\\HasNoChildren) "." INBOX.Foo', + ); + }); + }); + + group('ImapClient', () { + test('malformed FETCH data fails the command instead of hanging', () async { + mockServer.response = + '* 1 FETCH (UID notanumber RFC822.SIZE x FLAGS (\\Seen ()))\r\n' + ' OK FETCH completed'; + final result = await client.fetchMessages( + MessageSequence.fromId(1), + '(UID RFC822.SIZE FLAGS)', + ); + expect(result.messages, hasLength(1)); + expect(result.messages.first.uid, isNull); + expect(result.messages.first.flags, ['\\Seen']); + + // malformed untagged data is skipped, the command still completes: + mockServer.response = + '* THREAD (a)\r\n' + ' OK THREAD completed'; + await client.threadMessages(method: 'REFERENCES', since: DateTime(2020)); + + // a parser failing on the tagged response fails the command: + mockServer.response = ' OK NOOP completed'; + await expectLater( + client.sendCommand(Command('NOOP'), _ThrowingParser()), + throwsA(isA()), + ); + + // and the connection remains usable: + mockServer.response = ' OK NOOP completed'; + await client.noop(); + }); + + test('escaped quotes and backslashes in an ENVELOPE', () async { + mockServer.response = + r'* 1 FETCH (ENVELOPE ("Fri, 25 Oct 2019 16:35:28 +0200" ' + r'"Path C:\\ and \"quoted\"" (("Me" NIL "me" "example.com")) ' + r'(("Me" NIL "me" "me.com")) (("Me" NIL "me" "me.com")) ' + r'(("You" NIL "you" "example.com")) NIL NIL NIL ""))' + '\r\n OK FETCH completed'; + final result = await client.fetchMessages( + MessageSequence.fromId(1), + '(ENVELOPE)', + ); + final envelope = result.messages.first.envelope; + expect(envelope, isNotNull); + expect(envelope?.subject, r'Path C:\ and "quoted"'); + expect(envelope?.to?.first.email, 'you@example.com'); + }); + + test('partial fetch responses are attributed to the right part', () async { + mockServer.response = + '* 1 FETCH (UID 7 BODY[1]<0> {5}\r\nhello)\r\n' + ' OK FETCH completed'; + final result = await client.fetchMessages( + MessageSequence.fromId(1), + '(UID BODY[1]<0.5>)', + ); + final message = result.messages.first; + expect(message.uid, 7); + expect(message.getPart('1')?.decodeContentText(), 'hello'); + }); + + test('literal mailbox names in LIST responses', () async { + mockServer.response = + '* LIST (\\HasNoChildren) "." {9}\r\nINBOX.Foo\r\n' + ' OK LIST completed'; + final boxes = await client.listMailboxes(); + expect(boxes, hasLength(1)); + expect(boxes.first.path, 'INBOX.Foo'); + expect(boxes.first.name, 'Foo'); + }); + + test('huge VANISHED ranges are rejected without allocating them', () async { + mockServer.response = + '* VANISHED (EARLIER) 1:4294967295\r\n' + ' OK NOOP completed'; + await client.noop(); + expect( + () => MessageSequence.parse('1:4294967296'), + throwsA(isA()), + ); + expect( + () => MessageSequence.parse('1:99999999'), + throwsA(isA()), + ); + expect(MessageSequence.parse('1:1000000').length, 1000000); + }); + + test( + 'an oversized literal fails pending commands and disconnects', + () async { + client = ImapClient(); + final connection = MockConnection(); + client.connect( + connection.socketClient, + connectionInformation: const ConnectionInfo( + 'imaptest.enough.de', + 993, + isSecure: true, + ), + ); + mockServer = MockImapServer(connection.socketServer); + connection.socketServer.write('* OK ready\r\n'); + await Future.delayed(const Duration(milliseconds: 15)); + mockServer.response = + '* 1 FETCH (BODY[] {999999999999}\r\n OK FETCH completed'; + await expectLater( + client.fetchMessages(MessageSequence.fromId(1), '(BODY[])'), + throwsA(isA()), + ); + await Future.delayed(const Duration(milliseconds: 50)); + expect(client.isConnected, isFalse); + }, + ); + }); +} From 99ea91f0608cd95e58b6f8d0eb81e2465c854604 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:02:04 +0200 Subject: [PATCH 09/33] fix: fail pending operations on connection problems instead of hanging connectToServer() never completed when the server closed the socket or stayed silent before its greeting; it now fails, also when the greeting does not arrive within the connect timeout. SmtpClient and PopClient fail the command awaiting a response when the connection is lost, when the client disconnects or when writing to the socket fails (the write futures were dropped before), and PopClient fails a command whose reply cannot be parsed instead of leaving it pending. QUIT no longer disconnects from inside the response handler; the clients disconnect after the command completed. Event stream controllers stay open so a reconnected client can still report events, and ImapClient.disconnect() fails its pending tasks. Co-Authored-By: Claude Fable 5.1 --- lib/src/imap/imap_client.dart | 4 +- lib/src/pop/pop_client.dart | 77 ++++++++++++++--- .../pop/commands/pop_quit_command.dart | 4 +- .../smtp/commands/smtp_quit_command.dart | 4 +- lib/src/private/util/client_base.dart | 37 ++++++-- lib/src/smtp/smtp_client.dart | 59 +++++++++++-- test/connection_robustness_test.dart | 84 +++++++++++++++++++ 7 files changed, 241 insertions(+), 28 deletions(-) create mode 100644 test/connection_robustness_test.dart diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index 64f65ac3..f4edfeba 100644 --- a/lib/src/imap/imap_client.dart +++ b/lib/src/imap/imap_client.dart @@ -399,7 +399,7 @@ class ImapClient extends ClientBase { // the continuation would hang forever since onConnectionError is not // invoked on an expected disconnect. _failPendingIdleContinuation('client disconnected'); - await _eventController.close(); + _completePendingTasksWithError('client disconnected'); return super.disconnect(); } @@ -3033,5 +3033,5 @@ class ImapClient extends ClientBase { } @override - Object createClientError(String message) => ImapException(this, message); + Exception createClientError(String message) => ImapException(this, message); } diff --git a/lib/src/pop/pop_client.dart b/lib/src/pop/pop_client.dart index 5961af88..ada7ef5d 100644 --- a/lib/src/pop/pop_client.dart +++ b/lib/src/pop/pop_client.dart @@ -70,9 +70,53 @@ class PopClient extends ClientBase { @override void onConnectionError(dynamic error) { - _eventController - ..add(PopConnectionLostEvent(this)) - ..close(); + // a command awaiting its response would otherwise hang forever: + _failCurrentCommand( + PopException.message(this, 'connection lost: $error'), + StackTrace.current, + ); + if (!_eventController.isClosed) { + _eventController.add(PopConnectionLostEvent(this)); + } + } + + @override + Future disconnect() { + _failCurrentCommand( + PopException.message(this, 'client disconnected'), + StackTrace.current, + ); + + return super.disconnect(); + } + + void _failCurrentCommand(Object error, StackTrace stackTrace) { + final command = _currentCommand; + _currentCommand = null; + _currentFirstResponseLine = null; + if (command != null && !command.completer.isCompleted) { + command.completer.completeError(error, stackTrace); + } + } + + /// Writes to the socket and fails the [command] when writing fails, + /// instead of leaving it pending with an unhandled asynchronous error. + void _write(Future Function() write, PopCommand command) { + unawaited( + write().catchError((Object e, StackTrace s) { + if (_currentCommand == command) { + _currentCommand = null; + } + if (!command.completer.isCompleted) { + command.completer.completeError( + e is PopException + ? e + : PopException.message(this, 'unable to send command: $e'), + s, + ); + } + }), + ); } @override @@ -130,6 +174,7 @@ class PopClient extends ClientBase { Future quit() async { await sendCommand(PopQuitCommand(this)); isLoggedIn = false; + await disconnect(); } /// Checks the status ie the total number of messages and their size @@ -170,7 +215,7 @@ class PopClient extends ClientBase { Future sendCommand(PopCommand command) { _currentCommand = command; _currentFirstResponseLine = null; - writeText(command.command, command); + _write(() => writeText(command.command, command), command); return command.completer.future; } @@ -184,31 +229,39 @@ class PopClient extends ClientBase { } final command = _currentCommand; if (command == null) { - print( - 'ignoring response starting with [${responseTexts.first}] ' + logApp( + 'ignoring response starting with ' + '[${responseTexts.isEmpty ? '' : responseTexts.first}] ' 'with ${responseTexts.length} lines.', ); + + return; } - if (command != null) { - var parser = command.parser; - parser ??= _standardParser; + try { + final parser = command.parser ?? _standardParser; final response = parser.parse(responseTexts); final commandText = command.nextCommand(response); if (commandText != null) { - writeText(commandText); + _write(() => writeText(commandText), command); } else if (command.isCommandDone(response)) { if (response.isFailedStatus) { command.completer.completeError(PopException(this, response)); } else { command.completer.complete(response.result); } - //_log("Done with command ${_currentCommand.command}"); _currentCommand = null; } + } catch (e, s) { + // a malformed server reply must fail the command, not leave it pending + logApp('Unable to process response: $e $s'); + _failCurrentCommand( + PopException.message(this, 'unable to process response: $e'), + s, + ); } } @override - Object createClientError(String message) => + Exception createClientError(String message) => PopException.message(this, message); } diff --git a/lib/src/private/pop/commands/pop_quit_command.dart b/lib/src/private/pop/commands/pop_quit_command.dart index 14f6f6aa..75041864 100644 --- a/lib/src/private/pop/commands/pop_quit_command.dart +++ b/lib/src/private/pop/commands/pop_quit_command.dart @@ -10,7 +10,9 @@ class PopQuitCommand extends PopCommand { @override String? nextCommand(PopResponse response) { - _client.disconnect(); + // the server closes the connection after its reply, this is expected and + // the client disconnects itself after the command has completed + _client.isSocketClosingExpected = true; return null; } diff --git a/lib/src/private/smtp/commands/smtp_quit_command.dart b/lib/src/private/smtp/commands/smtp_quit_command.dart index f9e339d4..48974e5a 100644 --- a/lib/src/private/smtp/commands/smtp_quit_command.dart +++ b/lib/src/private/smtp/commands/smtp_quit_command.dart @@ -10,7 +10,9 @@ class SmtpQuitCommand extends SmtpCommand { @override String? nextCommand(SmtpResponse response) { - _client.disconnect(); + // the server closes the connection after its reply, this is expected and + // the client disconnects itself after the command has completed + _client.isSocketClosingExpected = true; return null; } diff --git a/lib/src/private/util/client_base.dart b/lib/src/private/util/client_base.dart index b98b9f2d..688aa9b9 100644 --- a/lib/src/private/util/client_base.dart +++ b/lib/src/private/util/client_base.dart @@ -68,7 +68,7 @@ abstract class ClientBase { /// Information about the connection late ConnectionInfo connectionInfo; - late Completer _greetingsCompleter; + Completer? _greetingsCompleter; bool _isConnected = false; @@ -127,11 +127,33 @@ abstract class ClientBase { context: securityContext, ).timeout(timeout) : await Socket.connect(host, port).timeout(timeout); - _greetingsCompleter = Completer(); + final greetingsCompleter = Completer(); + _greetingsCompleter = greetingsCompleter; _isServerGreetingDone = false; connect(socket); - return _greetingsCompleter.future; + // the server greeting has to arrive within the timeout as well, + // otherwise e.g. a TLS port contacted without TLS would hang forever + return greetingsCompleter.future.timeout( + timeout, + onTimeout: () { + isSocketClosingExpected = true; + _isConnected = false; + socket.destroy(); + throw createClientError( + 'no server greeting received from $host:$port within $timeout', + ); + }, + ); + } + + /// Fails a pending [connectToServer] call when the connection ends before + /// the server greeting was received. + void _failGreeting(String reason) { + final completer = _greetingsCompleter; + if (completer != null && !completer.isCompleted) { + completer.completeError(createClientError(reason)); + } } /// Starts to listen on the given [socket]. @@ -172,6 +194,7 @@ abstract class ClientBase { isLoggedIn = false; _isConnected = false; _writeFuture = null; + _failGreeting('connection error before server greeting: $e'); if (!isSocketClosingExpected) { isSocketClosingExpected = true; try { @@ -214,7 +237,10 @@ abstract class ClientBase { final serverGreeting = String.fromCharCodes(data); log(serverGreeting, isClient: false); onConnectionEstablished(connectionInfo, serverGreeting); - _greetingsCompleter.complete(connectionInfo); + final completer = _greetingsCompleter; + if (completer != null && !completer.isCompleted) { + completer.complete(connectionInfo); + } } } @@ -223,6 +249,7 @@ abstract class ClientBase { logApp('Done, connection closed'); isLoggedIn = false; _isConnected = false; + _failGreeting('connection closed before server greeting'); if (!isSocketClosingExpected) { isSocketClosingExpected = true; onConnectionError('onDone not expected'); @@ -340,7 +367,7 @@ abstract class ClientBase { } /// Subclasses need to be able to create client specific exceptions - Object createClientError(String message); + Exception createClientError(String message); } /// Extends Completer instances diff --git a/lib/src/smtp/smtp_client.dart b/lib/src/smtp/smtp_client.dart index 88db6b6c..104a8cd0 100644 --- a/lib/src/smtp/smtp_client.dart +++ b/lib/src/smtp/smtp_client.dart @@ -152,9 +152,52 @@ class SmtpClient extends ClientBase { @override void onConnectionError(dynamic error) { - _eventController - ..add(SmtpConnectionLostEvent(this)) - ..close(); + // a command awaiting its response would otherwise hang forever: + _failCurrentCommand( + SmtpException.message(this, 'connection lost: $error'), + StackTrace.current, + ); + if (!_eventController.isClosed) { + _eventController.add(SmtpConnectionLostEvent(this)); + } + } + + @override + Future disconnect() { + _failCurrentCommand( + SmtpException.message(this, 'client disconnected'), + StackTrace.current, + ); + + return super.disconnect(); + } + + void _failCurrentCommand(Object error, StackTrace stackTrace) { + final command = _currentCommand; + _currentCommand = null; + if (command != null && !command.completer.isCompleted) { + command.completer.completeError(error, stackTrace); + } + } + + /// Writes to the socket and fails the [command] when writing fails, + /// instead of leaving it pending with an unhandled asynchronous error. + void _write(Future Function() write, SmtpCommand command) { + unawaited( + write().catchError((Object e, StackTrace s) { + if (_currentCommand == command) { + _currentCommand = null; + } + if (!command.completer.isCompleted) { + command.completer.completeError( + e is SmtpException + ? e + : SmtpException.message(this, 'unable to send command: $e'), + s, + ); + } + }), + ); } @override @@ -438,14 +481,16 @@ class SmtpClient extends ClientBase { Future quit() async { final response = await sendCommand(SmtpQuitCommand(this)); isLoggedIn = false; + await disconnect(); return response; } /// Sends the command to the server Future sendCommand(SmtpCommand command) { + final text = command.command; _currentCommand = command; - writeText(command.command, command); + _write(() => writeText(text, command), command); return command.completer.future; } @@ -467,9 +512,9 @@ class SmtpClient extends ClientBase { final text = next?.text; final data = next?.data; if (text != null) { - writeText(text); + _write(() => writeText(text), cmd); } else if (data != null) { - writeData(data); + _write(() => writeData(data), cmd); } else if (cmd.isCommandDone(response)) { if (response.isFailedStatus) { cmd.completer.completeError(SmtpException(this, response)); @@ -488,6 +533,6 @@ class SmtpClient extends ClientBase { } @override - Object createClientError(String message) => + Exception createClientError(String message) => SmtpException.message(this, message); } diff --git a/test/connection_robustness_test.dart b/test/connection_robustness_test.dart new file mode 100644 index 00000000..d1a92883 --- /dev/null +++ b/test/connection_robustness_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +void main() { + late ServerSocket server; + late String host; + + setUp(() async { + server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + host = server.address.address; + }); + + tearDown(() => server.close()); + + test('connect fails when the server closes before greeting', () async { + server.listen((socket) => socket.destroy()); + final client = SmtpClient('test.example.com'); + await expectLater( + client.connectToServer(host, server.port, isSecure: false), + throwsA(isA()), + ); + }); + + test('connect fails when no greeting arrives within the timeout', () async { + server.listen((socket) { + // keep the socket open and stay silent + }); + final client = ImapClient(); + await expectLater( + client.connectToServer( + host, + server.port, + isSecure: false, + timeout: const Duration(milliseconds: 300), + ), + throwsA(isA()), + ); + expect(client.isConnected, isFalse); + }); + + test('SMTP: a pending command fails when the connection is lost', () async { + server.listen((socket) { + socket.write('220 mock ESMTP\r\n'); + socket.listen((_) => socket.destroy()); + }); + final client = SmtpClient('test.example.com'); + await client.connectToServer(host, server.port, isSecure: false); + await expectLater(client.ehlo(), throwsA(isA())); + }); + + test('POP: a pending command fails when the connection is lost', () async { + server.listen((socket) { + socket.write('+OK mock ready\r\n'); + socket.listen((_) => socket.destroy()); + }); + final client = PopClient(); + await client.connectToServer(host, server.port, isSecure: false); + await expectLater(client.status(), throwsA(isA())); + }); + + test('POP: a malformed multi-line reply fails the command', () async { + server.listen((socket) { + socket.write('+OK mock ready\r\n'); + socket.listen((_) => socket.write('+OK\r\nnot a listing\r\n.\r\n')); + }); + final client = PopClient(); + await client.connectToServer(host, server.port, isSecure: false); + await expectLater(client.list(), throwsA(isA())); + }); + + test('a pending command fails when the client disconnects', () async { + server.listen((socket) { + socket.write('220 mock ESMTP\r\n'); + // never answer any command + }); + final client = SmtpClient('test.example.com'); + await client.connectToServer(host, server.port, isSecure: false); + final pending = expectLater(client.ehlo(), throwsA(isA())); + await client.disconnect(); + await pending; + }); +} From cb2e138bfb179c176fb3867490ddfda1bd74cb3e Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:04:27 +0200 Subject: [PATCH 10/33] fix(smtp,pop): frame multi-line replies correctly POP3: the terminator of a multi-line reply was searched backwards over the whole buffer on every chunk and required a leading CRLF, so LIST or UIDL on an empty mailbox (+OK CRLF . CRLF) never completed and large messages were scanned in quadratic time. The reader now scans only new bytes with an incremental matcher, recognizes the terminator right after the status line and consumes it completely instead of leaving a stray LF. SMTP: all lines available in a chunk were treated as one complete reply, so a multi-line EHLO reply that arrived in two chunks was truncated and its remainder was attributed to the next command. Lines are now buffered until the final 'NNN text' line (RFC 5321 4.2), which also makes a single-line EHLO reply complete. Co-Authored-By: Claude Fable 5.1 --- .../smtp/commands/smtp_ehlo_command.dart | 6 -- lib/src/private/util/uint8_list_reader.dart | 96 +++++++++++++++---- lib/src/smtp/smtp_client.dart | 39 +++++++- test/pop/pop_framing_test.dart | 89 +++++++++++++++++ test/smtp/smtp_framing_test.dart | 80 ++++++++++++++++ 5 files changed, 279 insertions(+), 31 deletions(-) create mode 100644 test/pop/pop_framing_test.dart create mode 100644 test/smtp/smtp_framing_test.dart diff --git a/lib/src/private/smtp/commands/smtp_ehlo_command.dart b/lib/src/private/smtp/commands/smtp_ehlo_command.dart index 860725f0..88fd1c8c 100644 --- a/lib/src/private/smtp/commands/smtp_ehlo_command.dart +++ b/lib/src/private/smtp/commands/smtp_ehlo_command.dart @@ -1,4 +1,3 @@ -import '../../../smtp/smtp_response.dart'; import '../smtp_command.dart'; /// Says hello to the remote service @@ -15,9 +14,4 @@ class SmtpEhloCommand extends SmtpCommand { return super.command; } - - @override - bool isCommandDone(SmtpResponse response) => - (response.type != SmtpResponseType.success) || - (response.responseLines.length > 1); } diff --git a/lib/src/private/util/uint8_list_reader.dart b/lib/src/private/util/uint8_list_reader.dart index 2b372b0d..00872028 100644 --- a/lib/src/private/util/uint8_list_reader.dart +++ b/lib/src/private/util/uint8_list_reader.dart @@ -7,6 +7,7 @@ import 'ascii_runes.dart'; class Uint8ListReader { static const Utf8Decoder _utf8decoder = Utf8Decoder(allowMalformed: true); final OptimizedBytesBuilder _builder = OptimizedBytesBuilder(); + final _TerminatorScanner _terminatorScanner = _TerminatorScanner(); /// Adds the given [list] data to this builder void add(Uint8List list) => _builder.add(list); @@ -30,6 +31,7 @@ class Uint8ListReader { return null; } final data = _builder.takeFirst(pos + 1); + _terminatorScanner.reset(); final line = _utf8decoder.convert(data, 0, pos - 1).trimLeft(); return line; @@ -42,35 +44,32 @@ class Uint8ListReader { return null; } final data = _builder.takeFirst(pos + 1); + _terminatorScanner.reset(); final text = _utf8decoder.convert(data).trimLeft(); return text.split('\r\n')..removeLast(); } - /// Finds the last CR-LF.CR-LF sequence - int? findLastCrLfDotCrLfSequence() { - for (var charIndex = _builder.length; --charIndex > 4;) { - if (_builder.getByteAt(charIndex) == 10 && - _builder.getByteAt(charIndex - 1) == 13 && - _builder.getByteAt(charIndex - 2) == AsciiRunes.runeDot && - _builder.getByteAt(charIndex - 3) == 10 && - _builder.getByteAt(charIndex - 4) == 13) { - // ok found CRLF.CRLF sequence: - return charIndex; - } - } - - return null; - } - - /// Reads all data until a CT-LF.CT-LF + /// Reads all lines of a multi-line response that is terminated by a line + /// consisting of a single dot, as used by POP3 (RFC 1939 section 3). + /// + /// The data is expected to start right after the status line, so a + /// terminating `.CRLF` at the very beginning is recognized as an empty + /// response, e.g. for `LIST` on an empty mailbox. Only bytes that arrived + /// since the previous call are scanned, so large messages are processed in + /// linear time. + /// + /// Returns `null` when the terminator has not been received yet. List? readLinesToCrLfDotCrLfSequence() { - final pos = findLastCrLfDotCrLfSequence(); - if (pos == null) { + final end = _terminatorScanner.scan(_builder); + if (end == null) { return null; } - final data = _builder.takeFirst(pos); - final text = _utf8decoder.convert(data, 0, pos - 4); + final data = _builder.takeFirst(end); + _terminatorScanner.reset(); + // exclude the terminator, either ".CRLF" at the start or "CRLF.CRLF": + final contentLength = end >= 5 ? end - 5 : 0; + final text = _utf8decoder.convert(data, 0, contentLength); return text.split('\r\n'); } @@ -80,6 +79,7 @@ class Uint8ListReader { if (!isAvailable(length)) { return null; } + _terminatorScanner.reset(); return _builder.takeFirst(length); } @@ -88,6 +88,57 @@ class Uint8ListReader { bool isAvailable(int length) => length <= _builder.length; } +/// Incrementally searches the `CRLF.CRLF` terminator of a POP3 multi-line +/// response across the chunks of an [OptimizedBytesBuilder]. +class _TerminatorScanner { + static const _pattern = [13, 10, AsciiRunes.runeDot, 13, 10]; + + /// The number of pattern bytes matched so far. + /// + /// The scanned data starts right after the status line's CRLF, so the + /// first two pattern bytes count as matched initially. + int _matched = 2; + + /// The number of leading bytes of the builder that were scanned already + int _scanned = 0; + + /// Scans the not yet scanned bytes of the [builder]. + /// + /// Returns the exclusive end index of the terminator, or `null` if it is + /// not (yet) contained. + int? scan(OptimizedBytesBuilder builder) { + var chunkStart = 0; + for (final chunk in builder.chunks) { + final chunkEnd = chunkStart + chunk.length; + if (chunkEnd > _scanned) { + for (var i = _scanned - chunkStart; i < chunk.length; i++) { + final byte = chunk[i]; + if (byte == _pattern[_matched]) { + _matched++; + if (_matched == _pattern.length) { + _scanned = chunkStart + i + 1; + + return _scanned; + } + } else { + _matched = byte == 13 ? 1 : 0; + } + } + _scanned = chunkEnd; + } + chunkStart = chunkEnd; + } + + return null; + } + + /// Resets the scanner after data has been consumed from the builder + void reset() { + _matched = 2; + _scanned = 0; + } +} + /// A non-copying [BytesBuilder]. /// /// Accumulates lists of integers and lazily builds @@ -192,6 +243,9 @@ class OptimizedBytesBuilder { /// Retrieves the available length int get length => _length; + /// The chunks of data in their order + Iterable get chunks => _chunks; + /// Checks if this builder is empty bool get isEmpty => _length == 0; diff --git a/lib/src/smtp/smtp_client.dart b/lib/src/smtp/smtp_client.dart index 104a8cd0..9a6a6276 100644 --- a/lib/src/smtp/smtp_client.dart +++ b/lib/src/smtp/smtp_client.dart @@ -135,6 +135,7 @@ class SmtpClient extends ClientBase { final String _clientDomain; final Uint8ListReader _uint8listReader = Uint8ListReader(); + final List _pendingReplyLines = []; SmtpCommand? _currentCommand; @override @@ -202,13 +203,43 @@ class SmtpClient extends ClientBase { @override void onDataReceived(Uint8List data) { - //print('onData: [${String.fromCharCodes(data). - // replaceAll("\r\n", "\n")}]'); _uint8listReader.add(data); final lines = _uint8listReader.readLines(); - if (lines != null) { - onServerResponse(lines); + if (lines == null) { + return; } + // RFC 5321 section 4.2: a multi-line reply consists of `NNN-text` lines + // and ends with a `NNN text` (or bare `NNN`) line. The lines of a reply + // may arrive in separate chunks, so collect them until the final line. + for (final line in lines) { + if (line.isEmpty) { + continue; + } + _pendingReplyLines.add(line); + if (_isFinalReplyLine(line)) { + final reply = List.of(_pendingReplyLines); + _pendingReplyLines.clear(); + onServerResponse(reply); + } + } + } + + /// Checks if [line] ends a (possibly multi-line) reply. + /// + /// Malformed lines without a reply code end the reply as well, so that + /// the pending command fails instead of waiting forever. + static bool _isFinalReplyLine(String line) { + if (line.length < 3) { + return true; + } + for (var i = 0; i < 3; i++) { + final code = line.codeUnitAt(i); + if (code < 48 || code > 57) { + return true; + } + } + + return line.length == 3 || line.codeUnitAt(3) != 45; // '-' } /// Issues the enhanced helo command to find out the service capabilities diff --git a/test/pop/pop_framing_test.dart b/test/pop/pop_framing_test.dart new file mode 100644 index 00000000..c196fb1f --- /dev/null +++ b/test/pop/pop_framing_test.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/util/uint8_list_reader.dart'; +import 'package:test/test.dart'; + +Uint8List _bytes(String text) => Uint8List.fromList(utf8.encode(text)); + +void main() { + group('Uint8ListReader.readLinesToCrLfDotCrLfSequence', () { + test('recognizes an empty multi-line response', () { + final reader = Uint8ListReader()..add(_bytes('+OK 0 messages\r\n.\r\n')); + expect(reader.readLine(), '+OK 0 messages'); + expect(reader.readLinesToCrLfDotCrLfSequence(), ['']); + expect(reader.isAvailable(1), isFalse); + }); + + test('consumes the terminator completely', () { + final reader = Uint8ListReader() + ..add(_bytes('+OK\r\n1 100\r\n2 200\r\n.\r\n+OK next')); + expect(reader.readLine(), '+OK'); + expect(reader.readLinesToCrLfDotCrLfSequence(), ['1 100', '2 200']); + expect(reader.readLine(), isNull); + reader.add(_bytes('\r\n')); + expect(reader.readLine(), '+OK next'); + }); + + test('handles data split across chunks and dot-stuffed lines', () { + final reader = Uint8ListReader()..add(_bytes('+OK\r\nline 1\r')); + expect(reader.readLine(), '+OK'); + expect(reader.readLinesToCrLfDotCrLfSequence(), isNull); + reader.add(_bytes('\n..stuffed\r\n.\r')); + expect(reader.readLinesToCrLfDotCrLfSequence(), isNull); + reader.add(_bytes('\n')); + expect(reader.readLinesToCrLfDotCrLfSequence(), ['line 1', '..stuffed']); + }); + + test('scans a large message in linear time', () { + final reader = Uint8ListReader()..add(_bytes('+OK\r\n')); + expect(reader.readLine(), '+OK'); + final chunk = _bytes('${'x' * 1000}\r\n' * 60); + final stopwatch = Stopwatch()..start(); + for (var i = 0; i < 400; i++) { + reader.add(chunk); + expect(reader.readLinesToCrLfDotCrLfSequence(), isNull); + } + reader.add(_bytes('.\r\n')); + final lines = reader.readLinesToCrLfDotCrLfSequence(); + stopwatch.stop(); + expect(lines, hasLength(400 * 60)); + expect(stopwatch.elapsed, lessThan(const Duration(seconds: 5))); + }); + }); + + group('PopClient', () { + late ServerSocket server; + + setUp(() async { + server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + }); + + tearDown(() => server.close()); + + test('LIST and UIDL on an empty mailbox complete', () async { + server.listen((socket) { + socket.write('+OK POP3 ready\r\n'); + socket.listen((data) { + final request = String.fromCharCodes(data); + if (request.startsWith('LIST') || request.startsWith('UIDL')) { + socket.write('+OK 0 messages\r\n.\r\n'); + } else { + socket.write('+OK\r\n'); + } + }); + }); + final client = PopClient(); + await client.connectToServer( + server.address.address, + server.port, + isSecure: false, + ); + expect(await client.list(), isEmpty); + expect(await client.uidList(), isEmpty); + await client.disconnect(); + }); + }); +} diff --git a/test/smtp/smtp_framing_test.dart b/test/smtp/smtp_framing_test.dart new file mode 100644 index 00000000..7d319c70 --- /dev/null +++ b/test/smtp/smtp_framing_test.dart @@ -0,0 +1,80 @@ +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +void main() { + late ServerSocket server; + + setUp(() async { + server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + }); + + tearDown(() => server.close()); + + Future connect() async { + final client = SmtpClient('test.example.com'); + await client.connectToServer( + server.address.address, + server.port, + isSecure: false, + ); + + return client; + } + + test('multi-line EHLO reply split across chunks', () async { + server.listen((socket) { + socket.write('220 mock ESMTP\r\n'); + socket.listen((_) async { + socket.write('250-mock\r\n250-SIZE 1000\r\n'); + await socket.flush(); + await Future.delayed(const Duration(milliseconds: 100)); + socket.write('250-AUTH PLAIN\r\n250 8BITMIME\r\n'); + }); + }); + final client = await connect(); + final response = await client.ehlo(); + expect(response.responseLines, hasLength(4)); + expect(client.serverInfo.maxMessageSize, 1000); + expect(client.serverInfo.supportsAuth(AuthMechanism.plain), isTrue); + expect(client.serverInfo.supports8BitMime, isTrue); + await client.disconnect(); + }); + + test('single-line EHLO reply completes', () async { + server.listen((socket) { + socket.write('220 mock ESMTP\r\n'); + socket.listen((_) => socket.write('250 mock\r\n')); + }); + final client = await connect(); + final response = await client.ehlo(); + expect(response.code, 250); + expect(client.serverInfo.capabilities, ['mock']); + await client.disconnect(); + }); + + test('EHLO after STARTTLS replaces the capabilities', () async { + server.listen((socket) { + var count = 0; + socket.write('220 mock ESMTP\r\n'); + socket.listen((_) { + count++; + socket.write( + count == 1 + ? '250-mock\r\n250-STARTTLS\r\n250 AUTH LOGIN\r\n' + : '250-mock\r\n250 AUTH PLAIN\r\n', + ); + }); + }); + final client = await connect(); + await client.ehlo(); + expect(client.serverInfo.supportsStartTls, isTrue); + await client.ehlo(); + expect(client.serverInfo.supportsStartTls, isFalse); + expect(client.serverInfo.supportsAuth(AuthMechanism.login), isFalse); + expect(client.serverInfo.supportsAuth(AuthMechanism.plain), isTrue); + expect(client.serverInfo.capabilities, ['mock', 'AUTH PLAIN']); + await client.disconnect(); + }); +} From ec851741edee2390a97795f8b865b5a882d21ebf Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:06:34 +0200 Subject: [PATCH 11/33] fix(smtp): encode AUTH PLAIN and AUTH LOGIN credentials as UTF-8 Both commands base64-encoded String.codeUnits, so an umlaut was sent as Latin-1 and any character outside Latin-1 threw an ArgumentError. RFC 4616 requires UTF-8, which CRAM-MD5 and XOAUTH2 already used. Also corrects the CRAM-MD5 log labels that claimed to be XOAUTH2. Co-Authored-By: Claude Fable 5.1 --- .../commands/smtp_auth_cram_md5_command.dart | 4 +-- .../commands/smtp_auth_login_command.dart | 7 ++-- .../commands/smtp_auth_plain_command.dart | 4 +-- test/smtp/smtp_auth_encoding_test.dart | 32 +++++++++++++++++++ 4 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 test/smtp/smtp_auth_encoding_test.dart diff --git a/lib/src/private/smtp/commands/smtp_auth_cram_md5_command.dart b/lib/src/private/smtp/commands/smtp_auth_cram_md5_command.dart index be33c04b..ea3d002e 100644 --- a/lib/src/private/smtp/commands/smtp_auth_cram_md5_command.dart +++ b/lib/src/private/smtp/commands/smtp_auth_cram_md5_command.dart @@ -31,7 +31,7 @@ S: 235 Authentication succeeded */ if (response.code != 334 && response.code != 235) { print( - 'Warning: Unexpected status code during AUTH XOAUTH2: ' + 'Warning: Unexpected status code during AUTH CRAM-MD5: ' '${response.code}. Expected: 334 or 235. \nauthSent=$_authSent', ); } @@ -68,5 +68,5 @@ S: 235 Authentication succeeded bool isCommandDone(SmtpResponse response) => _authSent; @override - String toString() => 'AUTH XOAUTH2 '; + String toString() => 'AUTH CRAM-MD5 '; } diff --git a/lib/src/private/smtp/commands/smtp_auth_login_command.dart b/lib/src/private/smtp/commands/smtp_auth_login_command.dart index 08238ab0..9275a229 100644 --- a/lib/src/private/smtp/commands/smtp_auth_login_command.dart +++ b/lib/src/private/smtp/commands/smtp_auth_login_command.dart @@ -10,7 +10,6 @@ class SmtpAuthLoginCommand extends SmtpCommand { final String _userName; final String _password; - final Base64Codec _codec = const Base64Codec(); bool _userNameSent = false; bool _userPasswordSent = false; @@ -26,14 +25,16 @@ class SmtpAuthLoginCommand extends SmtpCommand { 'userPasswordSent=$_userPasswordSent', ); } + // the credentials are UTF-8 encoded, `String.codeUnits` would send + // Latin-1 for e.g. umlauts and fail for any other non-ASCII character if (!_userNameSent) { _userNameSent = true; - return _codec.encode(_userName.codeUnits); + return base64.encode(utf8.encode(_userName)); } else if (!_userPasswordSent) { _userPasswordSent = true; - return _codec.encode(_password.codeUnits); + return base64.encode(utf8.encode(_password)); } else { return null; } diff --git a/lib/src/private/smtp/commands/smtp_auth_plain_command.dart b/lib/src/private/smtp/commands/smtp_auth_plain_command.dart index db46c758..bc676a89 100644 --- a/lib/src/private/smtp/commands/smtp_auth_plain_command.dart +++ b/lib/src/private/smtp/commands/smtp_auth_plain_command.dart @@ -15,9 +15,9 @@ class SmtpAuthPlainCommand extends SmtpCommand { @override String get command { + // RFC 4616: the message is UTF-8 encoded final combined = '$userName\u{0000}$userName\u{0000}$password'; - const codec = Base64Codec(); - final encoded = codec.encode(combined.codeUnits); + final encoded = base64.encode(utf8.encode(combined)); return 'AUTH PLAIN $encoded'; } diff --git a/test/smtp/smtp_auth_encoding_test.dart b/test/smtp/smtp_auth_encoding_test.dart new file mode 100644 index 00000000..4a848702 --- /dev/null +++ b/test/smtp/smtp_auth_encoding_test.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/smtp/commands/smtp_auth_login_command.dart'; +import 'package:enough_mail/src/private/smtp/commands/smtp_auth_plain_command.dart'; +import 'package:test/test.dart'; + +void main() { + const user = 'jörg@example.com'; + const password = 'pässwörd€😀'; + + test('AUTH PLAIN encodes the credentials as UTF-8', () { + final command = SmtpAuthPlainCommand(user, password); + final expected = base64.encode( + utf8.encode('$user\u0000$user\u0000$password'), + ); + expect(command.command, 'AUTH PLAIN $expected'); + }); + + test('AUTH LOGIN encodes the credentials as UTF-8', () { + final command = SmtpAuthLoginCommand(user, password); + expect( + command.nextCommand(SmtpResponse(['334 VXNlcm5hbWU6'])), + base64.encode(utf8.encode(user)), + ); + expect( + command.nextCommand(SmtpResponse(['334 UGFzc3dvcmQ6'])), + base64.encode(utf8.encode(password)), + ); + expect(command.isCommandDone(SmtpResponse(['235 ok'])), isTrue); + }); +} From fcd7dd3caa153ff97827c59bbf9eac6aaf8a46d9 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:08:45 +0200 Subject: [PATCH 12/33] fix(smtp): honour negative replies during MAIL FROM, RCPT TO, DATA and BDAT The send commands ignored the reply codes of their intermediate steps: the message payload was sent even when DATA was refused, which made the server interpret the message as commands, and a rejected recipient was ignored unless it happened to be the last one, so a message was partially delivered while the caller saw a success. Both the DATA and the BDAT command now stop on a rejected MAIL FROM, remember the first rejected recipient or a refused DATA/chunk reply, abort the transaction with RSET so the connection stays usable, and fail with the rejecting reply. BDAT sends SMTPUTF8 only when the server announces it and together with BODY=8BITMIME instead of replacing it, and the debug print is gone. Co-Authored-By: Claude Fable 5.1 --- lib/src/mail/mail_client.dart | 4 +- .../smtp/commands/smtp_send_bdat_command.dart | 74 +++++--- .../smtp/commands/smtp_sendmail_command.dart | 58 ++++-- lib/src/private/smtp/smtp_command.dart | 7 + lib/src/smtp/smtp_client.dart | 7 +- test/smtp/smtp_send_state_test.dart | 167 ++++++++++++++++++ 6 files changed, 266 insertions(+), 51 deletions(-) create mode 100644 test/smtp/smtp_send_state_test.dart diff --git a/lib/src/mail/mail_client.dart b/lib/src/mail/mail_client.dart index 77d3c5f4..fda22ad5 100644 --- a/lib/src/mail/mail_client.dart +++ b/lib/src/mail/mail_client.dart @@ -3658,7 +3658,9 @@ class _OutgoingSmtpClient extends _OutgoingMailClient { await _smtpClient.sendChunkedMessage( message, from: from, - supportUnicode: supportUnicode, + // RFC 6531: SMTPUTF8 may only be used when announced + supportUnicode: + supportUnicode && _smtpClient.serverInfo.supports('SMTPUTF8'), use8BitEncoding: use8BitEncoding, recipients: recipients, ); diff --git a/lib/src/private/smtp/commands/smtp_send_bdat_command.dart b/lib/src/private/smtp/commands/smtp_send_bdat_command.dart index 49bb2904..02c37cbc 100644 --- a/lib/src/private/smtp/commands/smtp_send_bdat_command.dart +++ b/lib/src/private/smtp/commands/smtp_send_bdat_command.dart @@ -7,7 +7,7 @@ import '../../../mime_message.dart'; import '../../../smtp/smtp_response.dart'; import '../smtp_command.dart'; -enum _BdatSequence { mailFrom, rcptTo, bdat, done } +enum _BdatSequence { mailFrom, rcptTo, bdat, done, failed } class _SmtpSendBdatCommand extends SmtpCommand { _SmtpSendBdatCommand( @@ -67,31 +67,37 @@ class _SmtpSendBdatCommand extends SmtpCommand { @override String get command { - if (supportUnicode) { - print('supportUnicode $supportUnicode'); - // cSpell:ignore SMTPUTF8 - - return 'MAIL FROM:<$fromEmail> SMTPUTF8'; - } - if (use8BitEncoding) { - return 'MAIL FROM:<$fromEmail> BODY=8BITMIME'; - } - - return 'MAIL FROM:<$fromEmail>'; + // cSpell:ignore SMTPUTF8 + final parameters = [ + if (use8BitEncoding) 'BODY=8BITMIME', + if (supportUnicode) 'SMTPUTF8', + ]; + + return parameters.isEmpty + ? 'MAIL FROM:<$fromEmail>' + : 'MAIL FROM:<$fromEmail> ${parameters.join(' ')}'; } @override SmtpCommandData? next(SmtpResponse response) { - final step = _currentStep; - switch (step) { + switch (_currentStep) { case _BdatSequence.mailFrom: + if (response.type != SmtpResponseType.success) { + _currentStep = _BdatSequence.failed; + + return null; + } _currentStep = _BdatSequence.rcptTo; - _recipientIndex++; + _recipientIndex = 1; + return SmtpCommandData( _getRecipientToCommand(recipientEmails[0]), null, ); case _BdatSequence.rcptTo: + if (response.type != SmtpResponseType.success) { + failureResponse ??= response; + } final index = _recipientIndex; if (index < recipientEmails.length) { _recipientIndex++; @@ -100,18 +106,35 @@ class _SmtpSendBdatCommand extends SmtpCommand { _getRecipientToCommand(recipientEmails[index]), null, ); - } else if (response.type == SmtpResponseType.success) { - return _getCurrentChunk(); - } else { - return null; } + if (failureResponse != null) { + return _abort(); + } + _currentStep = _BdatSequence.bdat; + + return _getCurrentChunk(); case _BdatSequence.bdat: + if (response.type != SmtpResponseType.success) { + // RFC 3030: every chunk is acknowledged with a 250 reply + failureResponse = response; + + return _abort(); + } + return _getCurrentChunk(); - default: + case _BdatSequence.done: + case _BdatSequence.failed: return null; } } + /// Aborts the mail transaction so that the connection can be reused + SmtpCommandData _abort() { + _currentStep = _BdatSequence.failed; + + return SmtpCommandData('RSET', null); + } + SmtpCommandData _getCurrentChunk() { final chunk = _chunks[_chunkIndex]; _chunkIndex++; @@ -125,14 +148,9 @@ class _SmtpSendBdatCommand extends SmtpCommand { String _getRecipientToCommand(String email) => 'RCPT TO:<$email>'; @override - bool isCommandDone(SmtpResponse response) { - if (_currentStep == _BdatSequence.bdat) { - return response.code == 354; - } - - return (response.type != SmtpResponseType.success) || - (_currentStep == _BdatSequence.done); - } + bool isCommandDone(SmtpResponse response) => + _currentStep == _BdatSequence.done || + _currentStep == _BdatSequence.failed; } /// Sends a message using BDAT diff --git a/lib/src/private/smtp/commands/smtp_sendmail_command.dart b/lib/src/private/smtp/commands/smtp_sendmail_command.dart index 511f0408..d80d2220 100644 --- a/lib/src/private/smtp/commands/smtp_sendmail_command.dart +++ b/lib/src/private/smtp/commands/smtp_sendmail_command.dart @@ -1,7 +1,7 @@ import '../../../../enough_mail.dart'; import '../smtp_command.dart'; -enum _SmtpSendCommandSequence { mailFrom, rcptTo, data, done } +enum _SmtpSendCommandSequence { mailFrom, rcptTo, data, done, failed } class _SmtpSendCommand extends SmtpCommand { _SmtpSendCommand( @@ -38,45 +38,65 @@ class _SmtpSendCommand extends SmtpCommand { @override String? nextCommand(SmtpResponse response) { - final step = _currentStep; - switch (step) { + switch (_currentStep) { case _SmtpSendCommandSequence.mailFrom: + if (response.type != SmtpResponseType.success) { + // no transaction was started, nothing to reset + _currentStep = _SmtpSendCommandSequence.failed; + + return null; + } _currentStep = _SmtpSendCommandSequence.rcptTo; - _recipientIndex++; + _recipientIndex = 1; + return _getRecipientToCommand(recipientEmails[0]); case _SmtpSendCommandSequence.rcptTo: + if (response.type != SmtpResponseType.success) { + // remember the first rejected recipient, but let the server see + // all recipients before aborting the transaction + failureResponse ??= response; + } final index = _recipientIndex; if (index < recipientEmails.length) { _recipientIndex++; return _getRecipientToCommand(recipientEmails[index]); - } else if (response.type == SmtpResponseType.success) { - _currentStep = _SmtpSendCommandSequence.data; - - return 'DATA'; - } else { - return null; } + if (failureResponse != null) { + return _abort(); + } + _currentStep = _SmtpSendCommandSequence.data; + + return 'DATA'; case _SmtpSendCommandSequence.data: + if (response.code != 354) { + // the server does not want the message data + failureResponse = response; + + return _abort(); + } _currentStep = _SmtpSendCommandSequence.done; return applySmtpTransparency(getData()); - default: + case _SmtpSendCommandSequence.done: + case _SmtpSendCommandSequence.failed: return null; } } + /// Aborts the mail transaction so that the connection can be reused + String _abort() { + _currentStep = _SmtpSendCommandSequence.failed; + + return 'RSET'; + } + String _getRecipientToCommand(String email) => 'RCPT TO:<$email>'; @override - bool isCommandDone(SmtpResponse response) { - if (_currentStep == _SmtpSendCommandSequence.data) { - return response.code == 354; - } - - return (response.type != SmtpResponseType.success) || - (_currentStep == _SmtpSendCommandSequence.done); - } + bool isCommandDone(SmtpResponse response) => + _currentStep == _SmtpSendCommandSequence.done || + _currentStep == _SmtpSendCommandSequence.failed; } /// Sends a MIME message diff --git a/lib/src/private/smtp/smtp_command.dart b/lib/src/private/smtp/smtp_command.dart index 69a276b1..7ce8a96e 100644 --- a/lib/src/private/smtp/smtp_command.dart +++ b/lib/src/private/smtp/smtp_command.dart @@ -16,6 +16,13 @@ class SmtpCommand { /// The completer of this command final Completer completer = Completer(); + /// The reply that made a multi-step command fail, if any. + /// + /// A command that aborts its exchange, e.g. by sending `RSET` after a + /// rejected recipient, sets this so that the command fails with the + /// rejection instead of succeeding with the `250` reply to `RSET`. + SmtpResponse? failureResponse; + /// Tries to retrieve the next command data SmtpCommandData? next(SmtpResponse response) { final text = nextCommand(response); diff --git a/lib/src/smtp/smtp_client.dart b/lib/src/smtp/smtp_client.dart index 9a6a6276..82617f00 100644 --- a/lib/src/smtp/smtp_client.dart +++ b/lib/src/smtp/smtp_client.dart @@ -547,10 +547,11 @@ class SmtpClient extends ClientBase { } else if (data != null) { _write(() => writeData(data), cmd); } else if (cmd.isCommandDone(response)) { - if (response.isFailedStatus) { - cmd.completer.completeError(SmtpException(this, response)); + final result = cmd.failureResponse ?? response; + if (result.isFailedStatus) { + cmd.completer.completeError(SmtpException(this, result)); } else { - cmd.completer.complete(response); + cmd.completer.complete(result); } //_log("Done with command ${_currentCommand.command}"); _currentCommand = null; diff --git a/test/smtp/smtp_send_state_test.dart b/test/smtp/smtp_send_state_test.dart new file mode 100644 index 00000000..eb7752dd --- /dev/null +++ b/test/smtp/smtp_send_state_test.dart @@ -0,0 +1,167 @@ +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/smtp/commands/smtp_send_bdat_command.dart'; +import 'package:enough_mail/src/private/smtp/commands/smtp_sendmail_command.dart'; +import 'package:enough_mail/src/private/smtp/smtp_command.dart'; +import 'package:test/test.dart'; + +SmtpResponse _reply(String line) => SmtpResponse([line]); + +MimeMessage _message() => + (MessageBuilder() + ..from = const [MailAddress('Me', 'me@example.com')] + ..to = const [ + MailAddress('A', 'a@example.com'), + MailAddress('B', 'b@example.com'), + ] + ..subject = 'Hello' + ..addTextPlain('Body')) + .buildMimeMessage(); + +void main() { + group('SmtpSendMailCommand state machine', () { + test('fails when MAIL FROM is rejected', () { + final command = SmtpSendMailCommand(_message(), null, [ + 'a@example.com', + ], use8BitEncoding: false); + final rejection = _reply('550 5.1.8 sender rejected'); + expect(command.nextCommand(rejection), isNull); + expect(command.isCommandDone(rejection), isTrue); + expect(command.failureResponse, isNull); + }); + + test('resets the transaction when a recipient is rejected', () { + final message = _message(); + final command = SmtpSendMailCommand( + message, + null, + message.recipientAddresses, + use8BitEncoding: false, + ); + expect(command.nextCommand(_reply('250 ok')), 'RCPT TO:'); + expect( + command.nextCommand(_reply('550 5.1.1 unknown user')), + 'RCPT TO:', + ); + expect(command.nextCommand(_reply('250 ok')), 'RSET'); + final resetReply = _reply('250 flushed'); + expect(command.nextCommand(resetReply), isNull); + expect(command.isCommandDone(resetReply), isTrue); + expect(command.failureResponse?.code, 550); + }); + + test('does not send the payload when DATA is refused', () { + final command = SmtpSendMailCommand(_message(), null, [ + 'a@example.com', + ], use8BitEncoding: false); + expect(command.nextCommand(_reply('250 ok')), 'RCPT TO:'); + expect(command.nextCommand(_reply('250 ok')), 'DATA'); + expect(command.nextCommand(_reply('451 4.3.0 try later')), 'RSET'); + expect(command.failureResponse?.code, 451); + expect(command.isCommandDone(_reply('250 ok')), isTrue); + }); + + test('completes after the payload was accepted', () { + final command = + SmtpSendMailCommand(_message(), null, [ + 'a@example.com', + ], use8BitEncoding: false) + ..nextCommand(_reply('250 ok')) + ..nextCommand(_reply('250 ok')); + final payload = command.nextCommand(_reply('354 go')); + expect(payload, endsWith('\r\n.')); + final accepted = _reply('250 queued'); + expect(command.nextCommand(accepted), isNull); + expect(command.isCommandDone(accepted), isTrue); + expect(command.failureResponse, isNull); + }); + }); + + group('SmtpSendBdatMailCommand state machine', () { + test('sends SMTPUTF8 together with BODY=8BITMIME', () { + final command = SmtpSendBdatMailCommand( + _message(), + null, + ['a@example.com'], + use8BitEncoding: true, + supportUnicode: true, + ); + expect( + command.command, + 'MAIL FROM: BODY=8BITMIME SMTPUTF8', + ); + }); + + test('resets the transaction when a chunk is rejected', () { + // more than one 512 KiB chunk: + final command = SmtpSendBdatMailTextCommand( + 'x' * (600 * 1024), + const MailAddress(null, 'me@example.com'), + ['a@example.com'], + use8BitEncoding: false, + supportUnicode: false, + ); + expect(command.next(_reply('250 ok'))?.text, 'RCPT TO:'); + expect(command.next(_reply('250 ok'))?.data, isNotNull); + expect(command.next(_reply('554 5.6.0 rejected'))?.text, 'RSET'); + expect(command.failureResponse?.code, 554); + expect(command.isCommandDone(_reply('250 ok')), isTrue); + }); + + test('a rejected last chunk fails the command directly', () { + final command = + SmtpSendBdatMailCommand( + _message(), + null, + ['a@example.com'], + use8BitEncoding: false, + supportUnicode: false, + ) + ..next(_reply('250 ok')) + ..next(_reply('250 ok')); + final rejection = _reply('554 5.6.0 rejected'); + expect(command.next(rejection), isNull); + expect(command.isCommandDone(rejection), isTrue); + }); + }); + + test('sendMessage fails with the rejection and resets the session', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final requests = []; + server.listen((socket) { + socket.write('220 mock ESMTP\r\n'); + socket.listen((data) { + final request = String.fromCharCodes(data); + requests.add(request); + if (request.startsWith('EHLO')) { + socket.write('250 mock\r\n'); + } else if (request.startsWith('RCPT TO:().having((e) => e.response.code, 'code', 550)), + ); + expect(requests.any((r) => r.startsWith('RSET')), isTrue); + expect(requests.any((r) => r.startsWith('DATA')), isFalse); + // the connection is still usable: + final noop = await client.sendCommand(SmtpCommand('NOOP')); + expect(noop.code, 250); + await client.disconnect(); + await server.close(); + }); +} From ea1855565cc26ab39d3c2281b74753a7a535dfc1 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:13:14 +0200 Subject: [PATCH 13/33] fix(mail): make reconnecting deadlock-free and keep delivering events afterwards MailClient.reconnect() and resume() ran the retry loop while holding the incoming lock; the loop called MailClient.listMailboxes(), which takes the same non-reentrant lock, so a reconnect without previously listed mailboxes hung forever, and disconnect() could never abort the loop because it needs the lock as well. The loop now runs outside the lock and lists mailboxes directly. The retry interval grows exponentially up to the maximum instead of jumping to it, and an aborted reconnect resets the reconnecting flag so events are not buffered and dropped forever. Co-Authored-By: Claude Fable 5.1 --- lib/src/mail/mail_client.dart | 53 +++++++------ test/mail/imap_loopback_server.dart | 95 +++++++++++++++++++++++ test/mail/mail_client_reconnect_test.dart | 55 +++++++++++++ 3 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 test/mail/imap_loopback_server.dart create mode 100644 test/mail/mail_client_reconnect_test.dart diff --git a/lib/src/mail/mail_client.dart b/lib/src/mail/mail_client.dart index fda22ad5..8ded4743 100644 --- a/lib/src/mail/mail_client.dart +++ b/lib/src/mail/mail_client.dart @@ -355,11 +355,12 @@ class MailClient { /// Also compare [disconnect]. /// Also compare [connect]. Future reconnect() async { - await _incomingLock.synchronized(() async { - await _incomingMailClient.disconnect(); - await _incomingMailClient.reconnect(); - _isConnected = true; - }); + await _incomingLock.synchronized(() => _incomingMailClient.disconnect()); + // the reconnect loop must not run under the incoming lock: it retries + // until disconnect() is called, which needs the lock as well, and it + // lists the mailboxes through this client when required + await _incomingMailClient.reconnect(); + _isConnected = true; } // Future tryAuthenticate( @@ -1087,26 +1088,27 @@ class MailClient { /// Set the [startPollingWhenError] to `false` in case polling should not /// be started again when an error occurred. Future resume({bool startPollingWhenError = true}) async { - await _incomingLock.synchronized(() async { - _incomingMailClient.log('resume mail client'); - try { + _incomingMailClient.log('resume mail client'); + try { + await _incomingLock.synchronized(() async { await _incomingMailClient.stopPolling(); await _incomingMailClient.startPolling(defaultPollingDuration); - } catch (e, s) { - _incomingMailClient.log('error while resuming: $e $s'); - // re-connect explicitly: - try { - await _incomingMailClient.reconnect(); - if (startPollingWhenError && !_incomingMailClient.isPolling()) { - await _incomingMailClient.startPolling(defaultPollingDuration); - } - } catch (e2, s2) { - _incomingMailClient.log( - 'error while trying to reconnect in resume: $e2 $s2', - ); + }); + } catch (e, s) { + _incomingMailClient.log('error while resuming: $e $s'); + // re-connect explicitly, compare [reconnect] for why this must not + // happen under the incoming lock: + try { + await _incomingMailClient.reconnect(); + if (startPollingWhenError && !_incomingMailClient.isPolling()) { + await _incomingMailClient.startPolling(defaultPollingDuration); } + } catch (e2, s2) { + _incomingMailClient.log( + 'error while trying to reconnect in resume: $e2 $s2', + ); } - }); + } } /// Determines if message flags such as `\Seen` can be stored. @@ -2014,7 +2016,8 @@ class _IncomingImapClient extends _IncomingMailClient { _selectedMailbox = await _imapClient.selectInbox(); mailClient._selectedMailbox = _selectedMailbox; if (mailClient.mailboxes == null) { - await mailClient.listMailboxes(); + // not via MailClient.listMailboxes(), which takes the lock + mailClient._mailboxes = await listMailboxes(); } } _imapClient.logApp('done selecting mailbox $_selectedMailbox.'); @@ -2056,7 +2059,8 @@ class _IncomingImapClient extends _IncomingMailClient { _imapClient.logApp('Unable to reconnect: $e $s'); } await Future.delayed(Duration(seconds: retryDurationSeconds)); - retryDurationSeconds = max( + // exponential backoff up to the maximum + retryDurationSeconds = min( retryDurationSeconds * 2, maxRetryDurationSeconds, ); @@ -2164,6 +2168,9 @@ class _IncomingImapClient extends _IncomingMailClient { @override Future disconnect() { _reconnectCounter++; // this aborts the reconnect cycle + // an aborted reconnect must not keep swallowing events forever: + _isReconnecting = false; + _imapEventsDuringReconnecting.clear(); return _imapClient.disconnect(); } diff --git a/test/mail/imap_loopback_server.dart b/test/mail/imap_loopback_server.dart new file mode 100644 index 00000000..8794c819 --- /dev/null +++ b/test/mail/imap_loopback_server.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +/// A minimal stateful IMAP server for MailClient tests. +/// +/// Records every received command line in [requests]. +class ImapLoopbackServer { + ImapLoopbackServer._(this._server); + + static Future start() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final server = ImapLoopbackServer._(socket); + socket.listen(server._onConnection); + + return server; + } + + final ServerSocket _server; + final requests = []; + final _sockets = []; + + /// The number of connections that were accepted + int connections = 0; + + String get host => _server.address.address; + int get port => _server.port; + + Future close() async { + for (final socket in _sockets) { + socket.destroy(); + } + await _server.close(); + } + + void _onConnection(Socket socket) { + connections++; + _sockets.add(socket); + socket.write( + '* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=XOAUTH2] ready\r\n', + ); + var buffer = ''; + socket.listen((data) { + buffer += String.fromCharCodes(data); + while (true) { + final end = buffer.indexOf('\r\n'); + if (end == -1) { + return; + } + final line = buffer.substring(0, end); + buffer = buffer.substring(end + 2); + _onLine(socket, line); + } + }, onError: (_) {}); + } + + void _onLine(Socket socket, String line) { + requests.add(line); + final space = line.indexOf(' '); + if (space == -1) { + return; + } + final tag = line.substring(0, space); + final rest = line.substring(space + 1); + final command = rest.split(' ').first.toUpperCase(); + switch (command) { + case 'CAPABILITY': + socket.write( + '* CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=XOAUTH2\r\n$tag OK done\r\n', + ); + break; + case 'LOGIN': + case 'AUTHENTICATE': + socket.write('$tag OK [CAPABILITY IMAP4rev1] authenticated\r\n'); + break; + case 'LIST': + socket.write( + '* LIST (\\HasNoChildren) "/" INBOX\r\n$tag OK LIST completed\r\n', + ); + break; + case 'SELECT': + case 'EXAMINE': + socket.write( + '* 1 EXISTS\r\n* 0 RECENT\r\n* OK [UIDVALIDITY 1] ok\r\n' + '* OK [UIDNEXT 2] ok\r\n$tag OK [READ-WRITE] done\r\n', + ); + break; + case 'LOGOUT': + socket + ..write('* BYE\r\n$tag OK done\r\n') + ..destroy(); + break; + default: + socket.write('$tag OK done\r\n'); + } + } +} diff --git a/test/mail/mail_client_reconnect_test.dart b/test/mail/mail_client_reconnect_test.dart new file mode 100644 index 00000000..4bbb2aa8 --- /dev/null +++ b/test/mail/mail_client_reconnect_test.dart @@ -0,0 +1,55 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +import 'imap_loopback_server.dart'; + +void main() { + late ImapLoopbackServer server; + + setUp(() async { + server = await ImapLoopbackServer.start(); + }); + + tearDown(() => server.close()); + + MailClient client() => MailClient( + MailAccount.fromManualSettings( + name: 'test', + email: 'user@example.com', + password: 'secret', + incomingHost: server.host, + incomingPort: server.port, + incomingSocketType: SocketType.plainNoStartTls, + outgoingHost: server.host, + ), + ); + + test('reconnect() completes when no mailbox was listed yet', () async { + final mailClient = client(); + await mailClient.connect(); + expect(mailClient.mailboxes, isNull); + // used to deadlock: reconnect held the incoming lock while the + // reconnect loop called MailClient.listMailboxes(), which takes it too + await mailClient.reconnect().timeout(const Duration(seconds: 10)); + expect(mailClient.mailboxes, isNotNull); + expect(server.connections, 2); + await mailClient.disconnect(); + }); + + test('events are delivered after disconnect and connect', () async { + final mailClient = client(); + await mailClient.connect(); + await mailClient.selectInbox(); + await mailClient.disconnect(); + await mailClient.connect(); + await mailClient.selectInbox(); + final events = []; + mailClient.eventStream.listen(events.add); + // an IMAP event must still reach the MailClient after a reconnect + await mailClient.reconnect().timeout(const Duration(seconds: 10)); + // stream events are delivered asynchronously + await Future.delayed(const Duration(milliseconds: 50)); + expect(events.whereType(), isNotEmpty); + await mailClient.disconnect(); + }); +} From 60dd98886e5e189e515e368dd10e0a1601f4fae6 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:15:57 +0200 Subject: [PATCH 14/33] fix(pop): make polling, deletion and OAuth work in the POP3 path The POP poll compared the message counts the wrong way round, so new messages were never detected, every poll opened a new session without ending the previous one, retrieved messages carried no sequence id and therefore could not be deleted or re-fetched, undeleting sent RSET followed by DELE again and store() always threw. OAuth accounts sent the access token as the PASS argument; PopClient now offers authenticateWithOAuth2() using AUTH XOAUTH2 and OauthAuthentication uses it. PopClient also accepts a SecurityContext like the other clients, and exceptions from the polling timer are logged instead of surfacing as unhandled errors. Co-Authored-By: Claude Fable 5.1 --- lib/src/mail/mail_authentication.dart | 2 +- lib/src/mail/mail_client.dart | 33 ++++- lib/src/pop/pop_client.dart | 30 +++- .../private/pop/commands/all_commands.dart | 1 + .../commands/pop_auth_xoauth2_command.dart | 39 +++++ test/mail/mail_client_pop_test.dart | 135 ++++++++++++++++++ 6 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 lib/src/private/pop/commands/pop_auth_xoauth2_command.dart create mode 100644 test/mail/mail_client_pop_test.dart diff --git a/lib/src/mail/mail_authentication.dart b/lib/src/mail/mail_authentication.dart index 82f11278..fedcf7e8 100644 --- a/lib/src/mail/mail_authentication.dart +++ b/lib/src/mail/mail_authentication.dart @@ -326,7 +326,7 @@ class OauthAuthentication extends UserNameBasedAuthentication { case ServerType.pop: await pop .toValueOrThrow('no [PopClient] found') - .login(userName, accessToken); + .authenticateWithOAuth2(userName, accessToken); break; case ServerType.smtp: await smtp diff --git a/lib/src/mail/mail_client.dart b/lib/src/mail/mail_client.dart index 8ded4743..a09074de 100644 --- a/lib/src/mail/mail_client.dart +++ b/lib/src/mail/mail_client.dart @@ -1757,7 +1757,13 @@ abstract class _IncomingMailClient { Future _poll(Timer timer) async { final callback = _pollImplementation; if (callback != null) { - await callback(); + try { + await callback(); + } catch (e, s) { + // an exception from a timer callback would otherwise surface as an + // unhandled asynchronous error + log('polling failed: $e $s'); + } } } @@ -3274,6 +3280,15 @@ class _IncomingPopClient extends _IncomingMailClient { @override Future connect({Duration timeout = const Duration(seconds: 20)}) async { + if (_popClient.isConnected) { + // a POP3 session cannot be refreshed, end the previous one properly + // (which also commits pending deletions) instead of leaking its socket + try { + await _popClient.quit(); + } catch (e) { + await _popClient.disconnect(); + } + } final serverConfig = _config.serverConfig; final isSecure = serverConfig.socketType == SocketType.ssl; await _popClient.connectToServer( @@ -3331,19 +3346,18 @@ class _IncomingPopClient extends _IncomingMailClient { @override Future> poll() async { - final numberOfKNownMessages = _selectedMailbox - .toValueOrThrow('no mailbox selected') - .messagesExists; + final mailbox = _selectedMailbox.toValueOrThrow('no mailbox selected'); + final numberOfKnownMessages = mailbox.messagesExists; // in POP3 a new session is required to get a new status await connect(); final status = await _popClient.status(); final messages = []; final numberOfMessages = status.numberOfMessages; - if (numberOfMessages < numberOfKNownMessages) { + mailbox.messagesExists = numberOfMessages; + if (numberOfMessages > numberOfKnownMessages) { //TODO compare list UIDs with known message UIDs // instead of just checking the number of messages - final diff = numberOfMessages - numberOfKNownMessages; - for (var id = numberOfMessages; id > numberOfMessages - diff; id--) { + for (var id = numberOfKnownMessages + 1; id <= numberOfMessages; id++) { final message = await _popClient.retrieve(id); messages.add(message); mailClient._fireEvent(MailLoadEvent(message, mailClient)); @@ -3379,12 +3393,17 @@ class _IncomingPopClient extends _IncomingMailClient { ) async { if (flags.length == 1 && flags.first == MessageFlags.deleted) { if (action == StoreAction.remove) { + // RSET un-deletes all messages of this session await _popClient.reset(); + + return; } final ids = sequence.toList(_selectedMailbox?.messagesExists); for (final id in ids) { await _popClient.delete(id); } + + return; } throw InvalidArgumentException('POP does not support storing flags.'); } diff --git a/lib/src/pop/pop_client.dart b/lib/src/pop/pop_client.dart index ada7ef5d..af2b79b1 100644 --- a/lib/src/pop/pop_client.dart +++ b/lib/src/pop/pop_client.dart @@ -25,14 +25,19 @@ class PopClient extends ClientBase { /// The handler receives the [X509Certificate], and can inspect it and decide /// (or let the user decide) whether to accept the connection or not. /// The handler should return true to continue the [SecureSocket] connection. + /// + /// [securityContext] is an optional [SecurityContext] for mTLS + /// (mutual TLS / client certificate authentication). PopClient({ bool isLogEnabled = false, String? logName, bool Function(X509Certificate)? onBadCertificate, + SecurityContext? securityContext, }) : super( isLogEnabled: isLogEnabled, logName: logName, onBadCertificate: onBadCertificate, + securityContext: securityContext, ); /// Allows listening to events fired by this [PopClient]. @@ -168,6 +173,13 @@ class PopClient extends ClientBase { isLoggedIn = true; } + /// Logs the user in with the given [user] and [accessToken] via OAuth 2.0 + /// using the `AUTH XOAUTH2` mechanism. + Future authenticateWithOAuth2(String user, String accessToken) async { + await sendCommand(PopAuthXOAuth2Command(user, accessToken)); + isLoggedIn = true; + } + /// Ends the POP session. /// /// Also removes any messages that have been marked as deleted @@ -193,13 +205,23 @@ class PopClient extends ClientBase { sendCommand(PopUidListCommand(messageId)); /// Downloads the message with the specified [messageId] - Future retrieve(int messageId) => - sendCommand(PopRetrieveCommand(messageId)); + /// + /// The [messageId] is stored as the message's `sequenceId`. + Future retrieve(int messageId) async { + final message = await sendCommand(PopRetrieveCommand(messageId)); + + return message..sequenceId = messageId; + } /// Downloads the first [numberOfLines] lines of the message /// with the given [messageId] - Future retrieveTopLines(int messageId, int numberOfLines) => - sendCommand(PopTopCommand(messageId, numberOfLines)); + /// + /// The [messageId] is stored as the message's `sequenceId`. + Future retrieveTopLines(int messageId, int numberOfLines) async { + final message = await sendCommand(PopTopCommand(messageId, numberOfLines)); + + return message..sequenceId = messageId; + } /// Marks the message with the specified [messageId] as deleted Future delete(int messageId) => diff --git a/lib/src/private/pop/commands/all_commands.dart b/lib/src/private/pop/commands/all_commands.dart index 991dc3c3..aa386b5a 100644 --- a/lib/src/private/pop/commands/all_commands.dart +++ b/lib/src/private/pop/commands/all_commands.dart @@ -1,4 +1,5 @@ export 'pop_apop_command.dart'; +export 'pop_auth_xoauth2_command.dart'; export 'pop_delete_command.dart'; export 'pop_list_command.dart'; export 'pop_noop_command.dart'; diff --git a/lib/src/private/pop/commands/pop_auth_xoauth2_command.dart b/lib/src/private/pop/commands/pop_auth_xoauth2_command.dart new file mode 100644 index 00000000..3d078e9f --- /dev/null +++ b/lib/src/private/pop/commands/pop_auth_xoauth2_command.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; + +import '../../../pop/pop_response.dart'; +import '../pop_command.dart'; + +/// Signs in the user with the `AUTH XOAUTH2` SASL mechanism +/// +/// Compare https://developers.google.com/gmail/imap/xoauth2-protocol +class PopAuthXOAuth2Command extends PopCommand { + /// Creates a new `AUTH XOAUTH2` command + PopAuthXOAuth2Command(String user, String accessToken) + : super('AUTH XOAUTH2 ${_encode(user, accessToken)}'); + + static String _encode(String user, String accessToken) => base64.encode( + utf8.encode('user=$user\u{0001}auth=Bearer $accessToken\u{0001}\u{0001}'), + ); + + bool _errorDetailsRequested = false; + + @override + String? nextCommand(PopResponse response) { + // On failure the server sends a `+ ` continuation with the error + // details; an empty client response then yields the final `-ERR`. + final result = response.result; + if (!_errorDetailsRequested && + !response.isOkStatus && + result is String && + result.startsWith('+')) { + _errorDetailsRequested = true; + + return ''; + } + + return null; + } + + @override + String toString() => 'AUTH XOAUTH2 '; +} diff --git a/test/mail/mail_client_pop_test.dart b/test/mail/mail_client_pop_test.dart new file mode 100644 index 00000000..5d93f7e6 --- /dev/null +++ b/test/mail/mail_client_pop_test.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +/// A minimal POP3 server whose mailbox grows by one message per session. +class PopLoopbackServer { + PopLoopbackServer._(this._server); + + static Future start() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final server = PopLoopbackServer._(socket); + socket.listen(server._onConnection); + + return server; + } + + final ServerSocket _server; + final requests = []; + int sessions = 0; + + String get host => _server.address.address; + int get port => _server.port; + + Future close() => _server.close(); + + void _onConnection(Socket socket) { + sessions++; + final messageCount = sessions; + socket.write('+OK POP3 ready\r\n'); + socket.listen((data) { + for (final line in utf8.decode(data).split('\r\n')) { + if (line.isEmpty) { + continue; + } + requests.add(line); + final command = line.split(' ').first.toUpperCase(); + switch (command) { + case 'STAT': + socket.write('+OK $messageCount ${messageCount * 100}\r\n'); + break; + case 'RETR': + final id = line.split(' ').last; + socket.write( + '+OK 100 octets\r\nSubject: message $id\r\n\r\nbody $id\r\n.\r\n', + ); + break; + case 'AUTH': + final payload = utf8.decode(base64.decode(line.split(' ').last)); + if (payload.contains('Bearer bad')) { + final details = base64.encode(utf8.encode('{"status":"401"}')); + socket.write('+ $details\r\n'); + } else { + socket.write('+OK welcome\r\n'); + } + break; + case 'QUIT': + socket + ..write('+OK bye\r\n') + ..destroy(); + break; + default: + socket.write('+OK\r\n'); + } + } + if (utf8.decode(data) == '\r\n') { + // empty client response after an XOAUTH2 error challenge + socket.write('-ERR [AUTH] invalid credentials\r\n'); + } + }, onError: (_) {}); + } +} + +void main() { + late PopLoopbackServer server; + + setUp(() async { + server = await PopLoopbackServer.start(); + }); + + tearDown(() => server.close()); + + test('polling detects new messages and ends the previous session', () async { + final mailClient = MailClient( + MailAccount.fromManualSettings( + name: 'test', + email: 'user@example.com', + password: 'secret', + incomingHost: server.host, + incomingPort: server.port, + incomingType: ServerType.pop, + incomingSocketType: SocketType.plainNoStartTls, + outgoingHost: server.host, + ), + ); + final loaded = []; + mailClient.eventStream.listen((event) { + if (event is MailLoadEvent) { + loaded.add(event.message); + } + }); + await mailClient.connect(); + final inbox = await mailClient.selectInbox(); + expect(inbox.messagesExists, 1); + await mailClient.startPolling(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 350)); + await mailClient.stopPolling(); + expect(loaded, isNotEmpty); + expect(loaded.first.sequenceId, 2); + expect(loaded.first.decodeSubject(), 'message 2'); + expect(inbox.messagesExists, greaterThanOrEqualTo(2)); + // the first session was ended with QUIT before the second one started + expect(server.requests.indexOf('QUIT'), greaterThan(0)); + await mailClient.disconnect(); + }); + + test('AUTH XOAUTH2', () async { + final client = PopClient(); + await client.connectToServer(server.host, server.port, isSecure: false); + await client.authenticateWithOAuth2('user@example.com', 'good'); + expect(client.isLoggedIn, isTrue); + expect(server.requests.last, startsWith('AUTH XOAUTH2 ')); + await client.disconnect(); + + final failing = PopClient(); + await failing.connectToServer(server.host, server.port, isSecure: false); + await expectLater( + failing.authenticateWithOAuth2('user@example.com', 'bad'), + throwsA(isA()), + ); + expect(failing.isLoggedIn, isFalse); + await failing.disconnect(); + }); +} From aff5cd9e7461cedba13f747e1b324bca5cae5619 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:16:23 +0200 Subject: [PATCH 15/33] fix(mail): keep rotated OAuth refresh tokens and make token equality consistent The refreshed token only copied the access token and expiry, so a refresh token rotated by the provider was discarded and the next refresh failed. Concurrent callers could also refresh the same token twice, and the SMTP connection was authenticated without checking the expiry first. The refresh is now serialized, keeps the rotated refresh token and runs before the outgoing connection is authenticated. OauthToken defines equality and hashCode, and the authentications combine their fields with Object.hash so equal objects share a hash code. Co-Authored-By: Claude Fable 5.1 --- lib/src/mail/mail_authentication.dart | 40 ++++++++++-- lib/src/mail/mail_client.dart | 17 +++++- test/mail/mail_client_oauth_test.dart | 88 +++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 test/mail/mail_client_oauth_test.dart diff --git a/lib/src/mail/mail_authentication.dart b/lib/src/mail/mail_authentication.dart index fedcf7e8..45de6fb1 100644 --- a/lib/src/mail/mail_authentication.dart +++ b/lib/src/mail/mail_authentication.dart @@ -154,7 +154,7 @@ class PlainAuthentication extends UserNameBasedAuthentication { other.password == password; @override - int get hashCode => userName.hashCode | password.hashCode; + int get hashCode => Object.hash(userName, password); @override UserNameBasedAuthentication copyWithUserName(String userName) => @@ -258,16 +258,46 @@ class OauthToken { bool get isValid => !isExpired; /// Refreshes this token with the new [accessToken] and [expiresIn]. - OauthToken copyWith(String accessToken, int expiresIn) => OauthToken( + /// + /// Specify the [refreshToken] when the provider rotated it, otherwise the + /// current one is kept. + OauthToken copyWith( + String accessToken, + int expiresIn, { + String? refreshToken, + String? scope, + }) => OauthToken( accessToken: accessToken, expiresIn: expiresIn, - refreshToken: refreshToken, - scope: scope, + refreshToken: refreshToken ?? this.refreshToken, + scope: scope ?? this.scope, tokenType: tokenType, provider: provider, created: DateTime.now().toUtc(), ); + @override + bool operator ==(Object other) => + other is OauthToken && + other.accessToken == accessToken && + other.refreshToken == refreshToken && + other.expiresIn == expiresIn && + other.scope == scope && + other.tokenType == tokenType && + other.created == created && + other.provider == provider; + + @override + int get hashCode => Object.hash( + accessToken, + refreshToken, + expiresIn, + scope, + tokenType, + created, + provider, + ); + /// A JSON representation of this token with the secrets redacted. /// /// Use [toJson] to persist the token. @@ -347,7 +377,7 @@ class OauthAuthentication extends UserNameBasedAuthentication { other.token.accessToken == token.accessToken; @override - int get hashCode => userName.hashCode | token.hashCode; + int get hashCode => Object.hash(userName, token.accessToken); /// Copies this [OauthAuthentication] with the given [token] OauthAuthentication copyWith({String? userName, OauthToken? token}) => diff --git a/lib/src/mail/mail_client.dart b/lib/src/mail/mail_client.dart index a09074de..72b7954c 100644 --- a/lib/src/mail/mail_client.dart +++ b/lib/src/mail/mail_client.dart @@ -289,7 +289,16 @@ class MailClient { _isConnected = true; } - Future _prepareConnect() async { + final _tokenRefreshLock = Lock(); + + /// Refreshes the OAuth token when it is about to expire. + /// + /// Serialized so that concurrent callers, e.g. a reconnect and a send, + /// do not refresh the same token twice. + Future _prepareConnect() => + _tokenRefreshLock.synchronized(_refreshTokenIfRequired); + + Future _refreshTokenIfRequired() async { final refresh = _refreshOAuthToken; if (refresh != null) { final auth = account.incoming.authentication; @@ -306,9 +315,13 @@ class MailClient { if (refreshed == null) { throw MailException(this, 'Unable to refresh token'); } + // keep a rotated refresh token, otherwise the next refresh fails final newToken = auth.token.copyWith( refreshed.accessToken, refreshed.expiresIn, + refreshToken: refreshed.refreshToken.isNotEmpty + ? refreshed.refreshToken + : null, ); final incoming = account.incoming.copyWith( authentication: auth.copyWith(token: newToken), @@ -3624,6 +3637,8 @@ class _OutgoingSmtpClient extends _OutgoingMailClient { Future _connectOutgoingIfRequired() async { if (!_smtpClient.isLoggedIn) { + // the token may have expired in the meantime + await mailClient._prepareConnect(); final config = _mailConfig.serverConfig; final isSecure = config.socketType == SocketType.ssl; try { diff --git a/test/mail/mail_client_oauth_test.dart b/test/mail/mail_client_oauth_test.dart new file mode 100644 index 00000000..d14db166 --- /dev/null +++ b/test/mail/mail_client_oauth_test.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +import 'imap_loopback_server.dart'; + +OauthToken _token(String access, String refresh, {required bool expired}) => + OauthToken( + accessToken: access, + expiresIn: 3600, + refreshToken: refresh, + scope: 'mail', + tokenType: 'Bearer', + created: expired + ? DateTime.now().toUtc().subtract(const Duration(hours: 2)) + : DateTime.now().toUtc(), + ); + +void main() { + late ImapLoopbackServer server; + + setUp(() async { + server = await ImapLoopbackServer.start(); + }); + + tearDown(() => server.close()); + + test('a rotated refresh token is kept after refreshing', () async { + final account = MailAccount.fromManualSettingsWithAuth( + name: 'test', + email: 'user@example.com', + incomingHost: server.host, + incomingPort: server.port, + incomingSocketType: SocketType.plainNoStartTls, + outgoingHost: server.host, + auth: OauthAuthentication( + 'user@example.com', + _token('old-access', 'old-refresh', expired: true), + ), + ); + var refreshCalls = 0; + MailAccount? changedAccount; + final mailClient = MailClient( + account, + refresh: (client, expiredToken) async { + refreshCalls++; + expect(expiredToken.refreshToken, 'old-refresh'); + + return _token('new-access', 'new-refresh', expired: false); + }, + onConfigChanged: (account) async => changedAccount = account, + ); + await mailClient.connect(); + expect(refreshCalls, 1); + final auth = mailClient.account.incoming.authentication; + expect(auth, isA()); + final token = (auth as OauthAuthentication).token; + expect(token.accessToken, 'new-access'); + expect(token.refreshToken, 'new-refresh'); + expect(changedAccount, isNotNull); + final authenticate = server.requests.firstWhere( + (r) => r.contains('AUTHENTICATE XOAUTH2'), + ); + final payload = utf8.decode(base64.decode(authenticate.split(' ').last)); + expect(payload, contains('Bearer new-access')); + await mailClient.disconnect(); + }); + + test('OauthToken and authentications have consistent equality', () { + final a = _token('access', 'refresh', expired: false); + final b = OauthToken.fromJson(a.toJson()); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect( + OauthAuthentication('u', a).hashCode, + OauthAuthentication('u', b).hashCode, + ); + expect( + const PlainAuthentication('u', 'p').hashCode, + const PlainAuthentication('u', 'p').hashCode, + ); + expect( + const PlainAuthentication('u', 'p').hashCode, + isNot(const PlainAuthentication('p', 'u').hashCode), + ); + }); +} From 9c035c157122746988ea9aa85836e7eb591cdb50 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:23:57 +0200 Subject: [PATCH 16/33] fix(imap): quote and escape user-supplied strings in commands LOGIN interpolated user name and password into a quoted string without escaping, so credentials containing '"', '\', non-ASCII characters or line breaks produced malformed or injectable commands. LOGIN now uses RFC 3501 quoted strings with escaping and falls back to literals for anything that cannot be quoted. Mailbox names are quoted with escaping everywhere (SELECT/EXAMINE/DELETE/RENAME/SUBSCRIBE, GETMETADATA) and rejected when they contain line breaks, SETMETADATA declares the literal size over the bytes that are actually sent, search terms escape backslashes and use a literal for control characters, literal data in SEARCH/SORT commands may contain line feeds, and the OAUTHBEARER authorization identity escapes ',' and '='. Co-Authored-By: Claude Fable 5.1 --- lib/src/imap/imap_client.dart | 298 +++++++--- lib/src/imap/imap_search.dart | 964 ++++++++++++++++--------------- test/imap/imap_quoting_test.dart | 107 ++++ 3 files changed, 802 insertions(+), 567 deletions(-) create mode 100644 test/imap/imap_quoting_test.dart diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index f4edfeba..d4c19289 100644 --- a/lib/src/imap/imap_client.dart +++ b/lib/src/imap/imap_client.dart @@ -408,12 +408,30 @@ class ImapClient extends ClientBase { /// /// Requires the IMAP service to support `AUTH=PLAIN` capability. Future> login(String name, String password) async { - final cmd = Command( - 'LOGIN "$name" "$password"', - logText: 'LOGIN "$name" "(password scrambled)"', - writeTimeout: defaultWriteTimeout, - responseTimeout: defaultResponseTimeout, - ); + // RFC 3501: quoted strings may only contain 7-bit text without CR or LF + // and need " and \ escaped, anything else has to be sent as a literal. + final parts = []; + final line = StringBuffer('LOGIN '); + _writeImapString(line, parts, name); + line.write(' '); + _writeImapString(line, parts, password); + parts.add(line.toString()); + final logText = + 'LOGIN ${_canBeQuoted(name) ? _quote(name) : '{literal}'} ' + '"(password scrambled)"'; + final cmd = parts.length == 1 + ? Command( + parts.first, + logText: logText, + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ) + : Command.withContinuation( + parts, + logText: logText, + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); final parser = CapabilityParser(serverInfo); final response = await sendCommand>(cmd, parser); isLoggedIn = true; @@ -463,8 +481,11 @@ class ImapClient extends ClientBase { }) async { host ??= serverInfo.host; port ??= serverInfo.port; + // RFC 7628 / RFC 5801: "," and "=" in the authorization identity are + // escaped as =2C and =3D + final escapedUser = user.replaceAll('=', '=3D').replaceAll(',', '=2C'); final authText = - 'n,u=$user,\u{0001}' + 'n,u=$escapedUser,\u{0001}' 'host=$host\u{0001}' 'port=$port\u{0001}' 'auth=Bearer $accessToken\u{0001}\u{0001}'; @@ -1372,29 +1393,164 @@ class ImapClient extends ClientBase { } String _encodeMailboxPath(String path, [bool alwaysQuote = false]) { + if (path.contains('\r') || path.contains('\n') || path.contains('\u0000')) { + // could only come from a malicious server or caller, never let it + // terminate the command line + throw ArgumentError.value( + path, + 'path', + 'mailbox names must not contain line breaks', + ); + } if (_serverInfo.supportsUtf8) { - if (path.startsWith('\"')) { + if (path.startsWith('"')) { return path; } - return '"$path"'; + return _quote(path); } final pathSeparator = serverInfo.pathSeparator ?? '/'; - var encodedPath = Mailbox.encode(path, pathSeparator); + final 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". + // '"' and '\' additionally have to be escaped within the quotes. if (encodedPath.contains(' ') || encodedPath.contains('(') || encodedPath.contains(')') || encodedPath.contains('{') || + encodedPath.contains('"') || + encodedPath.contains(r'\') || (alwaysQuote && !encodedPath.startsWith('"'))) { - encodedPath = '"$encodedPath"'; + if (alwaysQuote && encodedPath.startsWith('"')) { + return encodedPath; + } + + return _quote(encodedPath); } return encodedPath; } + /// Quotes an already encoded mailbox path for use in a command. + static String _quoteMailboxPath(String encodedPath) { + if (encodedPath.contains('\r') || + encodedPath.contains('\n') || + encodedPath.contains('\u0000')) { + throw ArgumentError.value( + encodedPath, + 'encodedPath', + 'mailbox names must not contain line breaks', + ); + } + + return _quote(encodedPath); + } + + /// Renders [value] as an IMAP quoted string, escaping `\` and `"`. + static String _quote(String value) => + '"${value.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"'; + + /// Checks if [value] can be sent as a quoted string (RFC 3501 `QUOTED-CHAR`): + /// printable 7-bit ASCII only, no CR, LF or NUL. + static bool _canBeQuoted(String value) { + for (final code in value.codeUnits) { + if (code < 0x20 || code > 0x7e) { + return false; + } + } + + return true; + } + + /// Appends [value] as an IMAP string to [line]: as a quoted string when + /// possible, otherwise as a literal, in which case the line so far is + /// completed into [parts] and the literal starts the next part. + static void _writeImapString( + StringBuffer line, + List parts, + String value, + ) { + if (_canBeQuoted(value)) { + line.write(_quote(value)); + } else { + line.write('{${utf8.encode(value).length}}'); + parts.add(line.toString()); + line + ..clear() + ..write(value); + } + } + + /// Creates a command from [cmdText] that may contain literals in the form + /// `{n}\n`, e.g. as produced by [SearchQueryBuilder] for non-ASCII + /// search terms. Each literal is sent after the server's continuation + /// request. Line feeds within the literal data are not mistaken for + /// literal boundaries. + Command _commandWithLiterals(String cmdText, {Duration? responseTimeout}) { + final parts = _splitCommandLiterals(cmdText); + + return parts.length == 1 + ? Command( + cmdText, + writeTimeout: defaultWriteTimeout, + responseTimeout: responseTimeout, + ) + : Command.withContinuation( + parts, + writeTimeout: defaultWriteTimeout, + responseTimeout: responseTimeout, + ); + } + + static final _literalMarker = RegExp(r'\{(\d+)\}\n'); + + static List _splitCommandLiterals(String cmdText) { + final parts = []; + var partStart = 0; + var searchFrom = 0; + while (true) { + final match = + _literalMarker.matchAsPrefix(cmdText, searchFrom) ?? + _firstLiteralMarker(cmdText, searchFrom); + if (match == null) { + parts.add(cmdText.substring(partStart)); + + return parts; + } + // the part ends with the `{n}` marker, the literal data follows + parts.add(cmdText.substring(partStart, match.end - 1)); + partStart = match.end; + // skip the literal data (n UTF-8 bytes) before looking for the next + // marker, it may itself contain `{`, `}` or line feeds + var remainingBytes = int.parse(match.group(1)!); + var index = partStart; + while (remainingBytes > 0 && index < cmdText.length) { + final code = cmdText.codeUnitAt(index); + if (code >= 0xD800 && code <= 0xDBFF && index + 1 < cmdText.length) { + remainingBytes -= 4; + index += 2; + } else { + remainingBytes -= code < 0x80 + ? 1 + : code < 0x800 + ? 2 + : 3; + index++; + } + } + searchFrom = index; + } + } + + static RegExpMatch? _firstLiteralMarker(String text, int from) { + for (final match in _literalMarker.allMatches(text, from)) { + return match; + } + + return null; + } + /// Lists all mailboxes in the path [referenceName] that match /// the given [mailboxName] that can contain wildcards. /// @@ -1616,7 +1772,7 @@ class ImapClient extends ClientBase { bool enableCondStore = false, QResyncParameters? qresync, }) { - final path = '"${box.encodedPath}"'; + final path = _quoteMailboxPath(box.encodedPath); final buffer = StringBuffer() ..write(command) ..write(' ') @@ -1710,18 +1866,7 @@ class ImapClient extends ClientBase { buffer.write(searchCriteria); final cmdText = buffer.toString(); buffer.clear(); - final searchLines = cmdText.split('\n'); - final cmd = searchLines.length == 1 - ? Command( - cmdText, - writeTimeout: defaultWriteTimeout, - responseTimeout: responseTimeout, - ) - : Command.withContinuation( - searchLines, - writeTimeout: defaultWriteTimeout, - responseTimeout: responseTimeout, - ); + final cmd = _commandWithLiterals(cmdText, responseTimeout: responseTimeout); return sendCommand(cmd, parser); } @@ -1764,18 +1909,7 @@ class ImapClient extends ClientBase { buffer.write(searchCriteria); final cmdText = buffer.toString(); buffer.clear(); - final searchLines = cmdText.split('\n'); - final cmd = searchLines.length == 1 - ? Command( - cmdText, - writeTimeout: defaultWriteTimeout, - responseTimeout: responseTimeout, - ) - : Command.withContinuation( - searchLines, - writeTimeout: defaultWriteTimeout, - responseTimeout: responseTimeout, - ); + final cmd = _commandWithLiterals(cmdText, responseTimeout: responseTimeout); return sendCommand(cmd, parser); } @@ -2193,7 +2327,7 @@ class ImapClient extends ClientBase { if (maxSize != null || depth != null) { cmd += ') '; } - cmd += '"${mailboxName ?? ''}" ($entry)'; + cmd += '${_quote(mailboxName ?? '')} ($entry)'; final parser = MetaDataParser(); return sendCommand>(Command(cmd), parser); @@ -2202,35 +2336,37 @@ class ImapClient extends ClientBase { /// Checks if the specified value can be safely send to the IMAP server /// just in double-quotes. bool _isSafeForQuotedTransmission(String value) => - value.length < 80 && !value.contains('"') && !value.contains('\n'); + value.length < 80 && _canBeQuoted(value); - /// Saves the specified meta data [entry]. + /// Appends the metadata [value] to [line] either as a quoted string or, if + /// that is not possible, as a literal that starts the next part. /// - /// Set [MetaDataEntry.value] to null to delete the specified meta data entry - /// Compare https://tools.ietf.org/html/rfc5464 for details. - Future setMetaData(MetaDataEntry entry) { - final valueText = entry.valueText; - final Command cmd; - final value = entry.value; - if (value == null || _isSafeForQuotedTransmission(valueText ?? '')) { - final cmdText = - 'SETMETADATA "${entry.mailboxName}" ' - '(${entry.name} ' - '${value == null ? 'NIL' : '"$valueText"'})'; - cmd = Command(cmdText); + /// The literal is written through the text path of the connection, which + /// encodes it as UTF-8, so its size is computed over the UTF-8 encoding. + void _writeMetaDataValue( + StringBuffer line, + List parts, + Uint8List value, + ) { + final text = utf8.decode(value, allowMalformed: true); + if (_isSafeForQuotedTransmission(text)) { + line.write(_quote(text)); } else { - // this is a complex command that requires continuation responses - final setPart = - 'SETMETADATA "${entry.mailboxName}" ' - '(${entry.name} {${value.length}}'; - final parts = [setPart, '$valueText)']; - cmd = Command.withContinuation(parts); + line.write('{${utf8.encode(text).length}}'); + parts.add(line.toString()); + line + ..clear() + ..write(text); } - final parser = NoResponseParser(_selectedMailbox); - - return sendCommand(cmd, parser); } + /// Saves the specified meta data [entry]. + /// + /// Set [MetaDataEntry.value] to null to delete the specified meta data entry + /// Compare https://tools.ietf.org/html/rfc5464 for details. + Future setMetaData(MetaDataEntry entry) => + setMetaDataEntries([entry]).then((_) => _selectedMailbox); + /// Saves the given meta data [entries]. /// /// Note that each [MetaDataEntry.mailboxName] is expected to be the same. @@ -2238,23 +2374,23 @@ class ImapClient extends ClientBase { /// Compare https://tools.ietf.org/html/rfc5464 for details. Future setMetaDataEntries(List entries) { final parts = []; - var cmd = StringBuffer()..write('SETMETADATA '); - var entry = entries.first; - cmd.write('"${entry.mailboxName}" ('); - for (entry in entries) { + final cmd = StringBuffer()..write('SETMETADATA '); + _writeImapString(cmd, parts, entries.first.mailboxName); + cmd.write(' ('); + var isFirst = true; + for (final entry in entries) { + if (!isFirst) { + cmd.write(' '); + } + isFirst = false; cmd - ..write(' ') ..write(entry.name) ..write(' '); final value = entry.value; if (value == null) { cmd.write('NIL'); - } else if (_isSafeForQuotedTransmission(entry.valueText ?? '')) { - cmd.write('"${entry.valueText}"'); } else { - cmd.write('{${value.length}}'); - parts.add(cmd.toString()); - cmd = StringBuffer()..write(entry.valueText); + _writeMetaDataValue(cmd, parts, value); } } cmd.write(')'); @@ -2281,7 +2417,7 @@ class ImapClient extends ClientBase { /// query that mailbox's status without deselecting the current /// mailbox in the first IMAP4rev1 connection. Future statusMailbox(Mailbox box, List flags) { - final path = '"${box.encodedPath}"'; + final path = _quoteMailboxPath(box.encodedPath); final buffer = StringBuffer() ..write('STATUS ') ..write(path) @@ -2365,7 +2501,7 @@ class ImapClient extends ClientBase { /// [box] the mailbox that should be renamed /// [newName] the desired future name of the mailbox Future renameMailbox(Mailbox box, String newName) async { - final path = '"${box.encodedPath}"'; + final path = _quoteMailboxPath(box.encodedPath); final cmd = Command( 'RENAME $path ${_encodeMailboxPath(newName)}', @@ -2405,7 +2541,7 @@ class ImapClient extends ClientBase { _sendMailboxCommand('UNSUBSCRIBE', box); Future _sendMailboxCommand(String command, Mailbox box) async { - final path = '"${box.encodedPath}"'; + final path = _quoteMailboxPath(box.encodedPath); final cmd = Command( '$command $path', writeTimeout: defaultWriteTimeout, @@ -2630,13 +2766,7 @@ class ImapClient extends ClientBase { ..write(searchCriteria); final cmdText = buffer.toString(); buffer.clear(); - final sortLines = cmdText.split('\n'); - final cmd = sortLines.length == 1 - ? Command(cmdText, writeTimeout: defaultWriteTimeout) - : Command.withContinuation( - sortLines, - writeTimeout: defaultWriteTimeout, - ); + final cmd = _commandWithLiterals(cmdText); return sendCommand(cmd, parser); } @@ -2678,13 +2808,7 @@ class ImapClient extends ClientBase { ..write(searchCriteria); final cmdText = buffer.toString(); buffer.clear(); - final sortLines = cmdText.split('\n'); - final cmd = sortLines.length == 1 - ? Command(cmdText, writeTimeout: defaultWriteTimeout) - : Command.withContinuation( - sortLines, - writeTimeout: defaultWriteTimeout, - ); + final cmd = _commandWithLiterals(cmdText); return sendCommand(cmd, parser); } diff --git a/lib/src/imap/imap_search.dart b/lib/src/imap/imap_search.dart index b31f73ca..55cd82e5 100644 --- a/lib/src/imap/imap_search.dart +++ b/lib/src/imap/imap_search.dart @@ -1,480 +1,484 @@ -import 'dart:convert'; - -import '../codecs/date_codec.dart'; -import '../exception.dart'; -import 'message_sequence.dart'; - -/// Which part of the message should be searched -enum SearchQueryType { - /// Search for matching `Subject` header - subject, - - /// Search for matching `From` header - from, - - /// Search for matching `To` header - to, - - /// Search for matches in the body of the message - /// (a very resource intensive search, not every mail provider supports this) - body, - - /// Search in all common headers (not every mail provider supports this) - allTextHeaders, - - /// Search in either `FROM` or in `SUBJECT`. - /// - /// Specifically useful in cases where the mail provider - /// does not support `allTextHeaders` - fromOrSubject, - - /// Search in either `TO` or in `SUBJECT`. - /// - /// Specifically useful in cases where the mail provider - /// does not support `allTextHeaders` - toOrSubject, - - /// Search for matching `TO` or `FROM` headers - fromOrTo, -} - -/// Defines what kind of messages should be searched -enum SearchMessageType { - /// any message - all, - - /// any flagged messages - flagged, - - /// any messages that are not flagged - unflagged, - - /// any seen (read) messages - seen, - - /// any messages that have not been seen - unseen, - - /// any messages marked as deleted - deleted, - - /// any messages that are not marked as deleted - undeleted, - - /// any messages marked as draft - draft, - - /// any messages not marked as draft - undraft, -} - -/// Creates a new search query. -/// -/// In IMAP any search query is combined with AND meaning all conditions -/// must be met by matching messages. -class SearchQueryBuilder { - /// Creates a common search query. - /// - /// [query] contains the search text, define where to search - /// with the [queryType]. - /// - /// Optionally you can also define what kind of messages to search - /// with the [messageType], - /// - /// the internal date since a message has been received with [since], - /// - /// the internal date before a message has been received with [before], - /// - /// the internal date since a message has been sent with [sentSince], - /// - /// the internal date before a message has been sent with [sentBefore], - SearchQueryBuilder.from( - String query, - SearchQueryType queryType, { - SearchMessageType? messageType, - DateTime? since, - DateTime? before, - DateTime? sentSince, - DateTime? sentBefore, - }) { - if (query.isNotEmpty) { - if (_TextSearchTerm.containsNonAsciiCharacters(query)) { - add(const SearchTermCharsetUf8()); - } - switch (queryType) { - case SearchQueryType.subject: - add(SearchTermSubject(query)); - break; - case SearchQueryType.from: - add(SearchTermFrom(query)); - break; - case SearchQueryType.to: - add(SearchTermTo(query)); - break; - case SearchQueryType.allTextHeaders: - add(SearchTermText(query)); - break; - case SearchQueryType.body: - add(SearchTermBody(query)); - break; - case SearchQueryType.fromOrSubject: - add(SearchTermOr(SearchTermFrom(query), SearchTermSubject(query))); - break; - case SearchQueryType.toOrSubject: - add(SearchTermOr(SearchTermTo(query), SearchTermSubject(query))); - break; - case SearchQueryType.fromOrTo: - add(SearchTermOr(SearchTermFrom(query), SearchTermTo(query))); - break; - } - } - - if (messageType != null) { - switch (messageType) { - case SearchMessageType.all: - // ignore - break; - case SearchMessageType.flagged: - add(const SearchTermFlagged()); - break; - case SearchMessageType.unflagged: - add(const SearchTermUnflagged()); - break; - case SearchMessageType.seen: - add(const SearchTermSeen()); - break; - case SearchMessageType.unseen: - add(const SearchTermUnseen()); - break; - case SearchMessageType.deleted: - add(const SearchTermDeleted()); - break; - case SearchMessageType.undeleted: - add(const SearchTermUndeleted()); - break; - case SearchMessageType.draft: - add(const SearchTermDraft()); - break; - case SearchMessageType.undraft: - add(const SearchTermUndraft()); - break; - } - } - if (before != null) { - add(SearchTermBefore(before)); - } - if (since != null) { - add(SearchTermSince(since)); - } - if (sentBefore != null) { - add(SearchTermSentBefore(sentBefore)); - } - if (sentSince != null) { - add(SearchTermSentSince(sentSince)); - } - } - - /// The terms for this search query - final searchTerms = []; - - /// Adds a new search term - void add(SearchTerm term) { - searchTerms.add(term); - } - - /// Renders this search query to the given [buffer]. - void render(StringBuffer buffer) { - var addSpace = false; - for (final term in searchTerms) { - if (addSpace) { - buffer.write(' '); - } - buffer.write(term.term); - addSpace = !term.term.endsWith('\n'); - } - } - - @override - String toString() { - final buffer = StringBuffer(); - render(buffer); - - return buffer.toString(); - } -} - -/// Base class for all search terms -abstract class SearchTerm { - /// Creates a new search term - const SearchTerm(this.term); - - /// The search - final String term; - - /// Renders this term to the given [buffer]. - void render(StringBuffer buffer) { - buffer.write(term); - } -} - -class _TextSearchTerm extends SearchTerm { - _TextSearchTerm(String name, String? value) : super(merge(name, value)); - - static String merge(String name, String? value) { - if (value == null) { - return name; - } - // check if there are UTF-8 characters: - if (containsNonAsciiCharacters(value)) { - final encoded = utf8.encode(value); - - return '$name {${encoded.length}}\n$value'; - } - final escaped = value.replaceAll('"', r'\"'); - - return '$name "$escaped"'; - } - - static bool containsNonAsciiCharacters(String value) { - final runes = value.runes; - for (final rune in runes) { - if (rune >= 127) { - return true; - } - } - - return false; - } -} - -class _DateSearchTerm extends SearchTerm { - _DateSearchTerm(String name, DateTime value) - : super('$name ${DateCodec.encodeSearchDate(value)}'); -} - -/// Set the charset to UTF8 -class SearchTermCharsetUf8 extends SearchTerm { - /// Creates a new search term - const SearchTermCharsetUf8() : super('CHARSET "UTF-8"'); -} - -/// Searches all messages -class SearchTermAll extends SearchTerm { - /// Creates a new search term - const SearchTermAll() : super('ALL'); -} - -/// Searches for answered/replied messages -class SearchTermAnswered extends SearchTerm { - /// Creates a new search term - const SearchTermAnswered() : super('ANSWERED'); -} - -/// Searches for messages with a BCC recipient that matches -class SearchTermBcc extends _TextSearchTerm { - /// Creates a new search term - SearchTermBcc(String recipientPart) : super('BCC', recipientPart); -} - -/// Searches for messages stored before the given date. -class SearchTermBefore extends _DateSearchTerm { - /// Creates a new search term - SearchTermBefore(DateTime dateTime) : super('BEFORE', dateTime); -} - -/// Searches in the body of messages. -/// This is usually a long lasting operation. -class SearchTermBody extends _TextSearchTerm { - /// Creates a new search term - SearchTermBody(String match) : super('BODY', match); -} - -/// Searches for messages with a matching recipient on CC -class SearchTermCc extends _TextSearchTerm { - /// Creates a new search term - SearchTermCc(String recipientPart) : super('CC', recipientPart); -} - -/// Searches for deleted messages -class SearchTermDeleted extends SearchTerm { - /// Creates a new search term - const SearchTermDeleted() : super('DELETED'); -} - -/// Searches for draft messages -class SearchTermDraft extends SearchTerm { - /// Creates a new search term - const SearchTermDraft() : super('DRAFT'); -} - -/// Searches for flagged messages -class SearchTermFlagged extends SearchTerm { - /// Creates a new search term - const SearchTermFlagged() : super('FLAGGED'); -} - -/// Searches for messages where the sender matches the senderPart -class SearchTermFrom extends _TextSearchTerm { - /// Creates a new search term - SearchTermFrom(String senderPart) : super('FROM', senderPart); -} - -/// Searches for messages with the given header -class SearchTermHeader extends _TextSearchTerm { - /// Creates a new search term - SearchTermHeader(String headerName, {String? headerValue}) - : super('HEADER $headerName', headerValue); -} - -/// Searches for messages flagged with the given keyword -class SearchTermKeyword extends SearchTerm { - /// Creates a new search term - const SearchTermKeyword(String keyword) : super('KEYWORD $keyword'); -} - -/// Searches for messages that are bigger than the given size -class SearchTermLarger extends SearchTerm { - /// Creates a new search term - const SearchTermLarger(int bytes) : super('LARGER $bytes'); -} - -/// Searches for new messages -class SearchTermNew extends SearchTerm { - /// Creates a new search term - const SearchTermNew() : super('NEW'); -} - -/// Negates the given search term -class SearchTermNot extends SearchTerm { - /// Creates a new search term - SearchTermNot(SearchTerm term) : super('NOT ${term.term}'); -} - -/// Searches for old messages -class SearchTermOld extends SearchTerm { - /// Creates a new search term - const SearchTermOld() : super('OLD'); -} - -/// Searches for message stored at the given day -class SearchTermOn extends _DateSearchTerm { - /// Creates a new search term - SearchTermOn(DateTime dateTime) : super('ON', dateTime); -} - -/// Combines two atomic search terms in an OR way -/// Note that you cannot nest an OR term into another OR term -class SearchTermOr extends SearchTerm { - /// Creates a new search term - SearchTermOr(SearchTerm term1, SearchTerm term2) - : super(_merge(term1, term2)); - static String _merge(SearchTerm term1, SearchTerm term2) { - if (term1 is SearchTermOr || term2 is SearchTermOr) { - throw InvalidArgumentException('You cannot nest several OR search terms'); - } - - return 'OR ${term1.term} ${term2.term}'; - } -} - -/// Searches for recent messages -class SearchTermRecent extends SearchTerm { - /// Creates a new search term - const SearchTermRecent() : super('RECENT'); -} - -/// Searches for seen / read messages -class SearchTermSeen extends SearchTerm { - /// Creates a new search term - const SearchTermSeen() : super('SEEN'); -} - -/// Searches for messages sent before the given date -class SearchTermSentBefore extends _DateSearchTerm { - /// Creates a new search term - SearchTermSentBefore(DateTime dateTime) : super('SENTBEFORE', dateTime); -} - -/// Searches for message sent at the given day -class SearchTermSentOn extends _DateSearchTerm { - /// Creates a new search term - SearchTermSentOn(DateTime dateTime) : super('SENTON', dateTime); -} - -/// Searches message sent after the given time -class SearchTermSentSince extends _DateSearchTerm { - /// Creates a new search term - SearchTermSentSince(DateTime dateTime) : super('SENTSINCE', dateTime); -} - -/// Searches for messages stored after the given time -class SearchTermSince extends _DateSearchTerm { - /// Creates a new search term - SearchTermSince(DateTime dateTime) : super('SINCE', dateTime); -} - -/// Searches messages with a size less than given -class SearchTermSmaller extends SearchTerm { - /// Creates a new search term - const SearchTermSmaller(int bytes) : super('SMALLER $bytes'); -} - -/// Searches for messages with a matching subject -class SearchTermSubject extends _TextSearchTerm { - /// Creates a new search term - SearchTermSubject(String subjectPart) : super('SUBJECT', subjectPart); -} - -/// Searches any text header -class SearchTermText extends _TextSearchTerm { - /// Creates a new search term - SearchTermText(String textPart) : super('TEXT', textPart); -} - -/// Searches for recipients -class SearchTermTo extends _TextSearchTerm { - /// Creates a new search term - SearchTermTo(String recipientPart) : super('TO', recipientPart); -} - -/// Searches for the given UID messages -class UidSearchTerm extends SearchTerm { - /// Creates a new search term - UidSearchTerm(MessageSequence sequence) : super('UID $sequence'); -} - -/// Searches messages without the replied flag -class SearchTermUnanswered extends SearchTerm { - /// Creates a new search term - const SearchTermUnanswered() : super('UNANSWERED'); -} - -/// Searches messages that are not deleted -class SearchTermUndeleted extends SearchTerm { - /// Creates a new search term - const SearchTermUndeleted() : super('UNDELETED'); -} - -/// Searches for messages that carry no draft flag -class SearchTermUndraft extends SearchTerm { - /// Creates a new search term - const SearchTermUndraft() : super('UNDRAFT'); -} - -/// Search for not flagged messages -class SearchTermUnflagged extends SearchTerm { - /// Creates a new search term - const SearchTermUnflagged() : super('UNFLAGGED'); -} - -/// Searches for messages without the keyword -class SearchTermUnkeyword extends SearchTerm { - /// Creates a new search term - const SearchTermUnkeyword(String keyword) : super('UNKEYWORD $keyword'); -} - -/// Searches for unseen messages -class SearchTermUnseen extends SearchTerm { - /// Creates a new search term - const SearchTermUnseen() : super('UNSEEN'); -} +import 'dart:convert'; + +import '../codecs/date_codec.dart'; +import '../exception.dart'; +import 'message_sequence.dart'; + +/// Which part of the message should be searched +enum SearchQueryType { + /// Search for matching `Subject` header + subject, + + /// Search for matching `From` header + from, + + /// Search for matching `To` header + to, + + /// Search for matches in the body of the message + /// (a very resource intensive search, not every mail provider supports this) + body, + + /// Search in all common headers (not every mail provider supports this) + allTextHeaders, + + /// Search in either `FROM` or in `SUBJECT`. + /// + /// Specifically useful in cases where the mail provider + /// does not support `allTextHeaders` + fromOrSubject, + + /// Search in either `TO` or in `SUBJECT`. + /// + /// Specifically useful in cases where the mail provider + /// does not support `allTextHeaders` + toOrSubject, + + /// Search for matching `TO` or `FROM` headers + fromOrTo, +} + +/// Defines what kind of messages should be searched +enum SearchMessageType { + /// any message + all, + + /// any flagged messages + flagged, + + /// any messages that are not flagged + unflagged, + + /// any seen (read) messages + seen, + + /// any messages that have not been seen + unseen, + + /// any messages marked as deleted + deleted, + + /// any messages that are not marked as deleted + undeleted, + + /// any messages marked as draft + draft, + + /// any messages not marked as draft + undraft, +} + +/// Creates a new search query. +/// +/// In IMAP any search query is combined with AND meaning all conditions +/// must be met by matching messages. +class SearchQueryBuilder { + /// Creates a common search query. + /// + /// [query] contains the search text, define where to search + /// with the [queryType]. + /// + /// Optionally you can also define what kind of messages to search + /// with the [messageType], + /// + /// the internal date since a message has been received with [since], + /// + /// the internal date before a message has been received with [before], + /// + /// the internal date since a message has been sent with [sentSince], + /// + /// the internal date before a message has been sent with [sentBefore], + SearchQueryBuilder.from( + String query, + SearchQueryType queryType, { + SearchMessageType? messageType, + DateTime? since, + DateTime? before, + DateTime? sentSince, + DateTime? sentBefore, + }) { + if (query.isNotEmpty) { + if (_TextSearchTerm.containsNonAsciiCharacters(query)) { + add(const SearchTermCharsetUf8()); + } + switch (queryType) { + case SearchQueryType.subject: + add(SearchTermSubject(query)); + break; + case SearchQueryType.from: + add(SearchTermFrom(query)); + break; + case SearchQueryType.to: + add(SearchTermTo(query)); + break; + case SearchQueryType.allTextHeaders: + add(SearchTermText(query)); + break; + case SearchQueryType.body: + add(SearchTermBody(query)); + break; + case SearchQueryType.fromOrSubject: + add(SearchTermOr(SearchTermFrom(query), SearchTermSubject(query))); + break; + case SearchQueryType.toOrSubject: + add(SearchTermOr(SearchTermTo(query), SearchTermSubject(query))); + break; + case SearchQueryType.fromOrTo: + add(SearchTermOr(SearchTermFrom(query), SearchTermTo(query))); + break; + } + } + + if (messageType != null) { + switch (messageType) { + case SearchMessageType.all: + // ignore + break; + case SearchMessageType.flagged: + add(const SearchTermFlagged()); + break; + case SearchMessageType.unflagged: + add(const SearchTermUnflagged()); + break; + case SearchMessageType.seen: + add(const SearchTermSeen()); + break; + case SearchMessageType.unseen: + add(const SearchTermUnseen()); + break; + case SearchMessageType.deleted: + add(const SearchTermDeleted()); + break; + case SearchMessageType.undeleted: + add(const SearchTermUndeleted()); + break; + case SearchMessageType.draft: + add(const SearchTermDraft()); + break; + case SearchMessageType.undraft: + add(const SearchTermUndraft()); + break; + } + } + if (before != null) { + add(SearchTermBefore(before)); + } + if (since != null) { + add(SearchTermSince(since)); + } + if (sentBefore != null) { + add(SearchTermSentBefore(sentBefore)); + } + if (sentSince != null) { + add(SearchTermSentSince(sentSince)); + } + } + + /// The terms for this search query + final searchTerms = []; + + /// Adds a new search term + void add(SearchTerm term) { + searchTerms.add(term); + } + + /// Renders this search query to the given [buffer]. + void render(StringBuffer buffer) { + var addSpace = false; + for (final term in searchTerms) { + if (addSpace) { + buffer.write(' '); + } + buffer.write(term.term); + addSpace = !term.term.endsWith('\n'); + } + } + + @override + String toString() { + final buffer = StringBuffer(); + render(buffer); + + return buffer.toString(); + } +} + +/// Base class for all search terms +abstract class SearchTerm { + /// Creates a new search term + const SearchTerm(this.term); + + /// The search + final String term; + + /// Renders this term to the given [buffer]. + void render(StringBuffer buffer) { + buffer.write(term); + } +} + +class _TextSearchTerm extends SearchTerm { + _TextSearchTerm(String name, String? value) : super(merge(name, value)); + + static String merge(String name, String? value) { + if (value == null) { + return name; + } + // RFC 3501: a quoted string may only contain printable 7-bit ASCII with + // " and \ escaped; everything else - non-ASCII text, control characters + // and line breaks - is sent as a literal so that the search text can + // never break out of the command line. + if (containsNonAsciiCharacters(value)) { + final encoded = utf8.encode(value); + + return '$name {${encoded.length}}\n$value'; + } + final escaped = value.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); + + return '$name "$escaped"'; + } + + /// Checks if [value] contains characters outside of printable ASCII, + /// including control characters such as CR and LF. + static bool containsNonAsciiCharacters(String value) { + for (final code in value.codeUnits) { + if (code < 32 || code >= 127) { + return true; + } + } + + return false; + } +} + +class _DateSearchTerm extends SearchTerm { + _DateSearchTerm(String name, DateTime value) + : super('$name ${DateCodec.encodeSearchDate(value)}'); +} + +/// Set the charset to UTF8 +class SearchTermCharsetUf8 extends SearchTerm { + /// Creates a new search term + const SearchTermCharsetUf8() : super('CHARSET "UTF-8"'); +} + +/// Searches all messages +class SearchTermAll extends SearchTerm { + /// Creates a new search term + const SearchTermAll() : super('ALL'); +} + +/// Searches for answered/replied messages +class SearchTermAnswered extends SearchTerm { + /// Creates a new search term + const SearchTermAnswered() : super('ANSWERED'); +} + +/// Searches for messages with a BCC recipient that matches +class SearchTermBcc extends _TextSearchTerm { + /// Creates a new search term + SearchTermBcc(String recipientPart) : super('BCC', recipientPart); +} + +/// Searches for messages stored before the given date. +class SearchTermBefore extends _DateSearchTerm { + /// Creates a new search term + SearchTermBefore(DateTime dateTime) : super('BEFORE', dateTime); +} + +/// Searches in the body of messages. +/// This is usually a long lasting operation. +class SearchTermBody extends _TextSearchTerm { + /// Creates a new search term + SearchTermBody(String match) : super('BODY', match); +} + +/// Searches for messages with a matching recipient on CC +class SearchTermCc extends _TextSearchTerm { + /// Creates a new search term + SearchTermCc(String recipientPart) : super('CC', recipientPart); +} + +/// Searches for deleted messages +class SearchTermDeleted extends SearchTerm { + /// Creates a new search term + const SearchTermDeleted() : super('DELETED'); +} + +/// Searches for draft messages +class SearchTermDraft extends SearchTerm { + /// Creates a new search term + const SearchTermDraft() : super('DRAFT'); +} + +/// Searches for flagged messages +class SearchTermFlagged extends SearchTerm { + /// Creates a new search term + const SearchTermFlagged() : super('FLAGGED'); +} + +/// Searches for messages where the sender matches the senderPart +class SearchTermFrom extends _TextSearchTerm { + /// Creates a new search term + SearchTermFrom(String senderPart) : super('FROM', senderPart); +} + +/// Searches for messages with the given header +class SearchTermHeader extends _TextSearchTerm { + /// Creates a new search term + SearchTermHeader(String headerName, {String? headerValue}) + : super('HEADER $headerName', headerValue); +} + +/// Searches for messages flagged with the given keyword +class SearchTermKeyword extends SearchTerm { + /// Creates a new search term + const SearchTermKeyword(String keyword) : super('KEYWORD $keyword'); +} + +/// Searches for messages that are bigger than the given size +class SearchTermLarger extends SearchTerm { + /// Creates a new search term + const SearchTermLarger(int bytes) : super('LARGER $bytes'); +} + +/// Searches for new messages +class SearchTermNew extends SearchTerm { + /// Creates a new search term + const SearchTermNew() : super('NEW'); +} + +/// Negates the given search term +class SearchTermNot extends SearchTerm { + /// Creates a new search term + SearchTermNot(SearchTerm term) : super('NOT ${term.term}'); +} + +/// Searches for old messages +class SearchTermOld extends SearchTerm { + /// Creates a new search term + const SearchTermOld() : super('OLD'); +} + +/// Searches for message stored at the given day +class SearchTermOn extends _DateSearchTerm { + /// Creates a new search term + SearchTermOn(DateTime dateTime) : super('ON', dateTime); +} + +/// Combines two atomic search terms in an OR way +/// Note that you cannot nest an OR term into another OR term +class SearchTermOr extends SearchTerm { + /// Creates a new search term + SearchTermOr(SearchTerm term1, SearchTerm term2) + : super(_merge(term1, term2)); + static String _merge(SearchTerm term1, SearchTerm term2) { + if (term1 is SearchTermOr || term2 is SearchTermOr) { + throw InvalidArgumentException('You cannot nest several OR search terms'); + } + + return 'OR ${term1.term} ${term2.term}'; + } +} + +/// Searches for recent messages +class SearchTermRecent extends SearchTerm { + /// Creates a new search term + const SearchTermRecent() : super('RECENT'); +} + +/// Searches for seen / read messages +class SearchTermSeen extends SearchTerm { + /// Creates a new search term + const SearchTermSeen() : super('SEEN'); +} + +/// Searches for messages sent before the given date +class SearchTermSentBefore extends _DateSearchTerm { + /// Creates a new search term + SearchTermSentBefore(DateTime dateTime) : super('SENTBEFORE', dateTime); +} + +/// Searches for message sent at the given day +class SearchTermSentOn extends _DateSearchTerm { + /// Creates a new search term + SearchTermSentOn(DateTime dateTime) : super('SENTON', dateTime); +} + +/// Searches message sent after the given time +class SearchTermSentSince extends _DateSearchTerm { + /// Creates a new search term + SearchTermSentSince(DateTime dateTime) : super('SENTSINCE', dateTime); +} + +/// Searches for messages stored after the given time +class SearchTermSince extends _DateSearchTerm { + /// Creates a new search term + SearchTermSince(DateTime dateTime) : super('SINCE', dateTime); +} + +/// Searches messages with a size less than given +class SearchTermSmaller extends SearchTerm { + /// Creates a new search term + const SearchTermSmaller(int bytes) : super('SMALLER $bytes'); +} + +/// Searches for messages with a matching subject +class SearchTermSubject extends _TextSearchTerm { + /// Creates a new search term + SearchTermSubject(String subjectPart) : super('SUBJECT', subjectPart); +} + +/// Searches any text header +class SearchTermText extends _TextSearchTerm { + /// Creates a new search term + SearchTermText(String textPart) : super('TEXT', textPart); +} + +/// Searches for recipients +class SearchTermTo extends _TextSearchTerm { + /// Creates a new search term + SearchTermTo(String recipientPart) : super('TO', recipientPart); +} + +/// Searches for the given UID messages +class UidSearchTerm extends SearchTerm { + /// Creates a new search term + UidSearchTerm(MessageSequence sequence) : super('UID $sequence'); +} + +/// Searches messages without the replied flag +class SearchTermUnanswered extends SearchTerm { + /// Creates a new search term + const SearchTermUnanswered() : super('UNANSWERED'); +} + +/// Searches messages that are not deleted +class SearchTermUndeleted extends SearchTerm { + /// Creates a new search term + const SearchTermUndeleted() : super('UNDELETED'); +} + +/// Searches for messages that carry no draft flag +class SearchTermUndraft extends SearchTerm { + /// Creates a new search term + const SearchTermUndraft() : super('UNDRAFT'); +} + +/// Search for not flagged messages +class SearchTermUnflagged extends SearchTerm { + /// Creates a new search term + const SearchTermUnflagged() : super('UNFLAGGED'); +} + +/// Searches for messages without the keyword +class SearchTermUnkeyword extends SearchTerm { + /// Creates a new search term + const SearchTermUnkeyword(String keyword) : super('UNKEYWORD $keyword'); +} + +/// Searches for unseen messages +class SearchTermUnseen extends SearchTerm { + /// Creates a new search term + const SearchTermUnseen() : super('UNSEEN'); +} diff --git a/test/imap/imap_quoting_test.dart b/test/imap/imap_quoting_test.dart new file mode 100644 index 00000000..97e73695 --- /dev/null +++ b/test/imap/imap_quoting_test.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/util/client_base.dart'; +import 'package:test/test.dart'; + +import '../mock_socket.dart'; +import 'mock_imap_server.dart'; + +late ImapClient client; +late MockImapServer mockServer; + +Future setUpClient({bool login = true}) async { + client = ImapClient(); + final connection = MockConnection(); + client.connect( + connection.socketClient, + connectionInformation: const ConnectionInfo( + 'imaptest.enough.de', + 993, + isSecure: true, + ), + ); + mockServer = MockImapServer(connection.socketServer); + connection.socketServer.write( + '* OK [CAPABILITY IMAP4rev1 UIDPLUS] IMAP server ready\r\n', + ); + await Future.delayed(const Duration(milliseconds: 15)); + if (login) { + mockServer.response = ' OK LOGIN completed'; + await client.login('user', 'secret'); + mockServer.response = + '* LIST (\\HasNoChildren) "/" "INBOX"\r\n OK LIST completed'; + await client.listMailboxes(); + mockServer.requests.clear(); + } +} + +void main() { + group('LOGIN', () { + test('escapes quotes and backslashes', () async { + await setUpClient(login: false); + mockServer.response = ' OK LOGIN completed'; + await client.login('us"er', r'pa"ss\word'); + expect( + mockServer.requests.single, + 'a0 LOGIN "us\\"er" "pa\\"ss\\\\word"\r\n', + ); + }); + + test('sends non-ASCII credentials as literals', () async { + await setUpClient(login: false); + mockServer.response = '+ go on\r\n OK LOGIN completed'; + await client.login('user', 'pässwörd'); + // the mock server records the raw bytes as Latin-1 text: + final literal = String.fromCharCodes(utf8.encode('pässwörd')); + expect(mockServer.requests, ['a0 LOGIN "user" {10}\r\n', '$literal\r\n']); + }); + + test('line breaks in credentials cannot inject commands', () async { + await setUpClient(login: false); + mockServer.response = '+ go on\r\n OK LOGIN completed'; + await client.login('user', 'x\r\na1 DELETE INBOX'); + expect(mockServer.requests.first, 'a0 LOGIN "user" {18}\r\n'); + expect(mockServer.requests, hasLength(2)); + }); + }); + + test('mailbox names are quoted and escaped', () async { + await setUpClient(); + mockServer.response = '* 1 EXISTS\r\n OK [READ-WRITE] done'; + await client.selectMailboxByPath(r'Foo"Bar\Baz (1)'); + expect(mockServer.requests.single, 'a2 SELECT "Foo\\"Bar\\\\Baz (1)"\r\n'); + expect( + () => client.selectMailboxByPath('INBOX\r\na3 DELETE INBOX'), + throwsArgumentError, + ); + }); + + group('SEARCH', () { + test('quoted search values escape backslashes and quotes', () { + final query = SearchQueryBuilder.from(r'a\b"c', SearchQueryType.subject); + expect(query.toString(), 'SUBJECT "a\\\\b\\"c"'); + }); + + test('control characters force a literal', () { + final query = SearchQueryBuilder.from( + 'line\r\nbreak', + SearchQueryType.subject, + ); + expect(query.toString(), 'CHARSET "UTF-8" SUBJECT {11}\nline\r\nbreak'); + }); + + test('literals with line feeds are sent as one continuation', () async { + await setUpClient(); + mockServer.response = '+ go on\r\n* SEARCH 1\r\n OK SEARCH done'; + final result = await client.searchMessages( + searchCriteria: 'SUBJECT {5}\nab\ncd FROM "x"', + ); + expect(result.matchingSequence?.toList(), [1]); + expect(mockServer.requests, [ + 'a2 SEARCH SUBJECT {5}\r\n', + 'ab\ncd FROM "x"\r\n', + ]); + }); + }); +} From b184b6a516ddae72fc2a0fbefb126f9074948872 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:27:38 +0200 Subject: [PATCH 17/33] fix(mime): decode untrusted content without throwing and pick the right parts The quoted-printable text decoder indexed past the end on truncated input and used int.parse on non-hex data, the base64 decoder threw on any non-alphabet character or bad padding, and both were reached without a guard from file name and header decoding; they are now lenient as RFC 2045 requires, and an undecodable encoded word is kept verbatim. Binary bodies with 7bit/8bit/binary transfer encoding are returned byte for byte instead of going through a UTF-8 round trip that rejected or corrupted non-ASCII bytes, and quoted-printable attachments are decoded on the binary path. getPartWithContentId() compares the normalised content id, the text part lookup skips attached messages and text attachments, and header parameters are split at unquoted semicolons only, trimmed and decoded according to RFC 2231. Co-Authored-By: Claude Fable 5.1 --- lib/src/codecs/base64_mail_codec.dart | 54 ++++--- lib/src/codecs/mail_codec.dart | 10 +- .../codecs/quoted_printable_mail_codec.dart | 83 +++++++---- lib/src/mime_data.dart | 25 ++-- lib/src/mime_message.dart | 116 ++++++++++++++- test/mime_robustness_test.dart | 134 ++++++++++++++++++ 6 files changed, 356 insertions(+), 66 deletions(-) create mode 100644 test/mime_robustness_test.dart diff --git a/lib/src/codecs/base64_mail_codec.dart b/lib/src/codecs/base64_mail_codec.dart index c3968aac..2adc1bf0 100644 --- a/lib/src/codecs/base64_mail_codec.dart +++ b/lib/src/codecs/base64_mail_codec.dart @@ -119,32 +119,48 @@ class Base64MailCodec extends MailCodec { } } + /// Decodes base64 [part] data leniently. + /// + /// RFC 2045 section 6.8 requires decoders to ignore any character outside + /// of the base64 alphabet, e.g. line breaks or white space, and a missing + /// padding is added, so that broken input does not throw. @override Uint8List decodeData(final String part) { - var cleaned = part.replaceAll('\r\n', ''); - var numberOfRequiredPadding = cleaned.length % 4 == 0 - ? 0 - : 4 - cleaned.length % 4; - if (numberOfRequiredPadding > 0 && cleaned.endsWith('=')) { - cleaned = cleaned.substring(0, cleaned.length - 1); - numberOfRequiredPadding = cleaned.length % 4 == 0 - ? 0 - : 4 - cleaned.length % 4; - } - if (numberOfRequiredPadding > 0) { - final buffer = StringBuffer(cleaned); - var paddingRequired = true; - while (paddingRequired) { - buffer.write('='); - numberOfRequiredPadding--; - paddingRequired = numberOfRequiredPadding > 0; + final buffer = StringBuffer(); + for (final code in part.codeUnits) { + if (_isBase64Char(code)) { + buffer.writeCharCode(code); + } else if (code == AsciiRunes.runeEquals) { + // the padding ends the data + break; } - cleaned = buffer.toString(); } + var cleaned = buffer.toString(); + final remainder = cleaned.length % 4; + if (remainder == 1) { + // a single trailing character cannot encode anything + cleaned = cleaned.substring(0, cleaned.length - 1); + } else if (remainder > 1) { + cleaned = cleaned.padRight(cleaned.length + 4 - remainder, '='); + } + try { + return base64.decode(cleaned); + } on FormatException catch (e) { + print('unable to decode base64 data: ${e.message}'); - return base64.decode(cleaned); + return Uint8List(0); + } } + static bool _isBase64Char(int code) => + (code >= AsciiRunes.runeAUpperCase && + code <= AsciiRunes.runeZUpperCase) || + (code >= AsciiRunes.runeALowerCase && + code <= AsciiRunes.runeZLowerCase) || + (code >= AsciiRunes.rune0 && code <= AsciiRunes.rune9) || + code == 43 || // + + code == AsciiRunes.runeSlash; + @override String decodeText(String part, Encoding codec, {bool isHeader = false}) { final outputList = decodeData(part); diff --git a/lib/src/codecs/mail_codec.dart b/lib/src/codecs/mail_codec.dart index 14e71c59..81c7677c 100644 --- a/lib/src/codecs/mail_codec.dart +++ b/lib/src/codecs/mail_codec.dart @@ -278,7 +278,15 @@ abstract class MailCodec { contentStartIndex, sequence.length - _encodingEndSequence.length, ); - final decoded = decoder(part, codec, isHeader: true); + String decoded; + try { + decoded = decoder(part, codec, isHeader: true); + } catch (e) { + // keep an undecodable encoded word as is instead of failing the + // whole header, the input comes from untrusted senders + print('unable to decode encoded word [$sequence]: $e'); + decoded = sequence; + } buffer.write(decoded); reminder = reminder.substring(match?.end ?? 0); } diff --git a/lib/src/codecs/quoted_printable_mail_codec.dart b/lib/src/codecs/quoted_printable_mail_codec.dart index 56620815..abd52901 100644 --- a/lib/src/codecs/quoted_printable_mail_codec.dart +++ b/lib/src/codecs/quoted_printable_mail_codec.dart @@ -200,46 +200,77 @@ class QuotedPrintableMailCodec extends MailCodec { }) { final buffer = StringBuffer(); // remove all soft-breaks: - final cleaned = part.replaceAll('=\r\n', ''); - for (var i = 0; i < cleaned.length; i++) { - final char = cleaned[i]; - if (char == '=') { - final hexText = cleaned.substring(i + 1, i + 3); - var charCode = int.tryParse(hexText, radix: 16); - if (charCode == null) { - print( - 'unable to decode quotedPrintable [$cleaned]: ' - 'invalid hex code [$hexText] at $i.', - ); - buffer.write(hexText); - } else { - final charCodes = [charCode]; - while (cleaned.length > (i + 4) && cleaned[i + 3] == '=') { - i += 3; - final hexText = cleaned.substring(i + 1, i + 3); - charCode = int.parse(hexText, radix: 16); - charCodes.add(charCode); + final cleaned = part.replaceAll('=\r\n', '').replaceAll('=\n', ''); + var i = 0; + while (i < cleaned.length) { + final char = cleaned.codeUnitAt(i); + if (char == AsciiRunes.runeEquals) { + // collect consecutive =XX sequences so that multi-byte characters + // are decoded together + final bytes = []; + while (i < cleaned.length && + cleaned.codeUnitAt(i) == AsciiRunes.runeEquals) { + final byte = _decodeHexByte(cleaned, i + 1); + if (byte == null) { + break; } - + bytes.add(byte); + i += 3; + } + if (bytes.isNotEmpty) { try { - final decoded = codec.decode(charCodes); - buffer.write(decoded); + buffer.write(codec.decode(bytes)); } on FormatException catch (err) { print('unable to decode quotedPrintable buffer: ${err.message}'); - buffer.write(String.fromCharCodes(charCodes)); + buffer.write(String.fromCharCodes(bytes)); } + } else { + // RFC 2045 section 6.7: a "=" that is not followed by two hex digits + // is invalid and is kept as is (robustness) + buffer.writeCharCode(char); + i++; } - i += 2; - } else if (isHeader && char == '_') { + } else if (isHeader && char == AsciiRunes.runeUnderline) { buffer.write(' '); + i++; } else { - buffer.write(char); + buffer.writeCharCode(char); + i++; } } return buffer.toString(); } + /// Decodes the two hex digits at [index] of [text], or returns `null` if + /// there are none. + static int? _decodeHexByte(String text, int index) { + if (index + 1 >= text.length) { + return null; + } + final high = _hexValue(text.codeUnitAt(index)); + final low = _hexValue(text.codeUnitAt(index + 1)); + if (high == null || low == null) { + return null; + } + + return (high << 4) | low; + } + + static int? _hexValue(int code) { + if (code >= 48 && code <= 57) { + return code - 48; + } + if (code >= 65 && code <= 70) { + return code - 55; + } + if (code >= 97 && code <= 102) { + return code - 87; + } + + return null; + } + int _writeQuotedPrintable(int rune, StringBuffer buffer, Codec codec) { List encoded; if (rune < 128) { diff --git a/lib/src/mime_data.dart b/lib/src/mime_data.dart index f6463326..8fff82c5 100644 --- a/lib/src/mime_data.dart +++ b/lib/src/mime_data.dart @@ -415,20 +415,19 @@ class BinaryMimeData extends MimeData { @override Uint8List decodeBinary(String? contentTransferEncoding) { - final contentTransferEncodingLC = contentTransferEncoding?.toLowerCase(); - if (_bodyStartIndex == null || - // do not try to decode textual content: - contentTransferEncodingLC == '7bit' || - contentTransferEncodingLC == '8bit' || - contentTransferEncodingLC == 'quoted-printable') { - return _bodyData; + if (_bodyStartIndex == null) { + return Uint8List(0); + } + switch (contentTransferEncoding?.toLowerCase()) { + case 'base64': + return MailCodec.base64.decodeData(latin1.decode(_bodyData)); + case 'quoted-printable': + return MailCodec.quotedPrintable.decodeData(latin1.decode(_bodyData)); + default: + // 7bit, 8bit, binary or none: the body bytes are the payload; a UTF-8 + // round trip would corrupt or reject any non-ASCII byte + return _bodyData; } - // even with a 'binary' content transfer encoding there are \r\n - // characters that need to be handled, - // so translate to text first - final dataText = utf8.decode(_bodyData); - - return MailCodec.decodeBinary(dataText, contentTransferEncodingLC); } List
_parseHeader() { diff --git a/lib/src/mime_message.dart b/lib/src/mime_message.dart index 4cf918a5..3b04686c 100644 --- a/lib/src/mime_message.dart +++ b/lib/src/mime_message.dart @@ -1,5 +1,6 @@ // ignore_for_file: avoid_returning_this +import 'dart:convert'; import 'dart:typed_data'; import 'package:collection/collection.dart' show IterableExtension; @@ -420,6 +421,13 @@ class MimePart { final parts = part.parts; if (parts != null) { for (final childPart in parts) { + // the text of an attached or forwarded message is not the text of + // this message, and neither is an attached text file + if (childPart.mediaType.isMessage || + childPart.getHeaderContentDisposition()?.disposition == + ContentDisposition.attachment) { + continue; + } final decoded = _decodeTextPart(childPart, subtype); if (decoded != null) { return decoded; @@ -1217,7 +1225,7 @@ class MimeMessage extends MimePart { final allParts = allPartsFlat; for (final part in allParts) { final partCid = part._getLowerCaseHeaderValue('content-id'); - if (partCid != null && partCid.toLowerCase() == cid) { + if (partCid != null && partCid.toLowerCase() == contentId) { return part; } } @@ -1832,21 +1840,115 @@ class Envelope { /// A parameter that may contain additional parameters class ParameterizedHeader { /// Creates a new header with the given [rawValue] + /// + /// Parameters are separated at `;` outside of quoted strings, so a quoted + /// value such as `filename="a;b.txt"` stays intact. RFC 2231 parameter + /// continuations (`name*0=`, `name*1=`) and character set encoded values + /// (`name*=utf-8''%E2%82%AC.txt`) are decoded into the plain parameter. ParameterizedHeader(this.rawValue) { - final elements = rawValue.split(';'); - value = elements[0]; + final elements = _splitParameters(rawValue); + value = elements.first.trim(); + final sections = >{}; + final charsets = {}; for (var i = 1; i < elements.length; i++) { final element = elements[i].trim(); + if (element.isEmpty) { + continue; + } final splitPos = element.indexOf('='); if (splitPos == -1) { parameters[element.toLowerCase()] = ''; + continue; + } + var name = element.substring(0, splitPos).trim().toLowerCase(); + var value = removeQuotes(element.substring(splitPos + 1).trim()); + final isEncoded = name.endsWith('*'); + if (isEncoded) { + name = name.substring(0, name.length - 1); + } + int? section; + final sectionStart = name.lastIndexOf('*'); + if (sectionStart != -1) { + section = int.tryParse(name.substring(sectionStart + 1)); + if (section != null) { + name = name.substring(0, sectionStart); + } + } + if (isEncoded) { + var charset = charsets[name]; + if (section == null || section == 0) { + // the first section carries charset'language'value + final charsetEnd = value.indexOf("'"); + final languageEnd = charsetEnd == -1 + ? -1 + : value.indexOf("'", charsetEnd + 1); + if (languageEnd != -1) { + charset = value.substring(0, charsetEnd); + charsets[name] = charset; + value = value.substring(languageEnd + 1); + } + } + value = _decodeRfc2231Value(value, charset); + } + if (section != null) { + (sections[name] ??= {})[section] = value; } else { - final name = element.substring(0, splitPos).toLowerCase(); - final value = element.substring(splitPos + 1); - final valueWithoutQuotes = removeQuotes(value); - parameters[name] = valueWithoutQuotes; + parameters[name] = value; } } + for (final entry in sections.entries) { + final indices = entry.value.keys.toList()..sort(); + parameters[entry.key] = [ + for (final index in indices) entry.value[index]!, + ].join(); + } + } + + /// Splits [text] at every `;` that is not part of a quoted string. + static List _splitParameters(String text) { + final result = []; + var isInQuotes = false; + var isEscaped = false; + var start = 0; + for (var i = 0; i < text.length; i++) { + final code = text.codeUnitAt(i); + if (isEscaped) { + isEscaped = false; + } else if (isInQuotes && code == AsciiRunes.runeBackslash) { + isEscaped = true; + } else if (code == AsciiRunes.runeDoubleQuote) { + isInQuotes = !isInQuotes; + } else if (code == AsciiRunes.runeSemicolon && !isInQuotes) { + result.add(text.substring(start, i)); + start = i + 1; + } + } + result.add(text.substring(start)); + + return result; + } + + /// Percent-decodes an RFC 2231 [value] and converts it from [charset]. + static String _decodeRfc2231Value(String value, String? charset) { + final bytes = []; + for (var i = 0; i < value.length; i++) { + final code = value.codeUnitAt(i); + if (code == 37 /* % */ && i + 2 < value.length) { + final byte = int.tryParse(value.substring(i + 1, i + 3), radix: 16); + if (byte != null) { + bytes.add(byte); + i += 2; + continue; + } + } + bytes.addAll(utf8.encode(value[i])); + } + + return MailCodec.decodeAsText( + Uint8List.fromList(bytes), + '8bit', + charset ?? 'utf-8', + ); } /// The raw value of the header diff --git a/test/mime_robustness_test.dart b/test/mime_robustness_test.dart new file mode 100644 index 00000000..3448d6a9 --- /dev/null +++ b/test/mime_robustness_test.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +void main() { + group('quoted-printable', () { + test('malformed sequences never throw', () { + const qp = MailCodec.quotedPrintable; + expect(qp.decodeText('Hello=', utf8), 'Hello='); + expect(qp.decodeText('Hello=A', utf8), 'Hello=A'); + expect(qp.decodeText('=C3=A', utf8), 'Ã=A'); + expect(qp.decodeText('=C3=ZZ', utf8), 'Ã=ZZ'); + expect(qp.decodeText('x=-1y', utf8), 'x=-1y'); + expect(qp.decodeText('=C3=A4', utf8), 'ä'); + expect(qp.decodeText('a=\r\nb=\nc', utf8), 'abc'); + }); + + test('malformed encoded words in headers are kept as is', () { + expect(MailCodec.decodeHeader('=?utf-8?Q?abc=?='), 'abc='); + expect(MailCodec.decodeHeader('=?utf-8?B?!!!!?='), ''); + expect(MailCodec.decodeHeader('=?utf-8?Q?=C3=A4?='), 'ä'); + }); + }); + + group('base64', () { + test('ignores invalid characters and fixes padding', () { + const b64 = MailCodec.base64; + expect(b64.decodeText('aGVs bG8=', utf8), 'hello'); + expect(b64.decodeText('aGVs\nbG8', utf8), 'hello'); + expect(b64.decodeText('aGVsbG8=trailing', utf8), 'hello'); + expect(b64.decodeText('!!!!', utf8), ''); + expect(b64.decodeText('A', utf8), ''); + }); + + test('bare LF base64 attachments decode from binary data', () { + const text = + 'Content-Type: application/octet-stream\r\n' + 'Content-Transfer-Encoding: base64\r\n\r\n' + 'aGVs\nbG8=\r\n'; + final message = MimeMessage.parseFromData(utf8.encode(text)); + expect(utf8.decode(message.decodeContentBinary()!), 'hello'); + }); + }); + + group('binary transfer encodings', () { + test('binary and 8bit bodies are returned byte for byte', () { + final header = utf8.encode( + 'Content-Type: application/octet-stream\r\n' + 'Content-Transfer-Encoding: binary\r\n\r\n', + ); + final body = [0xFF, 0xC3, 0xA9, 0x00, 0x0D, 0x0A]; + final message = MimeMessage.parseFromData( + Uint8List.fromList([...header, ...body]), + ); + expect(message.decodeContentBinary(), body); + }); + + test('quoted-printable attachments are decoded from binary data', () { + const text = + 'Content-Type: application/pdf\r\n' + 'Content-Transfer-Encoding: quoted-printable\r\n\r\n' + '=25PDF=2D1=2E7\r\n'; + final message = MimeMessage.parseFromData(utf8.encode(text)); + expect( + String.fromCharCodes(message.decodeContentBinary()!), + startsWith('%PDF-1.7'), + ); + }); + }); + + group('parts', () { + test('getPartWithContentId accepts any cid notation', () { + const text = + 'Content-Type: multipart/related; boundary="b"\r\n\r\n' + '--b\r\nContent-Type: text/html\r\n\r\n\r\n' + '--b\r\nContent-Type: image/png\r\nContent-ID: \r\n\r\nx\r\n' + '--b--\r\n'; + final message = MimeMessage.parseFromText(text); + expect(message.getPartWithContentId('Image1@foo'), isNotNull); + expect(message.getPartWithContentId(''), isNotNull); + expect(message.getPartWithContentId(''), isNotNull); + }); + + test('text lookup ignores attached messages and text attachments', () { + const text = + 'Content-Type: multipart/mixed; boundary="b"\r\n\r\n' + '--b\r\nContent-Type: text/html\r\n\r\n

outer

\r\n' + '--b\r\nContent-Type: message/rfc822\r\n\r\n' + 'Content-Type: text/plain\r\n\r\nforwarded text\r\n' + '--b\r\nContent-Type: text/plain\r\n' + 'Content-Disposition: attachment; filename="notes.txt"\r\n\r\n' + 'attached notes\r\n' + '--b--\r\n'; + final message = MimeMessage.parseFromText(text); + expect(message.decodeTextHtmlPart(), '

outer

\r\n'); + expect(message.decodeTextPlainPart(), isNull); + }); + }); + + group('header parameters', () { + test('are split at unquoted semicolons only and trimmed', () { + final header = ContentDispositionHeader( + 'attachment; filename="a;b.txt" ; size=3', + ); + expect(header.filename, 'a;b.txt'); + expect(header.size, 3); + final contentType = ContentTypeHeader('text/plain ; charset=utf-8'); + expect(contentType.mediaType.sub, MediaSubtype.textPlain); + expect(contentType.charset, 'utf-8'); + }); + + test('decodes RFC 2231 encoded and continued parameters', () { + final header = ContentDispositionHeader( + "attachment; filename*=UTF-8''%E2%82%AC%20rates.txt", + ); + expect(header.filename, '€ rates.txt'); + final continued = ContentDispositionHeader( + "attachment; filename*0*=iso-8859-1''Gr%FC%DFe%20; " + 'filename*1*=aus%20; filename*2="München.txt"', + ); + expect(continued.filename, 'Grüße aus München.txt'); + }); + + test('a message/rfc822 part with astral display names parses', () { + const text = + 'From: "😀 Bob" \r\nSubject: hi\r\n\r\nbody'; + final message = MimeMessage.parseFromText(text); + expect(message.from?.first.email, 'bob@example.com'); + expect(message.from?.first.personalName, '😀 Bob'); + }); + }); +} From 7ca2971697aae59f2db2fd22384ccf2925d91e9c Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:29:25 +0200 Subject: [PATCH 18/33] fix(codecs): correct date zones, obsolete years, header encoders and folding Date headers rendered half-hour zones west of Greenwich as '-03-30' and UTC as '-0000' (RFC 5322 reserves that for 'no zone information'); the offset is now rendered as signed hours and unsigned minutes with '+0000' for UTC. Two-digit years follow the RFC 5322 4.3 rule (50-99 are 19xx), three-digit years get 1900 added and absurd years are rejected. The Q and B header encoders mixed rune and UTF-16 indices, so display names with astral characters lost their closing '?=' or produced replacement characters and lone surrogates. Header folding no longer breaks a line inside an encoded word. Co-Authored-By: Claude Fable 5.1 --- lib/src/codecs/base64_mail_codec.dart | 18 ++-- lib/src/codecs/date_codec.dart | 39 ++++----- .../codecs/quoted_printable_mail_codec.dart | 3 +- lib/src/mime_message.dart | 17 ++++ test/codecs/date_codec_test.dart | 2 +- test/codecs/encoding_correctness_test.dart | 83 +++++++++++++++++++ 6 files changed, 129 insertions(+), 33 deletions(-) create mode 100644 test/codecs/encoding_correctness_test.dart diff --git a/lib/src/codecs/base64_mail_codec.dart b/lib/src/codecs/base64_mail_codec.dart index 2adc1bf0..79106cf6 100644 --- a/lib/src/codecs/base64_mail_codec.dart +++ b/lib/src/codecs/base64_mail_codec.dart @@ -41,7 +41,7 @@ class Base64MailCodec extends MailCodec { int nameLength = 0, bool fromStart = false, }) { - final runes = List.from(text.runes, growable: false); + final runes = text.runes.toList(growable: false); var numberOfRunesAbove7Bit = 0; var startIndex = -1; var endIndex = -1; @@ -65,7 +65,7 @@ class Base64MailCodec extends MailCodec { const qpWordDelimiterSize = qpWordHead.length + qpWordTail.length; if (fromStart) { startIndex = 0; - endIndex = text.length - 1; + endIndex = runes.length - 1; } // Available space for the current encoded word var qpWordSize = @@ -74,12 +74,14 @@ class Base64MailCodec extends MailCodec { startIndex - (nameLength + 2); final buffer = StringBuffer(); + // startIndex and endIndex are rune indices, so slice the runes and not + // the UTF-16 code units of the text (which differ for e.g. emojis) if (startIndex > 0) { - buffer.write(text.substring(0, startIndex)); + buffer.write(String.fromCharCodes(runes.sublist(0, startIndex))); } - final textToEncode = fromStart - ? text - : text.substring(startIndex, endIndex + 1); + final textToEncode = String.fromCharCodes( + runes.sublist(startIndex, endIndex + 1), + ); final encoded = encodeText(textToEncode, wrap: false); buffer.write(qpWordHead); if (encoded.length < qpWordSize) { @@ -111,8 +113,8 @@ class Base64MailCodec extends MailCodec { } } buffer.write(qpWordTail); - if (endIndex < text.length - 1) { - buffer.write(text.substring(endIndex + 1)); + if (endIndex < runes.length - 1) { + buffer.write(String.fromCharCodes(runes.sublist(endIndex + 1))); } return buffer.toString(); diff --git a/lib/src/codecs/date_codec.dart b/lib/src/codecs/date_codec.dart index f0d2909d..8759cc46 100644 --- a/lib/src/codecs/date_codec.dart +++ b/lib/src/codecs/date_codec.dart @@ -333,27 +333,15 @@ Date and time values occur in several header fields. This section ..write(':') ..write(dateTime.second.toString().padLeft(2, '0')) ..write(' '); - if (dateTime.timeZoneOffset.inMinutes > 0) { - buffer.write('+'); - } else { - buffer.write('-'); - } - final hours = dateTime.timeZoneOffset.inHours; - if (hours < 10 && hours > -10) { - buffer.write('0'); - } - buffer.write(hours.abs()); - final minutes = - dateTime.timeZoneOffset.inMinutes - - (dateTime.timeZoneOffset.inHours * 60); - if (minutes == 0) { - buffer.write('00'); - } else { - if (minutes < 10 && minutes > -10) { - buffer.write('0'); - } - buffer.write(minutes); - } + final offset = dateTime.timeZoneOffset; + // RFC 5322 section 3.3: UTC is written as "+0000", "-0000" would state + // that the zone is unknown. Minutes are rendered unsigned, so that + // half-hour zones west of Greenwich yield e.g. "-0330" and not "-03-30". + buffer.write(offset.isNegative ? '-' : '+'); + final totalMinutes = offset.inMinutes.abs(); + buffer + ..write((totalMinutes ~/ 60).toString().padLeft(2, '0')) + ..write((totalMinutes % 60).toString().padLeft(2, '0')); return buffer.toString(); } @@ -494,8 +482,13 @@ Date and time values occur in several header fields. This section return null; } - final year = int.tryParse(yearText.length == 2 ? '20$yearText' : yearText); - if (year == null) { + var year = int.tryParse(yearText); + if (year != null && yearText.length <= 3) { + // RFC 5322 section 4.3 obsolete two and three digit years: + // 00-49 -> 20xx, 50-99 -> 19xx, three digits -> 1900 + value + year += yearText.length == 2 ? (year < 50 ? 2000 : 1900) : 1900; + } + if (year == null || year < 0 || year > 9999) { print('Invalid year $yearText in date $dateText'); return null; diff --git a/lib/src/codecs/quoted_printable_mail_codec.dart b/lib/src/codecs/quoted_printable_mail_codec.dart index abd52901..6b36b7e2 100644 --- a/lib/src/codecs/quoted_printable_mail_codec.dart +++ b/lib/src/codecs/quoted_printable_mail_codec.dart @@ -103,7 +103,8 @@ class QuotedPrintableMailCodec extends MailCodec { const qpWordDelimiterSize = qpWordHead.length + qpWordTail.length; if (fromStart) { startIndex = 0; - endIndex = text.length - 1; + // the loop below works on runes, not on UTF-16 code units + endIndex = runeCount - 1; } // Available space for the current encoded word var qpWordSize = diff --git a/lib/src/mime_message.dart b/lib/src/mime_message.dart index 3b04686c..0972d19e 100644 --- a/lib/src/mime_message.dart +++ b/lib/src/mime_message.dart @@ -1401,6 +1401,8 @@ class Header { @override String toString() => '$name: $value'; + static final _encodedWordPattern = RegExp(r'=\?[^?]*\?[^?]*\?[^?]*\?='); + /// Renders this header into a the [buffer] wrapping it if necessary. void render(StringBuffer buffer) { final value = this.value; @@ -1411,6 +1413,15 @@ class Header { return; } + // RFC 2047: an encoded word must not be split by folding + final encodedWords = value.contains('=?') + ? _encodedWordPattern + .allMatches(value) + .map((match) => (match.start, match.end)) + .toList() + : const <(int, int)>[]; + bool isInsideEncodedWord(int index) => + encodedWords.any((range) => index >= range.$1 && index < range.$2 - 1); final totalLength = value.length; var currentLineLength = name.length + ': '.length; buffer @@ -1435,6 +1446,9 @@ class Header { } var foundFoldingPoint = false; for (var i = startIndex + chunkLength; i > startIndex; i--) { + if (isInsideEncodedWord(i)) { + continue; + } final char = value.codeUnitAt(i); if (char == AsciiRunes.runeSemicolon || char == AsciiRunes.runeSpace || @@ -1462,6 +1476,9 @@ class Header { } break; } + if (isInsideEncodedWord(i)) { + continue; + } final char = value.codeUnitAt(i); if (char == AsciiRunes.runeSemicolon || char == AsciiRunes.runeSpace || diff --git a/test/codecs/date_codec_test.dart b/test/codecs/date_codec_test.dart index 9a80bf38..3df2cbb4 100644 --- a/test/codecs/date_codec_test.dart +++ b/test/codecs/date_codec_test.dart @@ -10,7 +10,7 @@ void main() { test('encodeDate for UTC DateTime', () { expect( DateCodec.encodeDate(DateTime.utc(2022, 1, 7, 22, 18)), - 'Fri, 07 Jan 2022 22:18:00 -0000', + 'Fri, 07 Jan 2022 22:18:00 +0000', ); }); test('encodeDate for DateTime east of Greenwich', () { diff --git a/test/codecs/encoding_correctness_test.dart b/test/codecs/encoding_correctness_test.dart new file mode 100644 index 00000000..10740551 --- /dev/null +++ b/test/codecs/encoding_correctness_test.dart @@ -0,0 +1,83 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; + +void main() { + setUpAll(tz.initializeTimeZones); + + group('DateCodec', () { + test('renders half-hour zones west of Greenwich correctly', () { + final date = tz.TZDateTime( + tz.getLocation('America/St_Johns'), + 2022, + 1, + 7, + 22, + 18, + ); + expect(DateCodec.encodeDate(date), 'Fri, 07 Jan 2022 22:18:00 -0330'); + final india = tz.TZDateTime( + tz.getLocation('Asia/Kolkata'), + 2022, + 1, + 7, + 22, + 18, + ); + expect(DateCodec.encodeDate(india), 'Fri, 07 Jan 2022 22:18:00 +0530'); + }); + + test('applies the obsolete two and three digit year rules', () { + expect(DateCodec.decodeDate('1 Jan 99 00:00:00 +0000')?.year, 1999); + expect(DateCodec.decodeDate('1 Jan 49 00:00:00 +0000')?.year, 2049); + expect(DateCodec.decodeDate('1 Jan 105 00:00:00 +0000')?.year, 2005); + expect(DateCodec.decodeDate('1 Jan 99999999 00:00:00 +0000'), isNull); + }); + }); + + group('header encoding', () { + test('Q encoding of names with astral characters is complete', () { + const address = MailAddress('Bob 😀', 'b@example.com'); + final encoded = address.encode(); + expect(encoded, endsWith('?=" ')); + expect(MailAddress.parse(encoded).personalName, 'Bob 😀'); + }); + + test('B encoding keeps surrounding text and astral characters', () { + final encoded = MailCodec.base64.encodeHeader('a😀b', nameLength: 7); + expect(encoded, isNot(contains('�'))); + expect(MailCodec.decodeHeader(encoded), 'a😀b'); + }); + + test('folding never splits an encoded word', () { + final message = + (MessageBuilder() + ..from = const [MailAddress('Me', 'me@example.com')] + ..to = [ + for (var i = 0; i < 4; i++) + MailAddress( + 'Müller, Hans-Jürgen (Vertrieb $i)', + 'hans.$i@example.com', + ), + ] + ..subject = 'x' + ..addTextPlain('Body')) + .buildMimeMessage(); + final rendered = message.renderMessage(); + final headerSection = rendered.substring(rendered.indexOf('\r\n\r\n')); + for (final line + in rendered + .substring(0, rendered.length - headerSection.length) + .split('\r\n')) { + expect( + '=?'.allMatches(line).length, + '?='.allMatches(line).length, + reason: 'encoded word split across lines in [$line]', + ); + } + expect(message.to?.length, 4); + expect(message.to?.last.personalName, 'Müller, Hans-Jürgen (Vertrieb 3)'); + }); + }); +} From 80e02429fae867e11e4af30f0b54406be5a68d2b Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:31:48 +0200 Subject: [PATCH 19/33] fix: secure random ids, exact plus-alias matching and a usable DKIM signer Message-IDs and multipart boundaries are generated from Random.secure() where available. Plus-alias matching required only a prefix match, so 'ad+min@example.com' was treated as 'admin@example.com'; it now requires the exact base address. The DKIM signer hashed only the first 72 UTF-16 code units of the body (throwing for shorter bodies and leaving the rest unsigned), signed only the From header and duplicated the text part because buildMimeMessage() was not idempotent; it now hashes the whole canonicalized body, signs From/To/Cc/Subject/Date/Message-ID/ MIME-Version/Content-Type in h= order, and building twice keeps a single text part. Co-Authored-By: Claude Fable 5.1 --- lib/src/mail_address.dart | 16 +- lib/src/message_builder.dart | 22 +- lib/src/private/util/mail_signature.dart | 244 ++++++++++++----------- test/mail_signature_test.dart | 95 +++++++++ 4 files changed, 250 insertions(+), 127 deletions(-) create mode 100644 test/mail_signature_test.dart diff --git a/lib/src/mail_address.dart b/lib/src/mail_address.dart index ba7fbdbf..a1e37f51 100644 --- a/lib/src/mail_address.dart +++ b/lib/src/mail_address.dart @@ -220,14 +220,14 @@ class MailAddress { return check; } else if (allowPlusAlias) { final plusIndex = check.indexOf('+'); - if (plusIndex > 1) { - final start = check.substring(0, plusIndex); - if (original.startsWith(start)) { - final atIndex = check.lastIndexOf('@'); - if (atIndex > plusIndex && - original.endsWith(check.substring(atIndex))) { - return check; - } + final atIndex = check.lastIndexOf('@'); + if (plusIndex > 0 && atIndex > plusIndex) { + // `name+alias@domain` only matches exactly `name@domain`, a prefix + // check would let `ad+min@domain` impersonate `admin@domain` + final withoutAlias = + check.substring(0, plusIndex) + check.substring(atIndex); + if (withoutAlias == original) { + return check; } } } diff --git a/lib/src/message_builder.dart b/lib/src/message_builder.dart index f871e108..0e4faf76 100644 --- a/lib/src/message_builder.dart +++ b/lib/src/message_builder.dart @@ -148,6 +148,7 @@ class PartBuilder { ContentTypeHeader? contentType; final _attachments = []; + bool _isTextPartInserted = false; /// The attachments in this builder List get attachments => _attachments; @@ -1310,7 +1311,10 @@ class MessageBuilder extends PartBuilder { setHeader(MailConventions.headerReferences, references); } final text = this.text; - if (text != null && _attachments.isNotEmpty) { + if (text != null && _attachments.isNotEmpty && !_isTextPartInserted) { + // building twice, e.g. once for signing and once for sending, must not + // duplicate the text part + _isTextPartInserted = true; addTextPlain(text, transferEncoding: transferEncoding, insert: true); } _buildPart(); @@ -1723,7 +1727,7 @@ class MessageBuilder extends PartBuilder { '0123456789_abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ'; final characterRunes = characters.runes; const max = characters.length; - final random = math.Random(); + final random = _secureRandom; final buffer = StringBuffer(); for (var count = length; count > 0; count--) { final charIndex = random.nextInt(max); @@ -1734,6 +1738,20 @@ class MessageBuilder extends PartBuilder { return buffer.toString(); } + /// Message-IDs and multipart boundaries must not be guessable, so use a + /// cryptographically secure source when the platform provides one. + static final math.Random _secureRandom = _createSecureRandom(); + + static math.Random _createSecureRandom() { + try { + return math.Random.secure(); + // Random.secure() signals a missing platform source with an Error: + // ignore: avoid_catching_errors + } on UnsupportedError { + return math.Random(); + } + } + /// Fills the given [template] with values /// extracted from the provided [message]. /// diff --git a/lib/src/private/util/mail_signature.dart b/lib/src/private/util/mail_signature.dart index a112f244..de52b18c 100644 --- a/lib/src/private/util/mail_signature.dart +++ b/lib/src/private/util/mail_signature.dart @@ -1,117 +1,127 @@ -import 'dart:convert'; - -import 'package:crypto/crypto.dart'; -import 'package:encrypter_plus/encrypter_plus.dart'; -import 'package:pointycastle/pointycastle.dart' show RSAPrivateKey; - -import '../../message_builder.dart'; -import '../../mime_message.dart'; -import 'non_nullable.dart'; - -/// Extends Message Builder with signature methods -extension MailSignature on MessageBuilder { - static final RSAKeyParser _rsaKeyParser = RSAKeyParser(); - static const List _signedHeaders = [ - 'from' /*, 'to', 'mime-version'*/, - ]; - static const int _bodyLength = 72; // Fails over >76 - static const String _crlf = '\r\n'; - static const String _headerName = 'DKIM-Signature'; - - String _cleanWhiteSpaces(String target) => - target.replaceAll(RegExp(r'\s+', multiLine: true), ' '); - String _cleanLineBreaks(String target) { - final parts = target - .replaceAll(_crlf, '\n') - .replaceAll('\n', _crlf) - .split(_crlf); - - for (var i = 0; i < parts.length; i++) { - parts[i] = _cleanWhiteSpaces(parts[i]).trimRight(); - } - - return parts.join(_crlf); - } - - int get _secondsSinceEpoch => - (DateTime.now().millisecondsSinceEpoch / 1000).floor(); - - Header _createDkimHeader(String body, String? domain, String? selector) => - Header( - _headerName, - ''' - v=1; t=$_secondsSinceEpoch; - d=$domain; s=$selector; - h=${_signedHeaders.join(':')}; - q=dns/txt; - l=$_bodyLength; - c=relaxed/relaxed; a=rsa-sha256; - bh=${_hash(body.substring(0, _bodyLength))}; - b= - ''' - .replaceAll(RegExp(r'^ +', multiLine: true), ''), - ); - - String _hash(String target) => - base64.encode(sha256.convert(utf8.encode(target)).bytes); - String _relaxedHeaderValue(Header head) { - final headValue = head.value?.replaceAll(RegExp(r'\r|\n'), ' ') ?? ''; - - return '${head.lowerCaseName}:' - '${_cleanWhiteSpaces(headValue).trim()}$_crlf'; - } - - bool _isSignedHeader(Header head) => - _signedHeaders.contains(head.lowerCaseName); - - String _relaxedHeader(List
headers) { - final relaxed = StringBuffer(); - - for (final head in headers.where(_isSignedHeader)) { - relaxed.write(_relaxedHeaderValue(head)); - } - - return _cleanLineBreaks(relaxed.toString()); - } - - // Use to see existence of escape characters - // void _debugTrace(String target) { - // print(target - // .replaceAll(' ', '') - // .replaceAll('\r', '') - // .replaceAll('\n', '\n')); - // } - - String _relaxedBody(String body) { - final cleaned = _cleanLineBreaks(body).trimRight(); - - return cleaned.isEmpty ? '' : cleaned + _crlf; - } - - String _sign(String privateKeyText, String value) { - final privateKey = _rsaKeyParser.parse(privateKeyText) as RSAPrivateKey?; - final data = utf8.encode(value); - - return RSASigner( - RSASignDigest.SHA256, - privateKey: privateKey, - ).sign(data).base64; - } - - /// Signs the builder with the given [privateKey] - /// - /// Adds the signature to the `DKIM-Signature` message header - bool sign({required String privateKey, String? domain, String? selector}) { - final msg = buildMimeMessage(); - final body = _relaxedBody(msg.renderMessage(renderHeader: false)); - final header = _relaxedHeader( - msg.headers.toValueOrThrow('no headers found'), - ); - final dkim = _relaxedHeaderValue(_createDkimHeader(body, domain, selector)); - final signature = dkim.trim() + _sign(privateKey, (header + dkim).trim()); - - addHeader(_headerName, signature.substring(_headerName.length + 1).trim()); - - return true; - } -} +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:encrypter_plus/encrypter_plus.dart'; +import 'package:pointycastle/pointycastle.dart' show RSAPrivateKey; + +import '../../message_builder.dart'; +import '../../mime_message.dart'; +import 'non_nullable.dart'; + +/// Extends Message Builder with signature methods +extension MailSignature on MessageBuilder { + static final RSAKeyParser _rsaKeyParser = RSAKeyParser(); + + /// The headers that are covered by the signature (RFC 6376 section 5.4), + /// a header that is missing in the message is signed as empty. + static const List _signedHeaders = [ + 'from', + 'to', + 'cc', + 'subject', + 'date', + 'message-id', + 'mime-version', + 'content-type', + ]; + static const String _crlf = '\r\n'; + static const String _headerName = 'DKIM-Signature'; + + String _cleanWhiteSpaces(String target) => + target.replaceAll(RegExp(r'\s+', multiLine: true), ' '); + String _cleanLineBreaks(String target) { + final parts = target + .replaceAll(_crlf, '\n') + .replaceAll('\n', _crlf) + .split(_crlf); + + for (var i = 0; i < parts.length; i++) { + parts[i] = _cleanWhiteSpaces(parts[i]).trimRight(); + } + + return parts.join(_crlf); + } + + int get _secondsSinceEpoch => + (DateTime.now().millisecondsSinceEpoch / 1000).floor(); + + // The whole canonicalized body is hashed: a body length limit (l=) allows + // appending arbitrary content to a signed message and made the hash fail + // for short or non-ASCII bodies. + Header _createDkimHeader(String body, String? domain, String? selector) => + Header( + _headerName, + 'v=1; a=rsa-sha256; c=relaxed/relaxed; q=dns/txt; ' + 't=$_secondsSinceEpoch; d=$domain; s=$selector; ' + 'h=${_signedHeaders.join(':')}; ' + 'bh=${_hash(body)}; ' + 'b=', + ); + + String _hash(String target) => + base64.encode(sha256.convert(utf8.encode(target)).bytes); + String _relaxedHeaderValue(Header head) { + final headValue = head.value?.replaceAll(RegExp(r'\r|\n'), ' ') ?? ''; + + return '${head.lowerCaseName}:' + '${_cleanWhiteSpaces(headValue).trim()}$_crlf'; + } + + String _relaxedHeader(List
headers) { + final relaxed = StringBuffer(); + // RFC 6376 section 5.4.2: in the order of the h= tag, using the last + // instance of a header when it occurs more than once + for (final name in _signedHeaders) { + final head = headers.lastWhere( + (h) => h.lowerCaseName == name, + orElse: () => Header(name, null), + ); + if (head.value != null) { + relaxed.write(_relaxedHeaderValue(head)); + } + } + + return _cleanLineBreaks(relaxed.toString()); + } + + // Use to see existence of escape characters + // void _debugTrace(String target) { + // print(target + // .replaceAll(' ', '') + // .replaceAll('\r', '') + // .replaceAll('\n', '\n')); + // } + + String _relaxedBody(String body) { + final cleaned = _cleanLineBreaks(body).trimRight(); + + return cleaned.isEmpty ? '' : cleaned + _crlf; + } + + String _sign(String privateKeyText, String value) { + final privateKey = _rsaKeyParser.parse(privateKeyText) as RSAPrivateKey?; + final data = utf8.encode(value); + + return RSASigner( + RSASignDigest.SHA256, + privateKey: privateKey, + ).sign(data).base64; + } + + /// Signs the builder with the given [privateKey] + /// + /// Adds the signature to the `DKIM-Signature` message header + bool sign({required String privateKey, String? domain, String? selector}) { + final msg = buildMimeMessage(); + final body = _relaxedBody(msg.renderMessage(renderHeader: false)); + final header = _relaxedHeader( + msg.headers.toValueOrThrow('no headers found'), + ); + final dkim = _relaxedHeaderValue(_createDkimHeader(body, domain, selector)); + final signature = dkim.trim() + _sign(privateKey, (header + dkim).trim()); + + addHeader(_headerName, signature.substring(_headerName.length + 1).trim()); + + return true; + } +} diff --git a/test/mail_signature_test.dart b/test/mail_signature_test.dart new file mode 100644 index 00000000..7be267e4 --- /dev/null +++ b/test/mail_signature_test.dart @@ -0,0 +1,95 @@ +import 'dart:typed_data'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/util/mail_signature.dart'; +import 'package:test/test.dart'; + +// A throw-away 2048 bit RSA key generated for this test only. +const _privateKey = ''' +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAxv9f8xWRb9e2qYXFZ742o3wjzdjaTbV7RxmQB5df5GXqOeNP +kBIaWauEQD8uWLv368yIm+ugBtPpBXJZjQi80F99fR/2kV7fv9jpepRrh9NwTyyx +fSp6+O8RAAZxh2bGR/suAgH2cKfJm8N5eXDibRv/vBoOerlKkm1av7vJ4KDqOBxu +C7DiJKXkH4AW61pNGQqt/RrxN0mVOY+qDDFJ7xF3AGZamjPHM8hXtFW5+mhlpygv +gmxu5qEVbZKoF6JbEuKL6ocazAlHg2kiZjccMz6JV736Ui2U/7VrZciPx6KXhrHu +S59J/Dk3g7wjjm+3lDU2XpRIZ4ldNcR/1mirowIDAQABAoIBAAgC6NSyDgWVqzVX +qqMBk4uMiE9VpzMejblG6gmmfJ0ZMmqt6VqFEA46HaDEhdPbRWoJPmPXcHP/DyuK +0o9DtBLaWd/7R0ynOnA+GFTLKbvxbDiBftV74sM5+4kVcC6hvivQjMqfnVGSUf4+ +XNDhzGdCsE6T6NPkSzdFoTQXKFOba4Nqb/GZPzrxCYQ1vkNwdN+Jxr76yfBtuviF +aYhEGFRveU37IKH8AWXXE1oSGr/kVPHkIlod3KdpTnzATIjJkGMf0SobLDJlfmfx +3i9bPSX8XZ9O5zL1oHlLBWYzSFYekkGD20DtEXpjhb2xZg9xw9P9HjqcKTUn1LLe +0A4UE/kCgYEA6YLWq8y2aA1Z/XXfAxCEEryBuHwM0EHzkXdMai15imVm3BQyDY2S +axifq5LAr319SpciXqf+Lutqro8Pgr2C62KHVrHXgs401R7fl4QXQkhgQpI/Hkej +3E3GISzACmu0fEu5F1H8J+eFl5nProYFbBCwpQuVo/OBR3fbsj7lno0CgYEA2imc ++rLaLvIb6UIlIKiakT49sY2aXndtKl1/Rs3UXnHp64WwAcJQjeVqSTDI+2E3UNl/ +3du6CWlXrZ/TWJaInGg+vKpT6jq82MUC7NTfqsgqbPLhkgXF1iE/GqTclbCk3qKg +xm6ppnOGcFlzOXkmfOknAxgvmn99rra8NJcRvu8CgYEA14HNJZk07ysDVoymWWmw +uqoG/oBeQwXbCPGVMJjvhu62035AA4oZC4YaNnqmIlAqheCd88YPLLZQKvIVWpAU +d7DjPvu67hnpYJexu2BJJv8s98OJRSTQ8c1FgfCO/A8S73PjSsZ7dUiTXqqxpVxD +PMzaejgKztk5AwB3XjX2LTECgYEApJP/+KA0OHYs2CsuFxUahbeOkwNgESPHFs6x +1ZgxPY5yCVsxDCKq4mDPbad/9yO/tx5dd+Dq127A1hpcNdhZ9qQtr+ZOp8Tn8h+t +tTxh/1RBrS8NPDteo8sw78ivH73CorHM1+Vj1k4QfXD9m73paxH4fD0irErBZaw1 +DvdoS8ECgYEA5B3JUSl9LUXphK2QRv+oL9CNEPYObxnAtPS5U7yXcrj/OS2Z3yBY ++fqDEVanIpqJXyQctSlKKhUNWJrUqP7ONYSIpBeeIVz5+lmeq1FAEfqSNJAGxDSx +dXXkw7ARQqip+epV428IcxckR3gZed9EqF17DqESSijiLKOQFiGLWKc= +-----END RSA PRIVATE KEY----- +'''; + +void main() { + test('sign() covers the whole body and the relevant headers', () { + final builder = MessageBuilder() + ..from = const [MailAddress('Me', 'me@example.com')] + ..to = const [MailAddress('You', 'you@example.com')] + ..subject = 'Signed' + ..text = 'Short bödy' + ..addBinary( + Uint8List.fromList([1, 2, 3]), + MediaSubtype.applicationOctetStream.mediaType, + filename: 'a.bin', + ); + expect( + builder.sign( + privateKey: _privateKey, + domain: 'example.com', + selector: 's1', + ), + isTrue, + ); + final message = builder.buildMimeMessage(); + final dkim = message.getHeaderValue('DKIM-Signature'); + expect(dkim, isNotNull); + expect(dkim, isNot(contains('l='))); + expect(dkim, contains('h=from:to:cc:subject:date:message-id')); + expect(dkim, contains('bh=')); + expect(dkim, matches(RegExp(r'b=[A-Za-z0-9+/=]+$'))); + // building the message again for sending must not duplicate the text + expect( + message.parts?.where((p) => p.mediaType.sub == MediaSubtype.textPlain), + hasLength(1), + ); + }); + + test('plus aliases only match the exact base address', () { + expect( + MailAddress.getMatchingEmail( + 'user@example.com', + 'user+tag@example.com', + allowPlusAlias: true, + ), + 'user+tag@example.com', + ); + expect( + MailAddress.getMatchingEmail( + 'admin@example.com', + 'ad+min@example.com', + allowPlusAlias: true, + ), + isNull, + ); + }); + + test('random ids come from a secure source and differ', () { + final ids = {for (var i = 0; i < 100; i++) MessageBuilder.createRandomId()}; + expect(ids, hasLength(100)); + }); +} From 9b938e47221fb4507cacfcb968fb9bdafe504b42 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:35:09 +0200 Subject: [PATCH 20/33] fix(mail): delete and move by UID and leave mailboxes with UNSELECT deleteMessages(), its copy-to-trash fallback and the move fallback issued a plain STORE for UID sequences, so the message with that sequence number was flagged as deleted and, with expunge, permanently removed while the intended message survived; every IMAP-fetched message carries a UID, so this was the normal path. They now use UID STORE and, where UIDPLUS is available, UID EXPUNGE limited to the affected messages. Switching mailboxes and the undo paths use UNSELECT instead of CLOSE when the server supports it, so messages that were deleted with canUndo are no longer expunged as a side effect. unselectMailbox() also passes the matching type argument so its parser no longer fails at runtime. Co-Authored-By: Claude Fable 5.1 --- lib/src/imap/imap_client.dart | 7 +-- lib/src/mail/mail_client.dart | 63 ++++++++++++++++--------- test/mail/imap_loopback_server.dart | 21 +++++++-- test/mail/mail_client_delete_test.dart | 65 ++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 28 deletions(-) create mode 100644 test/mail/mail_client_delete_test.dart diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index d4c19289..c63632f1 100644 --- a/lib/src/imap/imap_client.dart +++ b/lib/src/imap/imap_client.dart @@ -1825,7 +1825,7 @@ class ImapClient extends ClientBase { /// without triggering the expunge events. /// /// Compare [selectMailbox] - Future unselectMailbox() { + Future unselectMailbox() { if (_selectedMailbox == null) { return Future.value(); } @@ -1836,8 +1836,9 @@ class ImapClient extends ClientBase { ); final parser = NoResponseParser(_selectedMailbox); _selectedMailbox = null; - - return sendCommand(cmd, parser); + // the type argument must match the parser, otherwise the task's + // Response cannot be handed to the parser at runtime + return sendCommand(cmd, parser); } /// Searches messages by the given [searchCriteria] diff --git a/lib/src/mail/mail_client.dart b/lib/src/mail/mail_client.dart index 72b7954c..1a55d42c 100644 --- a/lib/src/mail/mail_client.dart +++ b/lib/src/mail/mail_client.dart @@ -2219,7 +2219,7 @@ class _IncomingImapClient extends _IncomingMailClient { await _pauseIdle(); try { if (_selectedMailbox != null) { - await _imapClient.closeMailbox(); + await _deselectMailbox(); } var quickReSync = qresync; if (qresync == null && @@ -2730,14 +2730,9 @@ class _IncomingImapClient extends _IncomingMailClient { if (trashMailbox == null || trashMailbox == selectedMailbox || expunge) { try { await _pauseIdle(); - await _imapClient.store( - sequence, - [MessageFlags.deleted], - action: StoreAction.add, - silent: true, - ); + await _markDeleted(sequence); if (expunge) { - await _imapClient.expunge(); + await _expunge(sequence); } final canUndo = !expunge; @@ -2772,12 +2767,7 @@ class _IncomingImapClient extends _IncomingMailClient { imapResult = sequence.isUidSequence ? await _imapClient.uidCopy(sequence, targetMailbox: trashMailbox) : await _imapClient.copy(sequence, targetMailbox: trashMailbox); - await _imapClient.store( - sequence, - [MessageFlags.deleted], - action: StoreAction.add, - silent: true, - ); + await _markDeleted(sequence); } // note: explicitly do not EXPUNGE after delete, // so that undo becomes easier @@ -2805,6 +2795,39 @@ class _IncomingImapClient extends _IncomingMailClient { } } + /// Flags the messages of [sequence] as deleted, using `UID STORE` for UID + /// sequences: a plain `STORE` would interpret the UIDs as sequence numbers + /// and flag the wrong messages. + Future _markDeleted(MessageSequence sequence) => sequence.isUidSequence + ? _imapClient.uidStore( + sequence, + [MessageFlags.deleted], + action: StoreAction.add, + silent: true, + ) + : _imapClient.store( + sequence, + [MessageFlags.deleted], + action: StoreAction.add, + silent: true, + ); + + /// Expunges the messages of [sequence] only (RFC 4315 `UID EXPUNGE`) when + /// possible, otherwise all messages flagged as deleted. + Future _expunge(MessageSequence sequence) => + sequence.isUidSequence && _imapClient.serverInfo.supportsUidPlus + ? _imapClient.uidExpunge(sequence) + : _imapClient.expunge(); + + /// Leaves the currently selected mailbox without expunging it. + /// + /// `CLOSE` implicitly expunges every message flagged as deleted, which + /// would silently destroy messages that were deleted with `canUndo`, so + /// `UNSELECT` (RFC 3691) is used whenever the server supports it. + Future _deselectMailbox() => _imapClient.serverInfo.supports('UNSELECT') + ? _imapClient.unselectMailbox() + : _imapClient.closeMailbox(); + @override Future undoDeleteMessages(DeleteResult deleteResult) async { switch (deleteResult.action) { @@ -2819,7 +2842,7 @@ class _IncomingImapClient extends _IncomingMailClient { case DeleteAction.move: try { await _pauseIdle(); - await _imapClient.closeMailbox(); + await _deselectMailbox(); await _imapClient.selectMailbox( deleteResult.targetMailbox.toValueOrThrow('no targetMailbox found'), ); @@ -2837,7 +2860,7 @@ class _IncomingImapClient extends _IncomingMailClient { targetMailbox: deleteResult.originalMailbox, ); } - await _imapClient.closeMailbox(); + await _deselectMailbox(); await _imapClient.selectMailbox(deleteResult.originalMailbox); if (result == null) { throw MailException( @@ -2871,7 +2894,7 @@ class _IncomingImapClient extends _IncomingMailClient { final targetMailbox = deleteResult.targetMailbox; final targetSequence = deleteResult.targetSequence; if (targetMailbox != null && targetSequence != null) { - await _imapClient.closeMailbox(); + await _deselectMailbox(); await _imapClient.selectMailbox(targetMailbox); if (targetSequence.isUidSequence) { @@ -2884,7 +2907,7 @@ class _IncomingImapClient extends _IncomingMailClient { ], action: StoreAction.add); } - await _imapClient.closeMailbox(); + await _deselectMailbox(); await _imapClient.selectMailbox(deleteResult.originalMailbox); } } on ImapException catch (e) { @@ -2965,9 +2988,7 @@ class _IncomingImapClient extends _IncomingMailClient { imapResult = sequence.isUidSequence ? await _imapClient.uidCopy(sequence, targetMailbox: target) : await _imapClient.copy(sequence, targetMailbox: target); - await _imapClient.store(sequence, [ - MessageFlags.deleted, - ], action: StoreAction.add); + await _markDeleted(sequence); } _selectedMailbox?.messagesExists -= sequence.length; final targetSequence = imapResult.responseCodeCopyUid?.targetSequence; diff --git a/test/mail/imap_loopback_server.dart b/test/mail/imap_loopback_server.dart index 8794c819..e33fa857 100644 --- a/test/mail/imap_loopback_server.dart +++ b/test/mail/imap_loopback_server.dart @@ -35,7 +35,8 @@ class ImapLoopbackServer { connections++; _sockets.add(socket); socket.write( - '* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=XOAUTH2] ready\r\n', + '* OK [CAPABILITY IMAP4rev1 UIDPLUS UNSELECT AUTH=PLAIN AUTH=XOAUTH2] ' + 'ready\r\n', ); var buffer = ''; socket.listen((data) { @@ -64,18 +65,30 @@ class ImapLoopbackServer { switch (command) { case 'CAPABILITY': socket.write( - '* CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=XOAUTH2\r\n$tag OK done\r\n', + '* CAPABILITY IMAP4rev1 UIDPLUS UNSELECT AUTH=PLAIN AUTH=XOAUTH2\r\n' + '$tag OK done\r\n', ); break; case 'LOGIN': case 'AUTHENTICATE': - socket.write('$tag OK [CAPABILITY IMAP4rev1] authenticated\r\n'); + socket.write( + '$tag OK [CAPABILITY IMAP4rev1 UIDPLUS UNSELECT] authenticated\r\n', + ); break; case 'LIST': socket.write( - '* LIST (\\HasNoChildren) "/" INBOX\r\n$tag OK LIST completed\r\n', + '* LIST (\\HasNoChildren) "/" INBOX\r\n' + '* LIST (\\HasNoChildren \\Trash) "/" Trash\r\n' + '$tag OK LIST completed\r\n', ); break; + case 'UID': + if (rest.toUpperCase().contains(' COPY ')) { + socket.write('$tag OK [COPYUID 1 4711 1] done\r\n'); + } else { + socket.write('$tag OK done\r\n'); + } + break; case 'SELECT': case 'EXAMINE': socket.write( diff --git a/test/mail/mail_client_delete_test.dart b/test/mail/mail_client_delete_test.dart new file mode 100644 index 00000000..93d8db09 --- /dev/null +++ b/test/mail/mail_client_delete_test.dart @@ -0,0 +1,65 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +import 'imap_loopback_server.dart'; + +void main() { + late ImapLoopbackServer server; + late MailClient mailClient; + + setUp(() async { + server = await ImapLoopbackServer.start(); + mailClient = MailClient( + MailAccount.fromManualSettings( + name: 'test', + email: 'user@example.com', + password: 'secret', + incomingHost: server.host, + incomingPort: server.port, + incomingSocketType: SocketType.plainNoStartTls, + outgoingHost: server.host, + ), + ); + await mailClient.connect(); + await mailClient.listMailboxes(); + await mailClient.selectInbox(); + server.requests.clear(); + }); + + tearDown(() async { + await mailClient.disconnect(); + await server.close(); + }); + + MimeMessage message() => MimeMessage() + ..uid = 4711 + ..sequenceId = 1; + + Iterable commands() => + server.requests.map((r) => r.substring(r.indexOf(' ') + 1)); + + test('deleting a message by UID uses UID STORE and UID EXPUNGE', () async { + await mailClient.deleteMessage(message(), expunge: true); + expect(commands(), contains('UID STORE 4711 +FLAGS.SILENT (\\Deleted)')); + expect(commands(), contains('UID EXPUNGE 4711')); + expect(commands().where((c) => c.startsWith('STORE ')), isEmpty); + expect(commands().where((c) => c == 'EXPUNGE'), isEmpty); + }); + + test('moving to trash without MOVE support flags by UID', () async { + final trash = mailClient.getMailbox(MailboxFlag.trash); + expect(trash, isNotNull); + final result = await mailClient.deleteMessage(message()); + expect(result.action, DeleteAction.copy); + expect(commands(), contains('UID COPY 4711 Trash')); + expect(commands(), contains('UID STORE 4711 +FLAGS.SILENT (\\Deleted)')); + expect(commands().where((c) => c.startsWith('STORE ')), isEmpty); + }); + + test('switching mailboxes uses UNSELECT instead of CLOSE', () async { + final trash = mailClient.getMailbox(MailboxFlag.trash)!; + await mailClient.selectMailbox(trash); + expect(commands(), contains('UNSELECT')); + expect(commands().where((c) => c == 'CLOSE'), isEmpty); + }); +} From a4cb12d5b492d6723817cba7e85247ee37583254 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 11:35:09 +0200 Subject: [PATCH 21/33] docs: describe the security and correctness fixes in the changelog Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fd0e8f..a87c0a74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,24 @@ # 2.2.4 * Feat: Support the IMAP `NAMESPACE` extension ([RFC 2342](https://datatracker.ietf.org/doc/html/rfc2342)): `ImapClient.namespace()` returns the personal, other users' and shared namespaces with their prefixes, hierarchy delimiters and response extensions; `ImapServerInfo.supportsNamespace` tells whether the server advertises it. - +* Security: `MailClient` refuses to authenticate over an unencrypted connection unless `SocketType.plainNoStartTls` is chosen explicitly; a missing STARTTLS capability no longer causes a silent clear-text fallback, and capabilities announced before STARTTLS are discarded. +* Security: SMTP DATA applies RFC 5321 transparency to every line, so message content can no longer terminate the DATA phase early and inject commands. +* Security: header values are sanitised of CR/LF, display names are rendered as quoted-strings with escaping, email addresses with unsafe characters are rejected for headers, the SMTP envelope and `mailto:` links, and the address parser honours quoted-pairs. +* Security: the `Bcc` header is stripped on the BDAT path as well, case-insensitively and only within the header section. +* Security: multipart delimiters are only recognised at the start of a line (RFC 2046), nested multiparts are capped at 50 levels. +* Security: `MailAccount`, `MailServerConfig` and `OauthToken` redact credentials in `toString()`. +* Security: auto-discovery bounds HTTP requests (timeout, 1 MiB, no https-to-http redirects), query-encodes the email address, flags configurations retrieved over plain HTTP via `ClientConfig.isFromInsecureSource`, and `forceSslConnection` updates every preferred server. +* Security: IMAP `LOGIN`, mailbox names, metadata and search terms are quoted with escaping or sent as literals; line breaks in mailbox names are rejected. +* Fix: malformed IMAP server responses fail the affected command instead of wedging the connection; literals are capped at 128 MiB, `{0}` literals, quoted-pairs, partial fetch items and literal mailbox names are parsed correctly, huge `VANISHED` ranges are rejected. +* Fix: `MailClient.deleteMessages()` and the move fallback used `STORE` with UID sequences, flagging and expunging the wrong messages; `selectMailbox()` uses `UNSELECT` instead of `CLOSE` where available so that flagged-as-deleted messages are not expunged on folder switch. +* Fix: `connectToServer()` fails when the server closes the socket or stays silent before its greeting; pending SMTP/POP commands fail on connection loss or disconnect instead of hanging. +* Fix: POP3 `LIST`/`UIDL` on an empty mailbox complete, large POP3 messages are read in linear time, and SMTP multi-line replies split across TCP chunks are framed correctly. +* Fix: SMTP `AUTH PLAIN`/`AUTH LOGIN` encode credentials as UTF-8; negative replies to `MAIL FROM`, `RCPT TO`, `DATA` and `BDAT` are honoured and the transaction is reset; `SMTPUTF8` is only sent when announced. +* Fix: `MailClient.reconnect()`/`resume()` no longer deadlock, the retry interval grows exponentially, and events keep being delivered after a reconnect. +* Fix: POP3 polling detects new messages, ends the previous session, retrieved messages carry their sequence id, and OAuth accounts authenticate with `AUTH XOAUTH2`. +* Fix: a rotated OAuth refresh token is kept; token refreshes are serialized; `OauthToken` implements equality. +* Fix: lenient quoted-printable and base64 decoding, byte-exact `binary`/`8bit` attachment decoding, `getPartWithContentId()` normalisation, text part lookup skips attached messages and text attachments, quote-aware header parameters with RFC 2231 support. +* Fix: `Date` header zones render correctly (`+0000`, `-0330`), obsolete two-digit years follow RFC 5322, header encoders handle astral characters, folding never splits an encoded word. +* Fix: `UsernameType.emailLocalPart` returns the local part; secure random Message-IDs; exact plus-alias matching; a working DKIM signer. # 2.2.3 * Chore: Shorten package description to 60-180 characters for pub.dev score * Docs: Add example file (example/example.dart) From c5c35f695e8ea7d74a7ad7cb515b38522ae43381 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 12:33:00 +0200 Subject: [PATCH 22/33] fix(pop): read the reply to a follow-up command afresh The first response line was only cleared in sendCommand, so after a multi-step command wrote its follow-up the stale line was dispatched again and the real reply stayed buffered for the next command. Co-Authored-By: Claude Fable 5.1 --- lib/src/pop/pop_client.dart | 3 +++ test/mail/mail_client_pop_test.dart | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/src/pop/pop_client.dart b/lib/src/pop/pop_client.dart index af2b79b1..4d10809e 100644 --- a/lib/src/pop/pop_client.dart +++ b/lib/src/pop/pop_client.dart @@ -264,6 +264,9 @@ class PopClient extends ClientBase { final response = parser.parse(responseTexts); final commandText = command.nextCommand(response); if (commandText != null) { + // the reply to the follow-up has to be read afresh, otherwise the + // current first line would be dispatched again + _currentFirstResponseLine = null; _write(() => writeText(commandText), command); } else if (command.isCommandDone(response)) { if (response.isFailedStatus) { diff --git a/test/mail/mail_client_pop_test.dart b/test/mail/mail_client_pop_test.dart index 5d93f7e6..f254633c 100644 --- a/test/mail/mail_client_pop_test.dart +++ b/test/mail/mail_client_pop_test.dart @@ -127,9 +127,17 @@ void main() { await failing.connectToServer(server.host, server.port, isSecure: false); await expectLater( failing.authenticateWithOAuth2('user@example.com', 'bad'), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.response.result, + 'result', + contains('invalid credentials'), + ), + ), ); expect(failing.isLoggedIn, isFalse); + // the final -ERR was consumed, so the session is still in sync: + await failing.noop(); await failing.disconnect(); }); } From 2e40621dfc44962414ba9aa5a11716947d6a28ab Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 12:34:27 +0200 Subject: [PATCH 23/33] fix(mail): keep unread POP messages when a retrieve fails The new message count was stored before the retrieve loop, so a failed retrieve made the next poll skip all remaining new messages. The count now advances with every loaded message. Co-Authored-By: Claude Fable 5.1 --- lib/src/mail/mail_client.dart | 22 ++++++---- test/mail/mail_client_pop_test.dart | 64 +++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/lib/src/mail/mail_client.dart b/lib/src/mail/mail_client.dart index 1a55d42c..ea06e58e 100644 --- a/lib/src/mail/mail_client.dart +++ b/lib/src/mail/mail_client.dart @@ -3387,15 +3387,19 @@ class _IncomingPopClient extends _IncomingMailClient { final status = await _popClient.status(); final messages = []; final numberOfMessages = status.numberOfMessages; - mailbox.messagesExists = numberOfMessages; - if (numberOfMessages > numberOfKnownMessages) { - //TODO compare list UIDs with known message UIDs - // instead of just checking the number of messages - for (var id = numberOfKnownMessages + 1; id <= numberOfMessages; id++) { - final message = await _popClient.retrieve(id); - messages.add(message); - mailClient._fireEvent(MailLoadEvent(message, mailClient)); - } + if (numberOfMessages <= numberOfKnownMessages) { + // no new messages, but messages may have been removed by another client + mailbox.messagesExists = numberOfMessages; + } + //TODO compare list UIDs with known message UIDs + // instead of just checking the number of messages + for (var id = numberOfKnownMessages + 1; id <= numberOfMessages; id++) { + final message = await _popClient.retrieve(id); + // only count a message once it has been loaded, so that a failed + // retrieve does not skip the remaining new messages in the next poll + mailbox.messagesExists = id; + messages.add(message); + mailClient._fireEvent(MailLoadEvent(message, mailClient)); } return messages; diff --git a/test/mail/mail_client_pop_test.dart b/test/mail/mail_client_pop_test.dart index f254633c..f4b84dc9 100644 --- a/test/mail/mail_client_pop_test.dart +++ b/test/mail/mail_client_pop_test.dart @@ -20,6 +20,12 @@ class PopLoopbackServer { final requests = []; int sessions = 0; + /// Additional messages reported by `STAT` on top of the session count + int extraMessages = 0; + + /// The id whose next `RETR` fails once with `-ERR` + int? failRetrieveOnce; + String get host => _server.address.address; int get port => _server.port; @@ -27,7 +33,7 @@ class PopLoopbackServer { void _onConnection(Socket socket) { sessions++; - final messageCount = sessions; + final messageCount = sessions + extraMessages; socket.write('+OK POP3 ready\r\n'); socket.listen((data) { for (final line in utf8.decode(data).split('\r\n')) { @@ -42,6 +48,11 @@ class PopLoopbackServer { break; case 'RETR': final id = line.split(' ').last; + if (int.tryParse(id) == failRetrieveOnce) { + failRetrieveOnce = null; + socket.write('-ERR temporary failure\r\n'); + break; + } socket.write( '+OK 100 octets\r\nSubject: message $id\r\n\r\nbody $id\r\n.\r\n', ); @@ -72,6 +83,18 @@ class PopLoopbackServer { } } +MailAccount _account(PopLoopbackServer server) => + MailAccount.fromManualSettings( + name: 'test', + email: 'user@example.com', + password: 'secret', + incomingHost: server.host, + incomingPort: server.port, + incomingType: ServerType.pop, + incomingSocketType: SocketType.plainNoStartTls, + outgoingHost: server.host, + ); + void main() { late PopLoopbackServer server; @@ -82,18 +105,7 @@ void main() { tearDown(() => server.close()); test('polling detects new messages and ends the previous session', () async { - final mailClient = MailClient( - MailAccount.fromManualSettings( - name: 'test', - email: 'user@example.com', - password: 'secret', - incomingHost: server.host, - incomingPort: server.port, - incomingType: ServerType.pop, - incomingSocketType: SocketType.plainNoStartTls, - outgoingHost: server.host, - ), - ); + final mailClient = MailClient(_account(server)); final loaded = []; mailClient.eventStream.listen((event) { if (event is MailLoadEvent) { @@ -115,6 +127,32 @@ void main() { await mailClient.disconnect(); }); + test('a failed retrieve does not skip the remaining new messages', () async { + final mailClient = MailClient(_account(server)); + final loaded = []; + mailClient.eventStream.listen((event) { + if (event is MailLoadEvent) { + loaded.add(event.message.sequenceId); + } + }); + await mailClient.connect(); + final inbox = await mailClient.selectInbox(); + expect(inbox.messagesExists, 1); + // the next session reports 4 messages, but message 2 cannot be loaded: + server + ..extraMessages = 2 + ..failRetrieveOnce = 2; + await mailClient.startPolling(const Duration(milliseconds: 100)); + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (loaded.length < 4 && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 50)); + } + await mailClient.stopPolling(); + // the failed poll must not have advanced the known message count: + expect(loaded, containsAllInOrder([2, 3, 4, 5])); + await mailClient.disconnect(); + }); + test('AUTH XOAUTH2', () async { final client = PopClient(); await client.connectToServer(server.host, server.port, isSecure: false); From fa8ea0ae74a2adcde1d1659554df97537f5c4855 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 12:37:04 +0200 Subject: [PATCH 24/33] fix(smtp): report invalid envelope addresses as SmtpException The send commands validated the envelope in their constructors and threw ArgumentError synchronously, which escaped the SmtpException handler of the mail client. The client now checks the envelope first and throws an SmtpException, so MailClient.sendMessage fails with a MailException. Co-Authored-By: Claude Fable 5.1 --- lib/src/private/smtp/smtp_command.dart | 22 +++++--- lib/src/smtp/smtp_client.dart | 55 +++++++++++-------- test/mail/mail_client_send_test.dart | 75 ++++++++++++++++++++++++++ test/smtp/smtp_send_state_test.dart | 50 +++++++++++++++++ 4 files changed, 173 insertions(+), 29 deletions(-) create mode 100644 test/mail/mail_client_send_test.dart diff --git a/lib/src/private/smtp/smtp_command.dart b/lib/src/private/smtp/smtp_command.dart index 7ce8a96e..572a5ed7 100644 --- a/lib/src/private/smtp/smtp_command.dart +++ b/lib/src/private/smtp/smtp_command.dart @@ -57,18 +57,26 @@ class SmtpCommand { /// control characters, angle brackets or list separators, compare /// [MailAddress.isSafeEmail]. String validateEnvelopeAddress(String? email, String description) { + final error = envelopeAddressError(email, description); + if (error != null) { + throw ArgumentError.value(email, description, error); + } + + return email!; +} + +/// Describes why [email] cannot be used as the [description] address of the +/// SMTP envelope, or returns `null` when it can. +String? envelopeAddressError(String? email, String description) { if (email == null || email.isEmpty) { - throw ArgumentError.value(email, description, 'no email address given'); + return 'no $description email address given'; } if (!MailAddress.isSafeEmail(email)) { - throw ArgumentError.value( - email, - description, - 'contains characters that are not allowed in an SMTP envelope address', - ); + return '$description email address <$email> contains characters that ' + 'are not allowed in an SMTP envelope address'; } - return email; + return null; } final _bccHeader = RegExp( diff --git a/lib/src/smtp/smtp_client.dart b/lib/src/smtp/smtp_client.dart index 82617f00..4e5f2b2b 100644 --- a/lib/src/smtp/smtp_client.dart +++ b/lib/src/smtp/smtp_client.dart @@ -300,6 +300,25 @@ class SmtpClient extends ClientBase { return response; } + /// Throws an [SmtpException] when the envelope addresses cannot be used in + /// `MAIL FROM` and `RCPT TO`, so that callers only have to handle SMTP + /// exceptions and no argument errors from the command constructors. + void _checkEnvelope(String? fromEmail, List recipientEmails) { + if (recipientEmails.isEmpty) { + throw SmtpException(this, SmtpResponse(['500 no recipients'])); + } + final senderError = envelopeAddressError(fromEmail, 'sender'); + if (senderError != null) { + throw SmtpException.message(this, senderError); + } + for (final recipient in recipientEmails) { + final recipientError = envelopeAddressError(recipient, 'recipient'); + if (recipientError != null) { + throw SmtpException.message(this, recipientError); + } + } + } + /// Sends the specified [message]. /// /// Set [use8BitEncoding] to `true` for sending a UTF-8 encoded message body. @@ -316,9 +335,7 @@ class SmtpClient extends ClientBase { final recipientEmails = recipients != null ? recipients.map((r) => r.email).toList() : message.recipientAddresses; - if (recipientEmails.isEmpty) { - throw SmtpException(this, SmtpResponse(['500 no recipients'])); - } + _checkEnvelope(from?.email ?? message.fromEmail, recipientEmails); return sendCommand( SmtpSendMailCommand( @@ -339,15 +356,14 @@ class SmtpClient extends ClientBase { List recipients, { bool use8BitEncoding = false, }) { - if (recipients.isEmpty) { - throw SmtpException(this, SmtpResponse(['500 no recipients'])); - } + final recipientEmails = recipients.map((r) => r.email).toList(); + _checkEnvelope(from.email, recipientEmails); return sendCommand( SmtpSendMailDataCommand( data, from, - recipients.map((r) => r.email).toList(), + recipientEmails, use8BitEncoding: use8BitEncoding, ), ); @@ -365,15 +381,14 @@ class SmtpClient extends ClientBase { List recipients, { bool use8BitEncoding = false, }) { - if (recipients.isEmpty) { - throw SmtpException(this, SmtpResponse(['500 no recipients'])); - } + final recipientEmails = recipients.map((r) => r.email).toList(); + _checkEnvelope(from.email, recipientEmails); return sendCommand( SmtpSendMailTextCommand( text, from, - recipients.map((r) => r.email).toList(), + recipientEmails, use8BitEncoding: use8BitEncoding, ), ); @@ -402,9 +417,7 @@ class SmtpClient extends ClientBase { final recipientEmails = recipients != null ? recipients.map((r) => r.email).toList() : message.recipientAddresses; - if (recipientEmails.isEmpty) { - throw SmtpException(this, SmtpResponse(['500 no recipients'])); - } + _checkEnvelope(from?.email ?? message.fromEmail, recipientEmails); return sendCommand( SmtpSendBdatMailCommand( @@ -432,15 +445,14 @@ class SmtpClient extends ClientBase { required bool supportUnicode, bool use8BitEncoding = false, }) { - if (recipients.isEmpty) { - throw SmtpException(this, SmtpResponse(['500 no recipients'])); - } + final recipientEmails = recipients.map((r) => r.email).toList(); + _checkEnvelope(from.email, recipientEmails); return sendCommand( SmtpSendBdatMailDataCommand( data, from, - recipients.map((r) => r.email).toList(), + recipientEmails, supportUnicode: supportUnicode, use8BitEncoding: use8BitEncoding, ), @@ -465,15 +477,14 @@ class SmtpClient extends ClientBase { required bool supportUnicode, bool use8BitEncoding = false, }) { - if (recipients.isEmpty) { - throw SmtpException(this, SmtpResponse(['500 no recipients'])); - } + final recipientEmails = recipients.map((r) => r.email).toList(); + _checkEnvelope(from.email, recipientEmails); return sendCommand( SmtpSendBdatMailTextCommand( text, from, - recipients.map((r) => r.email).toList(), + recipientEmails, supportUnicode: supportUnicode, use8BitEncoding: use8BitEncoding, ), diff --git a/test/mail/mail_client_send_test.dart b/test/mail/mail_client_send_test.dart new file mode 100644 index 00000000..045641c7 --- /dev/null +++ b/test/mail/mail_client_send_test.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +/// A clear-text mock SMTP server that accepts any login and records requests. +void main() { + late ServerSocket server; + final requests = []; + + setUp(() async { + requests.clear(); + server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0) + ..listen((socket) { + socket.write('220 mock ESMTP\r\n'); + socket.listen((data) { + final line = String.fromCharCodes(data); + requests.add(line); + if (line.startsWith('EHLO')) { + socket.write('250-mock\r\n250-AUTH PLAIN\r\n250 8BITMIME\r\n'); + } else if (line.startsWith('AUTH')) { + socket.write('235 ok\r\n'); + } else { + socket.write('250 ok\r\n'); + } + }); + }); + }); + + tearDown(() => server.close()); + + MailClient mailClient() => MailClient( + MailAccount.fromManualSettings( + name: 'test', + email: 'user@example.com', + password: 'secret', + incomingHost: 'imap.invalid', + outgoingHost: InternetAddress.loopbackIPv4.address, + outgoingPort: server.port, + outgoingSocketType: SocketType.plainNoStartTls, + ), + ); + + MimeMessage message() => + (MessageBuilder() + ..from = const [MailAddress('Me', 'me@example.com')] + ..to = const [MailAddress('A', 'a@example.com')] + ..subject = 'Hello' + ..addTextPlain('Body')) + .buildMimeMessage(); + + test('an invalid envelope is reported as MailException', () async { + final client = mailClient(); + await expectLater( + client.sendMessage( + message(), + from: const MailAddress(null, 'me @example.com'), + appendToSent: false, + ), + throwsA(isA()), + ); + await expectLater( + client.sendMessage( + message(), + recipients: const [MailAddress(null, 'a>b@example.com')], + appendToSent: false, + ), + throwsA(isA()), + ); + // the connection was established, but no transaction was started: + expect(requests.any((r) => r.startsWith('EHLO')), isTrue); + expect(requests.any((r) => r.startsWith('MAIL FROM')), isFalse); + await client.disconnect(); + }); +} diff --git a/test/smtp/smtp_send_state_test.dart b/test/smtp/smtp_send_state_test.dart index eb7752dd..2920d677 100644 --- a/test/smtp/smtp_send_state_test.dart +++ b/test/smtp/smtp_send_state_test.dart @@ -126,6 +126,56 @@ void main() { }); }); + group('envelope validation', () { + final client = SmtpClient('test.example.com'); + const recipients = [MailAddress(null, 'a@example.com')]; + + test('a missing sender is reported as SmtpException', () { + final message = MimeMessage(); + expect(message.fromEmail, isNull); + final matcher = throwsA( + isA().having( + (e) => e.message, + 'message', + contains('sender'), + ), + ); + expect( + () => client.sendMessage(message, recipients: recipients), + matcher, + ); + expect( + () => client.sendChunkedMessage( + message, + recipients: recipients, + supportUnicode: false, + ), + matcher, + ); + }); + + test('unsafe envelope addresses are reported as SmtpException', () { + const from = MailAddress(null, 'me@example.com'); + const unsafeFrom = MailAddress(null, 'me @example.com'); + const unsafeRecipients = [MailAddress(null, 'a>b@example.com')]; + final matcher = throwsA(isA()); + expect(() => client.sendMessage(_message(), from: unsafeFrom), matcher); + expect( + () => client.sendMessageText('x', from, unsafeRecipients), + matcher, + ); + expect( + () => client.sendChunkedMessageText( + 'x', + unsafeFrom, + recipients, + supportUnicode: false, + ), + matcher, + ); + }); + }); + test('sendMessage fails with the rejection and resets the session', () async { final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); final requests = []; From eda15f5587ad57a034cf49e9001069bfd088fde3 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 12:39:45 +0200 Subject: [PATCH 25/33] fix: reset the response parsers when a client reconnects The mail client reuses its IMAP, POP and SMTP clients. A reply that was received only partially when the connection was lost stayed buffered and corrupted the parsing of the first replies of the next connection. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + lib/src/imap/imap_client.dart | 2 + lib/src/pop/pop_client.dart | 3 + .../private/imap/imap_response_reader.dart | 8 ++ lib/src/private/util/uint8_list_reader.dart | 6 ++ lib/src/smtp/smtp_client.dart | 3 + test/reconnect_state_test.dart | 89 +++++++++++++++++++ 7 files changed, 112 insertions(+) create mode 100644 test/reconnect_state_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index a87c0a74..4f5a98a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ * Fix: lenient quoted-printable and base64 decoding, byte-exact `binary`/`8bit` attachment decoding, `getPartWithContentId()` normalisation, text part lookup skips attached messages and text attachments, quote-aware header parameters with RFC 2231 support. * Fix: `Date` header zones render correctly (`+0000`, `-0330`), obsolete two-digit years follow RFC 5322, header encoders handle astral characters, folding never splits an encoded word. * Fix: `UsernameType.emailLocalPart` returns the local part; secure random Message-IDs; exact plus-alias matching; a working DKIM signer. +* Fix: a reply that was received only partially when a connection was lost no longer corrupts the parsing of the replies after a reconnect. # 2.2.3 * Chore: Shorten package description to 60-180 characters for pub.dev score * Docs: Add example file (example/example.dart) diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index c63632f1..6b140157 100644 --- a/lib/src/imap/imap_client.dart +++ b/lib/src/imap/imap_client.dart @@ -331,6 +331,8 @@ class ImapClient extends ClientBase { ConnectionInfo connectionInfo, String serverGreeting, ) async { + // discard any partial response of a previous connection + _imapResponseReader.reset(); _isInIdleMode = false; _serverInfo = ImapServerInfo(connectionInfo); final startIndex = serverGreeting.indexOf('[CAPABILITY '); diff --git a/lib/src/pop/pop_client.dart b/lib/src/pop/pop_client.dart index 4d10809e..2947d220 100644 --- a/lib/src/pop/pop_client.dart +++ b/lib/src/pop/pop_client.dart @@ -65,6 +65,9 @@ class PopClient extends ClientBase { ConnectionInfo connectionInfo, String serverGreeting, ) { + // discard any partial reply of a previous connection + _uint8listReader.clear(); + _currentFirstResponseLine = null; if (serverGreeting.startsWith('+OK')) { final chunks = serverGreeting.split(' '); serverInfo = PopServerInfo(chunks.last.trimRight()); diff --git a/lib/src/private/imap/imap_response_reader.dart b/lib/src/private/imap/imap_response_reader.dart index f12d5647..566ce29c 100644 --- a/lib/src/private/imap/imap_response_reader.dart +++ b/lib/src/private/imap/imap_response_reader.dart @@ -39,6 +39,14 @@ class ImapResponseReader { _process(); } + /// Discards any partially received response and all buffered data, e.g. + /// when a new connection is established. + void reset() { + _rawReader.clear(); + _currentResponse = null; + _awaitingLiteralFor = null; + } + void _process() { while (true) { final response = _currentResponse; diff --git a/lib/src/private/util/uint8_list_reader.dart b/lib/src/private/util/uint8_list_reader.dart index 00872028..485500fa 100644 --- a/lib/src/private/util/uint8_list_reader.dart +++ b/lib/src/private/util/uint8_list_reader.dart @@ -86,6 +86,12 @@ class Uint8ListReader { /// Checks if the given [length] of data is available bool isAvailable(int length) => length <= _builder.length; + + /// Discards all buffered data, e.g. when a new connection is established + void clear() { + _builder.clear(); + _terminatorScanner.reset(); + } } /// Incrementally searches the `CRLF.CRLF` terminator of a POP3 multi-line diff --git a/lib/src/smtp/smtp_client.dart b/lib/src/smtp/smtp_client.dart index 4e5f2b2b..9dfa82cf 100644 --- a/lib/src/smtp/smtp_client.dart +++ b/lib/src/smtp/smtp_client.dart @@ -143,6 +143,9 @@ class SmtpClient extends ClientBase { ConnectionInfo connectionInfo, String serverGreeting, ) { + // discard any partial reply of a previous connection + _uint8listReader.clear(); + _pendingReplyLines.clear(); serverInfo = SmtpServerInfo( connectionInfo.host, connectionInfo.port, diff --git a/test/reconnect_state_test.dart b/test/reconnect_state_test.dart new file mode 100644 index 00000000..fa952e3e --- /dev/null +++ b/test/reconnect_state_test.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +/// The clients are reused for a new connection after the previous one was +/// lost in the middle of a reply. The partial reply must not corrupt the +/// parsing of the replies of the new connection. +void main() { + late ServerSocket server; + late String host; + + setUp(() async { + server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + host = server.address.address; + }); + + tearDown(() => server.close()); + + /// Sends the [greeting] on every connection, answers the first command of + /// the first connection with the [partialReply] and closes that + /// connection. Later connections are answered with [reply]. + void serve({ + required String greeting, + required String partialReply, + required String Function(String request) reply, + }) { + var connections = 0; + server.listen((socket) { + connections++; + final isFirst = connections == 1; + socket.write(greeting); + socket.listen((data) async { + if (isFirst) { + socket.write(partialReply); + await socket.flush(); + socket.destroy(); + } else { + socket.write(reply(String.fromCharCodes(data))); + } + }); + }); + } + + test('SMTP: a partial reply of a lost connection is discarded', () async { + serve( + greeting: '220 mock ESMTP\r\n', + partialReply: '250-mock\r\n250-PIPELINING\r\n250-SI', + reply: (_) => '250 mock\r\n', + ); + final client = SmtpClient('test.example.com'); + await client.connectToServer(host, server.port, isSecure: false); + await expectLater(client.ehlo(), throwsA(isA())); + await client.connectToServer(host, server.port, isSecure: false); + final response = await client.ehlo().timeout(const Duration(seconds: 5)); + expect(response.code, 250); + expect(client.serverInfo.capabilities, ['mock']); + await client.disconnect(); + }); + + test('POP: a partial reply of a lost connection is discarded', () async { + serve( + greeting: '+OK mock ready\r\n', + partialReply: '+OK 3 10', + reply: (_) => '+OK 5 200\r\n', + ); + final client = PopClient(); + await client.connectToServer(host, server.port, isSecure: false); + await expectLater(client.status(), throwsA(isA())); + await client.connectToServer(host, server.port, isSecure: false); + final status = await client.status().timeout(const Duration(seconds: 5)); + expect(status.numberOfMessages, 5); + await client.disconnect(); + }); + + test('IMAP: a partial literal of a lost connection is discarded', () async { + serve( + greeting: '* OK mock ready\r\n', + partialReply: '* 1 FETCH (BODY[] {100}\r\npartial', + reply: (request) => '${request.split(' ').first} OK done\r\n', + ); + final client = ImapClient(); + await client.connectToServer(host, server.port, isSecure: false); + await expectLater(client.noop(), throwsA(isA())); + await client.connectToServer(host, server.port, isSecure: false); + await client.noop().timeout(const Duration(seconds: 5)); + await client.disconnect(); + }); +} From 651f306ce3f63a107990ee9e58f9506e35377e8b Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 12:40:41 +0200 Subject: [PATCH 26/33] fix: no unawaited greeting completer for manually connected sockets connect() with connection information created a greeting completer that nobody awaited, so a socket closing before the greeting raised an unhandled asynchronous error. Co-Authored-By: Claude Fable 5.1 --- lib/src/private/util/client_base.dart | 5 ++++- test/connection_robustness_test.dart | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/src/private/util/client_base.dart b/lib/src/private/util/client_base.dart index 688aa9b9..ba417573 100644 --- a/lib/src/private/util/client_base.dart +++ b/lib/src/private/util/client_base.dart @@ -166,8 +166,11 @@ abstract class ClientBase { /// ``` void connect(Socket socket, {ConnectionInfo? connectionInformation}) { if (connectionInformation != null) { + // used for manually created sockets, e.g. in tests: nobody awaits the + // greeting, so there must be no completer that could fail unhandled connectionInfo = connectionInformation; - _greetingsCompleter = Completer(); + _greetingsCompleter = null; + _isServerGreetingDone = false; } _socket = socket; _writeFuture = null; diff --git a/test/connection_robustness_test.dart b/test/connection_robustness_test.dart index d1a92883..79e45728 100644 --- a/test/connection_robustness_test.dart +++ b/test/connection_robustness_test.dart @@ -1,8 +1,11 @@ import 'dart:io'; import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/util/client_base.dart'; import 'package:test/test.dart'; +import 'mock_socket.dart'; + void main() { late ServerSocket server; late String host; @@ -23,6 +26,24 @@ void main() { ); }); + test('a manually connected socket may close before the greeting', () async { + final connection = MockConnection(); + final client = SmtpClient('test.example.com') + ..connect( + connection.socketClient, + connectionInformation: const ConnectionInfo( + 'smtp.example.com', + 587, + isSecure: false, + ), + ); + // closing the stream before any greeting must not raise an unhandled + // error for a greeting that nobody awaits + await connection.socketClient.close(); + await Future.delayed(const Duration(milliseconds: 20)); + expect(client.isConnected, isFalse); + }); + test('connect fails when no greeting arrives within the timeout', () async { server.listen((socket) { // keep the socket open and stay silent From 56393310e9124ecd34e49320db4efedadb0d6455 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 12:41:36 +0200 Subject: [PATCH 27/33] fix(imap): do not nest the messages of failed pending tasks Protocol errors were reported as 'connection lost: ImapException: protocol error: ...' and an intentional disconnect as 'connection lost: client disconnected'. Pending tasks now fail with the plain message. Co-Authored-By: Claude Fable 5.1 --- lib/src/imap/imap_client.dart | 13 ++++++------- test/connection_robustness_test.dart | 24 ++++++++++++++++++++++++ test/imap/imap_robustness_test.dart | 11 ++++++++++- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index 6b140157..b7b08c61 100644 --- a/lib/src/imap/imap_client.dart +++ b/lib/src/imap/imap_client.dart @@ -321,7 +321,7 @@ class ImapClient extends ClientBase { // the response stream cannot be interpreted anymore, e.g. because the // server announced a literal above the size limit: logApp('Protocol error, closing connection: $e $s'); - _completePendingTasksWithError(ImapException(this, 'protocol error: $e')); + _completePendingTasksWithError('protocol error: $e'); unawaited(disconnect()); } } @@ -369,12 +369,13 @@ class ImapClient extends ClientBase { // the process, and even with one it waited out the full timeout for an // error that had already arrived. The event cannot complete the caller's // future on its own. - _completePendingTasksWithError(error); + _completePendingTasksWithError('connection lost: $error'); fireEvent(ImapConnectionLostEvent(this)); } - /// Error-completes every task that is queued or awaiting a response. - void _completePendingTasksWithError(dynamic error) { + /// Error-completes every task that is queued or awaiting a response with + /// an [ImapException] carrying the given [message]. + void _completePendingTasksWithError(String message) { final pending = [..._queue, ..._tasks.values]; _queue.clear(); _tasks.clear(); @@ -385,9 +386,7 @@ class ImapClient extends ClientBase { continue; } try { - task.completer.completeError( - ImapException(this, 'connection lost: $error'), - ); + task.completer.completeError(ImapException(this, message)); } catch (e) { logApp('unable to completeError for task $task: $e'); } diff --git a/test/connection_robustness_test.dart b/test/connection_robustness_test.dart index 79e45728..a70531c1 100644 --- a/test/connection_robustness_test.dart +++ b/test/connection_robustness_test.dart @@ -91,6 +91,30 @@ void main() { await expectLater(client.list(), throwsA(isA())); }); + test( + 'IMAP: a pending command fails plainly when the client disconnects', + () async { + server.listen((socket) { + socket.write('* OK mock ready\r\n'); + // never answer any command + }); + final client = ImapClient(); + await client.connectToServer(host, server.port, isSecure: false); + final pending = expectLater( + client.noop(), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'client disconnected', + ), + ), + ); + await client.disconnect(); + await pending; + }, + ); + test('a pending command fails when the client disconnects', () async { server.listen((socket) { socket.write('220 mock ESMTP\r\n'); diff --git a/test/imap/imap_robustness_test.dart b/test/imap/imap_robustness_test.dart index b314772a..54029661 100644 --- a/test/imap/imap_robustness_test.dart +++ b/test/imap/imap_robustness_test.dart @@ -212,7 +212,16 @@ void main() { '* 1 FETCH (BODY[] {999999999999}\r\n OK FETCH completed'; await expectLater( client.fetchMessages(MessageSequence.fromId(1), '(BODY[])'), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + startsWith('protocol error: '), + isNot(contains('connection lost')), + ), + ), + ), ); await Future.delayed(const Duration(milliseconds: 50)); expect(client.isConnected, isFalse); From c6c18d3e637b9e075191fee99e813eddeebc6fd7 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 13:00:54 +0200 Subject: [PATCH 28/33] fix(codecs): label encoded words with the charset name UTF-8 'utf8' is not the registered IANA name; decoding still accepts both. Co-Authored-By: Claude Fable 5.1 --- lib/src/codecs/base64_mail_codec.dart | 2 +- .../codecs/quoted_printable_mail_codec.dart | 2 +- test/codecs/base64_mail_codec_test.dart | 5 ++++- .../quoted_printable_mail_codec_test.dart | 2 +- test/header_injection_test.dart | 2 +- test/message_builder_test.dart | 18 +++++++++--------- 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/src/codecs/base64_mail_codec.dart b/lib/src/codecs/base64_mail_codec.dart index 79106cf6..f7d76ced 100644 --- a/lib/src/codecs/base64_mail_codec.dart +++ b/lib/src/codecs/base64_mail_codec.dart @@ -60,7 +60,7 @@ class Base64MailCodec extends MailCodec { if (numberOfRunesAbove7Bit == 0) { return text; } else { - const qpWordHead = '=?utf8?B?'; + const qpWordHead = '=?UTF-8?B?'; const qpWordTail = '?='; const qpWordDelimiterSize = qpWordHead.length + qpWordTail.length; if (fromStart) { diff --git a/lib/src/codecs/quoted_printable_mail_codec.dart b/lib/src/codecs/quoted_printable_mail_codec.dart index 6b36b7e2..b50e83bc 100644 --- a/lib/src/codecs/quoted_printable_mail_codec.dart +++ b/lib/src/codecs/quoted_printable_mail_codec.dart @@ -98,7 +98,7 @@ class QuotedPrintableMailCodec extends MailCodec { return text; } else { // TODO Set the correct encoding - const qpWordHead = '=?utf8?Q?'; + const qpWordHead = '=?UTF-8?Q?'; const qpWordTail = '?='; const qpWordDelimiterSize = qpWordHead.length + qpWordTail.length; if (fromStart) { diff --git a/test/codecs/base64_mail_codec_test.dart b/test/codecs/base64_mail_codec_test.dart index 59b8306b..89a3cd25 100644 --- a/test/codecs/base64_mail_codec_test.dart +++ b/test/codecs/base64_mail_codec_test.dart @@ -41,7 +41,10 @@ void main() { }); test('encodeHeader.base64 with UTF8 input', () { const input = 'Hello Wörld'; - expect(MailCodec.base64.encodeHeader(input), 'Hello W=?utf8?B?w7Y=?=rld'); + expect( + MailCodec.base64.encodeHeader(input), + 'Hello W=?UTF-8?B?w7Y=?=rld', + ); // counter test: expect( MailCodec.decodeHeader('Hello W=?utf8?B?w7Y=?=rld'), diff --git a/test/codecs/quoted_printable_mail_codec_test.dart b/test/codecs/quoted_printable_mail_codec_test.dart index 30843c53..4e4463f5 100644 --- a/test/codecs/quoted_printable_mail_codec_test.dart +++ b/test/codecs/quoted_printable_mail_codec_test.dart @@ -86,7 +86,7 @@ void main() { const input = 'Hello Wörld'; expect( MailCodec.quotedPrintable.encodeHeader(input), - 'Hello W=?utf8?Q?=C3=B6?=rld', + 'Hello W=?UTF-8?Q?=C3=B6?=rld', ); // counter test: expect( diff --git a/test/header_injection_test.dart b/test/header_injection_test.dart index ff787017..7b92d658 100644 --- a/test/header_injection_test.dart +++ b/test/header_injection_test.dart @@ -80,7 +80,7 @@ void main() { test('non-ASCII display names encode quotes and backslashes', () { const address = MailAddress(r'Jörg "JJ" \ Jung', 'jj@example.com'); final encoded = address.encode(); - expect(encoded, startsWith('"=?utf8?Q?')); + expect(encoded, startsWith('"=?UTF-8?Q?')); expect(encoded, endsWith('?=" ')); // no raw quote or backslash inside the quoted-string: final inner = encoded.substring(1, encoded.indexOf('" <')); diff --git a/test/message_builder_test.dart b/test/message_builder_test.dart index bd72244c..726e67dd 100644 --- a/test/message_builder_test.dart +++ b/test/message_builder_test.dart @@ -473,7 +473,7 @@ END:VCARD\r ); expect( message.getHeaderValue('cc'), - '"=?utf8?Q?One_m=C3=B6re?=" ', + '"=?UTF-8?Q?One_m=C3=B6re?=" ', ); expect( message.getHeaderValue('Content-Type'), @@ -597,7 +597,7 @@ END:VCARD\r ); expect( message.getHeaderValue('cc'), - '"=?utf8?Q?One_m=C3=B6re?=" ', + '"=?UTF-8?Q?One_m=C3=B6re?=" ', ); expect( message.getHeaderValue('Content-Type'), @@ -665,7 +665,7 @@ END:VCARD\r ); expect( message.getHeaderValue('cc'), - '"=?utf8?Q?One_m=C3=B6re?=" ', + '"=?UTF-8?Q?One_m=C3=B6re?=" ', ); expect( message.getHeaderContentType()?.mediaType.sub, @@ -717,7 +717,7 @@ END:VCARD\r expect(message.getHeaderValue('to'), '"Me" '); expect( message.getHeaderValue('cc'), - '"=?utf8?Q?One_m=C3=B6re?=" ', + '"=?UTF-8?Q?One_m=C3=B6re?=" ', ); }); @@ -748,7 +748,7 @@ END:VCARD\r expect(message.getHeaderValue('to'), '"Me" '); expect( message.getHeaderValue('cc'), - '"=?utf8?Q?One_m=C3=B6re?=" ', + '"=?UTF-8?Q?One_m=C3=B6re?=" ', ); expect( message.getHeaderValue('from'), @@ -783,7 +783,7 @@ END:VCARD\r expect(message.getHeaderValue('to'), '"Me" '); expect( message.getHeaderValue('cc'), - '"=?utf8?Q?One_m=C3=B6re?=" ', + '"=?UTF-8?Q?One_m=C3=B6re?=" ', ); expect( message.getHeaderValue('from'), @@ -828,7 +828,7 @@ END:VCARD\r ); expect( message.getHeaderValue('cc'), - '"=?utf8?Q?One_m=C3=B6re?=" ', + '"=?UTF-8?Q?One_m=C3=B6re?=" ', ); }); @@ -869,7 +869,7 @@ END:VCARD\r ); expect( message.getHeaderValue('cc'), - '"=?utf8?Q?One_m=C3=B6re?=" ', + '"=?UTF-8?Q?One_m=C3=B6re?=" ', ); }); }); @@ -1480,7 +1480,7 @@ END:VCARD\r ); final builder = MessageBuilder.prepareMailtoBasedMessage(mailto, from); final message = builder.buildMimeMessage(); - expect(message.getHeaderValue('subject'), 'hello w=?utf8?Q?=C3=B6?=rld'); + expect(message.getHeaderValue('subject'), 'hello w=?UTF-8?Q?=C3=B6?=rld'); expect(message.getHeaderValue('to'), 'recpient@domain.com'); expect(message.getHeaderValue('cc'), 'another@domain.com'); expect(message.decodeContentText(), 'let me unsubscribe'); From 15d2e9a7be2f66a55f7eef03abdcd5e778c246f1 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 13:04:20 +0200 Subject: [PATCH 29/33] fix(codecs): render non-ASCII display names as bare encoded-words RFC 2047 section 5 forbids an encoded-word inside a quoted-string, and strict receivers showed the raw encoded text as the display name. The quoted-printable header encoder gained a phrase mode that always encodes and only leaves letters, digits and !*+-/ unencoded; MailAddress.encode() uses it for non-ASCII names and keeps quoted-strings for ASCII names. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + .../codecs/quoted_printable_mail_codec.dart | 49 ++++++++++++++----- lib/src/mail_address.dart | 27 ++++++---- test/codecs/encoding_correctness_test.dart | 37 +++++++++++++- .../quoted_printable_mail_codec_test.dart | 6 +++ test/header_injection_test.dart | 16 +++--- test/message_builder_test.dart | 16 +++--- 7 files changed, 113 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f5a98a7..ccdf0517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ * Fix: `Date` header zones render correctly (`+0000`, `-0330`), obsolete two-digit years follow RFC 5322, header encoders handle astral characters, folding never splits an encoded word. * Fix: `UsernameType.emailLocalPart` returns the local part; secure random Message-IDs; exact plus-alias matching; a working DKIM signer. * Fix: a reply that was received only partially when a connection was lost no longer corrupts the parsing of the replies after a reconnect. +* Fix: non-ASCII display names are rendered as bare RFC 2047 encoded-words restricted to the characters allowed in a phrase instead of being placed inside a quoted-string; encoded words are labelled `UTF-8`. # 2.2.3 * Chore: Shorten package description to 60-180 characters for pub.dev score * Docs: Add example file (example/example.dart) diff --git a/lib/src/codecs/quoted_printable_mail_codec.dart b/lib/src/codecs/quoted_printable_mail_codec.dart index b50e83bc..22b0167e 100644 --- a/lib/src/codecs/quoted_printable_mail_codec.dart +++ b/lib/src/codecs/quoted_printable_mail_codec.dart @@ -69,12 +69,16 @@ class QuotedPrintableMailCodec extends MailCodec { /// [codec] the optional codec, which defaults to utf8. /// Set the optional [fromStart] to true in case the encoding should start /// at the beginning of the text and not in the middle. + /// Set [isPhrase] to true when the text replaces a `phrase`, e.g. the + /// display name of an address: the whole text is then always encoded and + /// only the characters allowed by RFC 2047 section 5 (3) are left as is. @override String encodeHeader( final String text, { int nameLength = 0, Codec codec = utf8, bool fromStart = false, + bool isPhrase = false, }) { final runes = List.from(text.runes, growable: false); var numberOfRunesAbove7Bit = 0; @@ -84,7 +88,7 @@ class QuotedPrintableMailCodec extends MailCodec { for (var runeIndex = 0; runeIndex < runeCount; runeIndex++) { final rune = runes[runeIndex]; - if (rune > 128) { + if (rune > 127) { numberOfRunesAbove7Bit++; if (startIndex == -1) { startIndex = runeIndex; @@ -94,14 +98,14 @@ class QuotedPrintableMailCodec extends MailCodec { } } } - if (numberOfRunesAbove7Bit == 0) { + if (numberOfRunesAbove7Bit == 0 && !isPhrase) { return text; } else { // TODO Set the correct encoding const qpWordHead = '=?UTF-8?Q?'; const qpWordTail = '?='; const qpWordDelimiterSize = qpWordHead.length + qpWordTail.length; - if (fromStart) { + if (fromStart || isPhrase) { startIndex = 0; // the loop below works on runes, not on UTF-16 code units endIndex = runeCount - 1; @@ -144,16 +148,7 @@ class QuotedPrintableMailCodec extends MailCodec { } buffer.write(qpWordHead); } - // " and \ are encoded as well, so that an encoded word can be used - // inside a quoted-string, e.g. for personal names in addresses - if ((rune > AsciiRunes.runeSpace && - rune <= 60 && - rune != AsciiRunes.runeDoubleQuote) || - (rune == 62) || - (rune > 63 && - rune <= 126 && - rune != AsciiRunes.runeUnderline && - rune != AsciiRunes.runeBackslash)) { + if (_isLiteralInEncodedWord(rune, isPhrase: isPhrase)) { wordCounter++; isWordSplit = wordCounter > qpWordSize; if (!isWordSplit) { @@ -187,6 +182,34 @@ class QuotedPrintableMailCodec extends MailCodec { } } + /// Checks if the [rune] may be written as is in a Q encoded word. + /// + /// Within a phrase RFC 2047 section 5 (3) only allows letters, digits and + /// `!*+-/`. In unstructured text every printable ASCII character but + /// `=`, `?` and `_` is allowed; `"` and `\` are encoded there as well to + /// stay on the safe side. + static bool _isLiteralInEncodedWord(int rune, {required bool isPhrase}) { + if (isPhrase) { + return (rune >= 0x30 && rune <= 0x39) || // 0-9 + (rune >= 0x41 && rune <= 0x5A) || // A-Z + (rune >= 0x61 && rune <= 0x7A) || // a-z + rune == 0x21 || // ! + rune == 0x2A || // * + rune == 0x2B || // + + rune == 0x2D || // - + rune == 0x2F; // / + } + + return (rune > AsciiRunes.runeSpace && + rune <= 60 && + rune != AsciiRunes.runeDoubleQuote) || + (rune == 62) || + (rune > 63 && + rune <= 126 && + rune != AsciiRunes.runeUnderline && + rune != AsciiRunes.runeBackslash); + } + /// Decodes the specified text /// /// [part] the text part that should be decoded diff --git a/lib/src/mail_address.dart b/lib/src/mail_address.dart index a1e37f51..d3a89d2a 100644 --- a/lib/src/mail_address.dart +++ b/lib/src/mail_address.dart @@ -117,8 +117,10 @@ class MailAddress { /// Encodes this mail address /// - /// The personal name is rendered as an RFC 5322 quoted-string, escaping - /// `"` and `\`, or as an encoded word when it contains non-ASCII text. + /// A personal name of printable ASCII characters is rendered as an + /// RFC 5322 quoted-string, escaping `"` and `\`. Any other personal name + /// is rendered as RFC 2047 encoded-words that replace the phrase, since an + /// encoded-word must not appear inside a quoted-string (section 5). /// Throws an [ArgumentError] when [email] contains characters that would /// break the address header, compare [isSafeEmail]. /// @@ -136,19 +138,26 @@ class MailAddress { return email; } final name = personalName.trim(); - final encoded = MailCodec.quotedPrintable.encodeHeader( - name, - fromStart: true, - ); + if (!_isPrintableAscii(name)) { + final encoded = MailCodec.quotedPrintable.encodeHeader( + name, + isPhrase: true, + ); + + return '$encoded <$email>'; + } // RFC 5322 section 3.2.4: within a quoted-string the characters " and \ // have to be escaped as quoted-pairs - final quoted = encoded == name - ? name.replaceAll(r'\', r'\\').replaceAll('"', r'\"') - : encoded; + final quoted = name.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); return '"$quoted" <$email>'; } + /// Checks if [text] consists of printable ASCII characters only, + /// i.e. of space up to and including `~`. + static bool _isPrintableAscii(String text) => + text.codeUnits.every((unit) => unit >= 0x20 && unit <= 0x7E); + /// Encodes this mail address into the given [buffer] void writeToStringBuffer(StringBuffer buffer) { if (hasPersonalName) { diff --git a/test/codecs/encoding_correctness_test.dart b/test/codecs/encoding_correctness_test.dart index 10740551..5892b75a 100644 --- a/test/codecs/encoding_correctness_test.dart +++ b/test/codecs/encoding_correctness_test.dart @@ -40,10 +40,45 @@ void main() { test('Q encoding of names with astral characters is complete', () { const address = MailAddress('Bob 😀', 'b@example.com'); final encoded = address.encode(); - expect(encoded, endsWith('?=" ')); + expect(encoded, endsWith('?= ')); expect(MailAddress.parse(encoded).personalName, 'Bob 😀'); }); + test('phrase encoded-words only contain the allowed characters', () { + const name = 'Dr. Müller, Jr. (Vertrieb) '; + final encoded = const MailAddress(name, 'm@example.com').encode(); + expect( + encoded, + '=?UTF-8?Q?Dr=2E_M=C3=BCller=2C_Jr=2E_=28Vertrieb=29_=3Cx=3E?= ' + '', + ); + final parsed = MailAddress.parse(encoded); + expect(parsed.personalName, name); + expect(parsed.email, 'm@example.com'); + }); + + test('long phrases are split into several short encoded-words', () { + final name = 'Ä' * 40; + final encoded = MailAddress(name, 'l@example.com').encode(); + final words = encoded.substring(0, encoded.indexOf(' <')).split(' '); + expect(words.length, greaterThan(1)); + for (final word in words) { + expect( + word, + matches(RegExp(r'^=\?UTF-8\?Q\?[A-Za-z0-9!*+\-/=_]*\?=$')), + ); + expect(word.length, lessThanOrEqualTo(75)); + } + expect(MailAddress.parse(encoded).personalName, name); + }); + + test('ASCII names with specials stay quoted-strings', () { + expect( + const MailAddress('Doe, John', 'j@example.com').encode(), + '"Doe, John" ', + ); + }); + test('B encoding keeps surrounding text and astral characters', () { final encoded = MailCodec.base64.encodeHeader('a😀b', nameLength: 7); expect(encoded, isNot(contains('�'))); diff --git a/test/codecs/quoted_printable_mail_codec_test.dart b/test/codecs/quoted_printable_mail_codec_test.dart index 4e4463f5..9a6cba29 100644 --- a/test/codecs/quoted_printable_mail_codec_test.dart +++ b/test/codecs/quoted_printable_mail_codec_test.dart @@ -94,6 +94,12 @@ void main() { 'Hello Wörld', ); }); + test('encodeHeader for a phrase always encodes and escapes specials', () { + expect( + MailCodec.quotedPrintable.encodeHeader('a.b c', isPhrase: true), + '=?UTF-8?Q?a=2Eb_c?=', + ); + }); test('encodeText.quoted-printable with UTF8 and = input', () { const input = diff --git a/test/header_injection_test.dart b/test/header_injection_test.dart index 7b92d658..9358671e 100644 --- a/test/header_injection_test.dart +++ b/test/header_injection_test.dart @@ -77,16 +77,16 @@ void main() { expect(message.recipientAddresses, ['bob@example.com']); }); - test('non-ASCII display names encode quotes and backslashes', () { + test('non-ASCII display names become bare phrase encoded-words', () { const address = MailAddress(r'Jörg "JJ" \ Jung', 'jj@example.com'); final encoded = address.encode(); - expect(encoded, startsWith('"=?UTF-8?Q?')); - expect(encoded, endsWith('?=" ')); - // no raw quote or backslash inside the quoted-string: - final inner = encoded.substring(1, encoded.indexOf('" <')); - expect(inner, isNot(contains('"'))); - expect(inner, isNot(contains(r'\'))); - expect(MailCodec.decodeHeader(inner), r'Jörg "JJ" \ Jung'); + // RFC 2047 section 5: an encoded-word must not be inside a quoted-string + expect(encoded, startsWith('=?UTF-8?Q?')); + expect(encoded, endsWith('?= ')); + // quotes and backslashes are encoded, not emitted: + expect(encoded, isNot(contains('"'))); + expect(encoded, isNot(contains(r'\'))); + expect(MailAddress.parse(encoded).personalName, r'Jörg "JJ" \ Jung'); }); test('an email address with unsafe characters cannot be encoded', () { diff --git a/test/message_builder_test.dart b/test/message_builder_test.dart index 726e67dd..d3460f69 100644 --- a/test/message_builder_test.dart +++ b/test/message_builder_test.dart @@ -473,7 +473,7 @@ END:VCARD\r ); expect( message.getHeaderValue('cc'), - '"=?UTF-8?Q?One_m=C3=B6re?=" ', + '=?UTF-8?Q?One_m=C3=B6re?= ', ); expect( message.getHeaderValue('Content-Type'), @@ -597,7 +597,7 @@ END:VCARD\r ); expect( message.getHeaderValue('cc'), - '"=?UTF-8?Q?One_m=C3=B6re?=" ', + '=?UTF-8?Q?One_m=C3=B6re?= ', ); expect( message.getHeaderValue('Content-Type'), @@ -665,7 +665,7 @@ END:VCARD\r ); expect( message.getHeaderValue('cc'), - '"=?UTF-8?Q?One_m=C3=B6re?=" ', + '=?UTF-8?Q?One_m=C3=B6re?= ', ); expect( message.getHeaderContentType()?.mediaType.sub, @@ -717,7 +717,7 @@ END:VCARD\r expect(message.getHeaderValue('to'), '"Me" '); expect( message.getHeaderValue('cc'), - '"=?UTF-8?Q?One_m=C3=B6re?=" ', + '=?UTF-8?Q?One_m=C3=B6re?= ', ); }); @@ -748,7 +748,7 @@ END:VCARD\r expect(message.getHeaderValue('to'), '"Me" '); expect( message.getHeaderValue('cc'), - '"=?UTF-8?Q?One_m=C3=B6re?=" ', + '=?UTF-8?Q?One_m=C3=B6re?= ', ); expect( message.getHeaderValue('from'), @@ -783,7 +783,7 @@ END:VCARD\r expect(message.getHeaderValue('to'), '"Me" '); expect( message.getHeaderValue('cc'), - '"=?UTF-8?Q?One_m=C3=B6re?=" ', + '=?UTF-8?Q?One_m=C3=B6re?= ', ); expect( message.getHeaderValue('from'), @@ -828,7 +828,7 @@ END:VCARD\r ); expect( message.getHeaderValue('cc'), - '"=?UTF-8?Q?One_m=C3=B6re?=" ', + '=?UTF-8?Q?One_m=C3=B6re?= ', ); }); @@ -869,7 +869,7 @@ END:VCARD\r ); expect( message.getHeaderValue('cc'), - '"=?UTF-8?Q?One_m=C3=B6re?=" ', + '=?UTF-8?Q?One_m=C3=B6re?= ', ); }); }); From f9dcb8968e28a414add480868fa5511086522570 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 13:08:34 +0200 Subject: [PATCH 30/33] refactor(imap): find the next literal marker with a single search allMatches(text, from) already yields the prefix match first, so the matchAsPrefix call and the first-match helper were redundant. Co-Authored-By: Claude Fable 5.1 --- lib/src/imap/imap_client.dart | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index b7b08c61..044851ad 100644 --- a/lib/src/imap/imap_client.dart +++ b/lib/src/imap/imap_client.dart @@ -1511,9 +1511,7 @@ class ImapClient extends ClientBase { var partStart = 0; var searchFrom = 0; while (true) { - final match = - _literalMarker.matchAsPrefix(cmdText, searchFrom) ?? - _firstLiteralMarker(cmdText, searchFrom); + final match = _literalMarker.allMatches(cmdText, searchFrom).firstOrNull; if (match == null) { parts.add(cmdText.substring(partStart)); @@ -1544,14 +1542,6 @@ class ImapClient extends ClientBase { } } - static RegExpMatch? _firstLiteralMarker(String text, int from) { - for (final match in _literalMarker.allMatches(text, from)) { - return match; - } - - return null; - } - /// Lists all mailboxes in the path [referenceName] that match /// the given [mailboxName] that can contain wildcards. /// From 7207b72df422d1bc8c6fbf89b281e38d3faffd9d Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 13:09:53 +0200 Subject: [PATCH 31/33] refactor(mail): share the STARTTLS enforcement between the clients The security-critical decision was implemented twice with hand-copied messages. One helper now serves IMAP, POP and SMTP; a failing POP STLS is thereby also reported as MailException. Co-Authored-By: Claude Fable 5.1 --- lib/src/mail/mail_client.dart | 112 ++++++++++++++++++---------------- 1 file changed, 60 insertions(+), 52 deletions(-) diff --git a/lib/src/mail/mail_client.dart b/lib/src/mail/mail_client.dart index ea06e58e..df26580f 100644 --- a/lib/src/mail/mail_client.dart +++ b/lib/src/mail/mail_client.dart @@ -1651,6 +1651,44 @@ enum ThreadPreference { latest, } +/// Ensures that the connection of [client] is encrypted as demanded by the +/// socket type of the server [config] before any credentials are sent. +/// +/// [SocketType.ssl] connections are encrypted already and +/// [SocketType.plainNoStartTls] allows clear text explicitly. Every other +/// socket type requires STARTTLS: when [supportsStartTls] reports that the +/// server does not offer it, the connection is closed and a client error is +/// thrown instead of silently falling back to clear text, because an +/// attacker could just strip the capability to obtain the credentials. +Future _requireEncryption( + ClientBase client, + ServerConfig config, { + required Future Function() supportsStartTls, + required Future Function() startTls, +}) async { + switch (config.socketType) { + case SocketType.ssl: + return; + case SocketType.plainNoStartTls: + client.logApp( + 'Warning: connecting without encryption, ' + 'your credentials are not secure.', + ); + + return; + default: + if (!await supportsStartTls()) { + await client.disconnect(); + throw client.createClientError( + 'STARTTLS is not supported by ${config.hostname}, ' + 'refusing to authenticate over an unencrypted connection. ' + 'Use SocketType.plainNoStartTls to allow this explicitly.', + ); + } + await startTls(); + } +} + abstract class _IncomingMailClient { _IncomingMailClient(this.downloadSizeLimit, this._config, this.mailClient); @@ -2126,31 +2164,19 @@ class _IncomingImapClient extends _IncomingMailClient { timeout: timeout, ); try { - if (!isSecure) { - if (serverConfig.socketType == SocketType.plainNoStartTls) { - log( - 'Warning: connecting without encryption, ' - 'your credentials are not secure.', - ); - } else { + await _requireEncryption( + _imapClient, + serverConfig, + supportsStartTls: () async { // the greeting does not have to announce the capabilities if (_imapClient.serverInfo.capabilities?.isEmpty ?? true) { await _imapClient.capability(); } - if (!_imapClient.serverInfo.supportsStartTls) { - // never fall back to clear text silently, an attacker could - // just strip the STARTTLS capability to obtain the credentials - await _imapClient.disconnect(); - throw ImapException( - _imapClient, - 'STARTTLS is not supported by ${serverConfig.hostname}, ' - 'refusing to authenticate over an unencrypted connection. ' - 'Use SocketType.plainNoStartTls to allow this explicitly.', - ); - } - await _imapClient.startTls(); - } - } + + return _imapClient.serverInfo.supportsStartTls; + }, + startTls: _imapClient.startTls, + ); await _config.authentication.authenticate( serverConfig, imap: _imapClient, @@ -3331,18 +3357,14 @@ class _IncomingPopClient extends _IncomingMailClient { isSecure: isSecure, timeout: timeout, ); - if (!isSecure) { - //TODO check POP3 server capabilities first - if (serverConfig.socketType != SocketType.plainNoStartTls) { - await _popClient.startTls(); - } else { - log( - 'Warning: not using secure connection, ' - 'your credentials are not secure.', - ); - } - } try { + await _requireEncryption( + _popClient, + serverConfig, + // TODO check the POP3 server capabilities (CAPA) for STLS first + supportsStartTls: () async => true, + startTls: _popClient.startTls, + ); final authResponse = await _config.authentication.authenticate( serverConfig, pop: _popClient, @@ -3673,26 +3695,12 @@ class _OutgoingSmtpClient extends _OutgoingMailClient { isSecure: isSecure, ); await _smtpClient.ehlo(); - if (!isSecure) { - if (config.socketType == SocketType.plainNoStartTls) { - _smtpClient.logApp( - 'Warning: not using secure connection, ' - 'your credentials are not secure.', - ); - } else if (!_smtpClient.serverInfo.supportsStartTls) { - // never fall back to clear text silently, an attacker could - // just strip the STARTTLS capability to obtain the credentials - await _smtpClient.disconnect(); - throw SmtpException.message( - _smtpClient, - 'STARTTLS is not supported by ${config.hostname}, ' - 'refusing to authenticate over an unencrypted connection. ' - 'Use SocketType.plainNoStartTls to allow this explicitly.', - ); - } else { - await _smtpClient.startTls(); - } - } + await _requireEncryption( + _smtpClient, + config, + supportsStartTls: () async => _smtpClient.serverInfo.supportsStartTls, + startTls: _smtpClient.startTls, + ); await _mailConfig.authentication.authenticate( config, smtp: _smtpClient, From 69ea4824c849dd1f6bb779717416e06b769b71c3 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 13:11:16 +0200 Subject: [PATCH 32/33] refactor: share the write failure handling of the SMTP and POP clients Both clients carried the same logic for failing a command whose write failed. ClientBase.writeOrFail() now owns it and uses createClientError, the clients only keep their own command bookkeeping. Co-Authored-By: Claude Fable 5.1 --- lib/src/pop/pop_client.dart | 52 ++++++++++----------------- lib/src/private/util/client_base.dart | 24 +++++++++++++ lib/src/smtp/smtp_client.dart | 48 ++++++++++--------------- 3 files changed, 62 insertions(+), 62 deletions(-) diff --git a/lib/src/pop/pop_client.dart b/lib/src/pop/pop_client.dart index 2947d220..cf65898b 100644 --- a/lib/src/pop/pop_client.dart +++ b/lib/src/pop/pop_client.dart @@ -79,10 +79,7 @@ class PopClient extends ClientBase { @override void onConnectionError(dynamic error) { // a command awaiting its response would otherwise hang forever: - _failCurrentCommand( - PopException.message(this, 'connection lost: $error'), - StackTrace.current, - ); + _failCurrentCommand('connection lost: $error'); if (!_eventController.isClosed) { _eventController.add(PopConnectionLostEvent(this)); } @@ -90,42 +87,34 @@ class PopClient extends ClientBase { @override Future disconnect() { - _failCurrentCommand( - PopException.message(this, 'client disconnected'), - StackTrace.current, - ); + _failCurrentCommand('client disconnected'); return super.disconnect(); } - void _failCurrentCommand(Object error, StackTrace stackTrace) { + /// Fails the current command, if any, with a client error [message] + void _failCurrentCommand(String message, [StackTrace? stackTrace]) { final command = _currentCommand; _currentCommand = null; _currentFirstResponseLine = null; if (command != null && !command.completer.isCompleted) { - command.completer.completeError(error, stackTrace); + command.completer.completeError( + createClientError(message), + stackTrace ?? StackTrace.current, + ); } } - /// Writes to the socket and fails the [command] when writing fails, - /// instead of leaving it pending with an unhandled asynchronous error. - void _write(Future Function() write, PopCommand command) { - unawaited( - write().catchError((Object e, StackTrace s) { - if (_currentCommand == command) { - _currentCommand = null; - } - if (!command.completer.isCompleted) { - command.completer.completeError( - e is PopException - ? e - : PopException.message(this, 'unable to send command: $e'), - s, - ); - } - }), - ); - } + /// Writes to the socket and fails the [command] when writing fails + void _write(Future Function() write, PopCommand command) => writeOrFail( + write, + command.completer, + onWriteError: () { + if (_currentCommand == command) { + _currentCommand = null; + } + }, + ); @override void onDataReceived(Uint8List data) { @@ -282,10 +271,7 @@ class PopClient extends ClientBase { } catch (e, s) { // a malformed server reply must fail the command, not leave it pending logApp('Unable to process response: $e $s'); - _failCurrentCommand( - PopException.message(this, 'unable to process response: $e'), - s, - ); + _failCurrentCommand('unable to process response: $e', s); } } diff --git a/lib/src/private/util/client_base.dart b/lib/src/private/util/client_base.dart index ba417573..c0b23c74 100644 --- a/lib/src/private/util/client_base.dart +++ b/lib/src/private/util/client_base.dart @@ -341,6 +341,30 @@ abstract class ClientBase { _writeFuture = null; } + /// Writes to the socket via [write] and fails the [completer] with a + /// client error when the write fails, instead of leaving the completer + /// pending with an unhandled asynchronous error. + /// + /// [onWriteError] is called before the completer is failed, e.g. to forget + /// the command that the completer belongs to. + void writeOrFail( + Future Function() write, + Completer completer, { + void Function()? onWriteError, + }) { + unawaited( + write().catchError((Object e, StackTrace s) { + onWriteError?.call(); + if (!completer.isCompleted) { + completer.completeError( + createClientError('unable to send command: $e'), + s, + ); + } + }), + ); + } + /// Logs the data from the app-side void logApp(dynamic logObject) => log(logObject, initial: initialApp); diff --git a/lib/src/smtp/smtp_client.dart b/lib/src/smtp/smtp_client.dart index 9dfa82cf..2cdfcf0b 100644 --- a/lib/src/smtp/smtp_client.dart +++ b/lib/src/smtp/smtp_client.dart @@ -157,10 +157,7 @@ class SmtpClient extends ClientBase { @override void onConnectionError(dynamic error) { // a command awaiting its response would otherwise hang forever: - _failCurrentCommand( - SmtpException.message(this, 'connection lost: $error'), - StackTrace.current, - ); + _failCurrentCommand('connection lost: $error'); if (!_eventController.isClosed) { _eventController.add(SmtpConnectionLostEvent(this)); } @@ -168,41 +165,34 @@ class SmtpClient extends ClientBase { @override Future disconnect() { - _failCurrentCommand( - SmtpException.message(this, 'client disconnected'), - StackTrace.current, - ); + _failCurrentCommand('client disconnected'); return super.disconnect(); } - void _failCurrentCommand(Object error, StackTrace stackTrace) { + /// Fails the current command, if any, with a client error [message] + void _failCurrentCommand(String message) { final command = _currentCommand; _currentCommand = null; if (command != null && !command.completer.isCompleted) { - command.completer.completeError(error, stackTrace); + command.completer.completeError( + createClientError(message), + StackTrace.current, + ); } } - /// Writes to the socket and fails the [command] when writing fails, - /// instead of leaving it pending with an unhandled asynchronous error. - void _write(Future Function() write, SmtpCommand command) { - unawaited( - write().catchError((Object e, StackTrace s) { - if (_currentCommand == command) { - _currentCommand = null; - } - if (!command.completer.isCompleted) { - command.completer.completeError( - e is SmtpException - ? e - : SmtpException.message(this, 'unable to send command: $e'), - s, - ); - } - }), - ); - } + /// Writes to the socket and fails the [command] when writing fails + void _write(Future Function() write, SmtpCommand command) => + writeOrFail( + write, + command.completer, + onWriteError: () { + if (_currentCommand == command) { + _currentCommand = null; + } + }, + ); @override void onDataReceived(Uint8List data) { From 2505b31a4acfb9193cbbd311098d150573dd8958 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Wed, 30 Sep 2026 13:12:07 +0200 Subject: [PATCH 33/33] test: cover the write failure path of the SMTP and POP clients Co-Authored-By: Claude Fable 5.1 --- test/write_failure_test.dart | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 test/write_failure_test.dart diff --git a/test/write_failure_test.dart b/test/write_failure_test.dart new file mode 100644 index 00000000..dc0a9e35 --- /dev/null +++ b/test/write_failure_test.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/smtp/smtp_command.dart'; +import 'package:enough_mail/src/private/util/client_base.dart'; +import 'package:test/test.dart'; + +import 'mock_socket.dart'; + +/// A socket whose writes never reach the wire +class _FailingSocket extends MockSocket { + @override + Future flush() => Future.error(const SocketException('write failed')); +} + +const _info = ConnectionInfo('mail.example.com', 143, isSecure: false); + +void main() { + test('SMTP: a command whose write fails is rejected', () async { + final client = SmtpClient('test.example.com') + ..connect(_FailingSocket(), connectionInformation: _info); + await expectLater( + client.sendCommand(SmtpCommand('NOOP')), + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('unable to send command'), + ), + ), + ); + }); + + test('POP: a command whose write fails is rejected', () async { + final client = PopClient() + ..connect(_FailingSocket(), connectionInformation: _info); + await expectLater( + client.noop(), + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('unable to send command'), + ), + ), + ); + }); +}