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:
+10
-1
@@ -309,7 +309,16 @@ minimisable and maximisable; full-screen on mobile.
|
|||||||
identity. Signature images live in Files too and are turned into inline
|
identity. Signature images live in Files too and are turned into inline
|
||||||
`cid:` parts when the message is sent.
|
`cid:` parts when the message is sent.
|
||||||
- **Templates**: named subject + body, inserted into any draft, managed in
|
- **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
|
- **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
|
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
|
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("<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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { ensureScheduledMailbox, useScheduled } from "./scheduled";
|
|||||||
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
|
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
|
||||||
import { t as translate } from "@/lib/i18n";
|
import { t as translate } from "@/lib/i18n";
|
||||||
import { settings } from "./settings";
|
import { settings } from "./settings";
|
||||||
|
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
||||||
|
|
||||||
export interface ComposeAttachment {
|
export interface ComposeAttachment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -575,8 +576,17 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
insertTemplate(key, html, subject) {
|
insertTemplate(key, html, subject) {
|
||||||
const d = get().drafts.find((x) => x.key === key);
|
const d = get().drafts.find((x) => x.key === key);
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
const patch: Partial<Draft> = { html: `<div>${sanitizeEditorHtml(html)}</div>${d.html}`, text: `${htmlToText(html)}\n${d.text}` };
|
// Placeholders are filled against the draft as it stands right now, which
|
||||||
if (subject && !d.subject) patch.subject = subject;
|
// 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);
|
get().update(key, patch);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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 { 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:hover { color: var(--accent); }
|
||||||
.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
.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 { RichEditor } from "../compose/RichEditor";
|
||||||
import { htmlToText } from "@/lib/text";
|
import { htmlToText } from "@/lib/text";
|
||||||
import { t as translate } from "@/lib/i18n";
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
import { PLACEHOLDER_NAMES } from "@/lib/templatePlaceholders";
|
||||||
|
|
||||||
export function TemplatesSettings() {
|
export function TemplatesSettings() {
|
||||||
const templates = useSettings((s) => s.settings.templates);
|
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…")} />
|
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder={translate("Template text…")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<PlaceholderReference />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user