Repository navigation
fix(plugin-email): a non-string recipient display name answers the send door's 400, not 500 - #29955
Conversation
…nd door's 400, not 500 The rejection-row writer (rawAddressText in recordRejectedMessage) called .trim() on the caller's raw display name after formatCallerAddress had already raised VALIDATION_FAILED, so POST /api/v1/email/send answered 500 EMAIL_SEND_FAILED. The writer now reads the name as unknown input and omits a non-string name from the row; formatCallerAddress stays the one place recipient shape is judged. Pinned through the real REST route over the real EmailServicePlugin with sys_email persistence (new rest devDependency on @objectstack/plugin-email). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzj6ujX8a8Krdcs92Ye
…ineUpdateDispatch and alias plugin-email to source check:engine-double-contract pins every engine double's update() to the real dispatch predicate (ledger regenerated with --write), and check:test-source-alias requires a cross-package value import in a test to resolve to the checkout's source, not dist. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzj6ujX8a8Krdcs92Ye
📓 Docs Drift Check1 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 23 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 b146c255ce2a62b1c4c5c1e95db2b01a2d8074b9 && git checkout b146c255ce2a62b1c4c5c1e95db2b01a2d8074b9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1f6bc5aadc9e463373eaf500e268d071263f4ddc 86cb5d384c1e52eed02b11726093ab7cd12db6d4 && git checkout -B drift-repro 1f6bc5aadc9e463373eaf500e268d071263f4ddc && git merge --no-ff 86cb5d384c1e52eed02b11726093ab7cd12db6d4
node scripts/docs-audit/affected-docs.mjs --json 1f6bc5aadc9e463373eaf500e268d071263f4ddc |
|
domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5 Contract reviewServed-tier: Face: declaration ( ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5 席位核对Verdict: ACCEPT at head
|
Requested by Yibo Wang · project thread
Fixes #29891
Clause-②: no
Metadata-protocol: none
维护者速读
POST /api/v1/email/send里to/cc/bcc/replyTo(含列表项)的显示名不是字符串时,现在返回 400VALIDATION_FAILED("the display name of … must be a string"),不再是 500EMAIL_SEND_FAILED;拒收记录照常写入sys_email。formatCallerAddress,fix(plugin-email): a malformed recipient address answers 400 VALIDATION_FAILED, not 500 #29250),但随后写拒收行的rawAddressText对调用方原始name调.trim(),抛 TypeError,把 400 变成 500。error列已写明原因);字符串、缺省、null显示名行为不变。回滚即 revert。packages/plugins/plugin-email/src/email-service.ts的rawAddressText;新 pinpackages/rest/src/email-send-display-name.test.ts。pnpm --filter @objectstack/rest exec vitest run --project local src/email-send-display-name.test.ts,预期 19 passed。What changes
@objectstack/plugin-email(patch).rawAddressText, the writer of the rejection row inEmailService.recordRejectedMessage, reads each address part as unknown input: a string display name is trimmed and kept, any other type is left out of the row, and a non-object, non-string entry records nothing. It still judges nothing.formatCallerAddressstays the one place a caller's recipient shape is refused (it already refused non-string names, fix(plugin-email): a malformed recipient address answers 400 VALIDATION_FAILED, not 500 #29250), so no second validation was added.@objectstack/rest(tests only, no changeset). A new pin drives the real route (HonoHttpServer+RestServer) over the realEmailServicePlugin(log provider) withsys_emailpersistence wired, which is the stock showcase shape.email-send-door-bounds.test.tsuses a mail double and never reaches the writer. Supporting changes: a devDependency on@objectstack/plugin-email(lockfile importer row), an anchored source alias invitest.config.ts(check:test-source-alias), the double'supdate()routed throughassertEngineUpdateDispatch, and the regeneratedscripts/engine-double-contract.pinned.jsonrow (check:engine-double-contract).Same-pattern sweep (what I checked)
Every caller-supplied field the rejection row reads:
to,cc,bcc,replyToandfromall go through the onerawAddressText, so the fix covers all five.subjectisString(...);text/htmlaretypeof-guarded;addressisString(...);relatedObject/relatedId/sentBy/organizationIdare stored, not trimmed. On the validation side,formatCallerAddresscovers all four recipient fields.formatAddress(the sender's path) still callsobj.name?.trim(), but the REST door replaces a callerfromwith the configured sender, so a caller's non-string sender name never reaches it through this route. Left as is.Why
Reproduced on
21510bba(main) through the new pin before the fix: every non-string shape in every field answered500(see the ablation below). The card's own reproduction:{"to":{"address":"ok@example.com","name":42},"subject":"x","text":"x"}.Pins
packages/rest/src/email-send-display-name.test.ts, 19 cases:to/cc/bcc/replyTo: 400VALIDATION_FAILEDwith the exact message naming the field, one rejection row withstatus: 'failed',error: 'rejected before delivery: VALIDATION_FAILED: …'and the bare address in the field's column (16);tolist entry with an object name: 400, and the row lists both entries (1);nullname sends (200), and a string name sends (200) (2).Ablation, once. Restored
const name = a.name?.trim()inrawAddressTextand rebuilt: 17 of 19 failed withAssertionError: expected 500 to be 400(all except the two 200 controls). Restored the fix: 19 passed.Deviation from the order (null). The order listed
nullamong the shapes that answer 400.formatCallerAddressdeliberately treatsnullas "no name" (name !== undefined && name !== null), andnull?.trim()never threw, sonullalready answered 200 before this PR. Making it 400 would refuse input the door accepts today (a narrowing), sonullis pinned at 200 instead.Verification
pnpm --filter @objectstack/plugin-email test,pnpm --filter @objectstack/rest test: exit 0.typecheckandcheck:test-typecheckfor both packages: exit 0.pnpm lint: exit 0.turbo run build --concurrency=2: exit 0.node scripts/pm/dispatch-gates.mjs --commandson the final head: 82 derived, 82 run, all exit 0;--ran: "82 run, 0 NOT-MEASURED". In the first round (before commit 2),check:engine-double-contractandcheck:test-source-aliaswere red and are fixed by commit 2.check:dual-build-cjs-loadsexited 3 until the full build ran.🤖 Generated with Claude Code
https://claude.ai/code/session_01WGufzj6ujX8a8Krdcs92Ye