diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fd0e8f..ccdf0517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,26 @@ # 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. +* 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/base64_mail_codec.dart b/lib/src/codecs/base64_mail_codec.dart index c3968aac..f7d76ced 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; @@ -60,12 +60,12 @@ 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) { 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,40 +113,56 @@ 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(); } } + /// 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/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/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 2ec20582..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,16 +98,17 @@ class QuotedPrintableMailCodec extends MailCodec { } } } - if (numberOfRunesAbove7Bit == 0) { + if (numberOfRunesAbove7Bit == 0 && !isPhrase) { 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) { + if (fromStart || isPhrase) { 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 = @@ -143,9 +148,7 @@ class QuotedPrintableMailCodec extends MailCodec { } buffer.write(qpWordHead); } - if ((rune > AsciiRunes.runeSpace && rune <= 60) || - (rune == 62) || - (rune > 63 && rune <= 126 && rune != AsciiRunes.runeUnderline)) { + if (_isLiteralInEncodedWord(rune, isPhrase: isPhrase)) { wordCounter++; isWordSplit = wordCounter > qpWordSize; if (!isWordSplit) { @@ -179,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 @@ -193,46 +224,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/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/imap/imap_client.dart b/lib/src/imap/imap_client.dart index b663865b..044851ad 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 @@ -304,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('protocol error: $e'); + unawaited(disconnect()); + } } @override @@ -312,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 '); @@ -348,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(); @@ -364,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'); } @@ -380,7 +400,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(); } @@ -389,12 +409,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; @@ -444,8 +482,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}'; @@ -498,6 +539,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; } @@ -1349,29 +1394,154 @@ 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.allMatches(cmdText, searchFrom).firstOrNull; + 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; + } + } + /// Lists all mailboxes in the path [referenceName] that match /// the given [mailboxName] that can contain wildcards. /// @@ -1593,7 +1763,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(' ') @@ -1646,7 +1816,7 @@ class ImapClient extends ClientBase { /// without triggering the expunge events. /// /// Compare [selectMailbox] - Future unselectMailbox() { + Future unselectMailbox() { if (_selectedMailbox == null) { return Future.value(); } @@ -1657,8 +1827,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] @@ -1687,18 +1858,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); } @@ -1741,18 +1901,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); } @@ -2170,7 +2319,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); @@ -2179,35 +2328,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. @@ -2215,23 +2366,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(')'); @@ -2258,7 +2409,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) @@ -2342,7 +2493,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)}', @@ -2382,7 +2533,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, @@ -2607,13 +2758,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); } @@ -2655,13 +2800,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); } @@ -2860,13 +2999,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) { @@ -2898,8 +3051,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'); } } @@ -2990,5 +3149,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/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/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/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..45de6fb1 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; @@ -123,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) => @@ -227,18 +258,51 @@ 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 - String toString() => jsonEncode(toJson()); + 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. + @override + String toString() => jsonEncode(MailAuthentication.redactSecrets(toJson())); } /// Provides an OAuth-compliant authentication @@ -292,7 +356,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 @@ -313,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 99a586d0..df26580f 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), @@ -355,11 +368,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 +1101,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. @@ -1636,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); @@ -1755,7 +1808,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'); + } } } @@ -2014,7 +2073,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 +2116,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, ); @@ -2102,18 +2163,20 @@ 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 { + await _requireEncryption( + _imapClient, + serverConfig, + supportsStartTls: () async { + // the greeting does not have to announce the capabilities + if (_imapClient.serverInfo.capabilities?.isEmpty ?? true) { + await _imapClient.capability(); + } + + return _imapClient.serverInfo.supportsStartTls; + }, + startTls: _imapClient.startTls, + ); await _config.authentication.authenticate( serverConfig, imap: _imapClient, @@ -2150,6 +2213,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(); } @@ -2179,7 +2245,7 @@ class _IncomingImapClient extends _IncomingMailClient { await _pauseIdle(); try { if (_selectedMailbox != null) { - await _imapClient.closeMailbox(); + await _deselectMailbox(); } var quickReSync = qresync; if (qresync == null && @@ -2690,14 +2756,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; @@ -2732,12 +2793,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 @@ -2765,6 +2821,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) { @@ -2779,7 +2868,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'), ); @@ -2797,7 +2886,7 @@ class _IncomingImapClient extends _IncomingMailClient { targetMailbox: deleteResult.originalMailbox, ); } - await _imapClient.closeMailbox(); + await _deselectMailbox(); await _imapClient.selectMailbox(deleteResult.originalMailbox); if (result == null) { throw MailException( @@ -2831,7 +2920,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) { @@ -2844,7 +2933,7 @@ class _IncomingImapClient extends _IncomingMailClient { ], action: StoreAction.add); } - await _imapClient.closeMailbox(); + await _deselectMailbox(); await _imapClient.selectMailbox(deleteResult.originalMailbox); } } on ImapException catch (e) { @@ -2925,9 +3014,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; @@ -3253,6 +3340,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( @@ -3261,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, @@ -3310,23 +3402,26 @@ 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) { - //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--) { - 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; @@ -3358,12 +3453,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.'); } @@ -3584,6 +3684,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 { @@ -3593,17 +3695,12 @@ class _OutgoingSmtpClient extends _OutgoingMailClient { isSecure: isSecure, ); await _smtpClient.ehlo(); - if (!isSecure) { - if (_smtpClient.serverInfo.supportsStartTls && - (config.socketType != SocketType.plainNoStartTls)) { - await _smtpClient.startTls(); - } else { - _smtpClient.logApp( - 'Warning: not using secure connection, ' - 'your credentials are not secure.', - ); - } - } + await _requireEncryption( + _smtpClient, + config, + supportsStartTls: () async => _smtpClient.serverInfo.supportsStartTls, + startTls: _smtpClient.startTls, + ); await _mailConfig.authentication.authenticate( config, smtp: _smtpClient, @@ -3635,7 +3732,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/mail_address.dart b/lib/src/mail_address.dart index fe49cc2b..d3a89d2a 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,29 +91,73 @@ 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 /// + /// 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]. + /// /// 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('>'); + final name = personalName.trim(); + if (!_isPrintableAscii(name)) { + final encoded = MailCodec.quotedPrintable.encodeHeader( + name, + isPhrase: true, + ); - return buffer.toString(); + 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 = 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) { @@ -184,14 +229,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 90bc1db8..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; @@ -705,6 +706,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 +935,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 +948,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': @@ -1305,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(); @@ -1718,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); @@ -1729,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/mime_data.dart b/lib/src/mime_data.dart index 3a3dceee..8fff82c5 100644 --- a/lib/src/mime_data.dart +++ b/lib/src/mime_data.dart @@ -1,411 +1,479 @@ -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) { + 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; + } + } + + 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/lib/src/mime_message.dart b/lib/src/mime_message.dart index 22ac2d71..0972d19e 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; } } @@ -1357,8 +1365,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; @@ -1375,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; @@ -1385,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 @@ -1409,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 || @@ -1436,6 +1476,9 @@ class Header { } break; } + if (isInsideEncodedWord(i)) { + continue; + } final char = value.codeUnitAt(i); if (char == AsciiRunes.runeSemicolon || char == AsciiRunes.runeSpace || @@ -1814,21 +1857,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/lib/src/pop/pop_client.dart b/lib/src/pop/pop_client.dart index 5961af88..cf65898b 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]. @@ -60,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()); @@ -70,11 +78,44 @@ class PopClient extends ClientBase { @override void onConnectionError(dynamic error) { - _eventController - ..add(PopConnectionLostEvent(this)) - ..close(); + // a command awaiting its response would otherwise hang forever: + _failCurrentCommand('connection lost: $error'); + if (!_eventController.isClosed) { + _eventController.add(PopConnectionLostEvent(this)); + } } + @override + Future disconnect() { + _failCurrentCommand('client disconnected'); + + return super.disconnect(); + } + + /// 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( + createClientError(message), + stackTrace ?? StackTrace.current, + ); + } + } + + /// 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) { _uint8listReader.add(data); @@ -124,12 +165,20 @@ 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 Future quit() async { await sendCommand(PopQuitCommand(this)); isLoggedIn = false; + await disconnect(); } /// Checks the status ie the total number of messages and their size @@ -148,13 +197,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) => @@ -170,7 +229,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 +243,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); + // 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) { 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('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/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..566ce29c 100644 --- a/lib/src/private/imap/imap_response_reader.dart +++ b/lib/src/private/imap/imap_response_reader.dart @@ -7,81 +7,111 @@ 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(); + } + + /// 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; + 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); + _currentResponse = newResponse; + _awaitingLiteralFor = line; + continue; } - if (_currentLine?.isWithLiteral ?? false) { - break; - } - 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/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/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_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/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/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/smtp/commands/smtp_send_bdat_command.dart b/lib/src/private/smtp/commands/smtp_send_bdat_command.dart index 22d81987..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,22 +7,30 @@ 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( 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; @@ -59,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++; @@ -92,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++; @@ -117,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 @@ -137,10 +163,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, @@ -161,10 +184,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 e5f80fd2..d80d2220 100644 --- a/lib/src/private/smtp/commands/smtp_sendmail_command.dart +++ b/lib/src/private/smtp/commands/smtp_sendmail_command.dart @@ -1,18 +1,27 @@ 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( 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; @@ -29,58 +38,67 @@ 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: - _currentStep = _SmtpSendCommandSequence.done; + if (response.code != 354) { + // the server does not want the message data + failureResponse = response; - final data = getData(); + return _abort(); + } + _currentStep = _SmtpSendCommandSequence.done; - // \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.'; - default: + return applySmtpTransparency(getData()); + 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; } -/// 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 @@ -90,7 +108,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, @@ -109,7 +127,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 200cca26..572a5ed7 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 @@ -15,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); @@ -42,6 +50,97 @@ 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) { + 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) { + return 'no $description email address given'; + } + if (!MailAddress.isSafeEmail(email)) { + return '$description email address <$email> contains characters that ' + 'are not allowed in an SMTP envelope address'; + } + + return null; +} + +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 +/// 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/private/util/client_base.dart b/lib/src/private/util/client_base.dart index a3249edc..c0b23c74 100644 --- a/lib/src/private/util/client_base.dart +++ b/lib/src/private/util/client_base.dart @@ -1,361 +1,418 @@ -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; + 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); + final greetingsCompleter = Completer(); + _greetingsCompleter = greetingsCompleter; + _isServerGreetingDone = false; + connect(socket); + + // 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]. + /// + /// 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) { + // 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 = null; + _isServerGreetingDone = false; + } + _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; + _failGreeting('connection error before server greeting: $e'); + 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); + final completer = _greetingsCompleter; + if (completer != null && !completer.isCompleted) { + completer.complete(connectionInfo); + } + } + } + + /// Informs about a closed connection + void onConnectionDone() { + logApp('Done, connection closed'); + isLoggedIn = false; + _isConnected = false; + _failGreeting('connection closed before server greeting'); + 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; + } + + /// 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); + + /// 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 + Exception 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/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/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/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/lib/src/private/util/uint8_list_reader.dart b/lib/src/private/util/uint8_list_reader.dart index 2b372b0d..485500fa 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,12 +79,70 @@ class Uint8ListReader { if (!isAvailable(length)) { return null; } + _terminatorScanner.reset(); return _builder.takeFirst(length); } /// 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 +/// 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]. @@ -192,6 +249,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 452b602d..2cdfcf0b 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 @@ -142,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, @@ -152,20 +156,83 @@ class SmtpClient extends ClientBase { @override void onConnectionError(dynamic error) { - _eventController - ..add(SmtpConnectionLostEvent(this)) - ..close(); + // a command awaiting its response would otherwise hang forever: + _failCurrentCommand('connection lost: $error'); + if (!_eventController.isClosed) { + _eventController.add(SmtpConnectionLostEvent(this)); + } + } + + @override + Future disconnect() { + _failCurrentCommand('client disconnected'); + + return super.disconnect(); + } + + /// 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( + createClientError(message), + StackTrace.current, + ); + } } + /// 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) { - //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 @@ -174,6 +241,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 +263,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,15 +282,36 @@ 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; } + /// 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. @@ -237,9 +328,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( @@ -260,15 +349,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, ), ); @@ -277,7 +365,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, @@ -285,15 +374,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, ), ); @@ -322,9 +410,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( @@ -352,15 +438,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, ), @@ -385,15 +470,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, ), @@ -432,14 +516,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; } @@ -461,14 +547,15 @@ 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)); + 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; @@ -482,6 +569,6 @@ class SmtpClient extends ClientBase { } @override - Object createClientError(String message) => + Exception createClientError(String message) => SmtpException.message(this, message); } 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/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..5892b75a --- /dev/null +++ b/test/codecs/encoding_correctness_test.dart @@ -0,0 +1,118 @@ +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('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('�'))); + 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)'); + }); + }); +} diff --git a/test/codecs/quoted_printable_mail_codec_test.dart b/test/codecs/quoted_printable_mail_codec_test.dart index 30843c53..9a6cba29 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( @@ -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/connection_robustness_test.dart b/test/connection_robustness_test.dart new file mode 100644 index 00000000..a70531c1 --- /dev/null +++ b/test/connection_robustness_test.dart @@ -0,0 +1,129 @@ +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; + + 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('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 + }); + 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( + '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'); + // 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; + }); +} diff --git a/test/header_injection_test.dart b/test/header_injection_test.dart new file mode 100644 index 00000000..9358671e --- /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 become bare phrase encoded-words', () { + const address = MailAddress(r'Jörg "JJ" \ Jung', 'jj@example.com'); + final encoded = address.encode(); + // 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', () { + 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/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', + ]); + }); + }); +} diff --git a/test/imap/imap_robustness_test.dart b/test/imap/imap_robustness_test.dart new file mode 100644 index 00000000..54029661 --- /dev/null +++ b/test/imap/imap_robustness_test.dart @@ -0,0 +1,231 @@ +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().having( + (e) => e.message, + 'message', + allOf( + startsWith('protocol error: '), + isNot(contains('connection lost')), + ), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + expect(client.isConnected, isFalse); + }, + ); + }); +} diff --git a/test/mail/imap_loopback_server.dart b/test/mail/imap_loopback_server.dart new file mode 100644 index 00000000..e33fa857 --- /dev/null +++ b/test/mail/imap_loopback_server.dart @@ -0,0 +1,108 @@ +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 UIDPLUS UNSELECT 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 UIDPLUS UNSELECT AUTH=PLAIN AUTH=XOAUTH2\r\n' + '$tag OK done\r\n', + ); + break; + case 'LOGIN': + case 'AUTHENTICATE': + socket.write( + '$tag OK [CAPABILITY IMAP4rev1 UIDPLUS UNSELECT] authenticated\r\n', + ); + break; + case 'LIST': + socket.write( + '* 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( + '* 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_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); + }); +} 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), + ); + }); +} diff --git a/test/mail/mail_client_pop_test.dart b/test/mail/mail_client_pop_test.dart new file mode 100644 index 00000000..f4b84dc9 --- /dev/null +++ b/test/mail/mail_client_pop_test.dart @@ -0,0 +1,181 @@ +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; + + /// 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; + + Future close() => _server.close(); + + void _onConnection(Socket socket) { + 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')) { + 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; + 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', + ); + 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: (_) {}); + } +} + +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; + + setUp(() async { + server = await PopLoopbackServer.start(); + }); + + tearDown(() => server.close()); + + test('polling detects new messages and ends the previous session', () async { + final mailClient = MailClient(_account(server)); + 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('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); + 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().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(); + }); +} 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(); + }); +} 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/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); + }); +} 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)); + }); +} 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'); + }); + }); +} 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)); + }); +} diff --git a/test/message_builder_test.dart b/test/message_builder_test.dart index bd72244c..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'), - '"=?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'); 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); + } + }); + }); +} 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'); + }); + }); +} 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/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(); + }); +} 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')); + }); +} 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); + }); +} 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(); + }); +} diff --git a/test/smtp/smtp_send_state_test.dart b/test/smtp/smtp_send_state_test.dart new file mode 100644 index 00000000..2920d677 --- /dev/null +++ b/test/smtp/smtp_send_state_test.dart @@ -0,0 +1,217 @@ +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); + }); + }); + + 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 = []; + 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(); + }); +} 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')); + }); + }); +} 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); + }); +} 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'), + ), + ), + ); + }); +}