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.
81 lines
3.6 KiB
TypeScript
81 lines
3.6 KiB
TypeScript
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("<script>");
|
|
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",
|
|
]);
|
|
});
|
|
});
|