diff --git a/FEATURES.md b/FEATURES.md index 17b8ce0..40c12bb 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -309,7 +309,16 @@ minimisable and maximisable; full-screen on mobile. identity. Signature images live in Files too and are turned into inline `cid:` parts when the message is sent. - **Templates**: named subject + body, inserted into any draft, managed in - Settings. + Settings. Both carry **placeholders** — `{{recipientName}}`, + `{{recipientFirstName}}`, `{{recipientEmail}}`, `{{myName}}`, `{{myEmail}}`, + `{{subject}}`, `{{date}}` and `{{time}}` — filled at the moment the template + is inserted, so what they came to is visible and editable before anything is + sent rather than changing under the message afterwards. Dates and times + follow the same format settings as the rest of the app. A placeholder that + cannot be answered yet — a recipient's name on a draft nobody has addressed — + is **left in the body exactly as written**, because substituting an empty + string there produces "Hi ,", a greeting that is wrong rather than one that + is visibly unfinished. A name that is not a placeholder is left alone too. - **Attachments** by picking or dragging onto the composer, with progress per file and the size limit the server states (`MAX_UPLOAD_BYTES`, 50 MB by default). A pasted image is inserted inline instead, and pasted HTML is diff --git a/web/src/lib/__tests__/templatePlaceholders.test.ts b/web/src/lib/__tests__/templatePlaceholders.test.ts new file mode 100644 index 0000000..93ffbfc --- /dev/null +++ b/web/src/lib/__tests__/templatePlaceholders.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { fillPlaceholders, PLACEHOLDER_NAMES, type PlaceholderContext } from "@/lib/templatePlaceholders"; + +const AT = new Date("2026-03-04T15:07:00Z"); + +function ctx(over: Partial = {}): PlaceholderContext { + return { + to: [{ name: "Ada Lovelace", email: "ada@example.com" }], + from: { name: "Grace Hopper", email: "grace@example.com" }, + subject: "Quarterly report", + now: AT, + ...over, + }; +} + +describe("fillPlaceholders", () => { + it("fills the names it knows", () => { + expect(fillPlaceholders("Hi {{recipientFirstName}},", ctx(), { html: true })).toBe("Hi Ada,"); + expect(fillPlaceholders("{{recipientName}} <{{recipientEmail}}>", ctx(), { html: false })).toBe("Ada Lovelace "); + expect(fillPlaceholders("-- {{myName}}", ctx(), { html: true })).toBe("-- Grace Hopper"); + expect(fillPlaceholders("Re: {{subject}}", ctx(), { html: false })).toBe("Re: Quarterly report"); + }); + + it("tolerates spaces inside the braces but not a different case", () => { + expect(fillPlaceholders("{{ myEmail }}", ctx(), { html: false })).toBe("grace@example.com"); + expect(fillPlaceholders("{{MyEmail}}", ctx(), { html: false })).toBe("{{MyEmail}}"); + }); + + it("leaves a placeholder it cannot answer exactly as written", () => { + // The case the design is about: a template inserted before the message is + // addressed. "Hi ," would be wrong; "Hi {{recipientFirstName}}," is unfinished. + const unaddressed = ctx({ to: [] }); + expect(fillPlaceholders("Hi {{recipientFirstName}},", unaddressed, { html: true })).toBe("Hi {{recipientFirstName}},"); + expect(fillPlaceholders("{{recipientEmail}}", unaddressed, { html: false })).toBe("{{recipientEmail}}"); + expect(fillPlaceholders("{{myName}}", ctx({ from: null }), { html: false })).toBe("{{myName}}"); + }); + + it("leaves a name it does not know alone rather than eating it", () => { + expect(fillPlaceholders("{{nonsense}} {{}} {{ }}", ctx(), { html: true })).toBe("{{nonsense}} {{}} {{ }}"); + }); + + it("falls back to the local part when a recipient has no name", () => { + const c = ctx({ to: [{ name: null, email: "ada.lovelace@example.com" }] }); + expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toBe("ada.lovelace"); + expect(fillPlaceholders("{{recipientFirstName}}", c, { html: false })).toBe("ada.lovelace"); + }); + + it("escapes a substituted value on the way into HTML, and not into a subject", () => { + const c = ctx({ to: [{ name: 'Ada ', email: "ada@example.com" }] }); + expect(fillPlaceholders("{{recipientName}}", c, { html: true })).not.toContain("