Skip to content

fix(plugin-email): a malformed recipient address answers 400 VALIDATION_FAILED, not 500 - #29250

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-pyqjxv
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-pyqjxv

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread
Fixes #29152

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:发邮件接口收到格式错误的收件人地址(to / cc / bcc / replyTo,含空 to、列表里一个坏地址、显示名不是字符串)时,现在回 400 VALIDATION_FAILED 并点名哪个字段哪个地址,不再回 500 EMAIL_SEND_FAILED。
  • 为什么改:调用方自己能改正的输入不该报 500(18.3.0 发版清单 email-template-render 验收第 7 条);500 会让集成方和自动化作者以为是服务器坏了。
  • 风险与回滚:只改 plugin-email 的收件人校验报错分类,接受哪些地址的判据一字未改;配置错误的发件人(OS_EMAIL_FROM / 邮件设置)仍按服务端故障回 500;被拒的发送照旧写一条 failed 的 sys_email 记录。回滚即 revert 本 PR。
  • 先看:packages/plugins/plugin-email/src/email-service.ts 的 formatCallerAddress(新增的拒收分类);packages/rest/src/email-send-door-bounds.test.ts 新的一组线上钉子。
  • 两分钟验证:pnpm --filter @objectstack/plugin-email exec vitest run src/email-service.test.ts 与 pnpm --filter @objectstack/rest exec vitest run src/email-send-door-bounds.test.ts,预期全绿。
  • 需要你做的:无,席位落地。

What changes

  • normalizeMessage (@objectstack/plugin-email) judges every CALLER-supplied address (to, cc, bcc, replyTo) through a new formatCallerAddress. Same acceptance as before (isSendableAddress, which shares EMAIL_REGEX with formatAddress); a refusal is now VALIDATION_FAILED: invalid recipient address in FIELD: ADDRESS (invalid reply-to address for replyTo). That is the spelling the REST send door already reads for its existing 400 (message.startsWith('VALIDATION_FAILED') in email-routes.ts, the same branch that answers subject: "" today), so the door needed no source change.
  • A non-string display name on a caller address (a JSON body can carry any value there) failed inside formatAddress as a TypeError ("obj.name?.trim is not a function"), which the door answers 500. It is now the same VALIDATION_FAILED refusal. This is an in-place fix of the same defect class in the same file, named here per the bounded-fix rule; evidence is the ablation below.
  • The SENDER keeps formatAddress's plain Invalid email address throw. from is the deployment's configured sender (OS_EMAIL_FROM, Settings → Mail): the REST door already refuses a body from that is not the configured address with 403 and otherwise forwards the configured value, so an unsendable from is never the caller's input and stays a 500.
  • Changeset: patch for @objectstack/plugin-email only (@objectstack/rest source is unchanged).

Tests

New, no existing test edited:

  • packages/plugins/plugin-email/src/email-service.test.ts: eight refusal cases (malformed to, empty to string, one bad member of a to list, object to, cc, bcc, replyTo, non-string display name) each asserting the exact VALIDATION_FAILED: … text; a configured sender at noreply@localhost asserting the plain Invalid email address text and NOT the prefix; well-formed addresses in every field still normalize; EmailService.send rejects with the prefix, never calls the transport, and still writes one failed row.
  • packages/rest/src/email-send-door-bounds.test.ts: bad to, empty to, a to list with one bad member, bad cc → 400 with code: 'VALIDATION_FAILED' and the error text asserted, the caller's addresses forwarded untouched; a well-formed send through the same service → 200; a configured-sender fault → 500 EMAIL_SEND_FAILED, not a 400. The service here is a double that throws the producer's text; the producer's own suite pins that text per field (see Acceptance notes).

