From 4e2de305d3d38fe6c430cac67ddf99fb48a9e707 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 11:52:54 -0700 Subject: [PATCH 1/5] Composer: smarter suggestions, links over selections, big images attached Recipient suggestions: - the words typed match in any order, each one the start of a word in the name, a nickname, the organization or the address: "jane smi" finds "Smith, Jane", and "globex" finds the people at Globex; - someone written to lately ranks a little above an equal match; - an address already in To, Cc or Bcc is no longer offered in the other two fields. Pasting: - a single web or mailto address pasted over selected words makes those words the link, instead of replacing them with the address; - a pasted or dropped image over 10 MB goes in as an attachment rather than inline, where it would swell every reply. No new strings. --- .../store/__tests__/recipient-suggest.test.ts | 60 +++++++++++ web/src/store/contacts.ts | 28 +++-- web/src/views/compose/RichEditor.tsx | 31 +++++- .../compose/__tests__/editor-paste.test.tsx | 102 ++++++++++++++++++ 4 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 web/src/store/__tests__/recipient-suggest.test.ts create mode 100644 web/src/views/compose/__tests__/editor-paste.test.tsx 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/contacts.ts b/web/src/store/contacts.ts index ca4a9da..78327ba 100644 --- a/web/src/store/contacts.ts +++ b/web/src/store/contacts.ts @@ -792,14 +792,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 +822,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/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(); + }); +}); -- 2.54.0 From 6e78d748d95a9a0c8c6a4b3956b18ad780e01f8d Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 11:55:51 -0700 Subject: [PATCH 2/5] Save the people you write to as contacts The addresses written to were remembered only in the browser that sent the message, as a list of recent recipients, so a new device or a cleared browser suggested nobody. After each confirmed send, the recipients who are not contacts yet are now saved on the server, in an address book of their own called Collected, so they are suggested everywhere. - Only addresses on no card in any address book, own or shared, are added, de-duplicated, and never the sender's own identities. - The book is created on first use and remembered by id in the synced settings, so its name can be anything; a deleted one is replaced. - Names are split as the contact editor does ("Smith, Jane"). - Settings > Calendar & contacts > Contacts has a switch, on by default, as mail clients do; the book can be emptied or deleted like any other. New strings: 3, in all ten catalogs. --- web/src/locales/de.ts | 6 + web/src/locales/es.ts | 6 + web/src/locales/fr.ts | 6 + web/src/locales/ja.ts | 6 + web/src/locales/nl.ts | 6 + web/src/locales/pt-BR.ts | 6 + web/src/locales/ru.ts | 6 + web/src/locales/tr.ts | 3 + web/src/locales/uk.ts | 6 + web/src/locales/zh-Hans.ts | 6 + .../store/__tests__/collect-on-send.test.ts | 68 +++++++++++ .../__tests__/collect-recipients.test.ts | 113 ++++++++++++++++++ web/src/store/compose.ts | 5 + web/src/store/contacts.ts | 28 ++++- web/src/store/settings.ts | 11 ++ web/src/views/settings/CalendarSettings.tsx | 8 ++ 16 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 web/src/store/__tests__/collect-on-send.test.ts create mode 100644 web/src/store/__tests__/collect-recipients.test.ts diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 730dee1..7c93a5e 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -652,6 +652,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", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 9d27901..afcfab0 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -644,6 +644,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", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index ff095e5..f44a445 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -649,6 +649,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", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 7c0799f..cf3929a 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -643,6 +643,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": "連絡先を編集", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 83f8566..45f4d56 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -644,6 +644,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", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index fb28e3b..8d77ad7 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -647,6 +647,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", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 86b26fe..b83b335 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -646,6 +646,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": "Изменить контакт", diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index 69c34a3..31a7efd 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ı", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index da0ad9e..885a7ea 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -640,6 +640,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": "Змінити контакт", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index e36dd41..be4f9ea 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -642,6 +642,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": "编辑联系人", 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/compose.ts b/web/src/store/compose.ts index 05831f4..bbbe105 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -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) { diff --git a/web/src/store/contacts.ts b/web/src/store/contacts.ts index 78327ba..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 } } }); diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index f9b359f..d805dbc 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -206,6 +206,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 +370,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/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")}

