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:
+30
-1
@@ -719,7 +719,7 @@ not reach another that already has ihasmail open until it signs in again.
|
|||||||
| Section | Holds |
|
| 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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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
|
under "Reading", the read-receipt policy under "Composing", the undo-send window
|
||||||
beside the default message format.
|
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
|
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
|
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.
|
removed by finding another message from the same sender.
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,22 @@ export interface Settings {
|
|||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
showHiddenFolders: boolean;
|
showHiddenFolders: boolean;
|
||||||
trustedImageSenders: string[];
|
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;
|
archiveOnReply: boolean;
|
||||||
autoAdvance: "newer" | "older" | "list";
|
autoAdvance: "newer" | "older" | "list";
|
||||||
spellcheck: boolean;
|
spellcheck: boolean;
|
||||||
@@ -217,6 +233,12 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
showHiddenFolders: false,
|
showHiddenFolders: false,
|
||||||
trustedImageSenders: [],
|
trustedImageSenders: [],
|
||||||
|
externalSenderBanner: false,
|
||||||
|
externalRecipientConfirm: false,
|
||||||
|
internalDomains: [],
|
||||||
|
replyAllThreshold: 0,
|
||||||
|
externalLinkWarning: false,
|
||||||
|
trustedLinkDomains: [],
|
||||||
archiveOnReply: false,
|
archiveOnReply: false,
|
||||||
autoAdvance: "list",
|
autoAdvance: "list",
|
||||||
spellcheck: true,
|
spellcheck: true,
|
||||||
|
|||||||
@@ -1398,3 +1398,6 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
|||||||
|
|
||||||
/* The senders whose remote images load without asking, in Privacy & safety. */
|
/* The senders whose remote images load without asking, in Privacy & safety. */
|
||||||
.trusted-senders { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
.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); }
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
|||||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||||
import { formatSize, formatRelative } from "@/lib/format";
|
import { formatSize, formatRelative } from "@/lib/format";
|
||||||
import { htmlToText, textToHtml } from "@/lib/text";
|
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 { attachmentIcon } from "../mail/MessageView";
|
||||||
import { FilePicker } from "./FilePicker";
|
import { FilePicker } from "./FilePicker";
|
||||||
import { RecipientPicker, type Field } from "./RecipientPicker";
|
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") });
|
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;
|
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 [email protected]" 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);
|
await send(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ import { useCalendar } from "@/store/calendar";
|
|||||||
import { startAppointment } from "@/lib/appointment";
|
import { startAppointment } from "@/lib/appointment";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { emlFilename } from "@/lib/emlName";
|
import { emlFilename } from "@/lib/emlName";
|
||||||
|
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
|
||||||
import { spamReport, type SpamReport } from "@/lib/spamScore";
|
import { spamReport, type SpamReport } from "@/lib/spamScore";
|
||||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
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 { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, sanitizeEmailHtml } from "@/lib/html";
|
||||||
import { openableInTab, previewKind } from "@/lib/preview";
|
import { openableInTab, previewKind } from "@/lib/preview";
|
||||||
import { FilePreviewDialog } from "@/ui/filepreview";
|
import { FilePreviewDialog } from "@/ui/filepreview";
|
||||||
import { findQuoteStart, textToHtml } from "@/lib/text";
|
import { findQuoteStart, textToHtml } from "@/lib/text";
|
||||||
import { Avatar } from "@/ui/misc";
|
import { Avatar } from "@/ui/misc";
|
||||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
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 { toast } from "@/ui/toast";
|
||||||
import type { ListActions } from "./MessageList";
|
import type { ListActions } from "./MessageList";
|
||||||
import { InviteCard } from "./InviteCard";
|
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 accountId = useMail((s) => s.accountId)!;
|
||||||
const settings = useSettings((s) => s.settings);
|
const settings = useSettings((s) => s.settings);
|
||||||
const updateSettings = useSettings((s) => s.update);
|
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: <strong className="notranslate" translate="no">{verdict.shownDomain}</strong>,
|
||||||
|
actual: <strong className="notranslate" translate="no">{verdict.domain}</strong>,
|
||||||
|
})
|
||||||
|
: tNode("The full address is {href}.", { href: <span className="mono small notranslate" translate="no">{href}</span> }),
|
||||||
|
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 reply = useCompose((s) => s.reply);
|
||||||
const cardRef = useRef<HTMLElement>(null);
|
const cardRef = useRef<HTMLElement>(null);
|
||||||
const [details, setDetails] = useState(false);
|
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 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 authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
|
||||||
const spam = useMemo(() => spamReport(e), [e]);
|
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 () => {
|
const openSource = async () => {
|
||||||
setShowSource(true);
|
setShowSource(true);
|
||||||
@@ -322,6 +386,16 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{externalSender && (
|
||||||
|
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
|
||||||
|
<ShieldAlert size={16} />
|
||||||
|
<span className="grow">
|
||||||
|
{tNode("This message came from {domain}, which is outside your organisation.", {
|
||||||
|
domain: <strong className="notranslate" translate="no">{domainOf(from?.email ?? "")}</strong>,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
|
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
|
||||||
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
|
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
|
||||||
<ImageIcon size={16} />
|
<ImageIcon size={16} />
|
||||||
@@ -333,7 +407,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
|||||||
{icsPart && <InviteCard email={e} part={icsPart} />}
|
{icsPart && <InviteCard email={e} part={icsPart} />}
|
||||||
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
|
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
|
||||||
<div className="message-body">
|
<div className="message-body">
|
||||||
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} onShowImages={showImages} /> : <TextBody text={textRaw ?? ""} />}
|
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} onShowImages={showImages} onFollowLink={linkGuard} /> : <TextBody text={textRaw ?? ""} onFollowLink={linkGuard} />}
|
||||||
</div>
|
</div>
|
||||||
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
|
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
|
||||||
{unsubscribe && (
|
{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"];
|
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<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const [hasQuote, setHasQuote] = useState(false);
|
const [hasQuote, setHasQuote] = useState(false);
|
||||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||||
@@ -411,13 +485,18 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (onFollowLink && /^https?:/i.test(href)) {
|
||||||
|
ev.preventDefault();
|
||||||
|
onFollowLink(href, a.textContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
a.setAttribute("target", "_blank");
|
a.setAttribute("target", "_blank");
|
||||||
a.setAttribute("rel", "noopener noreferrer nofollow");
|
a.setAttribute("rel", "noopener noreferrer nofollow");
|
||||||
}
|
}
|
||||||
const img = t.closest("img[data-ihm-blocked]");
|
const img = t.closest("img[data-ihm-blocked]");
|
||||||
if (img) onShowImages();
|
if (img) onShowImages();
|
||||||
},
|
},
|
||||||
[openCompose, onShowImages],
|
[openCompose, onShowImages, onFollowLink],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||||
const openCompose = useCompose((s) => s.open);
|
const openCompose = useCompose((s) => s.open);
|
||||||
@@ -535,14 +614,20 @@ function TextBody({ text }: { text: string }) {
|
|||||||
root.innerHTML = `<style>${TEXT_EMAIL_CSS}</style><div class="ihm-text-root">${textToHtml(main)}${quoted ? `<div class="ihm-quoted" ${quoteOpen ? "" : "hidden"}>\n${textToHtml(quoted)}</div>` : ""}</div>`;
|
root.innerHTML = `<style>${TEXT_EMAIL_CSS}</style><div class="ihm-text-root">${textToHtml(main)}${quoted ? `<div class="ihm-quoted" ${quoteOpen ? "" : "hidden"}>\n${textToHtml(quoted)}</div>` : ""}</div>`;
|
||||||
const onClick = (ev: Event) => {
|
const onClick = (ev: Event) => {
|
||||||
const a = (ev.target as HTMLElement).closest("a");
|
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();
|
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);
|
root.addEventListener("click", onClick);
|
||||||
return () => root.removeEventListener("click", onClick);
|
return () => root.removeEventListener("click", onClick);
|
||||||
}, [main, quoted, quoteOpen, openCompose]);
|
}, [main, quoted, quoteOpen, openCompose, onFollowLink]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useSettings, type ReadReceiptPolicy } from "@/store/settings";
|
import { useSettings, type ReadReceiptPolicy } from "@/store/settings";
|
||||||
|
import { useMail } from "@/store/mail";
|
||||||
|
import { domainOf } from "@/lib/address";
|
||||||
import { Switch } from "@/ui/misc";
|
import { Switch } from "@/ui/misc";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
@@ -22,6 +25,8 @@ export function PrivacySettings() {
|
|||||||
const s = useSettings((st) => st.settings);
|
const s = useSettings((st) => st.settings);
|
||||||
const update = useSettings((st) => st.update);
|
const update = useSettings((st) => st.update);
|
||||||
const trusted = s.trustedImageSenders;
|
const trusted = s.trustedImageSenders;
|
||||||
|
const identities = useMail((st) => st.identities);
|
||||||
|
const ownDomains = [...new Set(identities.map((i) => domainOf(i.email)).filter(Boolean))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -74,6 +79,60 @@ export function PrivacySettings() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2>{t("Warnings")}</h2>
|
||||||
|
<p className="hint" style={{ marginTop: -8 }}>
|
||||||
|
{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.")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={s.externalSenderBanner}
|
||||||
|
onChange={(v) => 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.")}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={s.externalRecipientConfirm}
|
||||||
|
onChange={(v) => update({ externalRecipientConfirm: v })}
|
||||||
|
label={t("Ask before sending outside")}
|
||||||
|
hint={t("Names the outside recipients and asks, rather than refusing.")}
|
||||||
|
/>
|
||||||
|
{(s.externalSenderBanner || s.externalRecipientConfirm) && (
|
||||||
|
<DomainList
|
||||||
|
label={t("Also count these domains as inside")}
|
||||||
|
hint={t("Your own identity domains are always inside and do not need listing. A domain here also covers its subdomains.")}
|
||||||
|
value={s.internalDomains}
|
||||||
|
onChange={(internalDomains) => update({ internalDomains })}
|
||||||
|
suggestions={ownDomains}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>{t("Ask before sending to a large group")}</label>
|
||||||
|
<select className="select" value={String(s.replyAllThreshold)} onChange={(e) => update({ replyAllThreshold: Number(e.target.value) })}>
|
||||||
|
<option value="0">{t("Never ask")}</option>
|
||||||
|
<option value="5">{t("5 people or more")}</option>
|
||||||
|
<option value="10">{t("10 people or more")}</option>
|
||||||
|
<option value="20">{t("20 people or more")}</option>
|
||||||
|
<option value="50">{t("50 people or more")}</option>
|
||||||
|
</select>
|
||||||
|
<p className="hint">{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.")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={s.externalLinkWarning}
|
||||||
|
onChange={(v) => 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 && (
|
||||||
|
<DomainList
|
||||||
|
label={t("Open links to these domains without asking")}
|
||||||
|
hint={t("Added here, or from the dialog when a link is opened. A domain also covers its subdomains.")}
|
||||||
|
value={s.trustedLinkDomains}
|
||||||
|
onChange={(trustedLinkDomains) => update({ trustedLinkDomains })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<h2>{t("Before it happens")}</h2>
|
<h2>{t("Before it happens")}</h2>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>{t("Undo send window")}</label>
|
<label>{t("Undo send window")}</label>
|
||||||
@@ -91,3 +150,78 @@ export function PrivacySettings() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className="field">
|
||||||
|
<label>{label}</label>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className="trusted-senders">
|
||||||
|
{value.map((d) => (
|
||||||
|
<span key={d} className="chip">
|
||||||
|
<span className="notranslate" translate="no">{d}</span>
|
||||||
|
<button className="chip-x" aria-label={t("Remove {domain}", { domain: d })} onClick={() => onChange(value.filter((x) => x !== d))}>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="row gap-4">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft}
|
||||||
|
placeholder={t("example.com")}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
add(draft);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-sm" disabled={!draft.trim()} onClick={() => add(draft)}>{t("Add")}</button>
|
||||||
|
</div>
|
||||||
|
{missing.length > 0 && (
|
||||||
|
<p className="hint">
|
||||||
|
{t("Your own:")}{" "}
|
||||||
|
{missing.map((d) => (
|
||||||
|
<button key={d} className="link-btn notranslate" translate="no" onClick={() => add(d)}>{d}</button>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="hint">{hint}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,4 +104,53 @@ describe("Privacy & safety", () => {
|
|||||||
});
|
});
|
||||||
expect(useSettings.getState().settings.trustedImageSenders).toEqual(["[email protected]"]);
|
expect(useSettings.getState().settings.trustedImageSenders).toEqual(["[email protected]"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers the three warnings, all switched off", async () => {
|
||||||
|
await render(<PrivacySettings />);
|
||||||
|
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(<PrivacySettings />);
|
||||||
|
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(<PrivacySettings />);
|
||||||
|
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(<PrivacySettings />);
|
||||||
|
const input = host.querySelector<HTMLInputElement>('input.input');
|
||||||
|
expect(input, "domain input").toBeTruthy();
|
||||||
|
|
||||||
|
for (const [typed, stored] of [["@Example.com", "example.com"], ["[email protected]", "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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user