Skip to content

非法 OS_EMAIL_FROM 只在第一个用户注册时爆、且爆在 better-auth 后台任务里:用户看到「已发送验证邮件」,邮件从未发出,sys_email 无失败行 #14318

Closed

Description

@hotlong

现象(用户被告知「已发送验证邮件」,邮件其实从未发出)

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)

plugin-email 的 EMAIL_REGEX 要求域名带点,noreply@localhost 不合法——这是合理的校验,问题是它在第一个用户注册时才爆,而且爆在没人看的地方:用户卡在验证页,「重新发送」同样静默失败,没有任何界面或运维信号。

修复要求

  1. 发件人地址在服务启动(EmailServicePlugin 初始化 / 配置解析)时校验,不合法就以启动错误或明确的 WARN 暴露,而不是在每次发信时抛进后台任务。
  2. sendVerificationEmail / 邮件发送失败要有可观测出口:至少 sys_email 落一条 status=failed + error 的行(本次 sys_email 里没有任何失败记录),并让 auth 层能把「发送失败」返回给调用方(cloud 前端才有机会不显示「已发送」)。
  3. 补测试:非法 from 在启动期被拒;发送失败时 sys_email 有失败行。

关联(cloud 侧):验证页在发送失败时的文案与重发行为,待本卡提供失败信号后跟进。

Activity

  1. added
    bugSomething isn't working
    priority:p1High: required for production / M2
    pm:queueReady for the PM dispatch loop
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Sep 2, 2026
  2. self-assigned this
    on Sep 2, 2026
  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 2, 2026
  4. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    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

  5. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    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

  6. added
    pm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-set
    on Sep 2, 2026
  7. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    ACCEPT — reviewer of record: PM (epic objectstack-ai/cloud#1841).

    • PR fix(plugin-email): validate the default sender at configuration time and record pre-delivery rejections in sys_email #14371: draft → main, 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).
    • 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.

    Flipping ready and enqueuing.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-setpm:dispatchedDispatched to a dev agent by /pm-dispatchpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions