diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts index a210103..5f37653 100644 --- a/web/src/jmap/types.ts +++ b/web/src/jmap/types.ts @@ -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 { diff --git a/web/src/lib/__tests__/llmOpinion.test.ts b/web/src/lib/__tests__/llmOpinion.test.ts new file mode 100644 index 0000000..fd66ee6 --- /dev/null +++ b/web/src/lib/__tests__/llmOpinion.test.ts @@ -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: ()`, 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(); + }); +}); diff --git a/web/src/lib/llmOpinion.ts b/web/src/lib/llmOpinion.ts new file mode 100644 index 0000000..554c768 --- /dev/null +++ b/web/src/lib/llmOpinion.ts @@ -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__`), 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]); +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 730dee1..10e0ec9 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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 ──────────────────────────────────── diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 9d27901..3aba8e7 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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 ──────────────────────────────────── diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index ff095e5..e893a70 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -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 ──────────────────────────────────── diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 7c0799f..0b1849f 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -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 ──────────────────────────────────── diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 83f8566..2accb60 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -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 ──────────────────────────────────── diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index fb28e3b..c4149f2 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -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 ──────────────────────────────────── diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 86b26fe..d92fc14 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -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 ──────────────────────────────────── diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index 69c34a3..a2259a7 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -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", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index da0ad9e..eb217b1 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -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 ──────────────────────────────────── diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index e36dd41..9d8b4de 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -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 ──────────────────────────────────── diff --git a/web/src/store/__tests__/collect-on-send.test.ts b/web/src/store/__tests__/collect-on-send.test.ts new file mode 100644 index 0000000..afe91ee --- /dev/null +++ b/web/src/store/__tests__/collect-on-send.test.ts @@ -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][] }; + 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; + +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: "me@example.org", 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: "ann@example.com" }], cc: [{ name: null, email: "bob@example.com" }], 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(["ann@example.com", "bob@example.com"]); + expect(own).toEqual(["me@example.org"]); + }); + + it("does not when the setting is off", async () => { + useSettings.setState((s) => ({ settings: { ...s.settings, collectRecipients: false } })); + await send(); + expect(collect).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/store/__tests__/collect-recipients.test.ts b/web/src/store/__tests__/collect-recipients.test.ts new file mode 100644 index 0000000..c7cfee4 --- /dev/null +++ b/web/src/store/__tests__/collect-recipients.test.ts @@ -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[]; + cards: Record[]; +} + +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][] }; + const methodResponses = body.methodCalls.map(([name, args, id]) => { + if (name === "AddressBook/set" && args.create) { + calls.books.push(args.create as Record); + 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; + calls.cards.push(...Object.values(create) as Record[]); + 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", "known@example.com") }, + 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: "KNOWN@example.com" }, + { name: "Smith, Jane", email: "jane@example.com" }, + { name: null, email: "me@example.org" }, + { name: "Jane again", email: "jane@example.com" }, + ], + ["me@example.org"], + ); + expect(n).toBe(1); + expect(calls.cards).toHaveLength(1); + const added = calls.cards[0]!; + expect(Object.values(added.emails as Record)[0]!.address).toBe("jane@example.com"); + // 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: "a@example.com" }], []); + expect(calls.books).toHaveLength(1); + expect(useSettings.getState().settings.collectedBookId).toBe("bNew"); + await useContacts.getState().collectRecipients([{ name: null, email: "b@example.com" }], []); + 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: "a@example.com" }], []); + 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: "known@example.com" }], []); + expect(n).toBe(0); + expect(calls.books).toHaveLength(0); + }); +}); diff --git a/web/src/store/__tests__/recipient-suggest.test.ts b/web/src/store/__tests__/recipient-suggest.test.ts new file mode 100644 index 0000000..3f9d84d --- /dev/null +++ b/web/src/store/__tests__/recipient-suggest.test.ts @@ -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 => + ({ 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", "jane@acme.test"), + c2: card("c2", "Robert Jones", "rj@corp.test", { nicknames: { n: { name: "Bobby" } } } as Partial), + c3: card("c3", "Ann Lee", "ann@example.org", { organizations: { o: { name: "Globex Industries" } } } as Partial), + c4: card("c4", "Ann Taylor", "ataylor@example.org"), + }, + }); +}); + +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(["jane@acme.test"]); + expect(await emails("smi jan")).toEqual(["jane@acme.test"]); + }); + + 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(["rj@corp.test"]); + expect(await emails("globex")).toEqual(["ann@example.org"]); + }); + + it("puts someone written to lately ahead of an equal match", async () => { + expect(await emails("ann")).toEqual(["ann@example.org", "ataylor@example.org"]); + useContacts.setState({ recent: [{ name: "Ann Taylor", email: "ataylor@example.org" }] }); + expect((await emails("ann"))[0]).toBe("ataylor@example.org"); + }); + + 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(["rj@corp.test"]); + }); +}); diff --git a/web/src/store/__tests__/template-recipients.test.ts b/web/src/store/__tests__/template-recipients.test.ts new file mode 100644 index 0000000..3fac7db --- /dev/null +++ b/web/src/store/__tests__/template-recipients.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import { useCompose } from "@/store/compose"; +import { useMail } from "@/store/mail"; +import { DEFAULT_SETTINGS, useSettings } from "@/store/settings"; +import type { JmapSession } from "@/jmap/types"; + +/* A template's saved recipients join the message it is inserted into (#58). */ + +beforeEach(() => { + client.session = { + capabilities: { [CAP.core]: {}, [CAP.mail]: {}, [CAP.submission]: {} }, + accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } }, + primaryAccounts: {}, + state: "s1", + } as unknown as JmapSession; + // Nothing here should reach the server; autosave is answered with nothing. + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ methodResponses: [], sessionState: "1" }) }) as unknown as Response)); + useSettings.setState({ settings: { ...DEFAULT_SETTINGS } }); + useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} }); + useMail.setState({ + accountId: "a1", + identities: [{ id: "i1", name: "Me", email: "me@example.org", replyTo: null }] as never, + mailboxes: {} as never, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const draft = (key: string) => useCompose.getState().drafts.find((d) => d.key === key)!; + +describe("inserting a template with recipients", () => { + it("fills To, Cc and Bcc, and shows the Cc and Bcc fields it used", () => { + const key = useCompose.getState().open({}); + useCompose.getState().insertTemplate(key, { + html: "

