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:
Coffey Labs
2026-09-01 22:48:12 -07:00
committed by GitHub
9 changed files with 665 additions and 11 deletions
+134
View File
@@ -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);
}
});
});