Merge pull request #202 from Coffey-Labs/feat/sender-link-warnings
Warn about outside senders, large sends and links that mislead
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