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:
@@ -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