Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
27b4cfc
fix(mail): enforce STARTTLS instead of silently falling back to clear…
robert-virkus Sep 30, 2026
a9285db
fix(smtp): apply RFC 5321 transparency to every line of the DATA payload
robert-virkus Sep 30, 2026
903a5a7
fix: prevent header and SMTP envelope injection through untrusted values
robert-virkus Sep 30, 2026
69cf7dd
fix(smtp): strip the Bcc header on the BDAT path and only in the head…
robert-virkus Sep 30, 2026
b9153f9
fix(mime): only accept multipart delimiters at the start of a line
robert-virkus Sep 30, 2026
9405965
fix(mail): redact credentials in toString() of accounts, configs and …
robert-virkus Sep 30, 2026
0199007
fix(discover): harden auto-configuration fetching and configuration h…
robert-virkus Sep 30, 2026
c59a72c
fix(imap): survive malformed server responses instead of wedging the …
robert-virkus Sep 30, 2026
99ea91f
fix: fail pending operations on connection problems instead of hanging
robert-virkus Sep 30, 2026
cb2e138
fix(smtp,pop): frame multi-line replies correctly
robert-virkus Sep 30, 2026
ec85174
fix(smtp): encode AUTH PLAIN and AUTH LOGIN credentials as UTF-8
robert-virkus Sep 30, 2026
fcd7dd3
fix(smtp): honour negative replies during MAIL FROM, RCPT TO, DATA an…
robert-virkus Sep 30, 2026
ea18555
fix(mail): make reconnecting deadlock-free and keep delivering events…
robert-virkus Sep 30, 2026
60dd988
fix(pop): make polling, deletion and OAuth work in the POP3 path
robert-virkus Sep 30, 2026
aff5cd9
fix(mail): keep rotated OAuth refresh tokens and make token equality …
robert-virkus Sep 30, 2026
9c035c1
fix(imap): quote and escape user-supplied strings in commands
robert-virkus Sep 30, 2026
b184b6a
fix(mime): decode untrusted content without throwing and pick the rig…
robert-virkus Sep 30, 2026
7ca2971
fix(codecs): correct date zones, obsolete years, header encoders and …
robert-virkus Sep 30, 2026
80e0242
fix: secure random ids, exact plus-alias matching and a usable DKIM s…
robert-virkus Sep 30, 2026
9b938e4
fix(mail): delete and move by UID and leave mailboxes with UNSELECT
robert-virkus Sep 30, 2026
a4cb12d
docs: describe the security and correctness fixes in the changelog
robert-virkus Sep 30, 2026
c5c35f6
fix(pop): read the reply to a follow-up command afresh
robert-virkus Sep 30, 2026
2e40621
fix(mail): keep unread POP messages when a retrieve fails
robert-virkus Sep 30, 2026
fa8ea0a
fix(smtp): report invalid envelope addresses as SmtpException
robert-virkus Sep 30, 2026
eda15f5
fix: reset the response parsers when a client reconnects
robert-virkus Sep 30, 2026
651f306
fix: no unawaited greeting completer for manually connected sockets
robert-virkus Sep 30, 2026
5639331
fix(imap): do not nest the messages of failed pending tasks
robert-virkus Sep 30, 2026
c6c18d3
fix(codecs): label encoded words with the charset name UTF-8
robert-virkus Sep 30, 2026
15d2e9a
fix(codecs): render non-ASCII display names as bare encoded-words
robert-virkus Sep 30, 2026
f9dcb89
refactor(imap): find the next literal marker with a single search
robert-virkus Sep 30, 2026
7207b72
refactor(mail): share the STARTTLS enforcement between the clients
robert-virkus Sep 30, 2026
69ea482
refactor: share the write failure handling of the SMTP and POP clients
robert-virkus Sep 30, 2026
2505b31
test: cover the write failure path of the SMTP and POP clients
robert-virkus Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
74 changes: 46 additions & 28 deletions lib/src/codecs/base64_mail_codec.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 =
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
39 changes: 16 additions & 23 deletions lib/src/codecs/date_codec.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 9 additions & 1 deletion lib/src/codecs/mail_codec.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading
Loading