Repository navigation
fix(plugin-email): a malformed recipient address answers 400 VALIDATION_FAILED, not 500 - #29250
Conversation
…_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>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 7 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 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
|
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 维护者速读
席位意见:与 diff 相符。用门已读的 席位核对ACCEPT ·
|
Requested by Steve · project thread
Fixes #29152
Clause-②: no
Metadata-protocol: none
维护者速读
to/cc/bcc/replyTo,含空to、列表里一个坏地址、显示名不是字符串)时,现在回 400VALIDATION_FAILED并点名哪个字段哪个地址,不再回 500EMAIL_SEND_FAILED。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 newformatCallerAddress. Same acceptance as before (isSendableAddress, which sharesEMAIL_REGEXwithformatAddress); a refusal is nowVALIDATION_FAILED: invalid recipient address in FIELD: ADDRESS(invalid reply-to addressforreplyTo). That is the spelling the REST send door already reads for its existing 400 (message.startsWith('VALIDATION_FAILED')inemail-routes.ts, the same branch that answerssubject: ""today), so the door needed no source change.formatAddressas aTypeError("obj.name?.trim is not a function"), which the door answers 500. It is now the sameVALIDATION_FAILEDrefusal. 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.formatAddress's plainInvalid email addressthrow.fromis the deployment's configured sender (OS_EMAIL_FROM, Settings → Mail): the REST door already refuses a bodyfromthat is not the configured address with 403 and otherwise forwards the configured value, so an unsendablefromis never the caller's input and stays a 500.@objectstack/plugin-emailonly (@objectstack/restsource is unchanged).Tests
New, no existing test edited:
packages/plugins/plugin-email/src/email-service.test.ts: eight refusal cases (malformedto, emptytostring, one bad member of atolist, objectto,cc,bcc,replyTo, non-string display name) each asserting the exactVALIDATION_FAILED: …text; a configured sender atnoreply@localhostasserting the plainInvalid email addresstext and NOT the prefix; well-formed addresses in every field still normalize;EmailService.sendrejects with the prefix, never calls the transport, and still writes onefailedrow.packages/rest/src/email-send-door-bounds.test.ts: badto, emptyto, atolist with one bad member, badcc→ 400 withcode: 'VALIDATION_FAILED'and theerrortext asserted, the caller's addresses forwarded untouched; a well-formed send through the same service → 200; a configured-sender fault → 500EMAIL_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_emailrow: kept as today. A 400-refused send still writes itsfailedrejection row:sendInternalcatches thenormalizeMessagethrow, callsrecordRejectedMessage, 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 newEmailService.sendcase.normalizeMessage: it has one call site,EmailService.sendInternal, reached bysend()/sendInline()/sendTemplate(). Every internal sender still sees a rejection: template sends and auth mail (plugin-authsendTemplatecallers) 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 itsclassifyErrormatches neither the old nor the new text, so it gradesretryableexactly as before. The only reader of theVALIDATION_FAILEDprefix on this path is the REST send door. No caller turns the failure into a success.HonoHttpServerdoor with a service double, because@objectstack/restdoes 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.retryable, so a bad address burns the retry ladder before going dead. Unchanged by this PR.Verification
Head
a53fb69fa(aftergit merge origin/mainat5a194b41).Tests and typecheck (both packages, under os-verify-lock)
@objectstack/plugin-emailtest: Test Files 47 passed (47), Tests 775 passed (775); typechecktsc --noEmit+ check:test-typecheck OK. VERDICT command-exit 0.@objectstack/resttest: Test Files 367 passed (367), Tests 6969 passed, 332 skipped (7301); typecheck + check:test-typecheck OK. VERDICT command-exit 0 (run on72cc0d7f; rest source and tests are byte-identical at the final head).eslint --no-inline-configonpackages/plugins/plugin-email/srcandpackages/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.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".if (false && message.startsWith('VALIDATION_FAILED'))): rest 4 failed / 38 passed,expected 500 to be 400.if (true || …)): rest 1 failed,expected 400 to be 500on the configured-sender case.Gates (dispatch-gates --commands, derived at the final head)
Generated by Claude Code