Invoice attached.

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

Hi

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

Thanks

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

Dear {{recipientName}},

", subject: "", to: [{ name: "Jane Smith", email: "jane@example.com" }] }); + expect(draft(key).html).toContain("Dear Jane Smith,"); + }); +}); diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index 05831f4..c892697 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -13,7 +13,7 @@ import { ensureScheduledMailbox, useScheduled } from "./scheduled"; import { formatScheduleTime, holdUntil } from "@/lib/schedule"; import { t as translate } from "@/lib/i18n"; import { BASE_PATH } from "@/lib/basePath"; -import { settings } from "./settings"; +import { settings, type Template } from "./settings"; import { useContacts } from "./contacts"; import { emlFilename } from "@/lib/text/emlName"; import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders"; @@ -128,7 +128,7 @@ interface ComposeState { send(key: string): Promise; undoSend(key: string): void; setIdentity(key: string, identityId: Id): void; - insertTemplate(key: string, html: string, subject?: string): void; + insertTemplate(key: string, template: Pick): void; } const AUTOSAVE_MS = 20_000; @@ -711,6 +711,11 @@ export const useCompose = create((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((set, get) => ({ get().update(key, { identityId, html, text, signatureHtml: newSig, ...replyToPatch }); }, - insertTemplate(key, html, subject) { + insertTemplate(key, { html, subject, to = [], cc = [], bcc = [] }) { const d = get().drafts.find((x) => x.key === key); if (!d) return; + // The template's recipients join the ones already there. An address that + // is already on the message, in any field, stays where it is rather than + // appearing twice. + const present = [...d.to, ...d.cc, ...d.bcc]; + const added = (list: EmailAddress[]) => { + const fresh = uniqueAddresses(list).filter((a) => !present.some((p) => sameAddress(p.email, a.email))); + present.push(...fresh); + return fresh; + }; + const recipients = { to: [...d.to, ...added(to)], cc: [...d.cc, ...added(cc)], bcc: [...d.bcc, ...added(bcc)] }; // Placeholders are filled against the draft as it stands right now, which // is why this happens on insert rather than on send: what the template is // filled with is visible and editable afterwards, instead of changing - // under the message between writing it and sending it. + // under the message between writing it and sending it. The template's own + // recipients count, so {{recipientName}} can name the person it is for. const ident = d.identityId ? useMail.getState().identities.find((i) => i.id === d.identityId) : undefined; - const ctx: PlaceholderContext = { to: d.to, from: ident ? { name: ident.name, email: ident.email } : null, subject: d.subject }; + const ctx: PlaceholderContext = { to: recipients.to, from: ident ? { name: ident.name, email: ident.email } : null, subject: d.subject }; // The body is filled once as HTML and the plain-text side derived from the // result, so the two cannot disagree about what a placeholder came to. const filled = fillPlaceholders(html, ctx, { html: true }); const patch: Partial = { html: `
${sanitizeEditorHtml(filled)}
${d.html}`, text: `${htmlToText(filled)}\n${d.text}` }; if (subject && !d.subject) patch.subject = fillPlaceholders(subject, ctx, { html: false }); + if (recipients.to.length !== d.to.length) patch.to = recipients.to; + if (recipients.cc.length !== d.cc.length) Object.assign(patch, { cc: recipients.cc, showCc: true }); + if (recipients.bcc.length !== d.bcc.length) Object.assign(patch, { bcc: recipients.bcc, showBcc: true }); get().update(key, patch); }, })); diff --git a/web/src/store/contacts.ts b/web/src/store/contacts.ts index ca4a9da..04b0ca0 100644 --- a/web/src/store/contacts.ts +++ b/web/src/store/contacts.ts @@ -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; + /** Save the addresses that are not on any contact yet, in the Collected address book. */ + collectRecipients(addrs: EmailAddress[], own: string[]): Promise; updateBook(id: Id, patch: Partial): Promise; destroyBook(id: Id): Promise; /** Import vCards, updating any whose UID this book already holds rather than duplicating it. */ @@ -615,6 +619,28 @@ export const useContacts = create((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>("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>("AddressBook/set", { accountId, create: { b: { name } } }); @@ -792,14 +818,29 @@ export const useContacts = create((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) => + parts.flatMap((p) => (p ?? "").toLowerCase().split(/[\s,.;:@_+()<>"'-]+/)).filter(Boolean); + const score = (name: string | null, email: string, extra: Array = []): 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 = []; // 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((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) { diff --git a/web/src/store/mail/props.ts b/web/src/store/mail/props.ts index 48f3c81..1f09b76 100644 --- a/web/src/store/mail/props.ts +++ b/web/src/store/mail/props.ts @@ -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"]; diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index f9b359f..179206e 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -7,6 +7,7 @@ import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync"; import { policyChanges, policyDefaults, policyEnforced, type PolicyChange } from "@/lib/settingsPolicy"; import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime"; import type { SwipeAction } from "@/lib/input/swipe"; +import type { EmailAddress } from "@/jmap/types"; import { resolveUiLanguage } from "@/lib/languages"; import { loadLanguage } from "@/lib/i18n"; @@ -55,6 +56,10 @@ export interface Template { name: string; subject: string; html: string; + /** Recipients added to the message when the template is inserted. Older templates have none. */ + to?: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; } /** A signer remembered for an address. See `knownSigners`. */ @@ -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: [], diff --git a/web/src/styles/__tests__/app-rail.test.ts b/web/src/styles/__tests__/app-rail.test.ts new file mode 100644 index 0000000..891c79b --- /dev/null +++ b/web/src/styles/__tests__/app-rail.test.ts @@ -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`); + } + }); +}); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 2ccfdf0..4f1deac 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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; } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 2f84b54..aafb62c 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -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 (
- + {/* On a wide screen the folder list is toggled from the bottom of the rail; phones keep the menu button for the drawer. */} + {isMobile && ( + + )} {/* A product name, not a word: translated it is a different product. @@ -165,9 +169,12 @@ export function AppShell({ children }: { children: ReactNode }) { - - - + {/* On a wide screen the rail carries Settings; phones have no rail, so they keep this one. */} + {isMobile && ( + + + + )} @@ -213,10 +220,30 @@ export function AppShell({ children }: { children: ReactNode }) {
setDrawer(false)} /> + {!isMobile && ( + + )}
{(section === "mail" || section === "search") && } - } {/* 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 ( + + {icon} + {badge > 0 && {badge > 999 ? "999+" : badge}} + + ); +} + /** 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 ( diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index a19224f..dd0d17f 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -350,7 +350,7 @@ export function Composer({ draft }: { draft: Draft }) { {settings.templates.length > 0 && } {translate("Templates")} - {settings.templates.map((t) => insertTemplate(key, t.html, t.subject)} />)} + {settings.templates.map((t) => insertTemplate(key, t)} />)} @@ -362,7 +362,7 @@ export function Composer({ draft }: { draft: Draft }) { patch({ priority: "normal" })} /> patch({ priority: "low" })} /> - } label={translate("Save as template")} onClick={async () => { const name = await promptDialog({ title: translate("Save as template"), defaultValue: d.subject || translate("Template"), placeholder: translate("Template name") }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} /> + } label={translate("Save as template")} onClick={async () => { const name = await promptDialog({ title: translate("Save as template"), defaultValue: d.subject || translate("Template"), placeholder: translate("Template name") }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text), to: d.to, cc: d.cc, bcc: d.bcc }] }); }} /> } label={translate("Save draft now")} onClick={() => void saveDraft(key)} /> diff --git a/web/src/views/compose/RichEditor.tsx b/web/src/views/compose/RichEditor.tsx index defb0e9..66c24a8 100644 --- a/web/src/views/compose/RichEditor.tsx +++ b/web/src/views/compose/RichEditor.tsx @@ -25,6 +25,15 @@ interface Props { imageUpload?: (file: File) => Promise; } +/** 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(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(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); }; diff --git a/web/src/views/compose/__tests__/editor-paste.test.tsx b/web/src/views/compose/__tests__/editor-paste.test.tsx new file mode 100644 index 0000000..c380ee5 --- /dev/null +++ b/web/src/views/compose/__tests__/editor-paste.test.tsx @@ -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:ann@example.com")).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 void>>; + let exec: ReturnType; + + 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( {}} onFiles={onFiles} showToolbar={false} />); + }); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + }); + + const editor = () => host.querySelector('[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(); + }); +}); diff --git a/web/src/views/mail/LlmOpinion.tsx b/web/src/views/mail/LlmOpinion.tsx new file mode 100644 index 0000000..32f3182 --- /dev/null +++ b/web/src/views/mail/LlmOpinion.tsx @@ -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 ( + <> + {opinion.category} + {opinion.confidence && {` · ${opinion.confidence}`}} + + ); +} + +/** In the message details, beside the spam filter's own working. */ +export function LlmOpinionDetail({ opinion }: { opinion: LlmOpinion }) { + return ( +
+
+ +
+ {opinion.explanation &&
{opinion.explanation}
} +
{translate("One of several signals the spam filter weighed")}
+
+ ); +} + +/** Above a message that's in Junk. */ +export function LlmOpinionBanner({ opinion }: { opinion: LlmOpinion }) { + return ( +
+ + + + {translate("Language model's opinion")} + + + + + {opinion.explanation && {opinion.explanation}} + {translate("One of several signals the spam filter weighed")} + +
+ ); +} + +/** The banner shows only for a message in Junk that carries an opinion. */ +export function llmBannerOpinion( + opinion: LlmOpinion | null, + mailboxIds: Record, + junkId: string | null | undefined, +): LlmOpinion | null { + return opinion && junkId && mailboxIds[junkId] ? opinion : null; +} diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 858532e..2bd752a 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -434,7 +434,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, > {twisty ? open ? : : null} - {icon} + {icon} {label} {count > 0 && {count > 9999 ? "9999+" : count}} {count > 0 && } diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index dec9298..e028817 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -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"] && <>
{translate("List")}
{e["header:List-Id:asText"]}
}
{translate("Size")}
{formatSize(e.size)}
{spam && <>
{translate("Spam filter")}
} + {llm && <>
{translate("Language model's opinion")}
} {receiptRequested && <>
{translate("Receipt")}
{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}
} )} @@ -431,6 +438,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
)} + {llmBanner && } {externalSender && (
diff --git a/web/src/views/mail/__tests__/folder-role-tint.test.tsx b/web/src/views/mail/__tests__/folder-role-tint.test.tsx new file mode 100644 index 0000000..0c891cb --- /dev/null +++ b/web/src/views/mail/__tests__/folder-role-tint.test.tsx @@ -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()); + }); + afterEach(() => { act(() => root.unmount()); host.remove(); }); + + const icons = () => Array.from(document.querySelectorAll(".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"])); + }); +}); diff --git a/web/src/views/mail/__tests__/llm-opinion.test.tsx b/web/src/views/mail/__tests__/llm-opinion.test.tsx new file mode 100644 index 0000000..b395ce9 --- /dev/null +++ b/web/src/views/mail/__tests__/llm-opinion.test.tsx @@ -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(); + 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( bold)')} />); + expect(host.querySelector("img")).toBeNull(); + expect(host.querySelector("b")).toBeNull(); + expect(host.textContent).toContain(''); + }); + + it("shows a confidence where the header carried one", async () => { + await render(); + expect(host.textContent).toContain("High"); + }); + + it("leaves out what the header didn't carry", async () => { + await render(); + expect(host.querySelector(".llm-explanation")).toBeNull(); + expect(host.textContent).not.toContain("·"); + }); + + it("banners a message in Junk with the same framing", async () => { + await render(); + 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(); + }); +}); diff --git a/web/src/views/settings/CalendarSettings.tsx b/web/src/views/settings/CalendarSettings.tsx index 3a9c567..9b5f6c0 100644 --- a/web/src/views/settings/CalendarSettings.tsx +++ b/web/src/views/settings/CalendarSettings.tsx @@ -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.")} /> +

