Skip to content

plugin-email:auth 邮件纯文本部分把链接里的 & 转义成 &,纯文本链接丢失 callbackURL #20374

Description

@hotlong

来源

2026-09-28 cloud 本地全链路验收(cloud 96eb092f,framework pin bdea10a1)。注册一个新用户,读取控制库 sys_email 里的验证邮件。对照 objectstack main 后确认,问题代码仍在。

现象

验证邮件的纯文本部分里,链接被 HTML 转义了:

http://localhost:4600/api/v1/auth/verify-email?token=eyJ…&callbackURL=%2F

HTML 部分的 href 是正确的 &callbackURL=。但只显示纯文本的客户端,或者用户复制文本里的链接时,拿到的查询参数是 amp;callbackURL:token 仍能被解析,callbackURL 却丢了。这会把验证后应该跳转的去向(比如接受邀请页)打回默认的 /。

机制

packages/plugins/plugin-email/src/templates/auth-templates.ts 的 bodyText 用的是 {{verificationUrl}}(双花括号,按 template-engine.ts 默认会做 HTML 转义)。HTML 部分的 href 用的是 {{{verificationUrl}}}。纯文本本来就不该做 HTML 转义。所有 locale 变体(AUTH_VERIFY_EMAIL_TEMPLATE_*)都一样,其他带 URL 的 auth 模板(重置密码、邀请等)大概率也有同样的问题。

修复方向

要么纯文本模板里的 URL 统一改用 {{{…}}},要么让模板引擎在渲染 bodyText 时整体不做 HTML 转义。后者更稳,能一次覆盖所有模板。

验收

  1. 验证、重置、邀请这几类邮件的 body_text 里,链接中的 & 都是字面量。
  2. 单测:渲染含 & 的 URL 时,纯文本部分不出现 &。

相关