Acceptance notes

  • sys_email row: kept as today. A 400-refused send still writes its failed rejection row: sendInternal catches the normalizeMessage throw, calls recordRejectedMessage, and rethrows; that method's docblock states the rule (the row is the only durable evidence when a caller swallows the error). Pinned in the new EmailService.send case.
  • Other callers of normalizeMessage: it has one call site, EmailService.sendInternal, reached by send() / sendInline() / sendTemplate(). Every internal sender still sees a rejection: template sends and auth mail (plugin-auth sendTemplate callers) receive the throw as before; the mail settings "Send test email" action returns { ok: false, severity: 'error', message } from its catch; the messaging email channel returns { ok: false, error } and its classifyError matches neither the old nor the new text, so it grades retryable exactly as before. The only reader of the VALIDATION_FAILED prefix on this path is the REST send door. No caller turns the failure into a success.
  • The rest pins drive the real HonoHttpServer door with a service double, because @objectstack/rest does not depend on @objectstack/plugin-email; the two suites together pin both halves (producer text, door mapping), and each half was ablated on its own.
  • Observation, not filed: the messaging email channel grades a malformed recipient retryable, so a bad address burns the retry ladder before going dead. Unchanged by this PR.

Verification

Head a53fb69fa (after git merge origin/main at 5a194b41).

Tests and typecheck (both packages, under os-verify-lock)
  • @objectstack/plugin-email test: Test Files 47 passed (47), Tests 775 passed (775); typecheck tsc --noEmit + check:test-typecheck OK. VERDICT command-exit 0.
  • @objectstack/rest test: Test Files 367 passed (367), Tests 6969 passed, 332 skipped (7301); typecheck + check:test-typecheck OK. VERDICT command-exit 0 (run on 72cc0d7f; rest source and tests are byte-identical at the final head).
  • After the merge: the four touched/neighbouring files re-run, plugin-email 57/57, rest 258/258.
  • eslint --no-inline-config on packages/plugins/plugin-email/src and packages/rest/src: exit 0, no output.
Ablations (each landed on disk via scripts/ablation-replace.mjs, restored to the HEAD blob)

Both suites import the subject from src/ by relative path, so no build sits between mutation and run.

  • A1, recipients back on the plain throw (if (false && !isSendableAddress(addr))): plugin-email 8 failed / 33 passed. Expected "VALIDATION_FAILED: invalid recipient address in to: not-an-email", received "Invalid email address: not-an-email".
  • A2, configured sender judged as a caller address: 1 failed. expected "Invalid email address: noreply@localhost", received "VALIDATION_FAILED: invalid recipient address in to: noreply@localhost".
  • A3, door prefix branch removed (if (false && message.startsWith('VALIDATION_FAILED'))): rest 4 failed / 38 passed, expected 500 to be 400.
  • A4, door answers every send throw 400 (if (true || …)): rest 1 failed, expected 400 to be 500 on the configured-sender case.
  • A5, display-name guard disabled: 1 failed. expected "VALIDATION_FAILED: invalid recipient address in cc: the display name of c@d.com must be a string", received "obj.name?.trim is not a function".
Gates (dispatch-gates --commands, derived at the final head)
  • Derived at a53fb69: 61 families. All 61 run on that head, every one exit 0. Reconciliation: "dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED (a DERIVED zero — all 61 recorded an exit code and none of them is 3)."
  • Two families first answered exit 3 (prerequisite, not a verdict) on the earlier head: check-plugin-teardown-shape --self-test needed a pinned fixture commit the shallow clone lacked (fetched it), and check:dual-build-cjs-loads needs every package's dist (ran a full pnpm build under the lock, exit 0). Both exit 0 in the final run.
  • Lint family is not derived: eslint on both packages, exit 0 (above).

Generated by Claude Code

…_FAILED

A malformed caller-supplied address in to / cc / bcc / replyTo threw a
plain `Invalid email address` error from normalizeMessage, which the REST
send door maps to 500 EMAIL_SEND_FAILED. It now throws
`VALIDATION_FAILED: invalid recipient address in FIELD: ADDRESS`, the
prefix the door already answers with 400. The configured sender keeps the
plain throw: an unsendable `from` is a server fault, not the caller's.

Claude-Session: https://claude.ai/code/session_01ADHkmRW6HXmiYEciBx61dj
Co-authored-by: Claude <noreply@anthropic.com>
A JSON body can put any value in a recipient's `name`; a non-string one
failed inside formatAddress as a TypeError, which the REST send door
answers 500. It is now the same VALIDATION_FAILED refusal as a malformed
recipient address.

