Interface refresh, smarter composer, and templates that remember recipients #59
No files matched your search
@@ -266,6 +266,8 @@ export interface Email {
|
||||
"header:Received:asText:all"?: string[] | null;
|
||||
"header:X-Spam-Status:asText"?: string | null;
|
||||
"header:X-Spam-Result:asText"?: string | null;
|
||||
/** The language model's opinion, when AI spam classification is on (lib/llmOpinion). */
|
||||
"header:X-Spam-LLM:asText"?: string | null;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LLM_HEADER_PROP, llmOpinion, parseLlmOpinion } from "@/lib/llmOpinion";
|
||||
|
||||
/*
|
||||
* The header as Stalwart's spam filter writes it (analysis/score.rs):
|
||||
* `X-Spam-LLM: <category> (<explanation>)`, folded at 78 columns.
|
||||
*/
|
||||
describe("parseLlmOpinion", () => {
|
||||
it("reads category and explanation", () => {
|
||||
expect(parseLlmOpinion("Unsolicited (Promotes a product the reader never asked about)")).toEqual({
|
||||
tag: "Unsolicited",
|
||||
category: "Unsolicited",
|
||||
confidence: null,
|
||||
explanation: "Promotes a product the reader never asked about",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a category with no explanation", () => {
|
||||
expect(parseLlmOpinion("Legitimate")).toEqual({ tag: "Legitimate", category: "Legitimate", confidence: null, explanation: null });
|
||||
});
|
||||
|
||||
it("makes a category in capitals or with underscores readable", () => {
|
||||
expect(parseLlmOpinion("COLD_OUTREACH (Sales email)")?.category).toBe("Cold outreach");
|
||||
expect(parseLlmOpinion("HARMFUL")?.category).toBe("Harmful");
|
||||
expect(parseLlmOpinion("Phishing-attempt")?.category).toBe("Phishing-attempt");
|
||||
});
|
||||
|
||||
it("reads a tag in the spam filter's form, with its confidence", () => {
|
||||
const o = parseLlmOpinion("LLM_COLD_OUTREACH_MEDIUM (Sales email)");
|
||||
expect(o?.category).toBe("Cold outreach");
|
||||
expect(o?.confidence).toBe("Medium");
|
||||
// An unknown last word is part of the category, not a confidence.
|
||||
expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.category).toBe("Cold outreach");
|
||||
expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.confidence).toBeNull();
|
||||
});
|
||||
|
||||
it("unfolds a folded header and keeps inner parentheses", () => {
|
||||
const o = parseLlmOpinion("Harmful (Asks for a password\r\n (urgently) via a link)");
|
||||
expect(o?.explanation).toBe("Asks for a password (urgently) via a link");
|
||||
});
|
||||
|
||||
it("returns null for anything that isn't the server's form", () => {
|
||||
for (const raw of [null, undefined, "", " ", "Yes, score=6.7", "LLM_", "X LLM_SPAM", "Spam because reasons"]) {
|
||||
expect(parseLlmOpinion(raw), String(raw)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the JMAP property a full message carries", () => {
|
||||
expect(llmOpinion({ [LLM_HEADER_PROP]: "Legitimate (Order confirmation)" })?.category).toBe("Legitimate");
|
||||
expect(llmOpinion({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The language model's opinion, read back off the message.
|
||||
*
|
||||
* When Stalwart's AI spam classification is on (an Enterprise feature), the
|
||||
* spam filter writes the model's answer into an `X-Spam-LLM` header at
|
||||
* delivery:
|
||||
*
|
||||
* X-Spam-LLM: Unsolicited (Promotes a product the reader never asked about)
|
||||
*
|
||||
* the category the model chose, from the categories the administrator
|
||||
* configured, then the model's explanation in parentheses. The confidence goes
|
||||
* only into the spam filter's tags (`LLM_<category>_<confidence>`), not this
|
||||
* header, so a header that does carry a tag in that form is read the same way.
|
||||
* The explanation may arrive as encoded words, which the JMAP `asText` form
|
||||
* decodes.
|
||||
*
|
||||
* Like `spamScore`, nothing here judges anything: it only reads what the
|
||||
* server wrote. It is one signal the spam filter weighed among many, and the
|
||||
* UI says so.
|
||||
*/
|
||||
|
||||
/** The JMAP property that carries the header, decoded and unfolded. */
|
||||
export const LLM_HEADER_PROP = "header:X-Spam-LLM:asText" as const;
|
||||
|
||||
/**
|
||||
* Confidence words of Stalwart's default prompt. A tag ending in one of these
|
||||
* is read as category + confidence; anything else is all category, since an
|
||||
* administrator's own categories may contain underscores.
|
||||
*/
|
||||
const CONFIDENCES = new Set(["LOW", "MEDIUM", "HIGH"]);
|
||||
|
||||
export interface LlmOpinion {
|
||||
/** The category as the server wrote it, e.g. `Unsolicited`. */
|
||||
tag: string;
|
||||
/** Readable category, e.g. `Unsolicited`. */
|
||||
category: string;
|
||||
/** Readable confidence, e.g. `High`, where the header carried one. */
|
||||
confidence: string | null;
|
||||
/** The model's own explanation, as plain text, where there is one. */
|
||||
explanation: string | null;
|
||||
}
|
||||
|
||||
/** `UNSOLICITED_BULK` -> `Unsolicited bulk`. */
|
||||
function readable(words: string[]): string {
|
||||
const s = words.join(" ").toLowerCase();
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/** Headers arrive folded, so tabs and newlines are whitespace like any other. */
|
||||
function flatten(v: string | null | undefined): string {
|
||||
return (v ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function parseLlmOpinion(raw: string | null | undefined): LlmOpinion | null {
|
||||
const s = flatten(raw);
|
||||
// The category is one word (letters, digits, `_` or `-`), then optionally the explanation.
|
||||
const m = /^([\p{L}\p{N}_-]+)(?:\s+(.*))?$/u.exec(s);
|
||||
if (!m) return null;
|
||||
const tag = m[1]!;
|
||||
|
||||
let category: string;
|
||||
let confidence: string | null = null;
|
||||
if (tag.startsWith("LLM_")) {
|
||||
const parts = tag.slice("LLM_".length).split("_").filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
if (parts.length > 1 && CONFIDENCES.has(parts[parts.length - 1]!.toUpperCase())) {
|
||||
confidence = readable([parts.pop()!]);
|
||||
}
|
||||
category = readable(parts);
|
||||
} else {
|
||||
// As the administrator wrote it, unless it is all capitals or underscores.
|
||||
category = /[a-z]/.test(tag) && !tag.includes("_") ? tag : readable(tag.split("_").filter(Boolean));
|
||||
if (!category) return null;
|
||||
}
|
||||
|
||||
let explanation: string | null = null;
|
||||
const rest = (m[2] ?? "").trim();
|
||||
if (rest) {
|
||||
// The server wraps the explanation in one pair of parentheses.
|
||||
if (!(rest.startsWith("(") && rest.endsWith(")"))) return null;
|
||||
explanation = rest.slice(1, -1).trim() || null;
|
||||
}
|
||||
|
||||
return { tag, category, confidence, explanation };
|
||||
}
|
||||
|
||||
/** The opinion on a message, if the server recorded one. */
|
||||
export function llmOpinion(email: { [LLM_HEADER_PROP]?: string | null }): LlmOpinion | null {
|
||||
return parseLlmOpinion(email[LLM_HEADER_PROP]);
|
||||
}
|
||||
@@ -481,6 +481,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",
|
||||
@@ -652,6 +657,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} weitere",
|
||||
|
||||
"Contacts": "Kontakte",
|
||||
|
||||
"Collected": "Gesammelt",
|
||||
|
||||
"Save people you write to as contacts": "Personen, denen Sie schreiben, als Kontakte speichern",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Alle, an die Sie senden und die noch keine Kontakte sind, werden dem Adressbuch „Gesammelt“ hinzugefügt und auf jedem Gerät vorgeschlagen. Ihre eigenen Adressen werden nie hinzugefügt.",
|
||||
"Contacts are not available": "Kontakte sind nicht verfügbar",
|
||||
"New contact": "Neuer Kontakt",
|
||||
"Edit contact": "Kontakt bearbeiten",
|
||||
@@ -733,6 +744,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Einstellungen",
|
||||
"Show folder list": "Ordnerliste einblenden",
|
||||
"Hide folder list": "Ordnerliste ausblenden",
|
||||
"All settings": "Alle Einstellungen",
|
||||
"Sections": "Bereiche",
|
||||
"General": "Allgemein",
|
||||
@@ -1714,6 +1727,9 @@ export const catalog: Catalog = {
|
||||
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} weist Sie darauf hin, wenn eine spätere Nachricht von dieser Adresse von jemand anderem signiert ist.",
|
||||
"itself, or an issuer it does not name": "sich selbst, oder einem nicht genannten Aussteller",
|
||||
"no address": "keine Adresse",
|
||||
// ── Spam filter: the language model's opinion ──────────────────
|
||||
"Language model's opinion": "Einschätzung des Sprachmodells",
|
||||
"One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
|
||||
@@ -473,6 +473,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",
|
||||
@@ -644,6 +649,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} más",
|
||||
|
||||
"Contacts": "Contactos",
|
||||
|
||||
"Collected": "Recopilados",
|
||||
|
||||
"Save people you write to as contacts": "Guardar como contactos a las personas a las que escribes",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todas las personas a las que envías y que aún no son contactos se añaden a la libreta Recopilados, para que se sugieran en todos tus dispositivos. Tus propias direcciones nunca se añaden.",
|
||||
"Contacts are not available": "Los contactos no están disponibles",
|
||||
"New contact": "Contacto nuevo",
|
||||
"Edit contact": "Editar el contacto",
|
||||
@@ -728,6 +739,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Configuración",
|
||||
"Show folder list": "Mostrar lista de carpetas",
|
||||
"Hide folder list": "Ocultar lista de carpetas",
|
||||
"All settings": "Toda la configuración",
|
||||
"Sections": "Secciones",
|
||||
"General": "General",
|
||||
@@ -1687,6 +1700,9 @@ export const catalog: Catalog = {
|
||||
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} le avisará si un mensaje posterior de esta dirección lo firma otra persona.",
|
||||
"itself, or an issuer it does not name": "sí mismo, o un emisor que no nombra",
|
||||
"no address": "ninguna dirección",
|
||||
// ── Spam filter: the language model's opinion ──────────────────
|
||||
"Language model's opinion": "Opinión del modelo de lenguaje",
|
||||
"One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
|
||||
@@ -478,6 +478,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",
|
||||
@@ -649,6 +654,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} autres",
|
||||
|
||||
"Contacts": "Contacts",
|
||||
|
||||
"Collected": "Collectés",
|
||||
|
||||
"Save people you write to as contacts": "Enregistrer comme contacts les personnes à qui vous écrivez",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Toute personne à qui vous écrivez et qui n’est pas encore un contact est ajoutée au carnet Collectés, pour vous être proposée sur tous vos appareils. Vos propres adresses ne sont jamais ajoutées.",
|
||||
"Contacts are not available": "Les contacts ne sont pas disponibles",
|
||||
"New contact": "Nouveau contact",
|
||||
"Edit contact": "Modifier le contact",
|
||||
@@ -734,6 +745,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Paramètres",
|
||||
"Show folder list": "Afficher la liste des dossiers",
|
||||
"Hide folder list": "Masquer la liste des dossiers",
|
||||
"All settings": "Tous les paramètres",
|
||||
"Sections": "Sections",
|
||||
"General": "Général",
|
||||
@@ -1692,6 +1705,9 @@ export const catalog: Catalog = {
|
||||
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} vous préviendra si un message ultérieur de cette adresse est signé par quelqu'un d'autre.",
|
||||
"itself, or an issuer it does not name": "lui-même, ou un émetteur qu'il ne nomme pas",
|
||||
"no address": "aucune adresse",
|
||||
// ── Spam filter: the language model's opinion ──────────────────
|
||||
"Language model's opinion": "Avis du modèle de langage",
|
||||
"One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
|
||||
@@ -472,6 +472,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": "ファイルを添付",
|
||||
@@ -643,6 +648,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "他 {n} 件",
|
||||
|
||||
"Contacts": "連絡先",
|
||||
|
||||
"Collected": "収集したアドレス",
|
||||
|
||||
"Save people you write to as contacts": "メールを送った相手を連絡先に保存する",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "まだ連絡先にない送信先は「収集したアドレス」アドレス帳に追加され、どのデバイスでも候補に表示されます。自分のアドレスは追加されません。",
|
||||
"Contacts are not available": "連絡先は利用できません",
|
||||
"New contact": "新しい連絡先",
|
||||
"Edit contact": "連絡先を編集",
|
||||
@@ -728,6 +739,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "設定",
|
||||
"Show folder list": "フォルダー一覧を表示",
|
||||
"Hide folder list": "フォルダー一覧を隠す",
|
||||
"All settings": "すべての設定",
|
||||
"Sections": "セクション",
|
||||
"General": "一般",
|
||||
@@ -1695,6 +1708,9 @@ export const catalog: Catalog = {
|
||||
"{app} will tell you if a later message from this address is signed by anybody else.": "このアドレスからの以降のメールが別の人の署名だった場合、{app} がお知らせします。",
|
||||
"itself, or an issuer it does not name": "自分自身、または名前のない発行者",
|
||||
"no address": "アドレスなし",
|
||||
// ── Spam filter: the language model's opinion ──────────────────
|
||||
"Language model's opinion": "言語モデルの見解",
|
||||
"One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
|
||||
@@ -472,6 +472,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",
|
||||
@@ -644,6 +649,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} meer",
|
||||
|
||||
"Contacts": "Contacten",
|
||||
|
||||
"Collected": "Verzameld",
|
||||
|
||||
"Save people you write to as contacts": "Mensen aan wie je schrijft als contact opslaan",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Iedereen aan wie je mailt en die nog geen contact is, wordt toegevoegd aan het adresboek Verzameld, zodat die op elk apparaat wordt voorgesteld. Je eigen adressen worden nooit toegevoegd.",
|
||||
"Contacts are not available": "Contacten zijn niet beschikbaar",
|
||||
"New contact": "Nieuw contact",
|
||||
"Edit contact": "Contact bewerken",
|
||||
@@ -729,6 +740,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Instellingen",
|
||||
"Show folder list": "Mappenlijst tonen",
|
||||
"Hide folder list": "Mappenlijst verbergen",
|
||||
"All settings": "Alle instellingen",
|
||||
"Sections": "Onderdelen",
|
||||
"General": "Algemeen",
|
||||
@@ -1687,6 +1700,9 @@ export const catalog: Catalog = {
|
||||
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} laat het weten als een later bericht van dit adres door iemand anders is ondertekend.",
|
||||
"itself, or an issuer it does not name": "zichzelf, of een uitgever die niet wordt genoemd",
|
||||
"no address": "geen adres",
|
||||
// ── Spam filter: the language model's opinion ──────────────────
|
||||
"Language model's opinion": "Oordeel van het taalmodel",
|
||||
"One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
|
||||
@@ -476,6 +476,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",
|
||||
@@ -647,6 +652,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} outros",
|
||||
|
||||
"Contacts": "Contatos",
|
||||
|
||||
"Collected": "Coletados",
|
||||
|
||||
"Save people you write to as contacts": "Salvar como contatos as pessoas para quem você escreve",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todos para quem você envia e que ainda não são contatos são adicionados à agenda Coletados, para serem sugeridos em todos os dispositivos. Seus próprios endereços nunca são adicionados.",
|
||||
"Contacts are not available": "Os contatos não estão disponíveis",
|
||||
"New contact": "Novo contato",
|
||||
"Edit contact": "Editar o contato",
|
||||
@@ -731,6 +742,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Configurações",
|
||||
"Show folder list": "Mostrar lista de pastas",
|
||||
"Hide folder list": "Ocultar lista de pastas",
|
||||
"All settings": "Todas as configurações",
|
||||
"Sections": "Seções",
|
||||
"General": "Geral",
|
||||
@@ -1690,6 +1703,9 @@ export const catalog: Catalog = {
|
||||
"{app} will tell you if a later message from this address is signed by anybody else.": "O {app} avisará você se uma mensagem posterior deste endereço for assinada por outra pessoa.",
|
||||
"itself, or an issuer it does not name": "ele mesmo, ou um emissor que ele não nomeia",
|
||||
"no address": "nenhum endereço",
|
||||
// ── Spam filter: the language model's opinion ──────────────────
|
||||
"Language model's opinion": "Opinião do modelo de linguagem",
|
||||
"One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
|
||||
@@ -475,6 +475,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": "Прикрепить файлы",
|
||||
@@ -646,6 +651,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n}",
|
||||
|
||||
"Contacts": "Контакты",
|
||||
|
||||
"Collected": "Собранные",
|
||||
|
||||
"Save people you write to as contacts": "Сохранять адресатов как контакты",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Все, кому вы пишете и кого ещё нет в контактах, добавляются в адресную книгу «Собранные» и предлагаются на всех устройствах. Ваши собственные адреса не добавляются.",
|
||||
"Contacts are not available": "Контакты недоступны",
|
||||
"New contact": "Новый контакт",
|
||||
"Edit contact": "Изменить контакт",
|
||||
@@ -731,6 +742,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Настройки",
|
||||
"Show folder list": "Показать список папок",
|
||||
"Hide folder list": "Скрыть список папок",
|
||||
"All settings": "Все настройки",
|
||||
"Sections": "Разделы",
|
||||
"General": "Общие",
|
||||
@@ -1689,6 +1702,9 @@ export const catalog: Catalog = {
|
||||
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} сообщит, если следующее письмо с этого адреса подпишет кто-то другой.",
|
||||
"itself, or an issuer it does not name": "самим собой или неназванным издателем",
|
||||
"no address": "нет адреса",
|
||||
// ── Spam filter: the language model's opinion ──────────────────
|
||||
"Language model's opinion": "Мнение языковой модели",
|
||||
"One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
|
||||
@@ -271,6 +271,9 @@ export const catalog: Catalog = {
|
||||
"Contact options": "Kişi seçenekleri",
|
||||
"Contact saved": "Kişi kaydedildi",
|
||||
"Contacts": "Kişiler",
|
||||
"Collected": "Toplananlar",
|
||||
"Save people you write to as contacts": "Yazdığınız kişileri kişi olarak kaydet",
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Gönderdiğiniz ve henüz kişileriniz arasında olmayan herkes Toplananlar adres defterine eklenir, böylece her cihazda önerilir. Kendi adresleriniz asla eklenmez.",
|
||||
"Contacts are not available": "Kişiler kullanılamıyor",
|
||||
"Continue": "Devam et",
|
||||
"Conversation moved to {folder}": "Yazışma {folder} klasörüne taşındı",
|
||||
@@ -1093,6 +1096,8 @@ export const catalog: Catalog = {
|
||||
"Set on this role": "Bu role ayarlandı",
|
||||
"Set password": "Şifre belirle",
|
||||
"Settings": "Ayarlar",
|
||||
"Show folder list": "Klasör listesini göster",
|
||||
"Hide folder list": "Klasör listesini gizle",
|
||||
"Settings imported": "Ayarlar içe aktarıldı",
|
||||
"Settings reset to defaults": "Ayarlar varsayılanlara sıfırlandı",
|
||||
"Settings → Filters & rules": "Ayarlar → Filtreler ve kurallar",
|
||||
@@ -1190,6 +1195,13 @@ 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.",
|
||||
"Language model's opinion": "Dil modelinin görüşü",
|
||||
"One of several signals the spam filter weighed": "İstenmeyen posta filtresinin değerlendirdiği birkaç işaretten biri",
|
||||
"Subject: {subject}": "Konu: {subject}",
|
||||
"Subscribe to a calendar": "Bir takvime abone ol",
|
||||
"Subscribed calendar": "Abone olunan takvim",
|
||||
|
||||
@@ -469,6 +469,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": "Прикріпити файли",
|
||||
@@ -640,6 +645,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n}",
|
||||
|
||||
"Contacts": "Контакти",
|
||||
|
||||
"Collected": "Зібрані",
|
||||
|
||||
"Save people you write to as contacts": "Зберігати адресатів як контакти",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Усі, кому ви пишете і кого ще немає в контактах, додаються до адресної книги «Зібрані» й пропонуються на всіх пристроях. Ваші власні адреси не додаються.",
|
||||
"Contacts are not available": "Контакти недоступні",
|
||||
"New contact": "Новий контакт",
|
||||
"Edit contact": "Змінити контакт",
|
||||
@@ -725,6 +736,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Налаштування",
|
||||
"Show folder list": "Показати список тек",
|
||||
"Hide folder list": "Сховати список тек",
|
||||
"All settings": "Усі налаштування",
|
||||
"Sections": "Розділи",
|
||||
"General": "Загальні",
|
||||
@@ -1683,6 +1696,9 @@ export const catalog: Catalog = {
|
||||
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} повідомить, якщо наступний лист із цієї адреси підпише хтось інший.",
|
||||
"itself, or an issuer it does not name": "самим собою або неназваним видавцем",
|
||||
"no address": "немає адреси",
|
||||
// ── Spam filter: the language model's opinion ──────────────────
|
||||
"Language model's opinion": "Думка мовної моделі",
|
||||
"One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
|
||||
@@ -471,6 +471,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": "添加附件",
|
||||
@@ -642,6 +647,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "还有 {n} 项",
|
||||
|
||||
"Contacts": "联系人",
|
||||
|
||||
"Collected": "已收集",
|
||||
|
||||
"Save people you write to as contacts": "将您发信的联系人保存为联系人",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "您发信的对象如果还不是联系人,会被添加到“已收集”通讯录,在所有设备上都会作为建议显示。您自己的地址不会被添加。",
|
||||
"Contacts are not available": "联系人不可用",
|
||||
"New contact": "新建联系人",
|
||||
"Edit contact": "编辑联系人",
|
||||
@@ -727,6 +738,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "设置",
|
||||
"Show folder list": "显示文件夹列表",
|
||||
"Hide folder list": "隐藏文件夹列表",
|
||||
"All settings": "全部设置",
|
||||
"Sections": "分区",
|
||||
"General": "常规",
|
||||
@@ -1694,6 +1707,9 @@ export const catalog: Catalog = {
|
||||
"{app} will tell you if a later message from this address is signed by anybody else.": "如果此地址之后的邮件由他人签名,{app} 会提醒您。",
|
||||
"itself, or an issuer it does not name": "其自身,或一个未具名的颁发者",
|
||||
"no address": "无地址",
|
||||
// ── Spam filter: the language model's opinion ──────────────────
|
||||
"Language model's opinion": "语言模型的判断",
|
||||
"One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/* A confirmed send hands its recipients to the contacts store, unless the reader turned that off. */
|
||||
|
||||
function okServer() {
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "Email/set" && args.create) return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: "e1" } } }, id];
|
||||
if (name === "EmailSubmission/set") return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: "s1" } } }, id];
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
}
|
||||
|
||||
let collect: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: {}, [CAP.mail]: {}, [CAP.submission]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
|
||||
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: [{ id: "i1", name: "Me", email: "[email protected]", replyTo: null }] as never,
|
||||
mailboxes: { mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" } } as never,
|
||||
});
|
||||
collect = vi.fn(async () => 1);
|
||||
useContacts.setState({ collectRecipients: collect as never });
|
||||
okServer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const send = async () => {
|
||||
const key = useCompose.getState().open({ to: [{ name: "Ann", email: "[email protected]" }], cc: [{ name: null, email: "[email protected]" }], subject: "Hi" });
|
||||
await useCompose.getState().send(key);
|
||||
};
|
||||
|
||||
describe("collecting recipients when a message is sent", () => {
|
||||
it("passes every recipient, and the sender's own addresses to leave out", async () => {
|
||||
await send();
|
||||
expect(collect).toHaveBeenCalledTimes(1);
|
||||
const [addrs, own] = collect.mock.calls[0]!;
|
||||
expect((addrs as { email: string }[]).map((a) => a.email)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(own).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("does not when the setting is off", async () => {
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, collectRecipients: false } }));
|
||||
await send();
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { ContactCard, JmapSession } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* People written to used to be remembered only in this browser, as a list of
|
||||
* recent addresses, so a new device suggested nobody. Each confirmed send now
|
||||
* saves the recipients who are not contacts yet, in an address book of their
|
||||
* own, on the server.
|
||||
*/
|
||||
|
||||
interface Calls {
|
||||
books: Record<string, unknown>[];
|
||||
cards: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function server(): Calls {
|
||||
const calls: Calls = { books: [], cards: [] };
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "AddressBook/set" && args.create) {
|
||||
calls.books.push(args.create as Record<string, unknown>);
|
||||
useContacts.setState((s) => ({ books: { ...s.books, bNew: { id: "bNew", name: "Collected" } as never } }));
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { b: { id: "bNew" } } }, id];
|
||||
}
|
||||
if (name === "ContactCard/set" && args.create) {
|
||||
const create = args.create as Record<string, unknown>;
|
||||
calls.cards.push(...Object.values(create) as Record<string, unknown>[]);
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: Object.fromEntries(Object.keys(create).map((k, i) => [k, { id: `n${i}` }])) }, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false, changed: [], created: [], updated: [], destroyed: [], hasMoreChanges: false, oldState: "1", newState: "1" }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
return calls;
|
||||
}
|
||||
|
||||
const card = (id: string, email: string): ContactCard =>
|
||||
({ id, uid: id, addressBookIds: { b1: true }, name: { full: id }, emails: { e: { address: email } } }) as unknown as ContactCard;
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInSet: 500 }, [CAP.contacts]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.contacts]: {} } } },
|
||||
primaryAccounts: { [CAP.contacts]: "a1" },
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
|
||||
useContacts.setState({
|
||||
accountId: "a1",
|
||||
available: true,
|
||||
loaded: true,
|
||||
books: { b1: { id: "b1", name: "Personal" } as never },
|
||||
cards: { c1: card("c1", "[email protected]") },
|
||||
sharedCards: {},
|
||||
syncCards: (async () => undefined) as never,
|
||||
loadBooks: (async () => undefined) as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("saving the people written to", () => {
|
||||
it("adds only addresses that are not contacts yet, and never your own", async () => {
|
||||
const calls = server();
|
||||
const n = await useContacts.getState().collectRecipients(
|
||||
[
|
||||
{ name: "Known Person", email: "[email protected]" },
|
||||
{ name: "Smith, Jane", email: "[email protected]" },
|
||||
{ name: null, email: "[email protected]" },
|
||||
{ name: "Jane again", email: "[email protected]" },
|
||||
],
|
||||
["[email protected]"],
|
||||
);
|
||||
expect(n).toBe(1);
|
||||
expect(calls.cards).toHaveLength(1);
|
||||
const added = calls.cards[0]!;
|
||||
expect(Object.values(added.emails as Record<string, { address: string }>)[0]!.address).toBe("[email protected]");
|
||||
// Filed under the Collected book, with the name split the way the editor does.
|
||||
expect(added.addressBookIds).toEqual({ bNew: true });
|
||||
expect((added.name as { components: { kind: string; value: string }[] }).components.map((c) => c.value)).toEqual(expect.arrayContaining(["Jane", "Smith"]));
|
||||
});
|
||||
|
||||
it("creates the Collected book once and remembers it", async () => {
|
||||
const calls = server();
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
expect(useSettings.getState().settings.collectedBookId).toBe("bNew");
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
expect(calls.cards.at(-1)!.addressBookIds).toEqual({ bNew: true });
|
||||
});
|
||||
|
||||
it("makes a new book when the remembered one has been deleted", async () => {
|
||||
const calls = server();
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, collectedBookId: "gone" } }));
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does nothing, and makes no book, when everyone is already a contact", async () => {
|
||||
const calls = server();
|
||||
const n = await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(n).toBe(0);
|
||||
expect(calls.books).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* What the recipient field offers as you type. Matching used to take the
|
||||
* typed text as one piece, so "jane smi" found nobody filed as "Smith, Jane",
|
||||
* and a nickname or a company name found nothing at all.
|
||||
*/
|
||||
|
||||
const card = (id: string, full: string, email: string, extra: Partial<ContactCard> = {}): ContactCard =>
|
||||
({ id, uid: id, addressBookIds: { b1: true }, name: { full }, emails: { e: { address: email } }, ...extra }) as unknown as ContactCard;
|
||||
|
||||
beforeEach(() => {
|
||||
useContacts.setState({
|
||||
accountId: "a1",
|
||||
available: false,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
principalsLoaded: true,
|
||||
principals: [],
|
||||
sharedCards: {},
|
||||
recent: [],
|
||||
cards: {
|
||||
c1: card("c1", "Smith, Jane", "[email protected]"),
|
||||
c2: card("c2", "Robert Jones", "[email protected]", { nicknames: { n: { name: "Bobby" } } } as Partial<ContactCard>),
|
||||
c3: card("c3", "Ann Lee", "[email protected]", { organizations: { o: { name: "Globex Industries" } } } as Partial<ContactCard>),
|
||||
c4: card("c4", "Ann Taylor", "[email protected]"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const emails = async (q: string) => (await useContacts.getState().suggest(q)).map((s) => s.email);
|
||||
|
||||
describe("recipient suggestions", () => {
|
||||
it("matches the words typed in any order", async () => {
|
||||
expect(await emails("jane smi")).toEqual(["[email protected]"]);
|
||||
expect(await emails("smi jan")).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("does not match when one of the words fits nobody", async () => {
|
||||
expect(await emails("jane xyz")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches a nickname and an organization", async () => {
|
||||
expect(await emails("bobby")).toEqual(["[email protected]"]);
|
||||
expect(await emails("globex")).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("puts someone written to lately ahead of an equal match", async () => {
|
||||
expect(await emails("ann")).toEqual(["[email protected]", "[email protected]"]);
|
||||
useContacts.setState({ recent: [{ name: "Ann Taylor", email: "[email protected]" }] });
|
||||
expect((await emails("ann"))[0]).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("still ranks a match at the start above a word inside the name", async () => {
|
||||
// "jo" starts Jones's surname word; it starts no other card's name or address.
|
||||
expect(await emails("jo")).toEqual(["[email protected]"]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -128,7 +128,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;
|
||||
@@ -711,6 +711,11 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
toast.success(translate("This message had already been sent, so it wasn't sent again."));
|
||||
return;
|
||||
}
|
||||
// Everyone written to who is not a contact yet, so they are suggested on every device.
|
||||
if (settings().collectRecipients) {
|
||||
const own = useMail.getState().identities.map((i) => i.email);
|
||||
void useContacts.getState().collectRecipients([...d.to, ...d.cc, ...d.bcc], own).catch(() => undefined);
|
||||
}
|
||||
toast.success(scheduling ? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) }) : translate("Message sent"));
|
||||
} catch (err) {
|
||||
if (err instanceof SendOutcomeUnknown) {
|
||||
@@ -780,20 +785,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);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2,10 +2,12 @@ import { create } from "zustand";
|
||||
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
|
||||
import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
|
||||
import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
||||
import { contactDisplayName, contactEmails, contactFromAddress, sortKey } from "@/lib/contacts";
|
||||
import { uniqueAddresses } from "@/lib/address";
|
||||
import { parseLdif, uidFromDn } from "@/lib/ldif";
|
||||
import { cardFromLdif } from "@/lib/mozillaAb";
|
||||
import { useSettings } from "./settings";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { useSession } from "./session";
|
||||
import { useMail } from "./mail";
|
||||
|
||||
@@ -225,6 +227,8 @@ interface ContactsState {
|
||||
*/
|
||||
emptyBook(bookId: Id): Promise<{ destroyed: number; unfiled: number; refused?: SetError }>;
|
||||
createBook(name: string): Promise<Id>;
|
||||
/** Save the addresses that are not on any contact yet, in the Collected address book. */
|
||||
collectRecipients(addrs: EmailAddress[], own: string[]): Promise<number>;
|
||||
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
||||
destroyBook(id: Id): Promise<void>;
|
||||
/** Import vCards, updating any whose UID this book already holds rather than duplicating it. */
|
||||
@@ -615,6 +619,28 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
return { destroyed: gone.length, unfiled, refused };
|
||||
},
|
||||
|
||||
async collectRecipients(addrs, own) {
|
||||
if (!get().available || !get().accountId) return 0;
|
||||
// Every card has to be known, or someone already a contact gets a second card.
|
||||
if (!get().loaded) await get().loadAll();
|
||||
const ownSet = new Set(own.map((e) => e.toLowerCase()));
|
||||
const fresh = uniqueAddresses(addrs).filter((a) => a.email && !ownSet.has(a.email.toLowerCase()) && !get().lookupByEmail(a.email));
|
||||
if (!fresh.length) return 0;
|
||||
const accountId = get().accountId!;
|
||||
const { settings, update } = useSettings.getState();
|
||||
let bookId = settings.collectedBookId && get().books[settings.collectedBookId] ? settings.collectedBookId : null;
|
||||
if (!bookId) {
|
||||
bookId = await get().createBook(translate("Collected"));
|
||||
update({ collectedBookId: bookId });
|
||||
}
|
||||
const create = Object.fromEntries(
|
||||
fresh.map((a, i) => [`c${i}`, { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...contactFromAddress(a), addressBookIds: { [bookId!]: true } }]),
|
||||
);
|
||||
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create });
|
||||
await get().syncCards();
|
||||
return Object.keys(res.created ?? {}).length;
|
||||
},
|
||||
|
||||
async createBook(name) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
|
||||
@@ -792,14 +818,29 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
seen.add(k);
|
||||
out.push(s);
|
||||
};
|
||||
const score = (name: string | null, email: string): number => {
|
||||
/*
|
||||
* Words in any order: "jane smi" finds "Smith, Jane", and a nickname or the
|
||||
* organization counts as much as the name. Each word typed has to start a
|
||||
* word of the person's name, nickname, organization or address.
|
||||
*/
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
const wordsOf = (parts: Array<string | null | undefined>) =>
|
||||
parts.flatMap((p) => (p ?? "").toLowerCase().split(/[\s,.;:@_+()<>"'-]+/)).filter(Boolean);
|
||||
const score = (name: string | null, email: string, extra: Array<string | null | undefined> = []): number => {
|
||||
const n = (name ?? "").toLowerCase();
|
||||
const e = email.toLowerCase();
|
||||
if (e.startsWith(q) || n.startsWith(q)) return 0;
|
||||
if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1;
|
||||
const words = wordsOf([name, email, ...extra]);
|
||||
if (tokens.every((t) => words.some((w) => w.startsWith(t)))) return 1;
|
||||
if (e.includes(q) || n.includes(q)) return 2;
|
||||
return 99;
|
||||
};
|
||||
// Someone written to lately ranks a little above someone not, within the same kind of match.
|
||||
const recentRank = new Map(st.recent.map((r, i) => [r.email.toLowerCase(), i] as const));
|
||||
const recency = (email: string) => {
|
||||
const i = recentRank.get(email.toLowerCase());
|
||||
return i === undefined ? 0 : -0.3 * (1 - i / Math.max(1, st.recent.length));
|
||||
};
|
||||
const candidates: Array<Suggestion & { score: number }> = [];
|
||||
// A shared address book is only useful if it answers when you are writing
|
||||
// to someone in it, so its cards are offered alongside the reader's own.
|
||||
@@ -807,19 +848,20 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
|
||||
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
|
||||
for (const { c, penalty } of [...own, ...shared]) {
|
||||
const extra = [...Object.values(c.nicknames ?? {}).map((x) => x.name), ...Object.values(c.organizations ?? {}).map((x) => x.name)];
|
||||
for (const a of contactEmails(c)) {
|
||||
const sc = score(a.name, a.email);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty });
|
||||
const sc = score(a.name, a.email, extra);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty + recency(a.email) });
|
||||
}
|
||||
}
|
||||
for (const p of st.principals) {
|
||||
if (!p.email) continue;
|
||||
const sc = score(p.name, p.email);
|
||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 });
|
||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 + recency(p.email) });
|
||||
}
|
||||
for (const r of st.recent) {
|
||||
const sc = score(r.name, r.email);
|
||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 });
|
||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 + recency(r.email) });
|
||||
}
|
||||
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
|
||||
for (const c of candidates) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SPAM_HEADER_PROPS } from "@/lib/spamScore";
|
||||
import { LLM_HEADER_PROP } from "@/lib/llmOpinion";
|
||||
|
||||
|
||||
/*
|
||||
@@ -67,6 +68,8 @@ export const FULL_PROPS = [
|
||||
"header:Precedence:asText",
|
||||
"header:Authentication-Results:asText",
|
||||
...SPAM_HEADER_PROPS,
|
||||
// The language model's opinion, when the server wrote one
|
||||
LLM_HEADER_PROP,
|
||||
];
|
||||
|
||||
export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
|
||||
@@ -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`. */
|
||||
@@ -206,6 +211,15 @@ export interface Settings {
|
||||
* dates nobody put there is a surprise rather than a feature.
|
||||
*/
|
||||
birthdayCalendar: boolean;
|
||||
/**
|
||||
* Save everyone written to who is not a contact yet, in an address book of
|
||||
* its own, so they are suggested on every device rather than only in the
|
||||
* browser that sent the message. On by default, as mail clients do; the
|
||||
* address book can be emptied or deleted like any other.
|
||||
*/
|
||||
collectRecipients: boolean;
|
||||
/** The address book collected recipients go to, once there is one. */
|
||||
collectedBookId: string | null;
|
||||
/**
|
||||
* Calendars subscribed to by URL. The subscription is the setting; the
|
||||
* events themselves are fetched on demand and never stored, so this follows
|
||||
@@ -361,6 +375,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
timeZone: null,
|
||||
labelsSidebar: true,
|
||||
birthdayCalendar: false,
|
||||
collectRecipients: true,
|
||||
collectedBookId: null,
|
||||
icalSubscriptions: [],
|
||||
listSortPreset: "newest",
|
||||
listSortLevels: [],
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Read off disk: `?raw` comes back empty for a stylesheet under the test runner.
|
||||
const css = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../app.css"), "utf8");
|
||||
|
||||
/*
|
||||
* The app rail, the collapsed sidebar and the raised surfaces are all CSS, and
|
||||
* jsdom does no layout, so these pin the rules themselves. Contrast for the
|
||||
* pill accent was measured in a browser across every palette, accent and
|
||||
* mode; the shape of that rule is pinned here.
|
||||
*/
|
||||
|
||||
const rule = (selector: string) => {
|
||||
const i = css.indexOf(selector + " {");
|
||||
expect(i, `no rule for ${selector}`).toBeGreaterThan(-1);
|
||||
return css.slice(i, css.indexOf("}", i));
|
||||
};
|
||||
|
||||
describe("app layout CSS", () => {
|
||||
it("gives the rail its own column ahead of the sidebar, collapsed or not", () => {
|
||||
expect(rule(".app-body.has-rail")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w\)/);
|
||||
expect(rule(".app-body.has-rail.collapsed")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w-collapsed\)/);
|
||||
});
|
||||
|
||||
it("hides a sidebar with no icon-only form instead of crushing it", () => {
|
||||
expect(rule(".app-body.has-rail.sidebar-hidden")).toMatch(/grid-template-columns:\s*56px 0 /);
|
||||
expect(rule(".app-body.sidebar-hidden > .sidebar")).toMatch(/visibility:\s*hidden/);
|
||||
});
|
||||
|
||||
it("hides section headings when collapsed, over the sidebar's own display rule", () => {
|
||||
// `.sidebar .nav-section { display: flex }` comes later at the same weight;
|
||||
// the collapsed rule has to outweigh it or "FOLDERS" is drawn cut off.
|
||||
expect(css).toMatch(/\.app-body\.collapsed \.sidebar \.nav-section[^{]*\{\s*display:\s*none/);
|
||||
});
|
||||
|
||||
it("darkens the pill accent in light themes and lightens it in dark ones", () => {
|
||||
expect(css).toMatch(/:root \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, black\); \}/);
|
||||
expect(css).toMatch(/:root\[data-theme="dark"\] \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, white\); \}/);
|
||||
});
|
||||
|
||||
it("lets a color the reader picked win over the role tint", () => {
|
||||
for (const role of ["inbox", "drafts", "sent", "archive", "junk", "trash", "scheduled"]) {
|
||||
expect(css).toContain(`.folder-icon[data-role="${role}"]:not([style*="--folder-color"]) svg`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1370,6 +1370,82 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.collapsed .nav-item .nav-dot { position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||
.nav-label-color { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
|
||||
/* Outlook-style module bar at the bottom of the sidebar */
|
||||
/* App rail. A fixed column left of the sidebar on wide screens; phones keep the tab bar. */
|
||||
.app-body.has-rail { grid-template-columns: 56px var(--sidebar-w) minmax(0, 1fr); }
|
||||
.app-body.has-rail.collapsed { grid-template-columns: 56px var(--sidebar-w-collapsed) minmax(0, 1fr); }
|
||||
.app-body.has-rail > .sidebar-splitter { left: calc(56px + var(--sidebar-w) - 3px); }
|
||||
/* Only Mail's folder list has an icon-only form. Calendar, Contacts, Files, Settings and Admin
|
||||
hide their sidebar outright when collapsed; the rail still navigates and its toggle brings it back. */
|
||||
.app-body.has-rail.sidebar-hidden { grid-template-columns: 56px 0 minmax(0, 1fr); }
|
||||
.app-body.sidebar-hidden > .sidebar { visibility: hidden; padding: 0; min-width: 0; }
|
||||
.app-rail { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 0 12px; border-right: 1px solid var(--border); min-height: 0; }
|
||||
.app-rail-spacer { flex: 1; }
|
||||
.rail-toggle { background: none; border: 0; cursor: pointer; margin-top: 4px; }
|
||||
.rail-link { position: relative; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 12px; color: var(--fg-muted); transition: background .12s, color .12s; }
|
||||
.rail-link:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
.rail-link.active { background: var(--accent-soft); color: var(--accent-soft-fg); }
|
||||
.rail-badge { position: absolute; top: -3px; right: -5px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--pill-accent); color: var(--accent-fg); font-size: 10.5px; font-weight: 700; line-height: 18px; text-align: center; box-shadow: 0 0 0 2px var(--bg); }
|
||||
|
||||
/* Pill counts. Accent-filled when the folder has unread mail, neutral for totals (Drafts, Scheduled). */
|
||||
.nav-item .nav-count { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; height: 20px; padding: 0 7px; border-radius: 999px; background: var(--bg-active); color: var(--fg-muted); font-size: .74em; font-weight: 650; font-variant-numeric: tabular-nums; }
|
||||
/* The accent as a fill behind small text (pills, the rail badge, Compose). The plain accent is
|
||||
a mid tone that white text does not read on in light themes, so it is darkened there and
|
||||
lightened a touch in dark ones: at least 4.5:1 in all 12 palettes x 6 accents x 2 modes,
|
||||
measured 2026-10-05 (lowest 4.83). */
|
||||
:root { --pill-accent: color-mix(in srgb, var(--accent) 70%, black); }
|
||||
:root[data-theme="dark"] { --pill-accent: color-mix(in srgb, var(--accent) 88%, white); }
|
||||
.nav-item.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); font-weight: 700; }
|
||||
.nav-item.active .nav-count, .nav-item.active.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); box-shadow: 0 1px 2px rgb(0 0 0 / .25); }
|
||||
.collapsed .nav-item .nav-count { display: none; }
|
||||
|
||||
/* Raised surfaces. A top-lit sheen, a 1px inner highlight on top and shade below, and a small drop,
|
||||
so buttons and the current selection read as pieces standing off the page. Tuned per theme lightness. */
|
||||
:root {
|
||||
--raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .04) 50%, rgb(0 0 0 / .06));
|
||||
--raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .32), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .14));
|
||||
--raise-hi: rgb(255 255 255 / .75);
|
||||
--raise-lo: rgb(0 0 0 / .12);
|
||||
--raise-drop: 0 1px 2px rgb(0 0 0 / .14), 0 3px 8px rgb(0 0 0 / .09);
|
||||
--raise-press: inset 0 1px 2px rgb(0 0 0 / .14);
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .14), rgb(255 255 255 / .02) 50%, rgb(0 0 0 / .16));
|
||||
--raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .22));
|
||||
--raise-hi: rgb(255 255 255 / .16);
|
||||
--raise-lo: rgb(0 0 0 / .45);
|
||||
--raise-drop: 0 1px 2px rgb(0 0 0 / .55), 0 3px 10px rgb(0 0 0 / .32);
|
||||
--raise-press: inset 0 1px 3px rgb(0 0 0 / .45);
|
||||
}
|
||||
.btn, .compose-btn, .rail-link.active, .nav-item.active, .module-link.active, .segmented button.active {
|
||||
background-image: var(--raise-sheen);
|
||||
box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo), var(--raise-drop);
|
||||
}
|
||||
.btn-primary, .rail-badge { background-image: var(--raise-sheen-strong); }
|
||||
.compose-btn, .compose-btn:hover { background-color: var(--pill-accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); }
|
||||
.compose-btn svg { color: inherit; }
|
||||
.compose-btn, .rail-link.active { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--raise-drop); }
|
||||
.compose-btn:hover { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--shadow-2); filter: brightness(1.05); }
|
||||
.rail-link.active, .rail-link.active:hover { background-color: var(--accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); }
|
||||
.btn-primary { box-shadow: inset 0 1px 0 rgb(255 255 255 / .22), inset 0 -1px 0 rgb(0 0 0 / .18), var(--raise-drop); }
|
||||
.btn:active, .compose-btn:active, .rail-link:active, .nav-item:active, .module-link:active, .segmented button:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: var(--raise-press);
|
||||
background-image: none;
|
||||
}
|
||||
/* The segmented control clips its children; the raise sits inside its border. */
|
||||
.segmented button.active { box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo); }
|
||||
|
||||
/* Tinted role-folder icons. A color the reader picked for a folder still wins (style attribute, fill rule above). */
|
||||
:root { --role-inbox: #2f6fe4; --role-drafts: #8a5cd6; --role-sent: #138a6c; --role-archive: #976214; --role-junk: #b8501c; --role-trash: #c93c4b; --role-scheduled: #0f8aa6; }
|
||||
:root[data-theme="dark"] { --role-inbox: #6ea2ff; --role-drafts: #b796f5; --role-sent: #4fcfa6; --role-archive: #e5b45a; --role-junk: #f2925f; --role-trash: #f07a87; --role-scheduled: #4fc3dc; }
|
||||
.folder-row .folder-icon[data-role="inbox"]:not([style*="--folder-color"]) svg { color: var(--role-inbox); }
|
||||
.folder-row .folder-icon[data-role="drafts"]:not([style*="--folder-color"]) svg { color: var(--role-drafts); }
|
||||
.folder-row .folder-icon[data-role="sent"]:not([style*="--folder-color"]) svg { color: var(--role-sent); }
|
||||
.folder-row .folder-icon[data-role="archive"]:not([style*="--folder-color"]) svg { color: var(--role-archive); }
|
||||
.folder-row .folder-icon[data-role="junk"]:not([style*="--folder-color"]) svg { color: var(--role-junk); }
|
||||
.folder-row .folder-icon[data-role="trash"]:not([style*="--folder-color"]) svg { color: var(--role-trash); }
|
||||
.folder-row .folder-icon[data-role="scheduled"]:not([style*="--folder-color"]) svg { color: var(--role-scheduled); }
|
||||
|
||||
.module-bar { display: flex; align-items: stretch; justify-content: space-around; gap: 2px; padding: 6px 4px 4px; border-top: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.module-link { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; flex: 1; min-width: 0; padding: 6px 2px; border-radius: var(--radius-sm); color: var(--fg-muted); text-decoration: none; font-size: 11px; font-weight: 500; transition: background .12s, color .12s; }
|
||||
.module-link:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
@@ -1605,6 +1681,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); }
|
||||
@@ -2398,6 +2477,13 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
||||
.spam-weight.bad { color: var(--danger); }
|
||||
.spam-weight.good { color: var(--success); }
|
||||
|
||||
/* The language model's opinion, in the details and above a message in
|
||||
Junk (views/mail/LlmOpinion.tsx). The explanation is the model's own words,
|
||||
so it keeps its line breaks out and wraps rather than widening the pane. */
|
||||
.llm-opinion { display: flex; flex-direction: column; gap: 4px; }
|
||||
.llm-heading { display: inline-flex; flex-wrap: wrap; gap: .4em; align-items: baseline; }
|
||||
.llm-explanation { overflow-wrap: anywhere; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
+47
-10
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
||||
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PanelLeftClose, PanelLeftOpen, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
||||
@@ -49,6 +49,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const [location, navigate] = useLocation();
|
||||
const isMobile = useIsMobile();
|
||||
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
|
||||
const inboxUnread = useMail((s) => Object.values(s.mailboxes).find((m) => m.role === "inbox")?.unreadEmails ?? 0);
|
||||
const sidebarWidth = useSettings((s) => s.settings.sidebarWidth);
|
||||
const update = useSettings((s) => s.update);
|
||||
/*
|
||||
@@ -143,9 +144,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}>
|
||||
<MenuIcon size={22} />
|
||||
</button>
|
||||
{/* On a wide screen the folder list is toggled from the bottom of the rail; phones keep the menu button for the drawer. */}
|
||||
{isMobile && (
|
||||
<button className="icon-btn" aria-label={t("Menu")} onClick={() => setDrawer((d) => !d)}>
|
||||
<MenuIcon size={22} />
|
||||
</button>
|
||||
)}
|
||||
<Link href="/mail" className="brand">
|
||||
<img src={withBase("/img/logo.png")} alt="" />
|
||||
{/* A product name, not a word: translated it is a different product.
|
||||
@@ -165,9 +169,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<HelpCircle size={21} />
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
|
||||
<Settings size={21} />
|
||||
</Link>
|
||||
{/* On a wide screen the rail carries Settings; phones have no rail, so they keep this one. */}
|
||||
{isMobile && (
|
||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
|
||||
<Settings size={21} />
|
||||
</Link>
|
||||
)}
|
||||
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
|
||||
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
||||
</button>
|
||||
@@ -213,10 +220,30 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`app-body ${collapsed && !isMobile ? "collapsed" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
|
||||
className={`app-body ${!isMobile ? "has-rail" : ""} ${collapsed && !isMobile ? "collapsed" : ""} ${collapsed && !isMobile && section !== "mail" && section !== "search" ? "sidebar-hidden" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
|
||||
style={shownSidebarWidth != null && !isMobile ? ({ "--sidebar-w": `${shownSidebarWidth}px` } as React.CSSProperties) : undefined}
|
||||
>
|
||||
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
||||
{!isMobile && (
|
||||
<nav className="app-rail" aria-label={t("Go to")}>
|
||||
<RailLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} badge={inboxUnread} />
|
||||
<RailLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
||||
<RailLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
||||
<RailLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||
<span className="app-rail-spacer" />
|
||||
<RailLink href="/settings" icon={<Settings size={20} />} label={t("Settings")} active={section === "settings"} />
|
||||
<button
|
||||
type="button"
|
||||
className="rail-link rail-toggle"
|
||||
aria-label={collapsed ? t("Show folder list") : t("Hide folder list")}
|
||||
title={collapsed ? t("Show folder list") : t("Hide folder list")}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => update({ sidebarCollapsed: !collapsed })}
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={20} />}
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
<aside ref={sidebarRef} className={`sidebar ${drawer ? "open" : ""}`}>
|
||||
{/*
|
||||
The way back out.
|
||||
@@ -265,12 +292,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
{section === "admin" && <AdminNav />}
|
||||
</div>
|
||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||
<nav className="module-bar" aria-label={t("Go to")}>
|
||||
{isMobile && <nav className="module-bar" aria-label={t("Go to")}>
|
||||
<ModuleLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} />
|
||||
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
||||
<ModuleLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
||||
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||
</nav>
|
||||
</nav>}
|
||||
</aside>
|
||||
{/* Not on a phone, where the sidebar is a drawer over the page, and not
|
||||
while collapsed to icons, where there is no width to choose. */}
|
||||
@@ -355,6 +382,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The app rail down the left edge, which replaces the module bar on a wide screen. */
|
||||
function RailLink({ href, icon, label, active, badge = 0 }: { href: string; icon: ReactNode; label: string; active: boolean; badge?: number }) {
|
||||
return (
|
||||
<Link href={href} className={`rail-link ${active ? "active" : ""}`} title={label} aria-label={label} aria-current={active ? "page" : undefined}>
|
||||
{icon}
|
||||
{badge > 0 && <span className="rail-badge">{badge > 999 ? "999+" : badge}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Outlook-style module switcher at the bottom of the folder pane. */
|
||||
function ModuleLink({ href, icon, label, active }: { href: string; icon: ReactNode; label: string; active: boolean }) {
|
||||
return (
|
||||
|
||||
@@ -350,7 +350,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}>
|
||||
@@ -362,7 +362,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>
|
||||
|
||||
@@ -25,6 +25,15 @@ interface Props {
|
||||
imageUpload?: (file: File) => Promise<string>;
|
||||
}
|
||||
|
||||
/** Pasted or dropped images larger than this go in as attachments, not inline. */
|
||||
export const INLINE_IMAGE_MAX = 10 * 1024 * 1024;
|
||||
|
||||
/** A single http(s) or mailto address, nothing else: what may become a link over a selection. */
|
||||
export function isLinkToPaste(text: string): boolean {
|
||||
if (!text || /\s/.test(text)) return false;
|
||||
return /^(https?:\/\/[^\s]+|mailto:[^\s@]+@[^\s@]+)$/i.test(text);
|
||||
}
|
||||
|
||||
const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" ");
|
||||
const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"];
|
||||
|
||||
@@ -133,10 +142,25 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
const f = imgItem.getAsFile();
|
||||
if (f) {
|
||||
e.preventDefault();
|
||||
insertImageFile(f);
|
||||
// A large image is a file to send, not a picture in the text: it goes
|
||||
// in as an attachment, where an inline copy would swell every reply.
|
||||
if (f.size > INLINE_IMAGE_MAX && onFiles) onFiles([f]);
|
||||
else insertImageFile(f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* A link pasted over selected words makes those words the link, the way
|
||||
* most editors do, instead of replacing them with the address.
|
||||
*/
|
||||
const pastedText = e.clipboardData.getData("text/plain").trim();
|
||||
const sel = window.getSelection();
|
||||
if (isLinkToPaste(pastedText) && sel && !sel.isCollapsed && elRef.current?.contains(sel.anchorNode)) {
|
||||
e.preventDefault();
|
||||
document.execCommand("createLink", false, pastedText);
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
const htmlData = e.clipboardData.getData("text/html");
|
||||
if (htmlData) {
|
||||
e.preventDefault();
|
||||
@@ -175,8 +199,9 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (!files.length) return;
|
||||
e.preventDefault();
|
||||
const images = files.filter((f) => f.type.startsWith("image/"));
|
||||
const others = files.filter((f) => !f.type.startsWith("image/"));
|
||||
const inline = (f: File) => f.type.startsWith("image/") && !(f.size > INLINE_IMAGE_MAX && onFiles);
|
||||
const images = files.filter(inline);
|
||||
const others = files.filter((f) => !inline(f));
|
||||
images.forEach(insertImageFile);
|
||||
if (others.length) onFiles?.(others);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { INLINE_IMAGE_MAX, RichEditor, isLinkToPaste } from "../RichEditor";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||
|
||||
/*
|
||||
* Two things pasting into a message now does: a link pasted over selected
|
||||
* words turns those words into the link, and an image too large to belong
|
||||
* in the text goes in as an attachment. jsdom does no editing, so these check
|
||||
* the command and the callback the editor reaches for.
|
||||
*/
|
||||
|
||||
describe("what counts as a link to paste", () => {
|
||||
it("takes one web or mailto address", () => {
|
||||
expect(isLinkToPaste("https://example.com/a?b=1")).toBe(true);
|
||||
expect(isLinkToPaste("http://example.com")).toBe(true);
|
||||
expect(isLinkToPaste("mailto:[email protected]")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves ordinary text and anything with spaces alone", () => {
|
||||
expect(isLinkToPaste("see https://example.com")).toBe(false);
|
||||
expect(isLinkToPaste("example.com")).toBe(false);
|
||||
expect(isLinkToPaste("javascript:alert(1)")).toBe(false);
|
||||
expect(isLinkToPaste("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pasting into the editor", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
let onFiles: ReturnType<typeof vi.fn<(files: File[]) => void>>;
|
||||
let exec: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
onFiles = vi.fn<(files: File[]) => void>();
|
||||
exec = vi.fn(() => true);
|
||||
(document as unknown as { execCommand: unknown }).execCommand = exec;
|
||||
await act(async () => {
|
||||
root.render(<RichEditor html="<p>read the docs here</p>" onChange={() => {}} onFiles={onFiles} showToolbar={false} />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
|
||||
|
||||
const paste = async (data: { text?: string; html?: string; files?: File[] }) => {
|
||||
const items = (data.files ?? []).map((f) => ({ type: f.type, kind: "file", getAsFile: () => f }));
|
||||
const ev = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "clipboardData", {
|
||||
value: { items, getData: (t: string) => (t === "text/plain" ? data.text ?? "" : t === "text/html" ? data.html ?? "" : "") },
|
||||
});
|
||||
await act(async () => editor().dispatchEvent(ev));
|
||||
return ev;
|
||||
};
|
||||
|
||||
const select = (word: string) => {
|
||||
const text = editor().querySelector("p")!.firstChild!;
|
||||
const i = text.textContent!.indexOf(word);
|
||||
const r = document.createRange();
|
||||
r.setStart(text, i);
|
||||
r.setEnd(text, i + word.length);
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(r);
|
||||
};
|
||||
|
||||
it("turns selected words into the pasted link", async () => {
|
||||
select("docs");
|
||||
const ev = await paste({ text: "https://example.com/docs" });
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(exec).toHaveBeenCalledWith("createLink", false, "https://example.com/docs");
|
||||
});
|
||||
|
||||
it("pastes a link as text when nothing is selected", async () => {
|
||||
window.getSelection()!.removeAllRanges();
|
||||
await paste({ text: "https://example.com/docs" });
|
||||
expect(exec).not.toHaveBeenCalledWith("createLink", expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it("attaches a pasted image too large to sit in the text", async () => {
|
||||
const big = new File([new Uint8Array(8)], "huge.png", { type: "image/png" });
|
||||
Object.defineProperty(big, "size", { value: INLINE_IMAGE_MAX + 1 });
|
||||
await paste({ files: [big] });
|
||||
expect(onFiles).toHaveBeenCalledWith([big]);
|
||||
});
|
||||
|
||||
it("keeps a normal-sized pasted image in the text", async () => {
|
||||
const small = new File([new Uint8Array(8)], "shot.png", { type: "image/png" });
|
||||
await paste({ files: [small] });
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Bot } from "lucide-react";
|
||||
import type { LlmOpinion } from "@/lib/llmOpinion";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
|
||||
/*
|
||||
* The language model's opinion on a message, where the server's AI
|
||||
* spam classification recorded one (lib/llmOpinion).
|
||||
*
|
||||
* Two rules. It is always labeled as one signal the spam filter weighed among
|
||||
* several, never as the reason a message was filed where it was: the model's
|
||||
* category only adds the score the administrator gave its tag. And the
|
||||
* explanation is the model's own output, so it is only ever rendered as text.
|
||||
*
|
||||
* The category and confidence come from the server's configuration and aren't
|
||||
* translated; only the two framing strings are.
|
||||
*/
|
||||
|
||||
function Verdict({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<>
|
||||
<strong>{opinion.category}</strong>
|
||||
{opinion.confidence && <span className="hint">{` · ${opinion.confidence}`}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** In the message details, beside the spam filter's own working. */
|
||||
export function LlmOpinionDetail({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<div className="llm-opinion">
|
||||
<div>
|
||||
<Verdict opinion={opinion} />
|
||||
</div>
|
||||
{opinion.explanation && <div className="llm-explanation">{opinion.explanation}</div>}
|
||||
<div className="hint">{translate("One of several signals the spam filter weighed")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Above a message that's in Junk. */
|
||||
export function LlmOpinionBanner({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<div className="remote-banner llm-banner" role="note" style={{ margin: "0 16px 8px" }}>
|
||||
<Bot size={16} />
|
||||
<span className="grow llm-opinion">
|
||||
<span className="llm-heading">
|
||||
<span>{translate("Language model's opinion")}</span>
|
||||
<span>
|
||||
<Verdict opinion={opinion} />
|
||||
</span>
|
||||
</span>
|
||||
{opinion.explanation && <span className="llm-explanation">{opinion.explanation}</span>}
|
||||
<span className="hint">{translate("One of several signals the spam filter weighed")}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The banner shows only for a message in Junk that carries an opinion. */
|
||||
export function llmBannerOpinion(
|
||||
opinion: LlmOpinion | null,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
junkId: string | null | undefined,
|
||||
): LlmOpinion | null {
|
||||
return opinion && junkId && mailboxIds[junkId] ? opinion : null;
|
||||
}
|
||||
@@ -434,7 +434,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
||||
>
|
||||
{twisty ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
|
||||
</span>
|
||||
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
||||
<span className="folder-icon" data-role={m.role ?? (scheduled ? "scheduled" : undefined)} style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
||||
<span className="nav-label">{label}</span>
|
||||
{count > 0 && <span className="nav-count" title={hiddenUnread ? t("{here} here, {inSubfolders} in subfolders", { here: own, inSubfolders: hiddenUnread }) : undefined}>{count > 9999 ? "9999+" : count}</span>}
|
||||
{count > 0 && <span className="nav-dot" />}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { emlFilename } from "@/lib/text/emlName";
|
||||
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
|
||||
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
|
||||
import { spamReport, type SpamReport } from "@/lib/spamScore";
|
||||
import { llmOpinion } from "@/lib/llmOpinion";
|
||||
import { LlmOpinionBanner, LlmOpinionDetail, llmBannerOpinion } from "./LlmOpinion";
|
||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||
import { displayName, domainOf, formatAddress } from "@/lib/address";
|
||||
import { remoteImagesAllowed } from "@/lib/mail/remoteImages";
|
||||
@@ -193,6 +195,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
const receiptRequested = Boolean(e["header:Disposition-Notification-To:asAddresses"]?.length);
|
||||
const authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
|
||||
const spam = useMemo(() => spamReport(e), [e]);
|
||||
// The language model's opinion, where the server's AI spam classification wrote one
|
||||
const llm = useMemo(() => llmOpinion(e), [e]);
|
||||
const junkId = useMail((st) => st.roleId("junk"));
|
||||
const llmBanner = llmBannerOpinion(llm, e.mailboxIds, junkId);
|
||||
const identities = useMail((st) => st.identities);
|
||||
/*
|
||||
* Only computed when the warning is on, because the domains it compares
|
||||
@@ -380,6 +386,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||
<dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
|
||||
{spam && <><dt>{translate("Spam filter")}</dt><dd><SpamSummary report={spam} /></dd></>}
|
||||
{llm && <><dt>{translate("Language model's opinion")}</dt><dd><LlmOpinionDetail opinion={llm} /></dd></>}
|
||||
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}</dd></>}
|
||||
</dl>
|
||||
)}
|
||||
@@ -431,6 +438,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
</div>
|
||||
)}
|
||||
<SignatureBanner state={signature} />
|
||||
{llmBanner && <LlmOpinionBanner opinion={llmBanner} />}
|
||||
{externalSender && (
|
||||
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<ShieldAlert size={16} />
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { MailboxTree } from "../MailboxTree";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { Mailbox, MailboxRole } from "@/jmap/types";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||
|
||||
/*
|
||||
* Role folders get their own icon tint, read from the icon's data-role; a
|
||||
* plain folder has none, and a color the reader chose still applies.
|
||||
*/
|
||||
|
||||
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
|
||||
const box = (id: string, name: string, role: MailboxRole, unread = 0): Mailbox => ({
|
||||
id, name, parentId: null, role, sortOrder: 0, totalEmails: unread, unreadEmails: unread, totalThreads: unread, unreadThreads: unread, myRights: rights, isSubscribed: true,
|
||||
});
|
||||
|
||||
describe("folder icons by role", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, "", "/mail/inbox");
|
||||
useMail.setState({
|
||||
mailboxes: { inbox: box("inbox", "Inbox", "inbox", 3), junk: box("junk", "Junk", "junk", 1), work: box("work", "Work", null, 2) },
|
||||
mailboxesLoaded: true,
|
||||
});
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, showHiddenFolders: false, labelsSidebar: false, folderColors: { junk: "#123456" } } }));
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
act(() => root.render(<MailboxTree />));
|
||||
});
|
||||
afterEach(() => { act(() => root.unmount()); host.remove(); });
|
||||
|
||||
const icons = () => Array.from(document.querySelectorAll<HTMLElement>(".nav-item.folder-row .folder-icon"));
|
||||
const byRole = (role: string) => icons().find((i) => i.dataset.role === role)!;
|
||||
|
||||
it("marks role folders with their role and leaves a plain folder unmarked", () => {
|
||||
expect(icons().map((i) => i.dataset.role ?? null).sort()).toEqual(["inbox", "junk", null].sort());
|
||||
});
|
||||
|
||||
it("keeps the reader's own color on a role folder", () => {
|
||||
expect(byRole("junk").style.getPropertyValue("--folder-color")).toBe("#123456");
|
||||
});
|
||||
|
||||
it("shows unread counts as pills", () => {
|
||||
const pill = Array.from(document.querySelectorAll(".nav-item.folder-row.unread .nav-count")).map((p) => p.textContent);
|
||||
expect(pill).toEqual(expect.arrayContaining(["3", "1", "2"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { LlmOpinionBanner, LlmOpinionDetail, llmBannerOpinion } from "../LlmOpinion";
|
||||
import { parseLlmOpinion, type LlmOpinion } from "@/lib/llmOpinion";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/*
|
||||
* The framing is the feature: the model's opinion is always one signal among
|
||||
* several, never presented as why a message is where it is, and its
|
||||
* explanation is model output, so it must never be rendered as markup.
|
||||
*/
|
||||
const opinion = (raw: string) => parseLlmOpinion(raw) as LlmOpinion;
|
||||
|
||||
describe("the language model's opinion", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const render = async (node: React.ReactNode) => {
|
||||
await act(async () => {
|
||||
root.render(node);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("shows category and explanation, as one signal of several", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion("Unsolicited (Sells something unasked)")} />);
|
||||
expect(host.textContent).toContain("Unsolicited");
|
||||
expect(host.textContent).toContain("Sells something unasked");
|
||||
expect(host.textContent).toContain("One of several signals the spam filter weighed");
|
||||
});
|
||||
|
||||
it("renders the explanation as text, never markup", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion('Harmful (<img src=x onerror="alert(1)"> <b>bold</b>)')} />);
|
||||
expect(host.querySelector("img")).toBeNull();
|
||||
expect(host.querySelector("b")).toBeNull();
|
||||
expect(host.textContent).toContain('<img src=x onerror="alert(1)">');
|
||||
});
|
||||
|
||||
it("shows a confidence where the header carried one", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion("LLM_UNSOLICITED_HIGH (Sells something unasked)")} />);
|
||||
expect(host.textContent).toContain("High");
|
||||
});
|
||||
|
||||
it("leaves out what the header didn't carry", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion("Legitimate")} />);
|
||||
expect(host.querySelector(".llm-explanation")).toBeNull();
|
||||
expect(host.textContent).not.toContain("·");
|
||||
});
|
||||
|
||||
it("banners a message in Junk with the same framing", async () => {
|
||||
await render(<LlmOpinionBanner opinion={opinion("Unsolicited (Bulk newsletter)")} />);
|
||||
expect(host.textContent).toContain("Language model's opinion");
|
||||
expect(host.textContent).toContain("Unsolicited");
|
||||
expect(host.textContent).toContain("Bulk newsletter");
|
||||
expect(host.textContent).toContain("One of several signals the spam filter weighed");
|
||||
});
|
||||
|
||||
it("banners only a message that's in Junk and carries an opinion", () => {
|
||||
const o = opinion("Unsolicited");
|
||||
expect(llmBannerOpinion(o, { junk1: true }, "junk1")).toBe(o);
|
||||
expect(llmBannerOpinion(o, { inbox1: true }, "junk1")).toBeNull();
|
||||
expect(llmBannerOpinion(o, { junk1: true }, null)).toBeNull();
|
||||
expect(llmBannerOpinion(null, { junk1: true }, "junk1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,14 @@ export function CalendarSettings() {
|
||||
hint={t("A calendar of its own, derived from the birthdays already on your contact cards. Nothing is written anywhere — the dates stay on the cards, and an event disappears when the contact does or the birthday is cleared. It can be hidden from the calendar\u2019s own sidebar without turning it off here.")}
|
||||
/>
|
||||
|
||||
<h2>{t("Contacts")}</h2>
|
||||
<Switch
|
||||
checked={s.collectRecipients}
|
||||
onChange={(v) => update({ collectRecipients: v })}
|
||||
label={t("Save people you write to as contacts")}
|
||||
hint={t("Everyone you send to who isn\u2019t a contact yet is added to the Collected address book, so they\u2019re suggested on every device. Your own addresses are never added.")}
|
||||
/>
|
||||
|
||||
<h2>{t("Working hours")}</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
|
||||
@@ -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