-- 2.54.0 From 2002120902bf4a844e0c6a5cc107a52d694a04a2 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 12:01:27 -0700 Subject: [PATCH 3/5] App rail, pill counts, tinted folder icons and raised controls On a wide screen the switcher moves from the foot of the folder pane to a rail down the left edge: Mail (with the inbox's unread badge), Calendar, Contacts and Files, then Settings and the folder-list toggle at the bottom. The top bar loses the menu button and the Settings gear there, both now on the rail. Phones are unchanged: no rail, the tab bar, the menu button for the drawer and the gear. - Folder counts are pills: filled with the accent when there is unread mail, neutral for totals (Drafts, Scheduled). - Role folders get their own icon tint (Inbox, Drafts, Sent, Archive, Junk, Trash, Scheduled); a color the reader picked still wins. - Buttons, Compose and the current selection have a top-lit, raised look with a pressed state; Compose and the selected rail item are filled with the accent. - Collapsing the sidebar keeps Mail's icon strip; Calendar, Contacts, Files, Settings and Admin have no icon-only form, so their sidebar hides instead of being crushed. - The collapsed sidebar no longer draws a cut-off "FOLDERS": the rule hiding section headings lost to a later one of the same weight (this is on main today too). Contrast: the accent behind small text is darkened in light themes and lightened a touch in dark ones. Measured in a browser across all 12 palettes x 6 accents x both modes: text on pills, the rail badge and Compose at least 4.83:1, tinted icons at least 3.14:1 on the sidebar. New strings: 2 ("Show folder list", "Hide folder list"), in all ten catalogs. --- web/src/locales/de.ts | 2 + web/src/locales/es.ts | 2 + web/src/locales/fr.ts | 2 + web/src/locales/ja.ts | 2 + web/src/locales/nl.ts | 2 + web/src/locales/pt-BR.ts | 2 + web/src/locales/ru.ts | 2 + web/src/locales/tr.ts | 2 + web/src/locales/uk.ts | 2 + web/src/locales/zh-Hans.ts | 2 + web/src/styles/__tests__/app-rail.test.ts | 49 ++++++++++++ web/src/styles/app.css | 76 +++++++++++++++++++ web/src/views/AppShell.tsx | 57 +++++++++++--- web/src/views/mail/MailboxTree.tsx | 2 +- .../mail/__tests__/folder-role-tint.test.tsx | 54 +++++++++++++ 15 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 web/src/styles/__tests__/app-rail.test.ts create mode 100644 web/src/views/mail/__tests__/folder-role-tint.test.tsx diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 7c93a5e..96e81eb 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -739,6 +739,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", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index afcfab0..ff23a64 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -734,6 +734,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", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index f44a445..19be1d5 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -740,6 +740,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", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index cf3929a..c343e85 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -734,6 +734,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "設定", + "Show folder list": "フォルダー一覧を表示", + "Hide folder list": "フォルダー一覧を隠す", "All settings": "すべての設定", "Sections": "セクション", "General": "一般", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 45f4d56..ea4bfd3 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -735,6 +735,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", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 8d77ad7..9a9cc50 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -737,6 +737,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", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index b83b335..9d04f7f 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -737,6 +737,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "Настройки", + "Show folder list": "Показать список папок", + "Hide folder list": "Скрыть список папок", "All settings": "Все настройки", "Sections": "Разделы", "General": "Общие", diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index 31a7efd..dc2de45 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -1096,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", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 885a7ea..0b4b5a1 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -731,6 +731,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "Налаштування", + "Show folder list": "Показати список тек", + "Hide folder list": "Сховати список тек", "All settings": "Усі налаштування", "Sections": "Розділи", "General": "Загальні", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index be4f9ea..4b00508 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -733,6 +733,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "设置", + "Show folder list": "显示文件夹列表", + "Hide folder list": "隐藏文件夹列表", "All settings": "全部设置", "Sections": "分区", "General": "常规", 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..a065e99 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); } 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/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/__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"])); + }); +}); -- 2.54.0 From d2b3fda9a40bd49a86341e120f6e0817893716f8 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 22:03:29 -0700 Subject: [PATCH 4/5] Show the language model's opinion on a message When Stalwart's AI spam classification is on, the spam filter records the model's answer in an X-Spam-LLM header: the category it chose and, in parentheses, its explanation. The full message now asks for it, and where it's there: - the message details show "Language model's opinion" beside the spam filter's own working, with the category and explanation (and a confidence, where the header carries one in the LLM__ form); - a message in Junk carries a banner saying the same. Both say it's one of several signals the spam filter weighed, never the reason on its own. The explanation is model output and is only ever rendered as text. Nothing shows without the header, so a server without the feature, or with it off, looks as before. New strings: 2 ("Language model's opinion", "One of several signals the spam filter weighed"), in nine catalogs; Turkish follows with the rest of this update's strings. --- web/src/jmap/types.ts | 2 + web/src/lib/__tests__/llmOpinion.test.ts | 52 +++++++++++ web/src/lib/llmOpinion.ts | 90 +++++++++++++++++++ web/src/locales/de.ts | 3 + web/src/locales/es.ts | 3 + web/src/locales/fr.ts | 3 + web/src/locales/ja.ts | 3 + web/src/locales/nl.ts | 3 + web/src/locales/pt-BR.ts | 3 + web/src/locales/ru.ts | 3 + web/src/locales/uk.ts | 3 + web/src/locales/zh-Hans.ts | 3 + web/src/store/mail/props.ts | 3 + web/src/styles/app.css | 7 ++ web/src/views/mail/LlmOpinion.tsx | 66 ++++++++++++++ web/src/views/mail/MessageView.tsx | 8 ++ .../views/mail/__tests__/llm-opinion.test.tsx | 77 ++++++++++++++++ 17 files changed, 332 insertions(+) create mode 100644 web/src/lib/__tests__/llmOpinion.test.ts create mode 100644 web/src/lib/llmOpinion.ts create mode 100644 web/src/views/mail/LlmOpinion.tsx create mode 100644 web/src/views/mail/__tests__/llm-opinion.test.tsx 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 96e81eb..21de1d1 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1722,6 +1722,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 ff23a64..05c2010 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1695,6 +1695,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 19be1d5..e4d7c39 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1700,6 +1700,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 c343e85..19cfcdf 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1703,6 +1703,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 ea4bfd3..7810754 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1695,6 +1695,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 9a9cc50..6560364 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1698,6 +1698,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 9d04f7f..045034d 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1697,6 +1697,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/uk.ts b/web/src/locales/uk.ts index 0b4b5a1..cbc88ae 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1691,6 +1691,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 4b00508..4046180 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1702,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/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/styles/app.css b/web/src/styles/app.css index a065e99..f8f7ce2 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -2474,6 +2474,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/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/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__/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(); + }); +}); -- 2.54.0 From 0190262bba33cfaafc762d3f4c3a5768f0270ad3 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 20 Oct 2026 13:32:14 -0700 Subject: [PATCH 5/5] Templates remember who they're for A template saved a subject and a body, so a message sent to the same people every time (an invoice, a weekly report) had to be addressed by hand each time. A template can now hold To, Cc and Bcc as well: - Settings > Templates has the three fields, with the composer's own suggestions, and each template's card lists who it adds; - inserting it adds them to the message beside anyone already there; an address already on the message, in any field, isn't added twice, and the Cc and Bcc rows open when the template uses them; - {{recipientName}} and the other placeholders see the template's own recipients, so "Dear {{recipientFirstName}}" names the person it's for; - "Save as template" in the composer keeps the message's recipients. Templates saved before this have none and insert exactly as they did. New strings: 5 ("To (optional)", "Cc (optional)", "Bcc (optional)", "Adds: {recipients}" and the field hint), in all ten catalogs. Turkish also gains the two strings for the language model's opinion. Fixes #58 --- web/src/locales/de.ts | 5 ++ web/src/locales/es.ts | 5 ++ web/src/locales/fr.ts | 5 ++ web/src/locales/ja.ts | 5 ++ web/src/locales/nl.ts | 5 ++ web/src/locales/pt-BR.ts | 5 ++ web/src/locales/ru.ts | 5 ++ web/src/locales/tr.ts | 7 ++ web/src/locales/uk.ts | 5 ++ web/src/locales/zh-Hans.ts | 5 ++ .../__tests__/template-recipients.test.ts | 83 +++++++++++++++++++ web/src/store/compose.ts | 24 ++++-- web/src/store/settings.ts | 5 ++ web/src/styles/app.css | 3 + web/src/views/compose/Composer.tsx | 4 +- web/src/views/settings/TemplatesSettings.tsx | 22 +++++ 16 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 web/src/store/__tests__/template-recipients.test.ts diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 21de1d1..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", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 05c2010..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", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index e4d7c39..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", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 19cfcdf..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": "ファイルを添付", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 7810754..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", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 6560364..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", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 045034d..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": "Прикрепить файлы", diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index dc2de45..a2259a7 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -1195,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 cbc88ae..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": "Прикріпити файли", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 4046180..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": "添加附件", 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 bbbe105..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; @@ -785,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/settings.ts b/web/src/store/settings.ts index d805dbc..179206e 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -7,6 +7,7 @@ import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync"; import { policyChanges, policyDefaults, policyEnforced, type PolicyChange } from "@/lib/settingsPolicy"; import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime"; import type { SwipeAction } from "@/lib/input/swipe"; +import type { EmailAddress } from "@/jmap/types"; import { resolveUiLanguage } from "@/lib/languages"; import { loadLanguage } from "@/lib/i18n"; @@ -55,6 +56,10 @@ export interface Template { name: string; subject: string; html: string; + /** Recipients added to the message when the template is inserted. Older templates have none. */ + to?: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; } /** A signer remembered for an address. See `knownSigners`. */ diff --git a/web/src/styles/app.css b/web/src/styles/app.css index f8f7ce2..4f1deac 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1681,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); } 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/settings/TemplatesSettings.tsx b/web/src/views/settings/TemplatesSettings.tsx index c48e7d5..2267128 100644 --- a/web/src/views/settings/TemplatesSettings.tsx +++ b/web/src/views/settings/TemplatesSettings.tsx @@ -3,6 +3,8 @@ import { Plus, Trash2 } from "lucide-react"; import { useSettings, type Template } from "@/store/settings"; import { Dialog } from "@/ui/dialog"; import { RichEditor } from "../compose/RichEditor"; +import { RecipientInput } from "../compose/RecipientInput"; +import { formatAddress } from "@/lib/address"; import { htmlToText } from "@/lib/text/text"; import { t as translate } from "@/lib/i18n"; import { PLACEHOLDER_NAMES } from "@/lib/templatePlaceholders"; @@ -22,6 +24,7 @@ export function TemplatesSettings() {
{t.subject &&
{translate("Subject: {subject}", { subject: t.subject })}
} + {recipientCount(t) > 0 &&
{translate("Adds: {recipients}", { recipients: [...(t.to ?? []), ...(t.cc ?? []), ...(t.bcc ?? [])].map(formatAddress).join(", ") })}
}
{htmlToText(t.html).slice(0, 140)}
))} @@ -32,6 +35,21 @@ export function TemplatesSettings() {
setEditing({ ...editing, name: e.target.value })} autoFocus />
setEditing({ ...editing, subject: e.target.value })} />
+
+ + setEditing({ ...editing, to })} exclude={[...(editing.cc ?? []), ...(editing.bcc ?? [])]} /> +
+
+
+ + setEditing({ ...editing, cc })} exclude={[...(editing.to ?? []), ...(editing.bcc ?? [])]} /> +
+
+ + setEditing({ ...editing, bcc })} exclude={[...(editing.to ?? []), ...(editing.cc ?? [])]} /> +
+
+

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

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