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
+33 -1
View File
@@ -10,7 +10,8 @@ import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { formatSize, formatRelative } from "@/lib/format";
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 { FilePicker } from "./FilePicker";
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") });
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);
};
+94 -9
View File
@@ -11,16 +11,17 @@ import { useCalendar } from "@/store/calendar";
import { startAppointment } from "@/lib/appointment";
import { client } from "@/jmap/client";
import { emlFilename } from "@/lib/emlName";
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
import { spamReport, type SpamReport } from "@/lib/spamScore";
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 { openableInTab, previewKind } from "@/lib/preview";
import { FilePreviewDialog } from "@/ui/filepreview";
import { findQuoteStart, textToHtml } from "@/lib/text";
import { Avatar } from "@/ui/misc";
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 type { ListActions } from "./MessageList";
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 settings = useSettings((s) => s.settings);
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 cardRef = useRef<HTMLElement>(null);
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 authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
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 () => {
setShowSource(true);
@@ -322,6 +386,16 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
</button>
</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 && (
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
<ImageIcon size={16} />
@@ -333,7 +407,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{icsPart && <InviteCard email={e} part={icsPart} />}
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
<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>
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
{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"];
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 [hasQuote, setHasQuote] = useState(false);
const [quoteOpen, setQuoteOpen] = useState(false);
@@ -411,13 +485,18 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod
ev.preventDefault();
return;
}
if (onFollowLink && /^https?:/i.test(href)) {
ev.preventDefault();
onFollowLink(href, a.textContent);
return;
}
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer nofollow");
}
const img = t.closest("img[data-ihm-blocked]");
if (img) onShowImages();
},
[openCompose, onShowImages],
[openCompose, onShowImages, onFollowLink],
);
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 [quoteOpen, setQuoteOpen] = useState(false);
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>`;
const onClick = (ev: Event) => {
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();
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);
return () => root.removeEventListener("click", onClick);
}, [main, quoted, quoteOpen, openCompose]);
}, [main, quoted, quoteOpen, openCompose, onFollowLink]);
return (
<>
+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);
}
});
});