You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
2026-09-02 cloud 最新主干本地 rig(framework pin afbf2711,OS_AUTH_REQUIRE_EMAIL_VERIFICATION=true,OS_EMAIL_PROVIDER=log),发件人配置为 OS_EMAIL_FROM="ObjectOS Local <noreply@localhost>":
POST /api/v1/auth/sign-up/email → 200,前端跳到 verify-email-prompt 显示「我们已向您的邮箱发送了一封验证邮件」;
服务端 better-auth 后台任务抛错,只在日志里:
ERROR [Better Auth]: Failed to run background task: Error: Invalid email address: noreply@localhost
at formatAddress (packages/plugins/plugin-email/dist/index.mjs:436:11)
at normalizeMessage … at EmailService.sendInternal … at EmailService.send … at EmailService.sendTemplate
at async Object.sendVerificationEmail (packages/plugins/plugin-auth/dist/index.mjs:4301:28)
Claim: PM loop round 1 (epic objectstack-ai/cloud#1841)
Session: session_c5c0ce54-bb9c-478c-9e5b-cf44b80d4569
Branch: claude/issue-14318-email-sender-validation-and-failure-signal
Worktree: objectstack-issue-14318
Domain: domain:services
File surface: packages/plugins/plugin-email/** (sender validation at init, sys_email failed row), packages/plugins/plugin-auth/** (sendVerificationEmail failure propagation) (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate, PM judgement)
Clause-②: no
Serial constraints cleared: objectstack#14293 (test-only, plugin-auth/src/admin-sso-bridge-gate.test.ts) and dependabot bumps are the only open PRs on these packages — no overlap; #14319 (template locale) is SERIAL after this card
{
"issue": 14318,
"status": "done",
"branch": "claude/issue-14318-email-sender-validation-and-failure-signal",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14371",
"premise_still_valid": true,
"summary": "Premise confirmed on origin/main: sendInternal calls normalizeMessage BEFORE inserting the sys_email row and rethrows, so a message rejected at normalization is the ONE failure class that produces no row at all; and better-auth 1.7.2 sign-up invokes sendVerificationEmail through runInBackgroundOrAwait (dist/api/routes/sign-up.mjs:254), which with no advanced.backgroundTasks.handler configured -- ObjectStack configures none -- awaits inside a try/catch that logs 'Failed to run background task' and returns, so sign-up answers 200 regardless. Fixed both halves the ruling names. (1) EmailServicePlugin.init() now refuses a declared defaultFrom no message could be sent from -- fail-closed, matching the file's own convention (resolveTransport already propagates makeTransport's throw for smtp-without-host; resolveEmailCapabilityArg throws for an undeliverable provider tag), with the consequence and the OS_EMAIL_FROM / config.email.defaultFrom fix in the message; an ABSENT sender is still accepted. The mail settings channel takes applyMailSettings' opposite documented trade -- never throws, refuses the value, KEEPS the previous sender, states it at error (durability class: the save succeeds and the page shows what was typed, so nothing looks broken). isSendableAddress shares the one EMAIL_REGEX with formatAddress, which is untouched and NOT relaxed. (2) EmailService.recordRejectedMessage writes the sys_email row for that window: status=failed, attempt_count 0, error prefixed 'rejected before delivery: ' so the column distinguishes it from a transport error; envelope columns carry the raw caller input (never re-canonicalised -- canonicalisation is what threw) with '(none)' where nothing was named, since from_address/to_addresses/subject are required; the row is unreachable by both re-delivery paths, which gate on status==='queued'; persisting is best-effort at warn so it can never replace the caller's error. ONE CORRECTION to the card: the auth half already propagated on main -- AuthManager's sendVerificationEmail lets a sendTemplate throw escape and turns a returned status:'failed' into a throw naming recipient and cause, and better-auth's /send-verification-email route awaits sendVerificationEmailFn directly and rethrows. No auth code was added; the behaviour is pinned by a new test, and the ablation below shows those three cases pass on the PRE-FIX tree too, so they are a regression pin, not evidence of a new fix. The residual 'sign-up still answers 200' is better-auth-internal and not ours to change, which is exactly why the fix is a configuration-time refusal plus a queryable sys_email row rather than more error plumbing.",
"tests": "Union at final commit 5170e7a85. ABLATION (reverse verification): fix committed FIRST, then both edited sources restored to the branch point 909a44171. Mutation proven on disk before running -- blobs moved d9a384b4 to 11b7ac28 (email-plugin.ts) and 0198f7ac to e031b07a (email-service.ts), and grep -c for the removed anchors (assertSendableDefaultFrom, recordRejectedMessage) both read 0. NO REBUILD NEEDED ON EITHER LEG and this is stated rather than skipped: both suites reach the subject through same-package RELATIVE SOURCE specifiers, so vitest resolves src and never dist. Observed directions: plugin-email/src/sender-address-validation.test.ts 'Tests 11 failed | 5 passed (16)' -- red as predicted, the 5 being the controls that must hold both ways (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); plugin-auth/src/verification-email-failure-propagation.test.ts 'Tests 3 passed (3)' -- GREEN on the pre-fix tree, reported as observed and not as predicted (regression pin). Restore proven by observation, not exit code: blobs back to the HEAD values, grep -c back to 2 and 2, and git diff HEAD EMPTY. Restore used git checkout HEAD -- ABSOLUTE paths, with a trap on EXIT INT TERM. TESTS: 'pnpm --filter @objectstack/plugin-email --filter @objectstack/plugin-auth run test' -> 'plugin-email test: Test Files 29 passed (29) / Tests 460 passed (460)' and 'plugin-auth test: Test Files 88 passed (88) / Tests 1806 passed (1806)'. CLI consumers that construct the plugin: 5 serve-email-* / serve-settings-ordering suites -> 'Test Files 5 passed (5) / Tests 58 passed (58)'. dogfood (boots the real plugin through dist): email-template-materialization -> 'Test Files 1 passed (1) / Tests 2 passed (2)'. TYPECHECK: both packages 'Done'. NOT MEASURED, declared: tsc --listFiles counts the new plugin-email test file 1 time but the new plugin-auth test file 0 times -- packages/plugins/plugin-auth/tsconfig.json excludes **/*.test.ts (pre-existing, ledgered), so 'plugin-auth typecheck clean' says nothing about that file; vitest does exercise it. GATES: dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 41 families at the final head (34 before scripts/engine-double-contract.pinned.json joined the change set); 38 green. check:engine-double-contract was genuinely RED and was 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 --write recorded the new pinned coverage ('684 (file, verb) row(s), 1 added or grown, 0 lost'), re-run green. Three non-green, none a finding: check:bash32-floor exit 1 in its OWN SELF-TEST (8 of 153 cases, all 'the probe is shell this host can parse' plus the simulated-3.2 harness) because the host bash IS 3.2.57 and the gate's bash-4 probes cannot run here -- its real-tree scan in the same run says '26 shell file(s) ... 0 finding(s)' and this diff contains no shell file; check-test-completeness.mjs exit 3 and check-half-states.mjs exit 3, which each script's own text defines as NOT MEASURED (a saved turbo test log / a GitHub route this container lacks), not a red. Every exit code captured by redirecting first, never through a pipe. ALSO: node scripts/check-nul-bytes.mjs OK (7826 files) plus a manual control-byte grep over all five touched files (no hits), and 'pnpm lint' -- the FULL repo scan, eslint . --no-inline-config -- exit 0 in 5m45s, so no lint narrowing was taken. DECLARED NARROWING: scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host (no usable flock; the lock is declared Linux-only), so every build and test ran UNLOCKED with no serialization guarantee -- declared here and in the PR body, not silent.",
"mcp_calls": "0 — the whole run used the gh CLI (a repo-scoped REST read was probed first and returned 200), never the MCP GitHub tools",
"open_questions": [],
"out_of_scope_findings": [
"filed as #14372 (label: finding, no assignee): sys_email.error is declared as 'Transport error message when status=failed' but the column now also carries pre-delivery rejections; the row text is self-describing so nothing is misleading in the data, but the field description Studio shows is narrower than the column. Left out of this PR because it pulls packages/platform-objects into a diff whose claimed surface was plugin-email / plugin-auth, and into a different gate family, for one line of prose."
]
}
Ruling check: fail-closed at configuration time for a declared undeliverable defaultFrom (matches the file's own smtp-without-host convention), settings channel keeps the previous sender and states it; pre-delivery rejections now land a sys_email row status=failed, error prefixed rejected before delivery:, unreachable by re-delivery paths. Card correction accepted: the auth half already propagated on main — pinned, not re-implemented.
Ablation proven on disk (blob hashes + anchor grep 0 → restore verified); plugin-email 460 tests, plugin-auth 1806 tests green; 41 gate families derived, engine-double-contract fixed not waived; full pnpm lint exit 0. NOT MEASURED declared honestly (plugin-auth tsconfig excludes the new test from typecheck; verify lock unavailable on macOS). CI 31 SUCCESS / 3 SKIPPED.
Operational note for rigs: scripts/dev-local/.env.local copies still carrying noreply@localhost will now refuse to boot — that is the intended loud failure; fix the sender.
Activity
hotlong commentedon Sep 2, 2026
Claim: PM loop round 1 (epic objectstack-ai/cloud#1841)
Session:
session_c5c0ce54-bb9c-478c-9e5b-cf44b80d4569Branch:
claude/issue-14318-email-sender-validation-and-failure-signalWorktree:
objectstack-issue-14318Domain: domain:services
File surface:
packages/plugins/plugin-email/**(sender validation at init, sys_email failed row),packages/plugins/plugin-auth/**(sendVerificationEmail failure propagation) (stop on breach; explain in the report)Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate, PM judgement)
Clause-②: no
Serial constraints cleared: objectstack#14293 (test-only, plugin-auth/src/admin-sso-bridge-gate.test.ts) and dependabot bumps are the only open PRs on these packages — no overlap; #14319 (template locale) is SERIAL after this card
hotlong commentedon Sep 2, 2026
os-dev-report
{ "issue": 14318, "status": "done", "branch": "claude/issue-14318-email-sender-validation-and-failure-signal", "pr": "https://github.com/objectstack-ai/objectstack/pull/14371", "premise_still_valid": true, "summary": "Premise confirmed on origin/main: sendInternal calls normalizeMessage BEFORE inserting the sys_email row and rethrows, so a message rejected at normalization is the ONE failure class that produces no row at all; and better-auth 1.7.2 sign-up invokes sendVerificationEmail through runInBackgroundOrAwait (dist/api/routes/sign-up.mjs:254), which with no advanced.backgroundTasks.handler configured -- ObjectStack configures none -- awaits inside a try/catch that logs 'Failed to run background task' and returns, so sign-up answers 200 regardless. Fixed both halves the ruling names. (1) EmailServicePlugin.init() now refuses a declared defaultFrom no message could be sent from -- fail-closed, matching the file's own convention (resolveTransport already propagates makeTransport's throw for smtp-without-host; resolveEmailCapabilityArg throws for an undeliverable provider tag), with the consequence and the OS_EMAIL_FROM / config.email.defaultFrom fix in the message; an ABSENT sender is still accepted. The mail settings channel takes applyMailSettings' opposite documented trade -- never throws, refuses the value, KEEPS the previous sender, states it at error (durability class: the save succeeds and the page shows what was typed, so nothing looks broken). isSendableAddress shares the one EMAIL_REGEX with formatAddress, which is untouched and NOT relaxed. (2) EmailService.recordRejectedMessage writes the sys_email row for that window: status=failed, attempt_count 0, error prefixed 'rejected before delivery: ' so the column distinguishes it from a transport error; envelope columns carry the raw caller input (never re-canonicalised -- canonicalisation is what threw) with '(none)' where nothing was named, since from_address/to_addresses/subject are required; the row is unreachable by both re-delivery paths, which gate on status==='queued'; persisting is best-effort at warn so it can never replace the caller's error. ONE CORRECTION to the card: the auth half already propagated on main -- AuthManager's sendVerificationEmail lets a sendTemplate throw escape and turns a returned status:'failed' into a throw naming recipient and cause, and better-auth's /send-verification-email route awaits sendVerificationEmailFn directly and rethrows. No auth code was added; the behaviour is pinned by a new test, and the ablation below shows those three cases pass on the PRE-FIX tree too, so they are a regression pin, not evidence of a new fix. The residual 'sign-up still answers 200' is better-auth-internal and not ours to change, which is exactly why the fix is a configuration-time refusal plus a queryable sys_email row rather than more error plumbing.", "tests": "Union at final commit 5170e7a85. ABLATION (reverse verification): fix committed FIRST, then both edited sources restored to the branch point 909a44171. Mutation proven on disk before running -- blobs moved d9a384b4 to 11b7ac28 (email-plugin.ts) and 0198f7ac to e031b07a (email-service.ts), and grep -c for the removed anchors (assertSendableDefaultFrom, recordRejectedMessage) both read 0. NO REBUILD NEEDED ON EITHER LEG and this is stated rather than skipped: both suites reach the subject through same-package RELATIVE SOURCE specifiers, so vitest resolves src and never dist. Observed directions: plugin-email/src/sender-address-validation.test.ts 'Tests 11 failed | 5 passed (16)' -- red as predicted, the 5 being the controls that must hold both ways (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); plugin-auth/src/verification-email-failure-propagation.test.ts 'Tests 3 passed (3)' -- GREEN on the pre-fix tree, reported as observed and not as predicted (regression pin). Restore proven by observation, not exit code: blobs back to the HEAD values, grep -c back to 2 and 2, and git diff HEAD EMPTY. Restore used git checkout HEAD -- ABSOLUTE paths, with a trap on EXIT INT TERM. TESTS: 'pnpm --filter @objectstack/plugin-email --filter @objectstack/plugin-auth run test' -> 'plugin-email test: Test Files 29 passed (29) / Tests 460 passed (460)' and 'plugin-auth test: Test Files 88 passed (88) / Tests 1806 passed (1806)'. CLI consumers that construct the plugin: 5 serve-email-* / serve-settings-ordering suites -> 'Test Files 5 passed (5) / Tests 58 passed (58)'. dogfood (boots the real plugin through dist): email-template-materialization -> 'Test Files 1 passed (1) / Tests 2 passed (2)'. TYPECHECK: both packages 'Done'. NOT MEASURED, declared: tsc --listFiles counts the new plugin-email test file 1 time but the new plugin-auth test file 0 times -- packages/plugins/plugin-auth/tsconfig.json excludes **/*.test.ts (pre-existing, ledgered), so 'plugin-auth typecheck clean' says nothing about that file; vitest does exercise it. GATES: dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 41 families at the final head (34 before scripts/engine-double-contract.pinned.json joined the change set); 38 green. check:engine-double-contract was genuinely RED and was 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 --write recorded the new pinned coverage ('684 (file, verb) row(s), 1 added or grown, 0 lost'), re-run green. Three non-green, none a finding: check:bash32-floor exit 1 in its OWN SELF-TEST (8 of 153 cases, all 'the probe is shell this host can parse' plus the simulated-3.2 harness) because the host bash IS 3.2.57 and the gate's bash-4 probes cannot run here -- its real-tree scan in the same run says '26 shell file(s) ... 0 finding(s)' and this diff contains no shell file; check-test-completeness.mjs exit 3 and check-half-states.mjs exit 3, which each script's own text defines as NOT MEASURED (a saved turbo test log / a GitHub route this container lacks), not a red. Every exit code captured by redirecting first, never through a pipe. ALSO: node scripts/check-nul-bytes.mjs OK (7826 files) plus a manual control-byte grep over all five touched files (no hits), and 'pnpm lint' -- the FULL repo scan, eslint . --no-inline-config -- exit 0 in 5m45s, so no lint narrowing was taken. DECLARED NARROWING: scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host (no usable flock; the lock is declared Linux-only), so every build and test ran UNLOCKED with no serialization guarantee -- declared here and in the PR body, not silent.", "mcp_calls": "0 — the whole run used the gh CLI (a repo-scoped REST read was probed first and returned 200), never the MCP GitHub tools", "open_questions": [], "out_of_scope_findings": [ "filed as #14372 (label: finding, no assignee): sys_email.error is declared as 'Transport error message when status=failed' but the column now also carries pre-delivery rejections; the row text is self-describing so nothing is misleading in the data, but the field description Studio shows is narrower than the column. Left out of this PR because it pulls packages/platform-objects into a diff whose claimed surface was plugin-email / plugin-auth, and into a different gate family, for one line of prose." ] }Generated by Claude Code
hotlong commentedon Sep 2, 2026
ACCEPT — reviewer of record: PM (epic objectstack-ai/cloud#1841).
Fixes #14318, 6 files: plugin-email (init refusal +recordRejectedMessage), plugin-auth regression pin,scripts/engine-double-contract.pinned.json(gate ledger grown via --write, 1 added 0 lost), changeset. Inside the surface; Clause-②: no (no spec file, no contract accept/reject change — the sender regex is untouched).defaultFrom(matches the file's own smtp-without-host convention), settings channel keeps the previous sender and states it; pre-delivery rejections now land asys_emailrowstatus=failed, error prefixedrejected before delivery:, unreachable by re-delivery paths. Card correction accepted: the auth half already propagated on main — pinned, not re-implemented.pnpm lintexit 0. NOT MEASURED declared honestly (plugin-auth tsconfig excludes the new test from typecheck; verify lock unavailable on macOS). CI 31 SUCCESS / 3 SKIPPED.scripts/dev-local/.env.localcopies still carryingnoreply@localhostwill now refuse to boot — that is the intended loud failure; fix the sender.Flipping ready and enqueuing.
Generated by Claude Code