diff --git a/FEATURES.md b/FEATURES.md index 09c9c81..9902edc 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); + } + }); });