|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The plain-text faces of a template render WITHOUT HTML escaping (#20374). |
| 5 | + * |
| 6 | + * A template has three faces and only one of them is markup: `body_html`. |
| 7 | + * The subject (a mail header, an inbox title) and `body_text` (the text/plain |
| 8 | + * part, an inbox body) are plain text. Rendering them through the HTML |
| 9 | + * escaper put `&` into every link carrying a query string, so a |
| 10 | + * plain-text client — or a user copying the text link — received |
| 11 | + * `…?token=…&callbackURL=%2F`: the token still parsed, the redirect |
| 12 | + * parameter arrived as `amp;callbackURL`, and a verified invitee landed on |
| 13 | + * `/` instead of the accept page. |
| 14 | + * |
| 15 | + * The switch lives in the engine (`renderPlainTextTemplate`), not in the |
| 16 | + * templates, so these pins run the REAL `EmailService` over the REAL seeded |
| 17 | + * built-in rows AND over an authored row nobody hand-braced — a per-template |
| 18 | + * `{{{…}}}` patch would pass the first half and fail the second. |
| 19 | + * |
| 20 | + * ⚠️ Every expectation is an independent literal: nothing is derived from the |
| 21 | + * template constants or from the renderer under test, so an edit that |
| 22 | + * reintroduces escaping cannot quietly agree with itself. |
| 23 | + */ |
| 24 | + |
| 25 | +import { describe, it, expect } from 'vitest'; |
| 26 | +import { EmailService, type EmailTemplateRow, type TemplateLoader } from './email-service.js'; |
| 27 | +import { BUILTIN_AUTH_TEMPLATES } from './templates/auth-templates.js'; |
| 28 | +import type { |
| 29 | + IEmailTransport, |
| 30 | + NormalizedEmailMessage, |
| 31 | + TransportSendResult, |
| 32 | +} from '@objectstack/spec/contracts'; |
| 33 | + |
| 34 | +const LOCALES = ['en-US', 'zh-CN', 'ja-JP', 'es-ES'] as const; |
| 35 | + |
| 36 | +/** A link whose second query parameter is the one the defect dropped. */ |
| 37 | +const LINK = 'https://acme.test/api/v1/auth/verify-email?token=TOK123&callbackURL=%2Faccept-invitation%2Finv_1'; |
| 38 | +/** The same link as HTML markup spells it — correct inside `body_html` only. */ |
| 39 | +const LINK_AS_MARKUP = 'https://acme.test/api/v1/auth/verify-email?token=TOK123&callbackURL=%2Faccept-invitation%2Finv_1'; |
| 40 | + |
| 41 | +class CaptureTransport implements IEmailTransport { |
| 42 | + public sent: NormalizedEmailMessage[] = []; |
| 43 | + async send(message: NormalizedEmailMessage): Promise<TransportSendResult> { |
| 44 | + this.sent.push(message); |
| 45 | + return { messageId: `msg-${this.sent.length}` }; |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +/** Exactly what `EmailServicePlugin` seeds, matched on `(name, locale)` with no fallback of its own. */ |
| 50 | +function seededRow(name: string, locale: string): EmailTemplateRow | null { |
| 51 | + const hit = BUILTIN_AUTH_TEMPLATES.find((t) => t.name === name && t.locale === locale); |
| 52 | + if (!hit) return null; |
| 53 | + return { |
| 54 | + name: hit.name, |
| 55 | + locale: hit.locale ?? 'en-US', |
| 56 | + subject: hit.subject, |
| 57 | + body_html: hit.bodyHtml ?? '', |
| 58 | + body_text: hit.bodyText ?? null, |
| 59 | + active: hit.active !== false, |
| 60 | + variables_json: JSON.stringify(hit.variables ?? []), |
| 61 | + }; |
| 62 | +} |
| 63 | + |
| 64 | +function harness(extraRows: EmailTemplateRow[] = []) { |
| 65 | + const transport = new CaptureTransport(); |
| 66 | + const rows: Array<Record<string, any>> = []; |
| 67 | + const loader: TemplateLoader = { |
| 68 | + async load(name, locale) { |
| 69 | + const authored = extraRows.find((r) => r.name === name && (locale === undefined || r.locale === locale)); |
| 70 | + if (authored) return authored; |
| 71 | + return locale === undefined ? null : seededRow(name, locale); |
| 72 | + }, |
| 73 | + }; |
| 74 | + const svc = new EmailService({ |
| 75 | + transport, |
| 76 | + defaultFrom: { address: 'no-reply@acme.test' }, |
| 77 | + templateLoader: loader, |
| 78 | + persistence: { |
| 79 | + async insert(row) { rows.push(row); return { id: row.id }; }, |
| 80 | + async update() { /* noop */ }, |
| 81 | + }, |
| 82 | + }); |
| 83 | + return { svc, transport, rows }; |
| 84 | +} |
| 85 | + |
| 86 | +const USER = { name: 'Alice', email: 'alice@acme.test', id: 'usr_1' }; |
| 87 | + |
| 88 | +/** The three link-carrying sends the card names, plus the magic link that shares the shape. */ |
| 89 | +const LINK_SENDS = [ |
| 90 | + { template: 'auth.verify_email', data: { user: USER, verificationUrl: LINK, appName: 'Acme' } }, |
| 91 | + { template: 'auth.password_reset', data: { user: USER, resetUrl: LINK, expiresInMinutes: 30, appName: 'Acme' } }, |
| 92 | + { |
| 93 | + template: 'auth.invitation', |
| 94 | + data: { |
| 95 | + inviter: { name: 'Bob', email: 'bob@acme.test' }, |
| 96 | + organization: { name: 'Acme' }, |
| 97 | + role: 'member', |
| 98 | + acceptUrl: LINK, |
| 99 | + appName: 'Acme', |
| 100 | + }, |
| 101 | + }, |
| 102 | + { template: 'auth.magic_link', data: { magicLinkUrl: LINK, expiresInMinutes: 10, appName: 'Acme' } }, |
| 103 | +] as const; |
| 104 | + |
| 105 | +describe('auth mail: the plain-text part carries the link verbatim', () => { |
| 106 | + for (const { template, data } of LINK_SENDS) { |
| 107 | + for (const locale of LOCALES) { |
| 108 | + it(`${template} [${locale}]: sys_email.body_text and the text part keep a literal &`, async () => { |
| 109 | + const { svc, transport, rows } = harness(); |
| 110 | + |
| 111 | + await svc.sendTemplate({ template, to: 'alice@acme.test', locale, data: data as Record<string, unknown> }); |
| 112 | + |
| 113 | + expect(rows).toHaveLength(1); |
| 114 | + const bodyText = String(rows[0].body_text); |
| 115 | + // The persisted audit row and the delivered text/plain part are the |
| 116 | + // same string, and both carry the link a user can actually follow. |
| 117 | + expect(bodyText).toContain(LINK); |
| 118 | + expect(bodyText).not.toContain('&'); |
| 119 | + expect(transport.sent[0].text).toBe(bodyText); |
| 120 | + }); |
| 121 | + |
| 122 | + it(`${template} [${locale}]: the HTML part is unchanged — href verbatim, visible copy still escaped markup`, async () => { |
| 123 | + const { svc, transport } = harness(); |
| 124 | + |
| 125 | + await svc.sendTemplate({ template, to: 'alice@acme.test', locale, data: data as Record<string, unknown> }); |
| 126 | + |
| 127 | + const html = String(transport.sent[0].html); |
| 128 | + // `{{{url}}}` in the href was never escaped … |
| 129 | + expect(html).toContain(`href="${LINK}"`); |
| 130 | + // … and the copy-paste `{{url}}` span is still HTML-escaped, which is |
| 131 | + // correct in markup: a mail client decodes it back to `&` on display. |
| 132 | + expect(html).toContain(LINK_AS_MARKUP); |
| 133 | + }); |
| 134 | + } |
| 135 | + } |
| 136 | +}); |
| 137 | + |
| 138 | +describe('control: a value carrying markup characters, per face', () => { |
| 139 | + it('a built-in template: < and & are escaped in body_html and literal in body_text and the subject', async () => { |
| 140 | + const { svc, transport, rows } = harness(); |
| 141 | + |
| 142 | + await svc.sendTemplate({ |
| 143 | + template: 'auth.invitation', |
| 144 | + to: 'alice@acme.test', |
| 145 | + locale: 'en-US', |
| 146 | + data: { |
| 147 | + inviter: { name: "O'Brien", email: 'ob@acme.test' }, |
| 148 | + organization: { name: 'R&D <Lab>' }, |
| 149 | + role: 'member', |
| 150 | + acceptUrl: LINK, |
| 151 | + appName: 'Acme', |
| 152 | + }, |
| 153 | + }); |
| 154 | + |
| 155 | + const sent = transport.sent[0]; |
| 156 | + expect(sent.html).toContain('<strong>R&D <Lab></strong>'); |
| 157 | + expect(sent.html).toContain('<strong>O'Brien</strong>'); |
| 158 | + expect(sent.text).toBe( |
| 159 | + "O'Brien (ob@acme.test) invited you to join R&D <Lab> on Acme.\n\n" + `Accept: ${LINK}`, |
| 160 | + ); |
| 161 | + expect(sent.subject).toBe("O'Brien invited you to R&D <Lab>"); |
| 162 | + expect(rows[0].subject).toBe("O'Brien invited you to R&D <Lab>"); |
| 163 | + }); |
| 164 | + |
| 165 | + const AUTHORED: EmailTemplateRow = { |
| 166 | + name: 'crm.deal_won', |
| 167 | + locale: 'en-US', |
| 168 | + subject: 'Won: {{deal.name}}', |
| 169 | + body_html: '<p>Deal <b>{{deal.name}}</b> closed. <a href="{{{deal.url}}}">Open</a> {{deal.url}}</p>', |
| 170 | + body_text: 'Deal {{deal.name}} closed. Open: {{deal.url}}', |
| 171 | + active: true, |
| 172 | + }; |
| 173 | + const DEAL = { deal: { name: 'Q3 <Renewal> & more', url: 'https://acme.test/deal?id=7&tab=notes' } }; |
| 174 | + |
| 175 | + it('an authored template (no per-template braces): escaped in body_html, literal in body_text and the subject', async () => { |
| 176 | + const { svc, transport, rows } = harness([AUTHORED]); |
| 177 | + |
| 178 | + await svc.sendTemplate({ template: 'crm.deal_won', to: 'alice@acme.test', data: DEAL }); |
| 179 | + |
| 180 | + const sent = transport.sent[0]; |
| 181 | + expect(sent.html).toBe( |
| 182 | + '<p>Deal <b>Q3 <Renewal> & more</b> closed. ' |
| 183 | + + '<a href="https://acme.test/deal?id=7&tab=notes">Open</a> https://acme.test/deal?id=7&tab=notes</p>', |
| 184 | + ); |
| 185 | + expect(sent.text).toBe('Deal Q3 <Renewal> & more closed. Open: https://acme.test/deal?id=7&tab=notes'); |
| 186 | + expect(sent.subject).toBe('Won: Q3 <Renewal> & more'); |
| 187 | + expect(rows[0].body_text).toBe(sent.text); |
| 188 | + }); |
| 189 | + |
| 190 | + it('renderTemplate (the render-only face the inbox channel reads) answers the same text and subject', async () => { |
| 191 | + const { svc } = harness([AUTHORED]); |
| 192 | + |
| 193 | + const out = await svc.renderTemplate({ template: 'crm.deal_won', data: DEAL }); |
| 194 | + |
| 195 | + expect(out.text).toBe('Deal Q3 <Renewal> & more closed. Open: https://acme.test/deal?id=7&tab=notes'); |
| 196 | + expect(out.subject).toBe('Won: Q3 <Renewal> & more'); |
| 197 | + expect(out.html).toContain('<b>Q3 <Renewal> & more</b>'); |
| 198 | + }); |
| 199 | + |
| 200 | + it('a row with NO body_text still derives its text part with the entities decoded', async () => { |
| 201 | + const { svc, transport } = harness([{ ...AUTHORED, body_text: null }]); |
| 202 | + |
| 203 | + await svc.sendTemplate({ template: 'crm.deal_won', to: 'alice@acme.test', data: DEAL }); |
| 204 | + |
| 205 | + expect(transport.sent[0].text).toBe('Deal Q3 <Renewal> & more closed. Open https://acme.test/deal?id=7&tab=notes'); |
| 206 | + }); |
| 207 | +}); |
0 commit comments