From c85525f3edde70586a6ad9dc293066deeb312b73 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 1 Sep 2026 21:30:18 -0700 Subject: [PATCH 1/4] Fill placeholders when a template is inserted Templates were a fixed subject and body, so anything that changed per message -- who it is going to, today's date -- had to be typed over afterwards. Eight names are recognised: recipientName, recipientFirstName, recipientEmail, myName, myEmail, subject, date and time. Dates and times go through datetime.ts rather than toLocaleDateString, so a template follows the date order and clock the app was already told to use. Filling happens on insert rather than on send. What a placeholder came to is then visible in the composer and can be edited, instead of the message changing between writing it and sending it. Two things are deliberately left alone. A placeholder that cannot be answered yet -- a recipient's name on a draft nobody has addressed -- stays in the body as written, because substituting an empty string produces "Hi ,", which is wrong rather than visibly unfinished; leaving the name says which word is still missing and can be typed over. And a name that is not a placeholder is left as written too, since a body that quietly ate an unrecognised token would be worse than one that shows it. Values are escaped on the way into HTML: a display name comes from a contact card or a typed address and is not trusted markup. --- FEATURES.md | 11 ++- .../__tests__/templatePlaceholders.test.ts | 80 ++++++++++++++++ web/src/lib/templatePlaceholders.ts | 91 +++++++++++++++++++ web/src/store/compose.ts | 14 ++- web/src/styles/app.css | 5 + web/src/views/settings/TemplatesSettings.tsx | 41 +++++++++ 6 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/__tests__/templatePlaceholders.test.ts create mode 100644 web/src/lib/templatePlaceholders.ts 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("