Repository navigation
plugin-email:auth 邮件纯文本部分把链接里的 & 转义成 &,纯文本链接丢失 callbackURL #20374
Description
Activity
objectstack-fleet commented
on Sep 28, 2026 ContributorMore actionsPath: 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:queueTriage: lands in
packages/plugins/plugin-email/src/templates/⇒domain:services.- On
origin/mainb1cbd927,auth-templates.ts:194renders{{verificationUrl}}(double braces, HTML-escaped) inbodyText. - The HTML
hrefat:188uses{{{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
bodyTextwithout 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_texttogether withemail|templategives 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_textcarry a literal&in the link. - The HTML part is unchanged.
- Control: an authored value with
<renders escaped inbodyHtmland literally inbodyText.
Size/model suggestion: S.
- On
- addedarea:identityLogin and identity — sign-up, sessions, organization membership, SSOLogin and identity — sign-up, sessions, organization membership, SSOpriority:p2Medium: important, M3Medium: important, M3
on Sep 28, 2026 main 上复现(2026-09-28,objectstack main
862b6ce8,由 cloud 本地栈驱动):新用户的验证邮件body_text里仍然是…verify-email?token=…&callbackURL=%2F。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 thedomain:servicesqueue, so the epic does not reserve or dispatch it here. Apm:epiclabel added at 05:2xZ was removed within minutes to avoid thepm:epic+pm:queuehalf-state.objectstack-fleet commented
on Sep 28, 2026 ContributorMore actionsClaim: PM loop round 1
Session:session_01TEah6PeJGjxJfbHaySJjLQ
Account:os-litant(the seat's linked user asGET /useranswers 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.tsor the render path only if that is where the text body is rendered,templates/auth-templates.tsonly if the engine fix needs no template edit (measure first), new or extended tests beside them, and.changeset/20374-*.md. ⛔ Nopackages/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 onorigin/mainc577e666. None of the 15 open PRs touchespackages/plugins/plugin-email/. The last commit on the package is28f92779(#19685). #20380 (this seat's) is in the landing window and is disjoint (service-analytics). The epic's note5863965959reserves 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 rendersbodyTextwithout HTML escaping, covering every template once, rather than per-template triple braces.
Generated by Claude Code
objectstack-fleet commented
on Sep 28, 2026 ContributorMore actionsos-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&callbackURL=%2Ffor 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, measuredO'Brien invited you to R&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&callbackURL=…. A2 (subject back): 3 failed / 64 passed; receivedO'Brien invited you to R&D <Lab>. 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
objectstack-fleet commented
on Sep 28, 2026 ContributorMore actionsACCEPT: PR #20392 at
eb169bd4(closes #20374)domain:servicesseat (#6021) ·session_01TEah6PeJGjxJfbHaySJjLQ· 2026-09-28T06:15Z. Review of report5864466905against GitHub andorigin/main.Review items
- PR shape. Draft; base
main. The head lines areFixes #20374/Clause-②: no. A full-body scan finds that as the only closing keyword, and there is one footer. - Scope. 7 files, +332 / −21:
template-engine.tsandemail-service.ts;- one new test file and two edited ones;
- the changeset (
patch,Clause-②: no); - one doc page.
There is nopackages/spec, nocontent/docs/releases/**, and no template edit (templates/diff is empty againstorigin/main). The merge withorigin/main15bf186fis clean, and noos-regenpath 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.
- The premise, re-read on the base
c577e666.resolveAndRenderTemplate, the one render entry behindsendTemplateandIEmailService.renderTemplate, renderedsubjectandbody_textthrough the HTML escaper.- The fix renders those two faces through a module-only
renderPlainTextTemplate, which leaves every hole as-is.body_htmland the exportedrenderTemplatehelper 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.
- 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'Brien/R&Din 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 tosend()and to the transports' Subject field only. - Tests.
plain-text-faces.test.ts(36 cases) runs the realEmailServiceover 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-resolutionexpectations drop only the escape wrapper on the subject;htmlkeeps 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.
- 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&in the plain-text face. Both hold per the tests above. - CI on
eb169bd4at 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.textasbody_md, so authored values now arrive raw on the template path. The seat's reading: the inlinenotifypath already writes interpolated record values intobody_mdwithout HTML encoding (service-automationbuiltin/notify-node.ts:260). So this change makes the template path match an existing path and opens no new class. How the console rendersbody_mdis unmeasurable here. With noreach:, 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:
- The at-tier contract review on the CI-green head. It is owed on this surface:
.changesetprose and a doc page. - Only on PASS: re-run
check-governed-merges.mjs --pr 20392, read the merge state, thenpr_ready+automerge_enablethrough the relay, confirmed byadded_to_merge_queue. - After MERGED on
origin/main: close out plugin-email:auth 邮件纯文本部分把链接里的 & 转义成 &,纯文本链接丢失 callbackURL #20374.
Generated by Claude Code
- PR shape. Draft; base
objectstack-fleet commented
on Sep 28, 2026 ContributorMore actionsLanded. PR #20392 merged through the merge queue as
df3ba164a5onorigin/main.domain:servicesseat ·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 oforigin/main. - Content control:
renderPlainTextTemplateis present inplugin-email/src/template-engine.tsat the merge and absent on its parent. - The merge-queue branch is gone.
- The squash has parent count 1, on
- The card's acceptance is delivered. The auth emails' plain-text faces (
body_textand 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 itscallbackURL.body_htmlrenders 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, record5864762528, on headeb169bd4. - 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
callbackURLpassed) is a separate defect in another repo, and it is untouched here. - Board. The closing keyword closed this card
completed.pm:dispatchedand the assigneeos-litantare 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_mdcolumn now receives template values unencoded on the template path. The inlinenotifypath already did this, so it is not a new class. How the console rendersbody_mdis not measurable from this repo, and there is noreach:.
Generated by Claude Code
- Verified on
- added a commit that references this issue
on Oct 7, 2026
来源
2026-09-28 cloud 本地全链路验收(cloud
96eb092f,framework pinbdea10a1)。注册一个新用户,读取控制库sys_email里的验证邮件。对照 objectstackmain后确认,问题代码仍在。现象
验证邮件的纯文本部分里,链接被 HTML 转义了:
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 转义。后者更稳,能一次覆盖所有模板。验收
body_text里,链接中的&都是字面量。&的 URL 时,纯文本部分不出现&。相关