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 [email protected] 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.
This commit is contained in:
2026-09-01 22:45:22 -07:00
parent 4c54eb74f9
commit 5b18f1d5d7
9 changed files with 665 additions and 11 deletions
+155
View File
@@ -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 [email protected] warning that example.com is
// external would be absurd, and is what an empty list would do.
const d = internalDomains(["[email protected]", "[email protected]"], []);
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(["[email protected]"], []);
it("returns only those outside, in the order addressed", () => {
const out = externalRecipients(
[addr("[email protected]"), addr("[email protected]"), addr("[email protected]"), addr("[email protected]")],
internal,
);
expect(out.map((a) => a.email)).toEqual(["[email protected]", "[email protected]"]);
});
it("is empty when everyone is inside", () => {
expect(externalRecipients([addr("[email protected]")], internal)).toEqual([]);
});
});
describe("isExternalSender", () => {
const internal = internalDomains(["[email protected]"], []);
it("reads the first From address", () => {
expect(isExternalSender([addr("[email protected]")], internal)).toBe(true);
expect(isExternalSender([addr("[email protected]")], 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:[email protected]", 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" });
});
});
+145
View File
@@ -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 `[email protected]` 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<string>, configured: Iterable<string>): Set<string> {
const out = new Set<string>();
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<string>): 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<EmailAddress>, internal: Set<string>): 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<string>): 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<string>): 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<string>();
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;
}
}