Templates remember who they're for #72
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ファイルを添付",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Прикрепить файлы",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Прикріпити файли",
|
||||
|
||||
@@ -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": "添加附件",
|
||||
|
||||
@@ -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: "[email protected]", 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: "<p>Invoice attached.</p>",
|
||||
subject: "Invoice",
|
||||
to: [{ name: "Accounts", email: "[email protected]" }],
|
||||
cc: [{ name: null, email: "[email protected]" }],
|
||||
bcc: [{ name: null, email: "[email protected]" }],
|
||||
});
|
||||
const d = draft(key);
|
||||
expect(d.to.map((a) => a.email)).toEqual(["[email protected]"]);
|
||||
expect(d.cc.map((a) => a.email)).toEqual(["[email protected]"]);
|
||||
expect(d.bcc.map((a) => a.email)).toEqual(["[email protected]"]);
|
||||
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: "[email protected]" }], cc: [{ name: null, email: "[email protected]" }] });
|
||||
useCompose.getState().insertTemplate(key, {
|
||||
html: "<p>Hi</p>",
|
||||
subject: "",
|
||||
to: [{ name: null, email: "[email protected]" }, { name: "Cat", email: "[email protected]" }],
|
||||
cc: [{ name: null, email: "[email protected]" }],
|
||||
bcc: [{ name: null, email: "[email protected]" }],
|
||||
});
|
||||
const d = draft(key);
|
||||
expect(d.to.map((a) => a.email)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(d.cc.map((a) => a.email)).toEqual(["[email protected]"]);
|
||||
expect(d.bcc).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the recipients alone for a template without any", () => {
|
||||
const key = useCompose.getState().open({ to: [{ name: "Ann", email: "[email protected]" }] });
|
||||
useCompose.getState().insertTemplate(key, { html: "<p>Thanks</p>", subject: "" });
|
||||
const d = draft(key);
|
||||
expect(d.to.map((a) => a.email)).toEqual(["[email protected]"]);
|
||||
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: "<p>Dear {{recipientName}},</p>", subject: "", to: [{ name: "Jane Smith", email: "[email protected]" }] });
|
||||
expect(draft(key).html).toContain("Dear Jane Smith,");
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
undoSend(key: string): void;
|
||||
setIdentity(key: string, identityId: Id): void;
|
||||
insertTemplate(key: string, html: string, subject?: string): void;
|
||||
insertTemplate(key: string, template: Pick<Template, "html" | "subject" | "to" | "cc" | "bcc">): void;
|
||||
}
|
||||
|
||||
const AUTOSAVE_MS = 20_000;
|
||||
@@ -857,20 +857,34 @@ export const useCompose = create<ComposeState>((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<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 });
|
||||
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);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -382,7 +382,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
{settings.templates.length > 0 && <button className="icon-btn" title={translate("Insert template")} onClick={templateMenu.open}><FileText size={18} /></button>}
|
||||
<Popover anchor={templateMenu.anchor} onClose={templateMenu.close} side="top" width={260}>
|
||||
<MenuTitle>{translate("Templates")}</MenuTitle>
|
||||
{settings.templates.map((t) => <MenuItem key={t.id} label={t.name} onClick={() => insertTemplate(key, t.html, t.subject)} />)}
|
||||
{settings.templates.map((t) => <MenuItem key={t.id} label={t.name} onClick={() => insertTemplate(key, t)} />)}
|
||||
</Popover>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label={translate("More options")}><MoreVertical size={18} /></button>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} side="top" width={260}>
|
||||
@@ -394,7 +394,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
<MenuItem label={translate("Normal")} checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
|
||||
<MenuItem label={translate("Low")} checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<ChevronsDown size={16} />} 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) }] }); }} />
|
||||
<MenuItem icon={<ChevronsDown size={16} />} 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 }] }); }} />
|
||||
<MenuItem icon={<FileText size={16} />} label={translate("Save draft now")} onClick={() => void saveDraft(key)} />
|
||||
</Popover>
|
||||
</span>
|
||||
|
||||
@@ -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() {
|
||||
<button className="icon-btn sm danger" aria-label={translate("Delete template")} onClick={(e) => { e.stopPropagation(); update({ templates: templates.filter((x) => x.id !== t.id) }); }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
{t.subject && <div className="hint">{translate("Subject: {subject}", { subject: t.subject })}</div>}
|
||||
{recipientCount(t) > 0 && <div className="hint truncate">{translate("Adds: {recipients}", { recipients: [...(t.to ?? []), ...(t.cc ?? []), ...(t.bcc ?? [])].map(formatAddress).join(", ") })}</div>}
|
||||
<div className="hint truncate">{htmlToText(t.html).slice(0, 140)}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -32,6 +35,21 @@ export function TemplatesSettings() {
|
||||
<div className="field"><label>{translate("Name")}</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
|
||||
<div className="field"><label>{translate("Subject (optional)")}</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="template-to">{translate("To (optional)")}</label>
|
||||
<RecipientInput id="template-to" value={editing.to ?? []} onChange={(to) => setEditing({ ...editing, to })} exclude={[...(editing.cc ?? []), ...(editing.bcc ?? [])]} />
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label htmlFor="template-cc">{translate("Cc (optional)")}</label>
|
||||
<RecipientInput id="template-cc" value={editing.cc ?? []} onChange={(cc) => setEditing({ ...editing, cc })} exclude={[...(editing.to ?? []), ...(editing.bcc ?? [])]} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="template-bcc">{translate("Bcc (optional)")}</label>
|
||||
<RecipientInput id="template-bcc" value={editing.bcc ?? []} onChange={(bcc) => setEditing({ ...editing, bcc })} exclude={[...(editing.to ?? []), ...(editing.cc ?? [])]} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="hint">{translate("Added to the message when you insert the template, beside anyone already on it.")}</p>
|
||||
<div className="field">
|
||||
<label>{translate("Body")}</label>
|
||||
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 8, minHeight: 200, display: "flex", flexDirection: "column" }}>
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user