From 5b18f1d5d7d2ebdfe880edc0671a51830c76da91 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 1 Sep 2026 22:45:22 -0700 Subject: [PATCH] Warn about outside senders, large sends and links that mislead Four warnings, in Privacy & safety, and all of them start switched off. That is not timidity. A client that begins by interrupting is one people learn to click through, and a warning clicked through without reading costs the same attention and buys nothing. The outside-sender warning could not be on by default in any case: it measures against the domains that count as yours, and with nothing configured every message in the mailbox is from outside. Your own identity domains are always internal and are not configuration. An account signed in as you@example.com warning that example.com is external would be absurd, and making it be typed in first is a foot-gun that leaves the feature useless the moment it is enabled. Configured domains are additional, and cover their subdomains -- matched on a dot boundary, so example.com covers mail.example.com and not notexample.com, which is exactly the domain somebody registers on purpose. The four: A banner names the sender's domain on a message from outside. Sending outside names the outside recipients and asks, rather than refusing. "This is going outside" is a rule and not something the sender can check; a list of addresses is. It reads the full identity list rather than the visible one, since hiding an identity from the From menu does not make its domain somebody else's. Sending to a large group asks once the count crosses a threshold, which is what catches a reply-all onto a long thread. It counts people rather than headers, so one address in To and nine in Cc is a message to ten. Opening a link asks when the destination is not trusted, and always when the link's text names one domain while its destination is another -- even where that destination is trusted, because being trusted is not the same as being the place the text claimed. On that mismatch the offer to trust the domain is withheld: what would be trusted is the destination, and the destination is not the thing in question. Anything that is not http or https is left alone, since warning about a mailto: is noise and noise is how a warning stops being read. Both bodies are covered, because a link in a plain-text mail is linkified by us and points wherever it likes just as readily as one the sender marked up. The click is cancelled and the navigation re-issued after the answer, since there is no way to hold a real navigation open across a dialog. The reopen runs in the continuation of the dialog's own click, which is still the gesture a popup blocker wants to see. --- FEATURES.md | 31 +++- web/src/lib/__tests__/warnings.test.ts | 155 ++++++++++++++++++ web/src/lib/warnings.ts | 145 ++++++++++++++++ web/src/store/settings.ts | 22 +++ web/src/styles/app.css | 3 + web/src/views/compose/Composer.tsx | 34 +++- web/src/views/mail/MessageView.tsx | 103 +++++++++++- web/src/views/settings/PrivacySettings.tsx | 134 +++++++++++++++ .../__tests__/privacy-settings.test.tsx | 49 ++++++ 9 files changed, 665 insertions(+), 11 deletions(-) create mode 100644 web/src/lib/__tests__/warnings.test.ts create mode 100644 web/src/lib/warnings.ts diff --git a/FEATURES.md b/FEATURES.md index 6191795..fd68f66 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -719,7 +719,7 @@ not reach another that already has ihasmail open until it signs in again. | Section | Holds | | --- | --- | | **General** | Reading pane, mark-as-read delay, auto-advance, conversation view, snippets, avatars; compose format, quoting, signature placement, spell check; time zone, week start, language & region, date format, time format; `mailto:` handler; export / import / reset | -| **Privacy & safety** | Remote images and the senders trusted with them, read receipts asked for and answered, undo-send window, attachment reminder, confirm-before-delete | +| **Privacy & safety** | Remote images and the senders trusted with them, read receipts asked for and answered; the three warnings and the domains they measure against; undo-send window, attachment reminder, confirm-before-delete | | **Appearance** | Theme, accent colour, density, font size, sidebar, swipe actions, interface language | | **Identities & signatures** | Addresses, names, Reply-To, HTML signatures, the default, and which to hide from the picker | | **Filters & rules** | The visual builder and raw Sieve editor | @@ -743,6 +743,35 @@ through General, which had grown five unrelated headings — remote images filed under "Reading", the read-receipt policy under "Composing", the undo-send window beside the default message format. +Three warnings live there, and **all three start switched off**. That is not +timidity: a client that begins by interrupting is one people learn to click +through, and a warning clicked through without reading costs the same attention +and buys nothing. The first could not be on by default in any case — it +measures against the domains that count as yours, and with nothing configured +every message in the mailbox is from outside. + +- **Messages from outside** get a banner naming the sender's domain. Your own + identity domains are always inside and are not configuration; anything listed + is additional, and covers its subdomains. The match is on a dot boundary, so + `example.com` covers `mail.example.com` and not `notexample.com`, which is the + shape somebody registers on purpose. +- **Sending outside** names the outside recipients and asks, rather than + refusing. A rule — "this is going outside" — is not something the sender can + check; a list of addresses is. +- **Sending to a large group** asks once the count crosses a threshold you set, + which catches a reply-all onto a long thread. It counts people rather than + headers, so one address in To and nine in Cc is a message to ten. +- **Opening a link** asks before following it, for a destination not on the + trusted list — and *always* where the link's own text names one domain and + its destination is another, even when that destination is trusted. Being + trusted is not the same as being the place the text claimed. A domain can be + trusted from the dialog, except on that mismatch: what would be trusted there + is the destination, and the destination is not the thing in question. Links + that are not http or https are left alone, since warning about a `mailto:` is + noise, and noise is how a warning stops being read. Both message bodies are + covered — a link in a plain-text mail is linkified by ihasmail and points + wherever it likes just as readily as marked-up one. + The senders trusted with remote images are listed there and can be withdrawn one at a time. Previously a sender was added from a message and could only be removed by finding another message from the same sender. diff --git a/web/src/lib/__tests__/warnings.test.ts b/web/src/lib/__tests__/warnings.test.ts new file mode 100644 index 0000000..a6ea30d --- /dev/null +++ b/web/src/lib/__tests__/warnings.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { + crossesRecipientThreshold, + domainCovered, + externalRecipients, + internalDomains, + isExternalSender, + linkVerdict, + shownDomain, +} from "@/lib/warnings"; + +const addr = (email: string, name: string | null = null) => ({ name, email }); + +describe("internalDomains", () => { + it("always counts your own identities, without them being configured", () => { + // An account signed in as you@example.com warning that example.com is + // external would be absurd, and is what an empty list would do. + const d = internalDomains(["you@example.com", "other@example.org"], []); + expect([...d].sort()).toEqual(["example.com", "example.org"]); + }); + + it("adds configured domains, tolerating a leading @ and stray case", () => { + const d = internalDomains([], ["@Partner.com", " sister.org "]); + expect([...d].sort()).toEqual(["partner.com", "sister.org"]); + }); + + it("ignores empty entries rather than adding an empty domain", () => { + expect(internalDomains(["notanemail"], ["", " ", "@"]).size).toBe(0); + }); +}); + +describe("domainCovered", () => { + const internal = internalDomains([], ["example.com"]); + + it("covers the domain itself and its subdomains", () => { + expect(domainCovered("example.com", internal)).toBe(true); + expect(domainCovered("mail.example.com", internal)).toBe(true); + expect(domainCovered("a.b.example.com", internal)).toBe(true); + }); + + it("does not cover a domain that merely ends with the same letters", () => { + // The whole point of matching on a dot boundary: this is the shape an + // attacker registers. + expect(domainCovered("notexample.com", internal)).toBe(false); + expect(domainCovered("example.com.evil.net", internal)).toBe(false); + }); + + it("is case-insensitive and says no to nothing", () => { + expect(domainCovered("MAIL.EXAMPLE.COM", internal)).toBe(true); + expect(domainCovered("", internal)).toBe(false); + }); +}); + +describe("externalRecipients", () => { + const internal = internalDomains(["you@example.com"], []); + + it("returns only those outside, in the order addressed", () => { + const out = externalRecipients( + [addr("a@example.com"), addr("b@outside.net"), addr("c@mail.example.com"), addr("d@other.org")], + internal, + ); + expect(out.map((a) => a.email)).toEqual(["b@outside.net", "d@other.org"]); + }); + + it("is empty when everyone is inside", () => { + expect(externalRecipients([addr("a@example.com")], internal)).toEqual([]); + }); +}); + +describe("isExternalSender", () => { + const internal = internalDomains(["you@example.com"], []); + + it("reads the first From address", () => { + expect(isExternalSender([addr("ada@outside.net")], internal)).toBe(true); + expect(isExternalSender([addr("colleague@example.com")], internal)).toBe(false); + }); + + it("claims nothing about a message with no sender", () => { + expect(isExternalSender(null, internal)).toBe(false); + expect(isExternalSender([], internal)).toBe(false); + }); +}); + +describe("crossesRecipientThreshold", () => { + it("is off at zero, whatever the count", () => { + expect(crossesRecipientThreshold(500, 0)).toBe(false); + }); + + it("fires at the threshold and above, not below", () => { + expect(crossesRecipientThreshold(9, 10)).toBe(false); + expect(crossesRecipientThreshold(10, 10)).toBe(true); + expect(crossesRecipientThreshold(11, 10)).toBe(true); + }); +}); + +describe("shownDomain", () => { + it("reads a domain out of link text that is a URL or a bare host", () => { + expect(shownDomain("https://example.com/x")).toBe("example.com"); + expect(shownDomain("example.com")).toBe("example.com"); + expect(shownDomain(" WWW.Example.COM ")).toBe("www.example.com"); + }); + + it("reads nothing out of text that is prose", () => { + // "click" and "here" are not claims about a destination. + expect(shownDomain("click here")).toBeNull(); + expect(shownDomain("here")).toBeNull(); + expect(shownDomain("")).toBeNull(); + expect(shownDomain(null)).toBeNull(); + }); +}); + +describe("linkVerdict", () => { + const trusted = ["example.com"]; + + it("says nothing about a trusted destination", () => { + expect(linkVerdict("https://example.com/a", "example.com", trusted)).toEqual({ warn: false }); + expect(linkVerdict("https://mail.example.com/a", null, trusted)).toEqual({ warn: false }); + }); + + it("warns about an untrusted destination", () => { + expect(linkVerdict("https://unknown.net/a", null, trusted)).toEqual({ + warn: true, + reason: "untrusted", + domain: "unknown.net", + }); + }); + + it("warns about a mismatch even when the destination is trusted", () => { + // Trusted is not the same as being the place the text claimed. + expect(linkVerdict("https://example.com/login", "yourbank.com", trusted)).toEqual({ + warn: true, + reason: "mismatch", + domain: "example.com", + shownDomain: "yourbank.com", + }); + }); + + it("treats a subdomain of the claimed domain as no mismatch", () => { + expect(linkVerdict("https://login.yourbank.com/", "yourbank.com", ["yourbank.com"])).toEqual({ warn: false }); + }); + + it("leaves alone anything that is not http or https", () => { + // mailto opens the composer; an anchor goes nowhere. Warning about these + // is noise, and noise is how a warning stops being read. + expect(linkVerdict("mailto:ada@example.com", null, [])).toEqual({ warn: false }); + expect(linkVerdict("#section", null, [])).toEqual({ warn: false }); + expect(linkVerdict("javascript:alert(1)", null, [])).toEqual({ warn: false }); + expect(linkVerdict("not a url at all", null, [])).toEqual({ warn: false }); + }); + + it("warns about everything when nothing is trusted yet", () => { + const v = linkVerdict("https://example.com/a", null, []); + expect(v).toEqual({ warn: true, reason: "untrusted", domain: "example.com" }); + }); +}); diff --git a/web/src/lib/warnings.ts b/web/src/lib/warnings.ts new file mode 100644 index 0000000..2477bdb --- /dev/null +++ b/web/src/lib/warnings.ts @@ -0,0 +1,145 @@ +/** + * The three warnings in Privacy & safety, as decisions rather than dialogs. + * + * All three are **off until switched on**, and that is not timidity. A mail + * client that starts by interrupting is one people learn to click through, and + * a warning clicked through without reading is worse than no warning: it costs + * the same attention and buys nothing. These are for someone who has decided + * they want them. + * + * The external-sender warning could not be on by default anyway. It compares + * against a list of domains that count as yours, and with nothing configured + * every message in the mailbox is from outside. + */ +import { domainOf } from "./address"; +import type { EmailAddress } from "@/jmap/types"; + +/** + * The domains that count as inside. + * + * Your own identities are always internal and are not configuration. An + * account signed in as `you@example.com` warning that `example.com` is + * external would be absurd, and requiring it to be typed in first is a + * foot-gun that makes the feature useless the moment it is switched on. + * Anything in `configured` is additional -- a parent company, a sister domain, + * a contractor. + */ +export function internalDomains(identityEmails: Iterable, configured: Iterable): Set { + const out = new Set(); + for (const e of identityEmails) { + const d = domainOf(e); + if (d) out.add(d); + } + for (const c of configured) { + const d = c.trim().toLowerCase().replace(/^@/, ""); + if (d) out.add(d); + } + return out; +} + +/** + * Whether a domain is covered, allowing subdomains of a listed domain. + * + * The boundary matters: `example.com` covers `mail.example.com` and must not + * cover `notexample.com`, which is exactly the shape an attacker registers. + * So the match is on a dot boundary rather than on `endsWith`. + */ +export function domainCovered(domain: string, internal: Set): boolean { + const d = domain.toLowerCase(); + if (!d) return false; + if (internal.has(d)) return true; + for (const i of internal) if (d.endsWith(`.${i}`)) return true; + return false; +} + +/** Recipients outside the internal domains, in the order they were addressed. */ +export function externalRecipients(addrs: Iterable, internal: Set): EmailAddress[] { + const out: EmailAddress[] = []; + for (const a of addrs) { + if (!a?.email) continue; + if (!domainCovered(domainOf(a.email), internal)) out.push(a); + } + return out; +} + +/** Whether the message came from outside. A message with no sender is not claimed either way. */ +export function isExternalSender(from: EmailAddress[] | null | undefined, internal: Set): boolean { + const first = from?.[0]?.email; + if (!first) return false; + return !domainCovered(domainOf(first), internal); +} + +/** + * Whether a send should stop and ask, given how many people it reaches. + * + * A threshold of 0 is off. The count is people, not headers -- one address in + * To and nine in Cc is a message to ten. + */ +export function crossesRecipientThreshold(recipientCount: number, threshold: number): boolean { + return threshold > 0 && recipientCount >= threshold; +} + +export type LinkVerdict = + | { warn: false } + | { warn: true; reason: "mismatch"; domain: string; shownDomain: string } + | { warn: true; reason: "untrusted"; domain: string }; + +/** + * Whether following a link in a message is worth asking about. + * + * Two different reasons, and the order matters because they are not equally + * serious: + * + * - **mismatch** — the link *says* one domain and goes to another. That is + * the shape of a phishing link rather than merely an unfamiliar one, so it + * is reported even when the destination is trusted: being trusted is not + * the same as being the place the text claimed. + * - **untrusted** — an ordinary link somewhere not on the list yet. + * + * Anything that is not http(s) is left alone. `mailto:` opens the composer and + * in-page anchors go nowhere; warning about those would be noise, and noise is + * how a warning stops being read. + */ +export function linkVerdict(href: string, text: string | null | undefined, trusted: Iterable): LinkVerdict { + let url: URL; + try { + url = new URL(href); + } catch { + return { warn: false }; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return { warn: false }; + const domain = url.hostname.toLowerCase(); + if (!domain) return { warn: false }; + + const shown = shownDomain(text); + if (shown && shown !== domain && !domain.endsWith(`.${shown}`)) { + return { warn: true, reason: "mismatch", domain, shownDomain: shown }; + } + + const list = new Set(); + for (const t of trusted) { + const d = t.trim().toLowerCase().replace(/^@/, ""); + if (d) list.add(d); + } + if (domainCovered(domain, list)) return { warn: false }; + return { warn: true, reason: "untrusted", domain }; +} + +/** + * The domain a link's own text claims, where its text is a URL or a bare + * hostname. Text that is a sentence claims nothing, and is not evidence of + * anything. + */ +export function shownDomain(text: string | null | undefined): string | null { + const s = (text ?? "").trim(); + if (!s || /\s/.test(s)) return null; + try { + const u = new URL(/^[a-z][a-z0-9+.-]*:/i.test(s) ? s : `https://${s}`); + const host = u.hostname.toLowerCase(); + // A bare word is not a hostname. Requiring a dot keeps "click" and + // "here" from being read as domains. + return host.includes(".") ? host : null; + } catch { + return null; + } +} diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index b4944c2..db20de8 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -126,6 +126,22 @@ export interface Settings { sidebarCollapsed: boolean; showHiddenFolders: boolean; trustedImageSenders: string[]; + /** + * The three warnings, each off until switched on. A client that starts by + * interrupting is one people learn to click through, and a warning clicked + * through without reading costs the same attention and buys nothing. + */ + externalSenderBanner: boolean; + externalRecipientConfirm: boolean; + /** + * Domains that count as inside, *in addition to* the account's own identity + * domains, which are always internal and are not configuration. + */ + internalDomains: string[]; + /** People on a message before sending asks. 0 is off. */ + replyAllThreshold: number; + externalLinkWarning: boolean; + trustedLinkDomains: string[]; archiveOnReply: boolean; autoAdvance: "newer" | "older" | "list"; spellcheck: boolean; @@ -217,6 +233,12 @@ export const DEFAULT_SETTINGS: Settings = { sidebarCollapsed: false, showHiddenFolders: false, trustedImageSenders: [], + externalSenderBanner: false, + externalRecipientConfirm: false, + internalDomains: [], + replyAllThreshold: 0, + externalLinkWarning: false, + trustedLinkDomains: [], archiveOnReply: false, autoAdvance: "list", spellcheck: true, diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 3e627eb..92ad004 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1398,3 +1398,6 @@ button.dp-open:disabled { cursor: default; opacity: .5; } /* The senders whose remote images load without asking, in Privacy & safety. */ .trusted-senders { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; } + +/* The banner naming a sender from outside the organisation. */ +.remote-banner.external-banner { background: var(--warn-soft); border-color: var(--warn); } diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index 75eff70..f0e8fc0 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -10,7 +10,8 @@ import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; import { confirmDialog, promptDialog } from "@/ui/dialog"; import { formatSize, formatRelative } from "@/lib/format"; import { htmlToText, textToHtml } from "@/lib/text"; -import { isValidEmail } from "@/lib/address"; +import { isValidEmail, uniqueAddresses } from "@/lib/address"; +import { crossesRecipientThreshold, externalRecipients, internalDomains } from "@/lib/warnings"; import { attachmentIcon } from "../mail/MessageView"; import { FilePicker } from "./FilePicker"; import { RecipientPicker, type Field } from "./RecipientPicker"; @@ -106,6 +107,37 @@ export function Composer({ draft }: { draft: Draft }) { const ok = await confirmDialog({ title: translate("Did you forget the attachment?"), message: translate("Your message mentions an attachment, but nothing is attached."), confirmLabel: translate("Send anyway") }); if (!ok) return; } + /* + * The two send-time warnings, in this order because they answer different + * questions and a message can trip both: who it is going to, then how many + * of them. Both name the specific thing rather than warning in general -- + * "this is going outside" is a rule, "this is going to ada@outside.net" is + * something the sender can check. + */ + if (settings.externalRecipientConfirm) { + // allIdentities, not the visible subset: hiding an identity from the + // picker is about the From menu, and does not make its domain + // somebody else's. + const outside = externalRecipients(all, internalDomains(allIdentities.map((i) => i.email), settings.internalDomains)); + if (outside.length) { + const names = outside.slice(0, 5).map((a) => a.email).join(", "); + const rest = outside.length > 5 ? translate(" and {count} more", { count: String(outside.length - 5) }) : ""; + const ok = await confirmDialog({ + title: translate("Send outside your organisation?"), + message: translate("This goes to {recipients}{rest}.", { recipients: names, rest }), + confirmLabel: translate("Send anyway"), + }); + if (!ok) return; + } + } + if (crossesRecipientThreshold(uniqueAddresses(all).length, settings.replyAllThreshold)) { + const ok = await confirmDialog({ + title: translate("Send to {count} people?", { count: String(uniqueAddresses(all).length) }), + message: translate("Everyone addressed will receive this."), + confirmLabel: translate("Send anyway"), + }); + if (!ok) return; + } await send(key); }; diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 9f3777d..8c7dde1 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -11,16 +11,17 @@ import { useCalendar } from "@/store/calendar"; import { startAppointment } from "@/lib/appointment"; import { client } from "@/jmap/client"; import { emlFilename } from "@/lib/emlName"; +import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings"; import { spamReport, type SpamReport } from "@/lib/spamScore"; import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; -import { displayName, formatAddress } from "@/lib/address"; +import { displayName, domainOf, formatAddress } from "@/lib/address"; import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, sanitizeEmailHtml } from "@/lib/html"; import { openableInTab, previewKind } from "@/lib/preview"; import { FilePreviewDialog } from "@/ui/filepreview"; import { findQuoteStart, textToHtml } from "@/lib/text"; import { Avatar } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; -import { Dialog } from "@/ui/dialog"; +import { Dialog, choiceDialog} from "@/ui/dialog"; import { toast } from "@/ui/toast"; import type { ListActions } from "./MessageList"; import { InviteCard } from "./InviteCard"; @@ -47,6 +48,59 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn const accountId = useMail((s) => s.accountId)!; const settings = useSettings((s) => s.settings); const updateSettings = useSettings((s) => s.update); + + /** Null when the warning is off, so an ordinary link keeps the browser's own handling. */ + const linkGuard = settings.externalLinkWarning ? (href: string, text: string | null) => void followLink(href, text) : null; + + /* + * Following a link out of a message, when the reader has asked to be asked. + * + * The click is cancelled and the navigation re-issued after the answer, + * because there is no way to hold a real navigation open across a dialog. + * `window.open` runs in the continuation of the dialog's own click, which is + * still the user gesture the popup blocker wants to see. + * + * Both message bodies go through here -- the sanitised HTML one and the + * plain-text one -- because a link in a plain-text mail is linkified by us + * and is exactly as capable of pointing somewhere else as one the sender + * marked up. + */ + const followLink = useCallback( + async (href: string, text: string | null) => { + const verdict = linkVerdict(href, text, settings.trustedLinkDomains); + const open = () => window.open(href, "_blank", "noopener,noreferrer"); + if (!verdict.warn) { + open(); + return; + } + const answer = await choiceDialog({ + title: verdict.reason === "mismatch" ? translate("This link does not go where it says") : translate("Open a link to {domain}?", { domain: verdict.domain }), + message: + verdict.reason === "mismatch" + ? tNode("It reads {shown} but goes to {actual}.", { + shown: {verdict.shownDomain}, + actual: {verdict.domain}, + }) + : tNode("The full address is {href}.", { href: {href} }), + choices: [ + { value: "open", label: translate("Open it") }, + // Not offered for a mismatch: what would be trusted is the + // destination, and the destination is not the thing in question. + ...(verdict.reason === "untrusted" + ? [{ value: "always", label: translate("Open, and stop asking about {domain}", { domain: verdict.domain }) }] + : []), + ], + }); + if (answer === "always") { + updateSettings({ trustedLinkDomains: [...settings.trustedLinkDomains, verdict.domain] }); + open(); + } else if (answer === "open") { + open(); + } + }, + [updateSettings, settings.trustedLinkDomains], + ); + const reply = useCompose((s) => s.reply); const cardRef = useRef(null); const [details, setDetails] = useState(false); @@ -115,6 +169,16 @@ 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]); + const identities = useMail((st) => st.identities); + /* + * Only computed when the warning is on, because the domains it compares + * against come from the identities and the settings, and neither is worth + * walking for a reader who has not asked for the banner. + */ + const externalSender = useMemo(() => { + if (!settings.externalSenderBanner) return false; + return isExternalSender(e.from, internalDomains(identities.map((i) => i.email), settings.internalDomains)); + }, [settings.externalSenderBanner, settings.internalDomains, identities, e.from]); const openSource = async () => { setShowSource(true); @@ -322,6 +386,16 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn )} + {externalSender && ( +
+ + + {tNode("This message came from {domain}, which is outside your organisation.", { + domain: {domainOf(from?.email ?? "")}, + })} + +
+ )} {rendered && rendered.remoteCount > 0 && !remoteAllowed && (
@@ -333,7 +407,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn {icsPart && } {vcfParts.map((p) => )}
- {showHtml && rendered ? : } + {showHtml && rendered ? : }
{attachments.length > 0 && } {unsubscribe && ( @@ -390,7 +464,7 @@ function findPart(p: EmailBodyPart | undefined, pred: (p: EmailBodyPart) => bool const QUOTE_SELECTORS = [".gmail_quote", "blockquote[type=cite]", ".moz-cite-prefix", "#divRplyFwdMsg", ".yahoo_quoted", "div[id^=appendonsend]", ".ms-outlook-mobile-reference-message", "#OLK_SRC_BODY_SECTION", ".protonmail_quote", ".ihm-quote"]; -function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bodyStyle: string; themed: boolean; onShowImages: () => void }) { +function HtmlBody({ html, bodyStyle, themed, onShowImages, onFollowLink }: { html: string; bodyStyle: string; themed: boolean; onFollowLink: ((href: string, text: string | null) => void) | null; onShowImages: () => void }) { const hostRef = useRef(null); const [hasQuote, setHasQuote] = useState(false); const [quoteOpen, setQuoteOpen] = useState(false); @@ -411,13 +485,18 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod ev.preventDefault(); return; } + if (onFollowLink && /^https?:/i.test(href)) { + ev.preventDefault(); + onFollowLink(href, a.textContent); + return; + } a.setAttribute("target", "_blank"); a.setAttribute("rel", "noopener noreferrer nofollow"); } const img = t.closest("img[data-ihm-blocked]"); if (img) onShowImages(); }, - [openCompose, onShowImages], + [openCompose, onShowImages, onFollowLink], ); useEffect(() => { @@ -517,7 +596,7 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod ); } -function TextBody({ text }: { text: string }) { +function TextBody({ text, onFollowLink }: { text: string; onFollowLink: ((href: string, text: string | null) => void) | null }) { const hostRef = useRef(null); const [quoteOpen, setQuoteOpen] = useState(false); const openCompose = useCompose((s) => s.open); @@ -535,14 +614,20 @@ function TextBody({ text }: { text: string }) { root.innerHTML = `
${textToHtml(main)}${quoted ? `
\n${textToHtml(quoted)}
` : ""}
`; const onClick = (ev: Event) => { const a = (ev.target as HTMLElement).closest("a"); - if (a && a.getAttribute("href")?.startsWith("mailto:")) { + const href = a?.getAttribute("href") ?? ""; + if (a && href.startsWith("mailto:")) { ev.preventDefault(); - openCompose({ to: [{ name: null, email: a.getAttribute("href")!.slice(7) }] }); + openCompose({ to: [{ name: null, email: href.slice(7) }] }); + return; + } + if (a && onFollowLink && /^https?:/i.test(href)) { + ev.preventDefault(); + void onFollowLink(href, a.textContent); } }; root.addEventListener("click", onClick); return () => root.removeEventListener("click", onClick); - }, [main, quoted, quoteOpen, openCompose]); + }, [main, quoted, quoteOpen, openCompose, onFollowLink]); return ( <> diff --git a/web/src/views/settings/PrivacySettings.tsx b/web/src/views/settings/PrivacySettings.tsx index 94c0cd5..40789e5 100644 --- a/web/src/views/settings/PrivacySettings.tsx +++ b/web/src/views/settings/PrivacySettings.tsx @@ -1,4 +1,7 @@ +import { useState } from "react"; import { useSettings, type ReadReceiptPolicy } from "@/store/settings"; +import { useMail } from "@/store/mail"; +import { domainOf } from "@/lib/address"; import { Switch } from "@/ui/misc"; import { X } from "lucide-react"; import { t } from "@/lib/i18n"; @@ -22,6 +25,8 @@ export function PrivacySettings() { const s = useSettings((st) => st.settings); const update = useSettings((st) => st.update); const trusted = s.trustedImageSenders; + const identities = useMail((st) => st.identities); + const ownDomains = [...new Set(identities.map((i) => domainOf(i.email)).filter(Boolean))]; return (
@@ -74,6 +79,60 @@ export function PrivacySettings() {

+

{t("Warnings")}

+

+ {t("All three start switched off. A client that begins by interrupting is one people learn to click through, and a warning clicked through without reading costs the same attention and buys nothing.")} +

+ + update({ externalSenderBanner: v })} + label={t("Mark messages from outside")} + hint={t("A banner on any message whose sender is not on one of your own domains.")} + /> + update({ externalRecipientConfirm: v })} + label={t("Ask before sending outside")} + hint={t("Names the outside recipients and asks, rather than refusing.")} + /> + {(s.externalSenderBanner || s.externalRecipientConfirm) && ( + update({ internalDomains })} + suggestions={ownDomains} + /> + )} + +
+ + +

{t("Counts people rather than headers, so one address in To and nine in Cc is a message to ten. Catches a reply-all onto a long thread.")}

+
+ + update({ externalLinkWarning: v })} + label={t("Ask before opening a link in a message")} + hint={t("A link whose text names one domain and whose destination is another is always flagged, even where the destination is trusted — being trusted is not the same as being the place the text claimed.")} + /> + {s.externalLinkWarning && ( + update({ trustedLinkDomains })} + /> + )} +

{t("Before it happens")}

@@ -91,3 +150,78 @@ export function PrivacySettings() {
); } + +/** + * A list of domains, added one at a time and removed by their chip. + * + * Typed entries are normalised on the way in -- a leading `@`, stray case, a + * whole address pasted instead of a domain -- because the thing being compared + * against is a hostname, and a list holding "@Example.com " silently matches + * nothing at all. + */ +function DomainList({ + label, + hint, + value, + onChange, + suggestions = [], +}: { + label: string; + hint: string; + value: string[]; + onChange: (next: string[]) => void; + suggestions?: string[]; +}) { + const [draft, setDraft] = useState(""); + const add = (raw: string) => { + const d = raw.trim().toLowerCase().replace(/^@/, "").replace(/^.*@/, "").replace(/^https?:\/\//, "").split("/")[0] ?? ""; + if (!d || value.includes(d)) { + setDraft(""); + return; + } + onChange([...value, d]); + setDraft(""); + }; + const missing = suggestions.filter((d) => !value.includes(d)); + return ( +
+ + {value.length > 0 && ( +
+ {value.map((d) => ( + + {d} + + + ))} +
+ )} +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + add(draft); + } + }} + /> + +
+ {missing.length > 0 && ( +

+ {t("Your own:")}{" "} + {missing.map((d) => ( + + ))} +

+ )} +

{hint}

+
+ ); +} diff --git a/web/src/views/settings/__tests__/privacy-settings.test.tsx b/web/src/views/settings/__tests__/privacy-settings.test.tsx index e0950a8..25e00aa 100644 --- a/web/src/views/settings/__tests__/privacy-settings.test.tsx +++ b/web/src/views/settings/__tests__/privacy-settings.test.tsx @@ -104,4 +104,53 @@ describe("Privacy & safety", () => { }); expect(useSettings.getState().settings.trustedImageSenders).toEqual(["bob@example.com"]); }); + + it("offers the three warnings, all switched off", async () => { + await render(); + const text = host.textContent ?? ""; + expect(text).toContain("Mark messages from outside"); + expect(text).toContain("Ask before sending outside"); + expect(text).toContain("Ask before sending to a large group"); + expect(text).toContain("Ask before opening a link in a message"); + + const s = useSettings.getState().settings; + expect(s.externalSenderBanner).toBe(false); + expect(s.externalRecipientConfirm).toBe(false); + expect(s.externalLinkWarning).toBe(false); + expect(s.replyAllThreshold).toBe(0); + }); + + it("hides each domain list until its warning is switched on", async () => { + await render(); + expect(host.textContent).not.toContain("Also count these domains as inside"); + expect(host.textContent).not.toContain("Open links to these domains without asking"); + + await act(async () => { + useSettings.setState({ settings: { ...DEFAULT_SETTINGS, externalSenderBanner: true, externalLinkWarning: true } }); + }); + await render(); + expect(host.textContent).toContain("Also count these domains as inside"); + expect(host.textContent).toContain("Open links to these domains without asking"); + }); + + it("normalises a typed domain, so the list holds something that can match", async () => { + await act(async () => { + useSettings.setState({ settings: { ...DEFAULT_SETTINGS, externalLinkWarning: true } }); + }); + await render(); + const input = host.querySelector('input.input'); + expect(input, "domain input").toBeTruthy(); + + for (const [typed, stored] of [["@Example.com", "example.com"], ["ada@Partner.ORG", "partner.org"], ["https://third.net/path", "third.net"]]) { + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + setter.call(input!, typed); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + expect(useSettings.getState().settings.trustedLinkDomains).toContain(stored); + } + }); });