Skip to content

fix(plugin-email): validate the default sender at configuration time and record pre-delivery rejections in sys_email - #14371

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-14318-email-sender-validation-and-failure-signal
Sep 2, 2026
Merged

fix(plugin-email): validate the default sender at configuration time and record pre-delivery rejections in sys_email#14371
hotlong merged 2 commits into
mainfrom
claude/issue-14318-email-sender-validation-and-failure-signal

Conversation

@hotlong

@hotlong hotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #14318

An unsendable OS_EMAIL_FROM was first judged on the first send, which for a fresh
deployment is the first user's sign-up — and better-auth swallows that throw, so the UI
reported a verification email nobody had sent, and sys_email held no record of it.

What was measured on origin/main

The card's premise holds, and the chain is exactly as reported:

  • EmailService.sendInternal calls normalizeMessage before it inserts the
    sys_email row, and rethrows. So the one failure class that produces no row at all
    is a message rejected at normalization — every other shape (transport error, template
    error at delivery) finalizes a row at status:'failed'.
  • formatAddress refuses noreply@localhost because EMAIL_REGEX requires a dotted
    domain. That refusal is correct and is not relaxed here.
  • better-auth 1.7.2 sign-up invokes emailVerification.sendVerificationEmail through
    ctx.context.runInBackgroundOrAwait (dist/api/routes/sign-up.mjs:254). With no
    advanced.backgroundTasks.handler configured — and ObjectStack configures none — that
    helper awaits the promise inside a try/catch that logs
    Failed to run background task and returns normally
    (dist/context/create-context.mjs:214-223). Sign-up therefore answers 200 no matter what
    this callback does. That layer is not ours to change, which is why the fix is a
    configuration-time refusal rather than more error plumbing.

One correction to the card's second requirement, stated because it changes what this PR
does: the auth half already propagates. AuthManager's sendVerificationEmail lets a
sendTemplate throw escape and turns a returned status:'failed' into a throw naming the
recipient and the cause; better-auth's /send-verification-email route awaits
sendVerificationEmailFn directly (no background task) and rethrows, so the resend path
is honest today. No code was added there — the behaviour is now pinned by a test, and
the report is explicit that those three cases pass on pre-fix main too.

The fix

1. The sender is judged where it is configured

EmailServicePlugin.init() refuses a declared defaultFrom no message could ever be sent
from. Fail-closed on this channel, and the reason is the file's own stated convention
rather than a new judgement: resolveTransport already propagates makeTransport's throw
for an SMTP provider with no host, on the grounds that "a boot that cannot deliver must
fail loudly rather than degrade to a LogTransport that reports success" — and
resolveEmailCapabilityArg in the CLI throws for an undeliverable provider tag or a
missing API key. A named sender is a deployment declaration of the same kind. The error
names the consequence (every send rejected, silently on sign-up) and the fix
(OS_EMAIL_FROM / config.email.defaultFrom).

An absent sender is still accepted: callers that always pass input.from are a
complete configuration, and normalizeMessage already refuses a send that has neither.

applyMailSettings — the mail settings channel — takes that method's opposite, already
documented trade: it never throws, because one save must not kill a running server. An
unsendable saved From address is refused, the previous sender is kept, and the
consequence is stated at error. error and not warn per AGENTS.md's degradation rule:
the save succeeds and the settings page shows the address the operator typed, so nothing
looks broken from the outside while every send made with it would be rejected. The
provider=log "mail settings applied" line now reports the address that was actually
applied, not the one that was refused.

isSendableAddress shares the one EMAIL_REGEX with formatAddress — a second spelling
of "valid enough to send" would let a boot pass a check the send path then fails.
formatAddress, EMAIL_REGEX and normalizeMessage keep their exact verdicts.

2. A send rejected before delivery leaves a sys_email row

EmailService.recordRejectedMessage writes the record for the one window that produced
none: status:'failed', attempt_count: 0, and the reason prefixed
rejected before delivery: so the error column distinguishes a message that never
reached a transport from one an SMTP host refused — an operator reading the old wording
would be sent to the wrong system.

  • Envelope columns carry what the caller actually passed, rendered raw. Deliberately
    not through formatAddress: canonicalization is what threw, and describing the throw
    must not throw again. (none) where the input named nothing, because from_address /
    to_addresses / subject are required: true and an empty string would make the
    rejection record itself unwritable.
  • The row is safe by construction. Both re-delivery paths gate on status === 'queued'
    the sys_email afterInsert outbox drain hook and sweepStrandedOutbox — so a
    rejection record can never be mistaken for an outbox entry and delivered.
  • Persisting it is best-effort and reported at warn, so a storage failure can never
    replace the caller's real error with its own.

Verification

Union run at 5170e7a85 (the final commit).

Reverse verification (ablation). Both edited sources were restored to their pre-fix
bytes at the branch point 909a44171 and the new suites re-run.

  • Mutation proven on disk before the run: blobs moved
    d9a384b4 -> 11b7ac28 (email-plugin.ts) and 0198f7ac -> e031b07a (email-service.ts),
    and grep -c for the removed anchors went to 0 for both
    (assertSendableDefaultFrom, recordRejectedMessage).
  • No rebuild needed on either leg: both suites import the subject through same-package
    relative source specifiers
    , so vitest resolves src, never dist.
  • Result — plugin-email/src/sender-address-validation.test.ts: 11 failed, 5 passed
    (the 5 are the controls that must pass in both directions: boots with a valid sender,
    boots with no sender, applies a deliverable saved address, never throws out of the
    settings path, writes nothing without persistence). Direction as predicted: red.
  • Result — plugin-auth/src/verification-email-failure-propagation.test.ts: 3 passed,
    i.e. GREEN on the pre-fix tree. Reported as observed, not as predicted: those three pin
    behaviour that already existed, so they are a regression pin, not evidence of a new fix.
  • Restore proven: blobs back to the HEAD values above, grep -c back to 2 and 2,
    git diff HEAD empty.

