From 0aad7dd71dcd24a577508b54d5ea5927c421cd66 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 20 Oct 2026 13:32:14 -0700 Subject: [PATCH] Templates remember who they're for A template saved a subject and a body, so a message sent to the same people every time (an invoice, a weekly report) had to be addressed by hand each time. A template can now hold To, Cc and Bcc as well: - Settings > Templates has the three fields, with the composer's own suggestions, and each template's card lists who it adds; - inserting it adds them to the message beside anyone already there; an address already on the message, in any field, isn't added twice, and the Cc and Bcc rows open when the template uses them; - {{recipientName}} and the other placeholders see the template's own recipients, so "Dear {{recipientFirstName}}" names the person it's for; - "Save as template" in the composer keeps the message's recipients. Templates saved before this have none and insert exactly as they did. New strings: 5 ("To (optional)", "Cc (optional)", "Bcc (optional)", "Adds: {recipients}" and the field hint), in all ten catalogs. Reported in coffey-labs/ihasmail#58 --- web/src/locales/de.ts | 5 ++ web/src/locales/es.ts | 5 ++ web/src/locales/fr.ts | 5 ++ web/src/locales/ja.ts | 5 ++ web/src/locales/nl.ts | 5 ++ web/src/locales/pt-BR.ts | 5 ++ web/src/locales/ru.ts | 5 ++ web/src/locales/tr.ts | 5 ++ web/src/locales/uk.ts | 5 ++ web/src/locales/zh-Hans.ts | 5 ++ .../__tests__/template-recipients.test.ts | 83 +++++++++++++++++++ web/src/store/compose.ts | 24 ++++-- web/src/store/settings.ts | 5 ++ web/src/styles/app.css | 3 + web/src/views/compose/Composer.tsx | 4 +- web/src/views/settings/TemplatesSettings.tsx | 22 +++++ 16 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 web/src/store/__tests__/template-recipients.test.ts diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index a34ad66..1aec786 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -474,6 +474,11 @@ export const catalog: Catalog = { "Bcc": "Bcc", "Subject": "Betreff", "Subject (optional)": "Betreff (optional)", + "To (optional)": "An (optional)", + "Cc (optional)": "Cc (optional)", + "Bcc (optional)": "Bcc (optional)", + "Adds: {recipients}": "Fügt hinzu: {recipients}", + "Added to the message when you insert the template, beside anyone already on it.": "Wird der Nachricht beim Einfügen der Vorlage hinzugefügt, neben allen, die schon darauf stehen.", "Body": "Text", "Body text": "Fließtext", "Attach files": "Dateien anhängen", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 40c427b..802c322 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -466,6 +466,11 @@ export const catalog: Catalog = { "Bcc": "Cco", "Subject": "Asunto", "Subject (optional)": "Asunto (opcional)", + "To (optional)": "Para (opcional)", + "Cc (optional)": "Cc (opcional)", + "Bcc (optional)": "Cco (opcional)", + "Adds: {recipients}": "Añade: {recipients}", + "Added to the message when you insert the template, beside anyone already on it.": "Se añaden al mensaje al insertar la plantilla, junto a quienes ya figuren en él.", "Body": "Cuerpo", "Body text": "Texto normal", "Attach files": "Adjuntar archivos", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 4f0159d..e709951 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -471,6 +471,11 @@ export const catalog: Catalog = { "Bcc": "Cci", "Subject": "Objet", "Subject (optional)": "Objet (facultatif)", + "To (optional)": "À (facultatif)", + "Cc (optional)": "Cc (facultatif)", + "Bcc (optional)": "Cci (facultatif)", + "Adds: {recipients}": "Ajoute : {recipients}", + "Added to the message when you insert the template, beside anyone already on it.": "Ajoutés au message quand vous insérez le modèle, à côté des destinataires déjà présents.", "Body": "Corps", "Body text": "Texte courant", "Attach files": "Joindre des fichiers", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 40aa6c8..d895d3f 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -465,6 +465,11 @@ export const catalog: Catalog = { "Bcc": "Bcc", "Subject": "件名", "Subject (optional)": "件名(省略可)", + "To (optional)": "宛先(省略可)", + "Cc (optional)": "Cc(省略可)", + "Bcc (optional)": "Bcc(省略可)", + "Adds: {recipients}": "追加: {recipients}", + "Added to the message when you insert the template, beside anyone already on it.": "テンプレートを挿入するとメッセージに追加されます。すでにある宛先はそのまま残ります。", "Body": "本文", "Body text": "本文テキスト", "Attach files": "ファイルを添付", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index ced3326..414f05f 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -465,6 +465,11 @@ export const catalog: Catalog = { "Bcc": "Bcc", "Subject": "Onderwerp", "Subject (optional)": "Onderwerp (optioneel)", + "To (optional)": "Aan (optioneel)", + "Cc (optional)": "Cc (optioneel)", + "Bcc (optional)": "Bcc (optioneel)", + "Adds: {recipients}": "Voegt toe: {recipients}", + "Added to the message when you insert the template, beside anyone already on it.": "Worden aan het bericht toegevoegd als je de sjabloon invoegt, naast wie er al op staat.", "Body": "Tekst", "Body text": "Bodytekst", "Attach files": "Bestanden bijvoegen", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 1cfb8d7..cf39983 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -469,6 +469,11 @@ export const catalog: Catalog = { "Bcc": "Cco", "Subject": "Assunto", "Subject (optional)": "Assunto (opcional)", + "To (optional)": "Para (opcional)", + "Cc (optional)": "Cc (opcional)", + "Bcc (optional)": "Cco (opcional)", + "Adds: {recipients}": "Adiciona: {recipients}", + "Added to the message when you insert the template, beside anyone already on it.": "Adicionados à mensagem quando você insere o modelo, junto de quem já estiver nela.", "Body": "Corpo", "Body text": "Texto normal", "Attach files": "Anexar arquivos", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index c84fe65..c9238a2 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -468,6 +468,11 @@ export const catalog: Catalog = { "Bcc": "Скрытая копия", "Subject": "Тема", "Subject (optional)": "Тема (необязательно)", + "To (optional)": "Кому (необязательно)", + "Cc (optional)": "Копия (необязательно)", + "Bcc (optional)": "Скрытая копия (необязательно)", + "Adds: {recipients}": "Добавляет: {recipients}", + "Added to the message when you insert the template, beside anyone already on it.": "Добавляются в письмо при вставке шаблона, рядом с уже указанными получателями.", "Body": "Текст", "Body text": "Обычный текст", "Attach files": "Прикрепить файлы", diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index e60428a..5feefbb 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -1180,6 +1180,11 @@ export const catalog: Catalog = { "Strikethrough": "Üstü çizili", "Subject": "Konu", "Subject (optional)": "Konu (isteğe bağlı)", + "To (optional)": "Kime (isteğe bağlı)", + "Cc (optional)": "Bilgi (isteğe bağlı)", + "Bcc (optional)": "Gizli (isteğe bağlı)", + "Adds: {recipients}": "Ekler: {recipients}", + "Added to the message when you insert the template, beside anyone already on it.": "Şablonu eklediğinizde iletiye, zaten içinde olanların yanına eklenir.", "Subject: {subject}": "Konu: {subject}", "Subscribe to a calendar": "Bir takvime abone ol", "Subscribed calendar": "Abone olunan takvim", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 00d38ce..2c54b86 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -462,6 +462,11 @@ export const catalog: Catalog = { "Bcc": "Прихована копія", "Subject": "Тема", "Subject (optional)": "Тема (необов'язково)", + "To (optional)": "Кому (необов'язково)", + "Cc (optional)": "Копія (необов'язково)", + "Bcc (optional)": "Прихована копія (необов'язково)", + "Adds: {recipients}": "Додає: {recipients}", + "Added to the message when you insert the template, beside anyone already on it.": "Додаються до листа, коли ви вставляєте шаблон, поряд з уже вказаними одержувачами.", "Body": "Текст", "Body text": "Звичайний текст", "Attach files": "Прикріпити файли", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 9dd0d73..18dc416 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -464,6 +464,11 @@ export const catalog: Catalog = { "Bcc": "密送", "Subject": "主题", "Subject (optional)": "主题(可选)", + "To (optional)": "收件人(可选)", + "Cc (optional)": "抄送(可选)", + "Bcc (optional)": "密送(可选)", + "Adds: {recipients}": "添加:{recipients}", + "Added to the message when you insert the template, beside anyone already on it.": "插入模板时添加到邮件中,已有的收件人保持不变。", "Body": "正文", "Body text": "正文文本", "Attach files": "添加附件", diff --git a/web/src/store/__tests__/template-recipients.test.ts b/web/src/store/__tests__/template-recipients.test.ts new file mode 100644 index 0000000..3fac7db --- /dev/null +++ b/web/src/store/__tests__/template-recipients.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import { useCompose } from "@/store/compose"; +import { useMail } from "@/store/mail"; +import { DEFAULT_SETTINGS, useSettings } from "@/store/settings"; +import type { JmapSession } from "@/jmap/types"; + +/* A template's saved recipients join the message it is inserted into (#58). */ + +beforeEach(() => { + client.session = { + capabilities: { [CAP.core]: {}, [CAP.mail]: {}, [CAP.submission]: {} }, + accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } }, + primaryAccounts: {}, + state: "s1", + } as unknown as JmapSession; + // Nothing here should reach the server; autosave is answered with nothing. + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ methodResponses: [], sessionState: "1" }) }) as unknown as Response)); + useSettings.setState({ settings: { ...DEFAULT_SETTINGS } }); + useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} }); + useMail.setState({ + accountId: "a1", + identities: [{ id: "i1", name: "Me", email: "me@example.org", replyTo: null }] as never, + mailboxes: {} as never, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const draft = (key: string) => useCompose.getState().drafts.find((d) => d.key === key)!; + +describe("inserting a template with recipients", () => { + it("fills To, Cc and Bcc, and shows the Cc and Bcc fields it used", () => { + const key = useCompose.getState().open({}); + useCompose.getState().insertTemplate(key, { + html: "

Invoice attached.

", + subject: "Invoice", + to: [{ name: "Accounts", email: "accounts@client.example" }], + cc: [{ name: null, email: "boss@client.example" }], + bcc: [{ name: null, email: "archive@example.org" }], + }); + const d = draft(key); + expect(d.to.map((a) => a.email)).toEqual(["accounts@client.example"]); + expect(d.cc.map((a) => a.email)).toEqual(["boss@client.example"]); + expect(d.bcc.map((a) => a.email)).toEqual(["archive@example.org"]); + expect(d.showCc).toBe(true); + expect(d.showBcc).toBe(true); + expect(d.subject).toBe("Invoice"); + }); + + it("adds to the recipients already there, never twice", () => { + const key = useCompose.getState().open({ to: [{ name: "Ann", email: "ann@example.com" }], cc: [{ name: null, email: "bob@example.com" }] }); + useCompose.getState().insertTemplate(key, { + html: "

Hi

", + subject: "", + to: [{ name: null, email: "ANN@example.com" }, { name: "Cat", email: "cat@example.com" }], + cc: [{ name: null, email: "cat@example.com" }], + bcc: [{ name: null, email: "bob@example.com" }], + }); + const d = draft(key); + expect(d.to.map((a) => a.email)).toEqual(["ann@example.com", "cat@example.com"]); + expect(d.cc.map((a) => a.email)).toEqual(["bob@example.com"]); + expect(d.bcc).toEqual([]); + }); + + it("leaves the recipients alone for a template without any", () => { + const key = useCompose.getState().open({ to: [{ name: "Ann", email: "ann@example.com" }] }); + useCompose.getState().insertTemplate(key, { html: "

Thanks

", subject: "" }); + const d = draft(key); + expect(d.to.map((a) => a.email)).toEqual(["ann@example.com"]); + expect(d.showCc).toBe(false); + expect(d.showBcc).toBe(false); + }); + + it("names the template's own recipient in a placeholder", () => { + const key = useCompose.getState().open({}); + useCompose.getState().insertTemplate(key, { html: "

Dear {{recipientName}},

", subject: "", to: [{ name: "Jane Smith", email: "jane@example.com" }] }); + expect(draft(key).html).toContain("Dear Jane Smith,"); + }); +}); diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index 200a6c9..f5881ae 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -13,7 +13,7 @@ import { ensureScheduledMailbox, useScheduled } from "./scheduled"; import { formatScheduleTime, holdUntil } from "@/lib/schedule"; import { t as translate } from "@/lib/i18n"; import { BASE_PATH } from "@/lib/basePath"; -import { settings } from "./settings"; +import { settings, type Template } from "./settings"; import { useContacts } from "./contacts"; import { emlFilename } from "@/lib/text/emlName"; import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders"; @@ -160,7 +160,7 @@ interface ComposeState { send(key: string): Promise; undoSend(key: string): void; setIdentity(key: string, identityId: Id): void; - insertTemplate(key: string, html: string, subject?: string): void; + insertTemplate(key: string, template: Pick): void; } const AUTOSAVE_MS = 20_000; @@ -857,20 +857,34 @@ export const useCompose = create((set, get) => ({ get().update(key, { identityId, html, text, signatureHtml: newSig, ...replyToPatch }); }, - insertTemplate(key, html, subject) { + insertTemplate(key, { html, subject, to = [], cc = [], bcc = [] }) { const d = get().drafts.find((x) => x.key === key); if (!d) return; + // The template's recipients join the ones already there. An address that + // is already on the message, in any field, stays where it is rather than + // appearing twice. + const present = [...d.to, ...d.cc, ...d.bcc]; + const added = (list: EmailAddress[]) => { + const fresh = uniqueAddresses(list).filter((a) => !present.some((p) => sameAddress(p.email, a.email))); + present.push(...fresh); + return fresh; + }; + const recipients = { to: [...d.to, ...added(to)], cc: [...d.cc, ...added(cc)], bcc: [...d.bcc, ...added(bcc)] }; // 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. + // under the message between writing it and sending it. The template's own + // recipients count, so {{recipientName}} can name the person it is for. 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 }; + const ctx: PlaceholderContext = { to: recipients.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 = { html: `
${sanitizeEditorHtml(filled)}
${d.html}`, text: `${htmlToText(filled)}\n${d.text}` }; if (subject && !d.subject) patch.subject = fillPlaceholders(subject, ctx, { html: false }); + if (recipients.to.length !== d.to.length) patch.to = recipients.to; + if (recipients.cc.length !== d.cc.length) Object.assign(patch, { cc: recipients.cc, showCc: true }); + if (recipients.bcc.length !== d.bcc.length) Object.assign(patch, { bcc: recipients.bcc, showBcc: true }); get().update(key, patch); }, })); diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index d805dbc..179206e 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -7,6 +7,7 @@ import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync"; import { policyChanges, policyDefaults, policyEnforced, type PolicyChange } from "@/lib/settingsPolicy"; import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime"; import type { SwipeAction } from "@/lib/input/swipe"; +import type { EmailAddress } from "@/jmap/types"; import { resolveUiLanguage } from "@/lib/languages"; import { loadLanguage } from "@/lib/i18n"; @@ -55,6 +56,10 @@ export interface Template { name: string; subject: string; html: string; + /** Recipients added to the message when the template is inserted. Older templates have none. */ + to?: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; } /** A signer remembered for an address. See `knownSigners`. */ diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 8628fda..6b3a704 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1701,6 +1701,9 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } .recipients { flex: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-width: 0; position: relative; } .recipients .chip { height: 24px; } .recipients input { flex: 1; min-width: 120px; border: 0; background: transparent; outline: none; height: 28px; } +/* A template's recipients, in a settings form: drawn as an input like the fields beside it. */ +.field .recipients { min-height: 38px; padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: var(--bg-elev); transition: border-color .12s, box-shadow .12s; } +.field .recipients:focus-within { border-color: var(--accent); box-shadow: var(--focus-ring); } .composer-editor { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; } /* Quoted mail lands in the app document, so contain it as .message-body does: its positioning cannot reach past the editor. */ .editor-area { flex: 1; min-height: 120px; overflow-y: auto; contain: layout; padding: 12px 16px; outline: none; line-height: 1.5; font-size: 14px; font-family: var(--font-sans); } diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index 20050df..bc8cb98 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -382,7 +382,7 @@ export function Composer({ draft }: { draft: Draft }) { {settings.templates.length > 0 && } {translate("Templates")} - {settings.templates.map((t) => insertTemplate(key, t.html, t.subject)} />)} + {settings.templates.map((t) => insertTemplate(key, t)} />)} @@ -394,7 +394,7 @@ export function Composer({ draft }: { draft: Draft }) { patch({ priority: "normal" })} /> patch({ priority: "low" })} /> - } label={translate("Save as template")} onClick={async () => { const name = await promptDialog({ title: translate("Save as template"), defaultValue: d.subject || translate("Template"), placeholder: translate("Template name") }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} /> + } label={translate("Save as template")} onClick={async () => { const name = await promptDialog({ title: translate("Save as template"), defaultValue: d.subject || translate("Template"), placeholder: translate("Template name") }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text), to: d.to, cc: d.cc, bcc: d.bcc }] }); }} /> } label={translate("Save draft now")} onClick={() => void saveDraft(key)} /> diff --git a/web/src/views/settings/TemplatesSettings.tsx b/web/src/views/settings/TemplatesSettings.tsx index c48e7d5..2267128 100644 --- a/web/src/views/settings/TemplatesSettings.tsx +++ b/web/src/views/settings/TemplatesSettings.tsx @@ -3,6 +3,8 @@ import { Plus, Trash2 } from "lucide-react"; import { useSettings, type Template } from "@/store/settings"; import { Dialog } from "@/ui/dialog"; import { RichEditor } from "../compose/RichEditor"; +import { RecipientInput } from "../compose/RecipientInput"; +import { formatAddress } from "@/lib/address"; import { htmlToText } from "@/lib/text/text"; import { t as translate } from "@/lib/i18n"; import { PLACEHOLDER_NAMES } from "@/lib/templatePlaceholders"; @@ -22,6 +24,7 @@ export function TemplatesSettings() { {t.subject &&
{translate("Subject: {subject}", { subject: t.subject })}
} + {recipientCount(t) > 0 &&
{translate("Adds: {recipients}", { recipients: [...(t.to ?? []), ...(t.cc ?? []), ...(t.bcc ?? [])].map(formatAddress).join(", ") })}
}
{htmlToText(t.html).slice(0, 140)}
))} @@ -32,6 +35,21 @@ export function TemplatesSettings() {
setEditing({ ...editing, name: e.target.value })} autoFocus />
setEditing({ ...editing, subject: e.target.value })} />
+
+ + setEditing({ ...editing, to })} exclude={[...(editing.cc ?? []), ...(editing.bcc ?? [])]} /> +
+
+
+ + setEditing({ ...editing, cc })} exclude={[...(editing.to ?? []), ...(editing.bcc ?? [])]} /> +
+
+ + setEditing({ ...editing, bcc })} exclude={[...(editing.to ?? []), ...(editing.cc ?? [])]} /> +
+
+

{translate("Added to the message when you insert the template, beside anyone already on it.")}

@@ -45,6 +63,10 @@ export function TemplatesSettings() { ); } +function recipientCount(t: Template): number { + return (t.to?.length ?? 0) + (t.cc?.length ?? 0) + (t.bcc?.length ?? 0); +} + /** * What each placeholder answers, shown under the body so the names are * discoverable without documentation. Rendered from `PLACEHOLDER_NAMES` rather -- 2.54.0