Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-email): refuse a present-but-unreadable smtp_port instead of silently sending on 587 - #13316
Conversation
…le port `smtpOptionsFromMailSettings` coerced `smtp_port` and then omitted the key whenever the coercion was not finite, so `SmtpTransport` applied its built-in 587 and a stored `smtp_port: 'abc'` became a working-looking connection nobody chose. Pass the coerced value through instead: `absent` (and `''`) still fall back to 587, while a present-but-unreadable port reaches the refusal that already exists in the constructor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9b822d922ad186e864170730093c176951397ae4 && git checkout 9b822d922ad186e864170730093c176951397ae4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 56c093c4ddb83bb6f0cb6d1b670a369e27284468 f87d9577e4ad499e77e61963c5e988578249fc13 && git checkout -B drift-repro 56c093c4ddb83bb6f0cb6d1b670a369e27284468 && git merge --no-ff f87d9577e4ad499e77e61963c5e988578249fc13
node scripts/docs-audit/affected-docs.mjs --json 56c093c4ddb83bb6f0cb6d1b670a369e27284468 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13190
smtpOptionsFromMailSettingscoercedsmtp_portwithNumber(...)and then omitted the key whenever the result was not finite.SmtpTransportthen applied its built-in 587, so a configuredsmtp_port: 'abc'became a working-looking connection to a port nobody chose, anddescribe()— the diagnostic surface — reported 587 as though it had been selected. Nothing threw, nothing warned.The refusal already existed one layer down. This PR stops hiding the value from it, and adds no second, parallel refusal.
portisundefinedonly whensmtp_portis absent or the empty string, so that one token is the wholeabsent/present but unreadabledistinction. The refusal itself is untouched:isValidSmtpPortand the generatedformatInvalidSmtpPortNoticefromsmtp-port-contract.ts(the module PR #13191 landed this morning) do the rejecting, and no bound and no sentence is re-spelled anywhere in this diff.The buckets, now distinct — and all five pinned
smtp_port'''abc'/'Infinity'/{}/[]SmtpTransport: invalid port 'NaN' (expected 1-65535)'99999''465'/465Row 2 is the one that would break working deployments if it drifted, so it is pinned beside the refusal rather than left to the reader.
Reachability is measured, not argued
The card said its own reachability was read off the resolver's precedence rather than measured. It is measured now, end to end, against the built
distat3322527fa, and both routes reachsmtpOptionsFromMailSettingswith a value the form would have rejected:service-settingsreally does enforce the bound so "the FORM refusesabc". Route 2 above shows the server-side save path accepts'abc'today — this is not only a legacy-row story. The mechanism:firstRangeViolationseeds fromnumericValue, which returnsnullfor a value it cannot read, and anullreading is no violation. A window rejects what is outside it; it cannot reject what is not on the line at all. That layer is filed separately as #13314 and is not touched here — the card's own ruling is that the refusal belongs at the transport, and it does regardless of what the settings layer decides to do.Ablation — the refusal test does fail against
main's lineImplementation committed first, then the fixed line mutated back to exactly what
origin/maincarries, then restored:Exactly the two refusal assertions go red and the two fall-back pins stay green, which is the direction that had to hold:
mainalready falls back correctly forabsentand'', and only the third bucket moves. No build leg —smtp.test.tsimports./smtp.jsrelatively, inside its own package, so vitest readssrcand there is nodistfor a stale artifact to hide in. The mutation was confirmed on disk by grep counts and a changed blob hash before the run, never by the editor's exit code; restore was proven by blob equality against theHEADblob plus an emptygit diff HEAD, not by the trap having fired.Verification
All at
f87d9577e, the final commit.pnpm --filter @objectstack/plugin-email testTest Files 28 passed (28) · Tests 440 passed (440)pnpm --filter @objectstack/plugin-email typecheck@objectstack/plugin-email@17.2.0 typecheckechoedtsc --noEmit --listFilessmtp.ts1 ·smtp.test.ts1 ·smtp-port-contract.ts1 — the new tests really are inside the program the typecheck readspnpm lint(eslint . --no-inline-config, whole repo)The gate list came from
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the real changed set, not from a copied list. Two families are NOT MEASURED locally and are named rather than counted as passes:node scripts/check-test-completeness.mjs— PREREQUISITE NOT MET (exit 3). It grades a savedturbo run testlog and the derivation names it with no argument; its own text says to record it as NOT MEASURED. CI tees the log and passes the path.pnpm check:dual-build-cjs-loads(exit 3) andpnpm check:type-check-debt --re-measure(throws) — both refuse without a full workspace build, which does not fit this container's foreground ceiling once the shared verify-lock queue is included. Declared narrowing, not a skip. Exposure is bounded and stated: this diff changes no package's entry points orexports, andplugin-emailcarries noDEBT/TEST_DEBTledger entry at all — so the only way it could move the ratchet is a type error in the new test code, whichtypecheckabove answers directly with the test files proven inside the program. CI runs both.Scope
587.5) is a separate card and is not addressed here; it stays open. It does not interact:isValidSmtpPortaccepts a non-integer in range exactly as before, and this PR changes only which values reach it.type: 'number'settings specifier accepts a non-numeric string on BOTH doors — the declared window cannot reject what it cannot read #13314 (the settings layer above) and The SMTP port refusal names 'NaN' rather than the value the operator configured, on the one path that actually produces it #13315 (the refusal names'NaN'rather than the operator's spelling — a diagnostic-quality gap this change makes reachable, and one the card's dispatch explicitly kept out of scope).Reviewer's attention, two points
error). Shipped as a patch per dispatch. A reviewer could argue this is an accept-set narrowing on a published export and wants the repo'sminor+ breaking-marker convention instead; the counter-argument is that no one can be relying onsmtp_port: 'abc'meaning 587, since the setting was being ignored. Flagged rather than decided.noand I concur, having looked for the reading that would flip it: the accept set narrows ('abc'stops being silently accepted),SmtpTransportOptionsis not edited in any direction, and nopackages/specfile is touched. The one honest wrinkle:smtpOptionsFromMailSettingsis a published export, and for the unreadable input its return value gains aport: NaNmember. An external consumer spellingopts.port ?? 587would now getNaN— but that consumer is the defect being repaired, in miniature.Generated by Claude Code