Tests.

  • pnpm --filter @objectstack/plugin-email --filter @objectstack/plugin-auth run test
    plugin-email 29 files / 460 tests passed; plugin-auth 88 files / 1806 tests passed.
  • pnpm --filter @objectstack/cli exec vitest run over the five serve-email-* /
    serve-settings-ordering suites that construct EmailServicePlugin — 5 files / 58 tests
    passed.
  • pnpm --filter @objectstack/dogfood exec vitest run test/email-template-materialization.dogfood.test.ts
    — 1 file / 2 tests passed (boots the real plugin through dist).
  • pnpm --filter @objectstack/plugin-email --filter @objectstack/plugin-auth run typecheck
    — both Done.

Typecheck coverage — one honest gap. tsc --listFiles counts the new plugin-email test
file 1 time (it is in the program) and the new plugin-auth test file 0 times:
packages/plugins/plugin-auth/tsconfig.json excludes **/*.test.ts, pre-existing and
ledgered. So "plugin-auth typecheck clean" says nothing about that file — read it as NOT
MEASURED, not as green. It is exercised by vitest.

Gates. node scripts/pm/dispatch-gates.mjs --commands derived 41 families at the
final head (34 before the ledger row below joined the change set). 38 green. The other
three, none of them a finding:

  • pnpm check:engine-double-contract was genuinely red and is fixed, not waived: the
    new fake engine's update() now routes through assertEngineUpdateDispatch (the shape
    email-plugin.template-runtime-write.test.ts already pins), and the new pinned coverage
    was recorded with --write (scripts/engine-double-contract.pinned.json, 1 row added,
    0 lost). Re-run green.
  • pnpm check:bash32-floor exits 1 on this host, in its own self-test (8 of 153 cases,
    all of the form "the probe is shell this host can parse" plus the simulated-3.2 harness):
    the host bash IS 3.2.57, so the gate's bash-4 probes cannot be parsed or run here. Its
    real-tree scan in the same run reports 26 shell file(s) ... 0 finding(s), and this
    diff contains no shell file at all. Host artifact, not a verdict about this card.
  • node scripts/check-test-completeness.mjs and node scripts/pm/check-half-states.mjs
    both exit 3, which each script's own text defines as NOT MEASURED rather than a
    finding — the first needs a saved turbo run test log to parse, the second a GitHub
    route this container has no access to.

Plus node scripts/check-nul-bytes.mjs (OK, 7826 files) and pnpm lint — the full
repo scan, eslint . --no-inline-config, exit 0 in 5m45s, so no narrowing was taken there.

Declared narrowing — verification ran UNLOCKED. scripts/pm/os-verify-lock.sh could
not take the shared verify lock on this host: no usable flock. The shared verify lock is
declared Linux-only (flock is util-linux, and a stock macOS does not ship it), so every
build and test command below was run directly, without the lock — a declared narrowing,
not a silent one. No serialization guarantee held for these runs, nor for any sibling agent
in this container while they ran.

Out of scope

  • The cloud-side follow-up the card names — what the verify-email screen says and how
    Resend behaves once a failure signal exists — is not touched here.
  • sys_email.error's field description still reads "Transport error message when
    status=failed". Widening it would pull packages/platform-objects into this diff and a
    different gate family, so the row's own text was made self-describing instead
    (rejected before delivery: ...). Noted, not changed.

Generated by Claude Code

hotlong and others added 2 commits September 2, 2026 10:14
…and record pre-delivery rejections in sys_email

An unsendable OS_EMAIL_FROM was first judged inside normalizeMessage on the
first send, which for a fresh deployment is the first user's sign-up -- and
better-auth's runInBackgroundOrAwait logs and swallows that throw, so the UI
reported a verification email nobody had sent. EmailServicePlugin.init() now
refuses a declared defaultFrom no message could be sent from; the mail settings
channel refuses the value, keeps the previous sender and says so at error.

EmailService also writes a sys_email row at status='failed' for a message
rejected before delivery -- the one window that previously produced no record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rtEngineUpdateDispatch

check:engine-double-contract flagged the double in
sender-address-validation.test.ts: a fake looser than ObjectQL.update is how a
dead route once shipped with a green suite. Routed through the predicate (the
shape email-plugin.template-runtime-write.test.ts already pins) and recorded the
new pinned coverage in the ledger with --write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-email, touching 12 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/services.mdx (via EmailServicePlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/plugin-spec.mdx (via EmailService (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 82fdc295ea025ebed1f4a7f3f43f6ea44978aa00 — the merge of head 5170e7a85c80db628cfb9fe9133a9dd5e13d5fc7 into base d62f990a985eb15152579ad34c99f43e76e3657a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 82fdc295ea025ebed1f4a7f3f43f6ea44978aa00 && git checkout 82fdc295ea025ebed1f4a7f3f43f6ea44978aa00
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 5170e7a85c80db628cfb9fe9133a9dd5e13d5fc7 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 5170e7a85c80db628cfb9fe9133a9dd5e13d5fc7

node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d62f990a985eb15152579ad34c99f43e76e3657a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlong
hotlong marked this pull request as ready for review September 2, 2026 09:29
@hotlong
hotlong added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit 8e9e630 Sep 2, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14318-email-sender-validation-and-failure-signal branch September 2, 2026 10:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant