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.
This commit is contained in:
2026-09-01 21:30:18 -07:00
parent 34578ba426
commit c85525f3ed
6 changed files with 239 additions and 3 deletions
@@ -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> = {}): PlaceholderContext {
return {
to: [{ name: "Ada Lovelace", email: "[email protected]" }],
from: { name: "Grace Hopper", email: "[email protected]" },
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 <[email protected]>");
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("[email protected]");
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: "[email protected]" }] });
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 <script>alert("x")</script>', email: "[email protected]" }] });
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).not.toContain("<script>");
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).toContain("&lt;script&gt;");
expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toContain("<script>");
});
it("repeats a placeholder as many times as it appears", () => {
expect(fillPlaceholders("{{recipientFirstName}} {{recipientFirstName}}", ctx(), { html: true })).toBe("Ada Ada");
});
it("answers date and time from the injected clock", () => {
const date = fillPlaceholders("{{date}}", ctx(), { html: false });
const time = fillPlaceholders("{{time}}", ctx(), { html: false });
expect(date).not.toBe("{{date}}");
expect(date).toMatch(/2026/);
expect(time).not.toBe("{{time}}");
expect(time).toMatch(/\d/);
});
it("names every resolver in the list Settings shows", () => {
expect(PLACEHOLDER_NAMES).toEqual([
"recipientName",
"recipientFirstName",
"recipientEmail",
"myName",
"myEmail",
"subject",
"date",
"time",
]);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Placeholders in templates, filled at the moment one is inserted.
*
* Two rules decide the whole design:
*
* - **An unresolved placeholder is left exactly as written.** A template
* inserted before the message is addressed cannot know who it is going to,
* and substituting an empty string there produces "Hi ," -- a greeting that
* is wrong rather than unfinished. Leaving `{{recipientName}}` in the body
* says which word is still missing, and it can be typed over. It is also
* what makes inserting a template early a valid thing to do rather than a
* mistake to undo.
* - **A name that is not a placeholder is left alone too.** Templates are
* written by hand and `{{` is not reserved anywhere else, but a body that
* silently ate an unrecognised token would be worse than one that shows it.
*
* Dates and times go through `datetime.ts` rather than `toLocaleDateString`,
* so a template follows the same date order and clock the rest of the app was
* told to use.
*/
import { escapeHtml } from "./text";
import { formatDate, formatClock } from "./datetime";
import type { EmailAddress } from "@/jmap/types";
export interface PlaceholderContext {
/** Where the message is addressed, in order; the first is what the singular names refer to. */
to: EmailAddress[];
/** The identity the draft is sending as. */
from: { name?: string | null; email?: string | null } | null;
subject: string;
/** Injectable so tests do not depend on the clock. */
now?: Date;
}
/**
* What each name resolves to, in the order they are shown in Settings.
* `null` from a resolver means "cannot be answered yet", which is the case
* the rule above is about -- distinct from an empty string, which is an answer.
*/
const RESOLVERS: Record<string, (c: PlaceholderContext) => string | null> = {
recipientName: (c) => personalName(c.to[0]),
recipientFirstName: (c) => {
const n = personalName(c.to[0]);
return n ? (n.split(/\s+/)[0] ?? null) : null;
},
recipientEmail: (c) => c.to[0]?.email || null,
myName: (c) => c.from?.name?.trim() || null,
myEmail: (c) => c.from?.email || null,
subject: (c) => c.subject || null,
date: (c) => formatDate(c.now ?? new Date()),
time: (c) => formatClock(c.now ?? new Date()),
};
/** The names, for the list shown under the template editor. */
export const PLACEHOLDER_NAMES = Object.keys(RESOLVERS);
/**
* A recipient's human name: what they are called if we know it, otherwise the
* local part, which for `firstname.lastname@` is still better than the whole
* address in the middle of a sentence. Never the domain.
*/
function personalName(a: EmailAddress | undefined): string | null {
if (!a) return null;
const name = a.name?.trim();
if (name) return name;
const local = (a.email ?? "").split("@")[0] ?? "";
return local || null;
}
/**
* `{{ name }}` tolerates the spaces; the name itself is matched exactly,
* because `{{Date}}` meaning `{{date}}` would make the list in Settings a
* suggestion rather than the set.
*/
const TOKEN = /\{\{\s*([A-Za-z][A-Za-z0-9]*)\s*\}\}/g;
/**
* Fill `input`, escaping substituted values when the destination is HTML.
* Escaping happens here rather than at the call site because the values come
* from contact cards and typed addresses -- a display name is not trusted
* markup, and the body it lands in is inserted as HTML.
*/
export function fillPlaceholders(input: string, ctx: PlaceholderContext, opts: { html: boolean }): string {
return input.replace(TOKEN, (whole, name: string) => {
const resolver = RESOLVERS[name];
if (!resolver) return whole;
const value = resolver(ctx);
if (value === null) return whole;
return opts.html ? escapeHtml(value) : value;
});
}