Activity

  1. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    Contributor

    Path: identity · 第一次装好就有主人:零用户首跑、注册闸门、邮箱验证 | 缺项 (no item reads the text part of an auth email's link) | P2

    Triage: first grade — bug · priority:p2 · domain:services · area:identity · pm:queue

    Triage: lands in packages/plugins/plugin-email/src/templates/ ⇒ domain:services.

    • On origin/main b1cbd927, auth-templates.ts:194 renders {{verificationUrl}} (double braces, HTML-escaped) in bodyText.
    • The HTML href at :188 uses {{{verificationUrl}}}.
    • The same holds for every locale variant (:79, :112, :136, :160).

    Rationale: a plain-text client, or a user who copies the text link, loses callbackURL, so a verified invitee lands on / instead of the accept page. It runs but answers wrong ⇒ p2 (NORTH-STAR 〈优先级〉 rule 2). Most clients render the HTML part, which is why it is not higher.

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-09-28T04:57Z. ⛔ Not a claim, ⛔ not a dispatch.

    Direction confirmed: the engine renders bodyText without HTML escaping.

    • HTML escaping belongs to the HTML part only. A plain-text body never needs it, so fixing the engine covers every template once, including the reset and invitation ones and any authored template.
    • Patching per template with {{{…}}} leaves the next text template wrong.

    Duplicate check. Same corpus. &|html escap|bodyText|body_text together with email|template gives 3 hits, all closed PRs (#16239, #13956, #1521). None is this.

    • Related, separate: objectui#10893 (sign-up passes no callbackURL). That is a different repo and a different mechanism. Both matter for the invite path.

    Pins.

    • The verify, reset and invite emails' body_text carry a literal & in the link.
    • The HTML part is unchanged.
    • Control: an authored value with < renders escaped in bodyHtml and literally in bodyText.

    Size/model suggestion: S.

  2. hotlong commented on Sep 28, 2026

    @hotlong
    ContributorAuthor

    main 上复现(2026-09-28,objectstack main 862b6ce8,由 cloud 本地栈驱动):新用户的验证邮件 body_text 里仍然是 …verify-email?token=…&amp;callbackURL=%2F。

  3. hotlong commented on Sep 28, 2026

    @hotlong
    ContributorAuthor

    Epic objectstack-ai/cloud#2440 (session 786273a0-0246-4bb2-b026-bb37ba5d7295) tracks this card as a sub-issue only. Central triage has already routed it to the domain:services queue, so the epic does not reserve or dispatch it here. A pm:epic label added at 05:2xZ was removed within minutes to avoid the pm:epic + pm:queue half-state.

  4. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 1
    Session: session_01TEah6PeJGjxJfbHaySJjLQ
    Account: os-litant (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-20374-plain-text-unescaped
    Worktree: objectstack-issue-20374
    Domain: domain:services
    Seat: domain:services#1
    Ruling-ref: 5863748439
    File surface: packages/plugins/plugin-email/src/: template-engine.ts (and its render entry, which triage says is where the fix belongs), email-service.ts or the render path only if that is where the text body is rendered, templates/auth-templates.ts only if the engine fix needs no template edit (measure first), new or extended tests beside them, and .changeset/20374-*.md. ⛔ No packages/spec. ⛔ No change to the HTML part's escaping. (Stop on breach; explain in the report.)
    Container & model: S, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
    Clause-②: no
    Thread-read: 5863965959
    Serial constraints cleared: read at 2026-09-28T05:26Z on origin/main c577e666. None of the 15 open PRs touches packages/plugins/plugin-email/. The last commit on the package is 28f92779 (#19685). #20380 (this seat's) is in the landing window and is disjoint (service-analytics). The epic's note 5863965959 reserves nothing.

    Clause-②: no: the plain-text body stops carrying HTML entities. No accept set or public surface widens; the HTML part is unchanged. Triage direction (5863748439): the engine renders bodyText without HTML escaping, covering every template once, rather than per-template triple braces.


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    Contributor

    os-dev-report
    {
    "issue": 20374,
    "status": "done",
    "branch": "claude/issue-20374-plain-text-unescaped",
    "pr": "#20392",
    "session": "session_01TEah6PeJGjxJfbHaySJjLQ — the harness-stamped id (container CLAUDE_CODE_REMOTE_SESSION_ID cse_01TEah6PeJGjxJfbHaySJjLQ); subagent = parent's",
    "premise_still_valid": true,
    "summary": "Premise held on c577e66 (measured via the real EmailService over the seeded BUILTIN_AUTH_TEMPLATES rows): body_text rendered ?token=abc&amp;callbackURL=%2F for verify / reset / invitation / magic_link. Fix at the engine, per the triage direction: template-engine.ts gains an internal render() with a per-face hole encoder. renderTemplate (the HTML face and the exported helper) is unchanged. The new module-only renderPlainTextTemplate renders every hole verbatim. The one render entry, email-service.ts resolveAndRenderTemplate (behind sendTemplate and IEmailService.renderTemplate), uses it for body_text AND the subject. The package entry does not re-export it: dist/index.d.ts names it 0 times, so the public surface is unchanged and Clause-② stays no. No template edit. The invitation template lives in this package and is covered. Two deviations are declared in the PR body. (1) The subject was fixed in place under the bounded in-place exemption: same defect class, measured O&#39;Brien invited you to R&amp;D, same line of the same render entry, same test family. (2) CONFLICT between the claim's file surface and os-dev.md: content/docs/automation/email-templates.mdx said a double-brace hole is HTML-escaped in 'Subject and both bodies', which this change makes false. It was edited per os-dev.md ('本轮改动令其变假…必修'), which overrides the dispatch; this is the only file outside packages/plugins/plugin-email/src/ plus the changeset. Worktree node_modules removed and the worktree removed. CI not awaited (in_progress at report time).",
    "tests": "HEAD eb169bd. pnpm --filter @objectstack/plugin-email test: 31 files / 510 tests passed (run before the ablations and again after them, from the committed tree). typecheck: tsc --noEmit exit 0; check:test-typecheck 'OK … 0 error(s)'. New src/plain-text-faces.test.ts has 36 cases: 4 link templates x 4 locales x (body_text literal & + HTML unchanged), plus 4 controls (built-in and authored rows: less-than and & escaped in body_html, literal in body_text and the subject; render-only renderTemplate; no-body_text fallback). template-engine.test.ts has +6 renderPlainTextTemplate cases, including a control that the HTML face still escapes. template-locale-resolution.test.ts subject expectations dropped a vacuous esc() wrapper; html still uses esc(). HTML byte-identity, one-time: BASE c577e66 renderTemplate vs HEAD renderTemplate on 24 built-in bodyHtml plus 2 hole-shape extras with hostile data: 0 of 26 differ; control: 24 of 24 bodyText renders differ in text mode. ABLATION via scripts/ablation-replace.mjs WRAP on the committed fix (anchor 1 hit to 0, blob changed on disk). Tests import src relatively, so no dist is in the path and no rebuild legs apply. A1 (body_text back to renderTemplate): 19 failed / 48 passed; received …?token=TOK123&amp;callbackURL=…. A2 (subject back): 3 failed / 64 passed; received O&#39;Brien invited you to R&amp;D &lt;Lab&gt;. A3 (plain-text encoder made to escape): 23 failed / 44 passed. Every restore: blob == HEAD (email-service.ts e5a222f83fb1, template-engine.ts 86d358762d50), git diff HEAD empty, status clean. GATES: dispatch-gates --repo objectstack-ai/objectstack --commands (no paths; 7 paths vs merge base c577e66) gives 90 commands (dispatch-time list 61; the delta is the docs families from the .mdx edit). Each command ran with its exit captured after a single redirect. --ran verdict: '90 derived famil(ies) accounted for — 88 run, 2 NOT-MEASURED'. check:skill-examples first exited 3 (client-react unbuilt); after building the @objectstack/client-react... closure it exited 0 (259 examples). NOT MEASURED: check:dual-build-cjs-loads, reason: exit 3 PREREQUISITE NOT MET (51 packages without dist; needs a whole-repo build). Declared narrowing: no package.json, exports or tsup change; plugin-email dist CJS require and ESM import both load, with 93 exports and an identical key set. NOT MEASURED: check:type-check-debt, reason: exit 3 PREREQUISITE NOT MET (16 unbuilt deps). Declared narrowing: the DEBT entries are cloud-connection, hono, observability and the root; none of the three packages depends on plugin-email, and the root program is scripts/ plus configs, which are untouched. Lint narrowed: eslint --no-inline-config --format json on the 5 touched .ts files gives 5 files, 0 errors, 0 warnings, none ignored (each resolves a config via --print-config). Invariance: eslint.config.mjs enables no type-aware linting (:326-328). Repo-wide pnpm lint is left to CI. Only @objectstack/plugin-email was tested; its public surface bytes are unchanged, so it owes no downstream-consumer sweep.",
    "mcp_calls": "0 — none",
    "api_writes": "3 — each one relay repository_dispatch (POST /repos/objectstack-ai/objectstack/dispatches, HTTP 204) executed as objectstack-fleet[bot]: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft, #20392); (2) label-write --assign, POST /repos//issues/20392/assignees [os-litant], read back as matching; (3) post-stamped, POST /repos//issues/20374/comments (this report). Not REST: git push x2 (the empty-branch probe, then eb169bd). Reads: single-card REST GETs of #20374, its comments and PR #20392.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · noted in PR #20392 Acceptance notes, not filed. The messaging inbox channel stores rendered.text as body_md, so an authored value carrying markup characters now reaches that markdown field raw instead of entity-encoded through the declared body_text path. It already did so through the htmlToText fallback, and RenderTemplateResult.text is contracted as plain text. How the objectui console renders body_md (raw HTML allowed or not) is NOT MEASURED, because no objectui checkout is in the container."
    ]
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    Contributor

    ACCEPT: PR #20392 at eb169bd4 (closes #20374)

    domain:services seat (#6021) · session_01TEah6PeJGjxJfbHaySJjLQ · 2026-09-28T06:15Z. Review of report 5864466905 against GitHub and origin/main.

    Review items

    1. PR shape. Draft; base main. The head lines are Fixes #20374 / Clause-②: no. A full-body scan finds that as the only closing keyword, and there is one footer.
    2. Scope. 7 files, +332 / −21:
      • template-engine.ts and email-service.ts;
      • one new test file and two edited ones;
      • the changeset (patch, Clause-②: no);
      • one doc page.
        There is no packages/spec, no content/docs/releases/**, and no template edit (templates/ diff is empty against origin/main). The merge with origin/main 15bf186f is clean, and no os-regen path is touched. check-governed-merges: NOT governed, 353 lines.
      • Surface revision, accepted: content/docs/automation/email-templates.mdx. It stated that double-brace holes are HTML-escaped in the subject and both bodies, and this change makes that false. Correcting it is owed under the dev rules, and it is recorded here as the revised claim surface.
    3. The premise, re-read on the base c577e666.
      • resolveAndRenderTemplate, the one render entry behind sendTemplate and IEmailService.renderTemplate, rendered subject and body_text through the HTML escaper.
      • The fix renders those two faces through a module-only renderPlainTextTemplate, which leaves every hole as-is. body_html and the exported renderTemplate helper are byte-for-byte unchanged. The dev's one-time proof: 0 of 26 HTML renders differ, against a positive control of 24 of 24.
    4. Declared deviation: the subject is fixed in place. Accepted. It is the same defect class on the same line of the same render entry, and the dev measured O&#39;Brien / R&amp;D in subjects. The fix is the same call, and it is pinned (ablation A2 goes red). A subject never reaches markup on the send path: it goes to send() and to the transports' Subject field only.
    5. Tests.
      • plain-text-faces.test.ts (36 cases) runs the real EmailService over the seeded rows: verify, reset, invitation and magic link in 4 locales, text part literal and HTML part unchanged, plus per-face controls. There are also 6 new engine cases.
      • The edited template-locale-resolution expectations drop only the escape wrapper on the subject; html keeps it.
      • Ablations A1 (19 red), A2 (3 red) and A3 (23 red) all go red, and every restore is proven.
      • plugin-email: 31 files / 510 tests.
      • Gates: 90 derived, 88 run, 2 NOT MEASURED (check:dual-build-cjs-loads, check:type-check-debt, both exit 3 on missing prerequisites), each with a declared narrowing. The CI checks on this head answer them.
    6. The card's acceptance. (1) The text part of the verify, reset and invitation emails (and magic link) keeps a literal &. (2) The unit pins show no &amp; in the plain-text face. Both hold per the tests above.
    7. CI on eb169bd4 at review time: still running. This is not a landing read.

    The dev's out-of-scope note stays in the PR's Acceptance notes; it is not filed. The messaging inbox stores rendered.text as body_md, so authored values now arrive raw on the template path. The seat's reading: the inline notify path already writes interpolated record values into body_md without HTML encoding (service-automation builtin/notify-node.ts:260). So this change makes the template path match an existing path and opens no new class. How the console renders body_md is unmeasurable here. With no reach:, the note is not filed.

    Consumer. The reporter's stack (cloud, epic objectstack-ai/cloud#2440) consumes a framework commit pin. This lane's landing criterion is MERGED on origin/main, and the cloud side picks the fix up with its next pin bump.

    Landing to-dos, recorded now:

    1. The at-tier contract review on the CI-green head. It is owed on this surface: .changeset prose and a doc page.
    2. Only on PASS: re-run check-governed-merges.mjs --pr 20392, read the merge state, then pr_ready + automerge_enable through the relay, confirmed by added_to_merge_queue.
    3. After MERGED on origin/main: close out plugin-email:auth 邮件纯文本部分把链接里的 & 转义成 &amp;,纯文本链接丢失 callbackURL #20374.

    Generated by Claude Code

  7. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    Contributor

    Landed. PR #20392 merged through the merge queue as df3ba164a5 on origin/main. domain:services seat · session_01TEah6PeJGjxJfbHaySJjLQ · 2026-09-28T06:59Z

    • Verified on origin/main, not from the PR-closed event.
      • The squash has parent count 1, on 681868ca, and it is an ancestor of origin/main.
      • Content control: renderPlainTextTemplate is present in plugin-email/src/template-engine.ts at the merge and absent on its parent.
      • The merge-queue branch is gone.
    • The card's acceptance is delivered. The auth emails' plain-text faces (body_text and the subject) render values unencoded, so the link in the text part of the verify, reset, invitation and magic-link mails keeps a literal & and its callbackURL. body_html renders exactly as before. The switch is in the renderer, so it also covers declared and Studio-authored templates, with no template edit. The at-tier contract review is PASS, record 5864762528, on head eb169bd4.
    • Consumer. The reporter's stack (cloud, epic objectstack-ai/cloud#2440) consumes a framework commit pin, so it picks this up with its next pin bump to a commit at or after the merge. The sign-up side (邀请注册链路丢失 redirect:signUp 不传 callbackURL,被邀请人验证邮箱后被带去「创建工作区」 objectui#10893, no callbackURL passed) is a separate defect in another repo, and it is untouched here.
    • Board. The closing keyword closed this card completed. pm:dispatched and the assignee os-litant are removed in the same stroke as this note. The claim is discharged, and there is no remainder on this card.
    • Not filed, on record in the PR's Acceptance notes: the inbox body_md column now receives template values unencoded on the template path. The inline notify path already did this, so it is not a new class. How the console renders body_md is not measurable from this repo, and there is no reach:.

    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

No one assigned

    Labels

    area:identityLogin and identity — sign-up, sessions, organization membership, SSObugSomething isn't workingdomain:servicespriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions