Merge pull request #195 from Coffey-Labs/feat/template-placeholders

Fill placeholders when a template is inserted
This commit is contained in:
Coffey Labs
2026-09-01 22:27:04 -07:00
committed by GitHub
6 changed files with 239 additions and 3 deletions
+10 -1
View File
@@ -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
@@ -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;
});
}
+12 -2
View File
@@ -11,6 +11,7 @@ import { ensureScheduledMailbox, useScheduled } from "./scheduled";
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n";
import { settings } from "./settings";
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
export interface ComposeAttachment {
id: string;
@@ -575,8 +576,17 @@ export const useCompose = create<ComposeState>((set, get) => ({
insertTemplate(key, html, subject) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const patch: Partial<Draft> = { html: `<div>${sanitizeEditorHtml(html)}</div>${d.html}`, text: `${htmlToText(html)}\n${d.text}` };
if (subject && !d.subject) patch.subject = subject;
// Placeholders are filled against the draft as it stands right now, which
// is why this happens on insert rather than on send: what the template is
// filled with is visible and editable afterwards, instead of changing
// under the message between writing it and sending it.
const ident = d.identityId ? useMail.getState().identities.find((i) => i.id === d.identityId) : undefined;
const ctx: PlaceholderContext = { to: d.to, from: ident ? { name: ident.name, email: ident.email } : null, subject: d.subject };
// The body is filled once as HTML and the plain-text side derived from the
// result, so the two cannot disagree about what a placeholder came to.
const filled = fillPlaceholders(html, ctx, { html: true });
const patch: Partial<Draft> = { html: `<div>${sanitizeEditorHtml(filled)}</div>${d.html}`, text: `${htmlToText(filled)}\n${d.text}` };
if (subject && !d.subject) patch.subject = fillPlaceholders(subject, ctx, { html: false });
get().update(key, patch);
},
}));
+5
View File
@@ -1376,3 +1376,8 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.composer-field label .link-btn { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
.composer-field label .link-btn:hover { color: var(--accent); }
.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
/* The placeholder reference under a template's body. */
.placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; }
.placeholder-row { display: contents; }
.placeholder-list code { font-family: var(--font-mono); font-size: 12.5px; background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 4px; padding: .1em .4em; white-space: nowrap; }
@@ -5,6 +5,7 @@ import { Dialog } from "@/ui/dialog";
import { RichEditor } from "../compose/RichEditor";
import { htmlToText } from "@/lib/text";
import { t as translate } from "@/lib/i18n";
import { PLACEHOLDER_NAMES } from "@/lib/templatePlaceholders";
export function TemplatesSettings() {
const templates = useSettings((s) => s.settings.templates);
@@ -37,8 +38,48 @@ export function TemplatesSettings() {
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder={translate("Template text…")} />
</div>
</div>
<PlaceholderReference />
</Dialog>
)}
</div>
);
}
/**
* What each placeholder answers, shown under the body so the names are
* discoverable without documentation. Rendered from `PLACEHOLDER_NAMES` rather
* than from this map, so a name added to the lib and forgotten here appears
* with no description instead of quietly not appearing at all.
*/
function placeholderHint(name: string): string {
switch (name) {
case "recipientName": return translate("Who the message is addressed to");
case "recipientFirstName": return translate("Their first name alone");
case "recipientEmail": return translate("Their address");
case "myName": return translate("The name on the identity you are sending as");
case "myEmail": return translate("That identity's address");
case "subject": return translate("The subject already on the message");
case "date": return translate("Today, in your date format");
case "time": return translate("Now, on your clock");
default: return "";
}
}
function PlaceholderReference() {
return (
<div className="field">
<label>{translate("Placeholders")}</label>
<div className="placeholder-list">
{PLACEHOLDER_NAMES.map((name) => (
<div key={name} className="placeholder-row">
<code>{`{{${name}}}`}</code>
<span className="hint">{placeholderHint(name)}</span>
</div>
))}
</div>
<p className="hint">
{translate("Filled in when the template is inserted, so you can edit the result before sending. One that cannot be answered yet — a recipient's name on a message you have not addressed — is left in the body as written, rather than becoming a blank.")}
</p>
</div>
);
}