Skip to content

fix(smtp): tolerate bare reply code without text (RFC 5321 section 4.2) - #285

Merged
robert-virkus merged 1 commit into
Enough-Software:mainfrom
brlumen:fix/smtp-bare-reply-code
Sep 16, 2026
Merged

robert-virkus merged 1 commit into
Enough-Software:mainfrom
brlumen:fix/smtp-bare-reply-code

Conversation

@brlumen

@brlumen brlumen commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Problem

SmtpResponseLine.parse at smtp_response.dart:77 calls text.substring(4) unconditionally once the 3-digit code parses, so a reply line consisting of the code only (334<CRLF>) throws:

RangeError (start): Invalid value: Not in inclusive range 0..3: 4

This happens on every XOAUTH2 login against smtp.yandex.ru: SmtpAuthXOauth2Command sends AUTH XOAUTH2 without an initial response, and Yandex writes the empty SASL challenge as a bare 334 — no trailing space (RFC 4954 §4 asks for 334 , but the RFC 5321 §4.2 reply grammar Reply-code [ SP textstring ] CRLF allows the code-only form, and a client must not crash on it either way). Reproducible with openssl s_client and no credentials, see the issue.

The consequence is worse than a crash: SmtpClient.onServerResponse builds SmtpResponse(responseTexts) outside the try block introduced in #81, so the error escapes the socket callback as an unhandled exception and the pending command's completer never completes. The caller hangs until its own timeout, and every retry fails the same way.

Fixes #284.

Fix

lib/src/smtp/smtp_response.dart

  • Parse Reply-code [ SP textstring ] per RFC 5321: a bare code yields an empty message; lines shorter than a reply code no longer throw from substring(0, 3) and fall into the existing "not a code → 500 + raw text" branch.

lib/src/smtp/smtp_client.dart

No behaviour change for well-formed replies.

Tests

  • New test/src/smtp/smtp_response_test.dart: 250 OK, 250-text, bare 250, 250 / 250-, non-numeric line, line shorter than a code, multiline SmtpResponse ending with a bare code.

  • New client-level tests in test/smtp/smtp_client_test.dart:

    • SmtpClient AUTH XOAUTH2 with bare 334 challenge — mirrors the Yandex exchange (334 → base64 → 235); MockSmtpServer gained a small responses queue for the two-step reply.
    • SmtpClient EHLO with bare reply code — multiline reply ending in a bare 250.

    Without the fix both throw the RangeError above and the returned future never completes (the XOAUTH2 test only ends by the test timeout).

dart format --set-exit-if-changed ., dart analyze and the full dart test suite (425 tests) pass locally.

Downstream context

Hit in a Flutter mail client sending through a Yandex account with OAuth: authenticate() never returned, the send stalled for the full per-phase timeout on every retry, with an Unhandled Exception in the socket zone rather than an error on the returned future.

`SmtpResponseLine.parse` called `text.substring(4)` unconditionally, so a
reply line consisting of the 3-digit code only (`250<CRLF>`) threw a
`RangeError`. Because `SmtpResponse` was constructed outside the
try/catch in `SmtpClient.onServerResponse`, the error escaped from the
socket callback and the pending command's completer never completed.

* parse `Reply-code [ SP textstring ]` per RFC 5321: empty message for a
  bare code, no throw for lines shorter than a code
* construct `SmtpResponse` inside the guard so any parse error fails the
  pending command instead of hanging it
@brlumen

brlumen commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

The Verify formatting failure is not caused by this change — it is a pre-existing formatter drift on main.

The workflow installs the latest stable Dart SDK, which as of this run is 3.13.4 (released 2026-09-15). Its dart format now requires a blank line between the import block and a following comment, and 22 test files on main have this pattern:

import 'package:test/test.dart';
// cSpell:disable   <-- 3.13 inserts a blank line here

Reproduction on pristine main (e5e665d) with Dart 3.13.4, no changes from this PR:

$ dart --version
Dart SDK version: 3.13.4 (stable)
$ dart format --output=none --set-exit-if-changed .
Formatted 154 files (23 changed)

With this PR: Formatted 168 files (22 changed) — the same set of files, none of which are touched by the fix (in the two test files I did edit, the only diff is that one blank line after the imports, on lines I did not change). Every check on the last green main run (May 2026) ran on an older SDK; #283 will hit the same failure.

The other steps are fine: dart analyze and the full dart test suite (425 tests) pass locally under both Dart 3.12.2 and 3.13.4.

@robert-virkus
robert-virkus merged commit 2a76272 into Enough-Software:main Sep 16, 2026
1 check failed
@robert-virkus

Copy link
Copy Markdown
Member

Thanks a lot, much appreciated!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SMTP: RangeError on a code-only reply line (e.g. bare 334 from smtp.yandex.ru) leaves the command hanging

2 participants