Claude-Session: https://claude.ai/code/session_01ADHkmRW6HXmiYEciBx61dj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/kernel/runtime-services/email-service.mdx (via replyTo (literal, a string literal in CallerAddressField; a string literal in formatCallerAddress; a string literal in listToArray; a string literal in normalizeMessage))
What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 7 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 07eaec2db7f3dcae58cbec99f91e04cd8518e4c0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 35370a657488105afa8f6f9e3091146b0546454b — the merge of head a53fb69fa6d6541877866ffa1cab9eae7b8902a5 into base 07eaec2db7f3dcae58cbec99f91e04cd8518e4c0, 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 35370a657488105afa8f6f9e3091146b0546454b && git checkout 35370a657488105afa8f6f9e3091146b0546454b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 07eaec2db7f3dcae58cbec99f91e04cd8518e4c0 a53fb69fa6d6541877866ffa1cab9eae7b8902a5 && git checkout -B drift-repro 07eaec2db7f3dcae58cbec99f91e04cd8518e4c0 && git merge --no-ff a53fb69fa6d6541877866ffa1cab9eae7b8902a5

node scripts/docs-audit/affected-docs.mjs --json 07eaec2db7f3dcae58cbec99f91e04cd8518e4c0

⚠️ 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 07eaec2db7f3dcae58cbec99f91e04cd8518e4c0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

维护者速读

  • 改了什么:POST /api/v1/email/send 收到格式错误的收件人(to / cc / bcc / replyTo,含空 to、列表里一个坏地址、显示名不是字符串)回 400 VALIDATION_FAILED 并点名字段,不再回 500 EMAIL_SEND_FAILED。
  • 为什么改:18.3.0 发版清单 email-template-render 验收第 7 条:调用方能改正的输入不回 500。
  • 风险与回滚:接受哪些地址的判据不变;配置的发件人出错仍是 500(服务端故障);被拒的发送照旧写 failed 的 sys_email 行。回滚即撤销本 PR。
  • 先看:plugin-email/src/email-service.ts 的 formatCallerAddress;rest/src/email-send-door-bounds.test.ts 新增的一组。
  • 两分钟验证:pnpm --filter @objectstack/plugin-email exec vitest run src/email-service.test.ts 与 pnpm --filter @objectstack/rest exec vitest run src/email-send-door-bounds.test.ts。
  • 需要你做的:无,席位落地。

席位意见:与 diff 相符。用门已读的 VALIDATION_FAILED 前缀,rest 源码不动;发件人与收件人的区分守住了。两个开放问题都取 dev 的推荐:rest 钉子保留替身(A),保留 sys_email 失败行。

席位核对

ACCEPT · domain:server seat 5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 · 2026-11-02T10:22Z · head a53fb69f · #29152

  1. Shape: draft against main, Fixes #29152 (line 3, after the harness attribution block), no other card number beside a closing keyword; patch changeset for @objectstack/plugin-email only; Clause-②: no, Metadata-protocol: none. Not governed, +189 / -7, 4 files.
  2. Against the order: normalizeMessage judges every caller-supplied address through formatCallerAddress (same isSendableAddress acceptance), refusing with the VALIDATION_FAILED: prefix email-routes.ts already maps to 400; from stays on formatAddress's plain throw (server fault). Internal callers still see the rejection; only the door reads the prefix. Read in the diff.
  3. Accepted deviations: the non-string display-name guard (same defect class, same file, a TypeError 500 before); the first local commit redone through the repo hooks before any push; rest full suite read on 72cc0d7f (rest files unchanged since).
  4. Gates: dev reports 61/61 derived gates exit 0 on the final head (0 NOT-MEASURED), plugin-email 775/775, rest 6969 passed, both typechecks and scoped eslint exit 0; five ablations, each red then restored. Check-runs on this head were still running at ACCEPT; the seat readies it only after Lint & Repo Gates and TypeScript Type Check read success and no check is red.
  5. Out of scope: none.
  6. Landing: pr_ready + automerge_enable through the relay once CI is green.

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/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

email: POST /api/v1/email/send answers 500 EMAIL_SEND_FAILED for a malformed recipient address instead of 400 VALIDATION_FAILED

1 participant