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:
@@ -1,4 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useSettings, type ReadReceiptPolicy } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { domainOf } from "@/lib/address";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { X } from "lucide-react";
|
||||
import { t } from "@/lib/i18n";
|
||||
@@ -22,6 +25,8 @@ export function PrivacySettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
const update = useSettings((st) => st.update);
|
||||
const trusted = s.trustedImageSenders;
|
||||
const identities = useMail((st) => st.identities);
|
||||
const ownDomains = [...new Set(identities.map((i) => domainOf(i.email)).filter(Boolean))];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -74,6 +79,60 @@ export function PrivacySettings() {
|
||||
</p>
|
||||
</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>
|
||||
<div className="field">
|
||||
<label>{t("Undo send window")}</label>
|
||||
@@ -91,3 +150,78 @@ export function PrivacySettings() {
|
||||
</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]"]);
|
||||
});
|
||||
|
||||
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