{t("Contacts")}

+ 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.")} + /> +

{t("Working hours")}

diff --git a/web/src/views/settings/TemplatesSettings.tsx b/web/src/views/settings/TemplatesSettings.tsx index c48e7d5..2267128 100644 --- a/web/src/views/settings/TemplatesSettings.tsx +++ b/web/src/views/settings/TemplatesSettings.tsx @@ -3,6 +3,8 @@ import { Plus, Trash2 } from "lucide-react"; import { useSettings, type Template } from "@/store/settings"; import { Dialog } from "@/ui/dialog"; import { RichEditor } from "../compose/RichEditor"; +import { RecipientInput } from "../compose/RecipientInput"; +import { formatAddress } from "@/lib/address"; import { htmlToText } from "@/lib/text/text"; import { t as translate } from "@/lib/i18n"; import { PLACEHOLDER_NAMES } from "@/lib/templatePlaceholders"; @@ -22,6 +24,7 @@ export function TemplatesSettings() {
{t.subject &&
{translate("Subject: {subject}", { subject: t.subject })}
} + {recipientCount(t) > 0 &&
{translate("Adds: {recipients}", { recipients: [...(t.to ?? []), ...(t.cc ?? []), ...(t.bcc ?? [])].map(formatAddress).join(", ") })}
}
{htmlToText(t.html).slice(0, 140)}
))} @@ -32,6 +35,21 @@ export function TemplatesSettings() {
setEditing({ ...editing, name: e.target.value })} autoFocus />
setEditing({ ...editing, subject: e.target.value })} />
+
+ + setEditing({ ...editing, to })} exclude={[...(editing.cc ?? []), ...(editing.bcc ?? [])]} /> +
+
+
+ + setEditing({ ...editing, cc })} exclude={[...(editing.to ?? []), ...(editing.bcc ?? [])]} /> +
+
+ + setEditing({ ...editing, bcc })} exclude={[...(editing.to ?? []), ...(editing.cc ?? [])]} /> +
+
+

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

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