Repository navigation
fix(plugin-email): the subject and body_text faces render without HTML escaping, so a text-part link keeps its literal & - #20392
Conversation
…L escaping A template's plain-text faces went through the HTML escaper, so the text/plain part of every auth mail carried `&` in its link and a plain-text reader lost the `callbackURL` parameter. The engine now has a plain-text render mode; `EmailService` uses it for `subject` and `body_text`, and keeps the HTML mode for `body_html` only. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 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 f6e536b9e198a3a82afd67bcd2c9614567b2e47c && git checkout f6e536b9e198a3a82afd67bcd2c9614567b2e47c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 15bf186f509521a1be530d19351e0ea63db4a2af eb169bd4581954e934785b082f26488b967f18aa && git checkout -B drift-repro 15bf186f509521a1be530d19351e0ea63db4a2af && git merge --no-ff eb169bd4581954e934785b082f26488b967f18aa
node scripts/docs-audit/affected-docs.mjs --json 15bf186f509521a1be530d19351e0ea63db4a2af
|
Contract reviewServed-tier: ① Derived judgmentsInputs read: card #20374 body and all 6 comments; PR #20392 body, its 7-file list and the net diff
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20374
Clause-②: no
What was wrong
EmailServicerendered every face of asys_email_templaterow through the HTML escaper (escapeHtmlintemplate-engine.ts), but onlybody_htmlis markup. Measured onc577e666through the realEmailServiceover the seededBUILTIN_AUTH_TEMPLATESrows, the text part of the verify, reset, invitation and magic-link mails read…?token=abc&callbackURL=%2F. A plain-text client, or a user copying the link, gets a parameter namedamp;callbackURL, and the post-verification redirect falls back to/.What changes
template-engine.ts: one internalrender()with a per-face hole encoder.renderTemplate(the HTML face, and the helper the package exports) keepsescapeHtmlfor{{x}}and verbatim for{{{x}}}, exactly as before.renderPlainTextTemplate(the plain-text face) renders every hole verbatim, double or triple braced, formatter output included. It is exported from the module only. The package entry does not re-export it, so the public surface is unchanged: the builtdist/index.d.tsnames it 0 times, and the built CJSrenderTemplatestill answersa&b.email-service.tsresolveAndRenderTemplate: this is the ONE render entry behind bothsendTemplateandIEmailService.renderTemplate.subjectandbody_textnow go through the plain-text renderer, andbody_htmlthrough the HTML renderer. The derived fallback is unchanged and was already right: when a row has nobody_text, the text ishtmlToText(html), which decodes the entities.templates/auth-templates.tschanges. This follows the triage direction: the engine switch covers every row that reaches the renderer, whether built-in, declared or authored in Studio.Also fixed in place: the subject (same defect class)
The subject is a plain-text face too (the mail Subject header,
sys_email.subject, and the inbox title). Onc577e666it renderedVerify your R&D email addressandO'Brien invited you to R&D. It passes all four bounded in-place conditions:renderPlainTextTemplatecall;email-service.ts, where the text body is rendered);Evidence: the subject pins below, red under ablation A2.
One file outside the claim's file surface: a doc sentence this change made false
content/docs/automation/email-templates.mdxsaid that{{path}}is HTML-escaped in "Subject and both bodies". After this change that is false forsubjectandbodyText. The page now says that escaping follows the face..claude/agents/os-dev.mdrequires fixing a published statement that a change makes false, while the claim's surface names onlypackages/plugins/plugin-email/src/and the changeset. The report calls out this conflict. No other doc or skill describes template escaping (agit grepfor escaping incontent/docsandskills).Measurements behind the dispatch's mechanism assumptions
template-engine.tshas a single render function; the{{/{{{switch was at:103/:120andescapeHtmlat:78.ENTITIES/decodeEntitiesare used only byhtmlToText, the derived-fallback path. The declaredbody_textpath called no decode and had no text mode.body_textholds&for a URL carrying an ampersand,<for a value carrying a less-than sign and'for an apostrophe (measured, all four link templates).git grepfinds exactly one non-test caller of the engine'srenderTemplatein the repo:resolveAndRenderTemplate.emailTemplatesand Studio-authored rows all reach it throughsendTemplateorIEmailService.renderTemplate.IEmailService.renderTemplateand storessubjectas the title andtextasbody_md.template-engine.test.ts.template-locale-resolution.test.tswrapped its subject expectations in anesc()mirror of the escaper. That was vacuous on its Intl date values, which carry no escapable character, but it stated the old belief, so it is rewritten: subjects are now compared unescaped, andhtmlexpectations keepesc().auth.invitationlives in this package (templates/auth-templates.ts, in en-US, zh-CN, ja-JP and es-ES).plugin-authauth-manager.tssends it throughsendTemplate, so the engine fix covers it with no edit outside the file surface. It is pinned below.Tests
New
src/plain-text-faces.test.ts(36 cases) runs the realEmailServiceover the real seeded rows, plus one authored row that nobody hand-braced:sys_email.body_textand the delivered text part contain the link with a literal ampersand and no&.hrefcarries the link verbatim, and the visible copy-paste span is still the escaped markup.body_htmland literally inbody_textand the subject. This holds for a built-in template, for an authored template, for the render-onlyrenderTemplatethe inbox reads, and for a row with nobody_text(the derived fallback, with entities decoded).src/template-engine.test.ts: 6 newrenderPlainTextTemplatecases, including a control that runs the same inputs throughrenderTemplateand gets them escaped.Results at
eb169bd4for@objectstack/plugin-email:pnpm test: 31 files and 510 tests passed. It ran before the ablations and again after them, from the committed tree.typecheck:tsc --noEmitexits 0, andcheck:test-typecheckreports 0 errors.HTML part byte-identity. One-time proof, not kept as a test. BASE
c577e666renderTemplateand HEADrenderTemplaterendered every built-inbodyHtml(24), plus 2 hole-shape extras, with hostile data (apostrophe, quotes, ampersand, markup). 0 of 26 differ. The positive control shows the comparison can see a change: 24 of 24bodyTextrenders differ between BASE HTML mode and HEAD text mode.Ablation: every negative pin, one-time, not committed
Each leg ran through
scripts/ablation-replace.mjsin WRAP mode on the committed fix ateb169bd4. The anchor hit 1 of 1 each time, and the blob changed on disk. The tests import./email-service.jsand./template-engine.jsrelatively, so vitest runssrc/and nodist/is in the resolution path.body_textback torenderTemplateinemail-service.tssubjectback torenderTemplatetemplate-engine.ts)auth.verify_email [en-US]it received…?token=TOK123&callbackURL=…. The 16 HTML-unchanged pins and the fallback pin stayed green, as expected.O'Brien invited you to R&D <Lab>.Every restore is proven: the blob equals HEAD (
e5a222f83fb1foremail-service.ts,86d358762d50fortemplate-engine.ts),git diff HEADis empty andgit statusis clean.Gates (HEAD
eb169bd4)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandswas run with no paths: 7 paths against merge basec577e6663, 90 commands. The dispatch-time list had 61 commands; the extra ones are the docs families that thecontent/docsedit adds. Each command was run with its exit code captured right after a single redirect. The--ranverdict: 90 accounted for: 88 run, 2 NOT MEASURED, 0 UNRUN.check:skill-examplesfirst exited 3, because@objectstack/client-reactwas unbuilt. After building the@objectstack/client-react...closure it exited 0: 259 examples type-check.check:dual-build-cjs-loads. Exit 3: 51 packages have nodist/, and the gate needs a whole-repopnpm build. Declared narrowing: the diff touches nopackage.json, tsup config orexports, so only this package'sdistbytes move. On plugin-email's build, CJSrequireand ESMimportboth load, each with 93 exports and an identical key set.check:type-check-debt. Exit 3: 16 dependencies of ledgered packages are unbuilt. Declared narrowing:cloud-connection,hono,observabilityand the workspace root.scripts/plus top-level configs, which this diff does not touch.tsc --noEmitexits 0 and its test layer has 0 errors.eslint --no-inline-config --format jsonon the 5 touched.tsfiles: 5 files, 0 errors, 0 warnings.--print-config(exit 0) and none is reported ignored.eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules, as its own comment at:326-328states), so this diff cannot move the verdict on any untouched file.origin/mainmoved 6 commits since the base. None of them touches plugin-email, the doc page, service-messaging or the spec email contract (empty diffstat).Acceptance notes
rendered.textasbody_md, so an authored value carrying markup characters now reaches that markdown field raw instead of entity-encoded. This is not a new exposure class: a row with nobody_textalready delivered raw text through thehtmlToTextfallback, and the contract (RenderTemplateResult.text, "rendered plain-text body") already said plain text. How the console rendersbody_md(raw HTML allowed or not) is NOT MEASURED, because no objectui checkout is in this container. Whoever next touches the inbox renderer owns that question. Owner: none.callbackURL, which is a different repo and a different mechanism.Generated by Claude Code