import { useState } from "react"; import { useAppName } from "@/lib/brand"; 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"; import { isEnforced } from "@/lib/settingsPolicy"; /** * Everything about what reaches a sender, and what asks before it happens. * * These settings were spread through General, which had grown into five * unrelated headings -- remote images filed under "Reading", the read-receipt * policy under "Composing", the undo-send window beside the default message * format. They are the same kind of decision and they belong together, and * gathering them leaves General smaller as well. * * The boundary against **Security & sessions** is worth keeping sharp, since * two similar words next to each other in a nav is how a menu becomes * something people hunt through: that section is credentials and access -- * password, two-factor, app passwords, live sessions. This one is how the app * behaves toward the reader and toward senders. */ export function PrivacySettings() { const appName = useAppName(); 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 (

{t("Privacy & safety")}

{t("What reaches a sender, and what asks before it happens.")}

{t("Remote content")}

{t("An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.", { app: appName })}

{trusted.length > 0 && (
{trusted.map((addr) => ( {addr} ))}

{t("Added from a message, and removable here — previously the only way to undo one was to find another message from the same sender.")}

)}

{t("Read receipts")}

update({ requestReadReceipt: v })} label={t("Always request read receipts")} />

{t("A receipt tells whoever asked that this address is live and when the message was read, and the sender chooses where it goes — so there is no automatic option. Bulk mail, mailing lists and anything marked auto-submitted are never offered one at all.")}

{t("Warnings")}

{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.")}

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.")} /> update({ externalRecipientConfirm: v })} label={t("Ask before sending outside")} hint={t("Names the outside recipients and asks, rather than refusing.")} /> {(s.externalSenderBanner || s.externalRecipientConfirm) && ( update({ internalDomains })} suggestions={ownDomains} /> )}

{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.")}

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 && ( update({ trustedLinkDomains })} /> )}

{t("Before it happens")}

{t("The message is held in this browser and has not been submitted yet, so taking it back costs nothing.")}

update({ attachmentReminder: v })} label={t("Attachment reminder")} hint={t("Warn when the message mentions an attachment but none is attached.")} /> update({ confirmDelete: v })} label={t("Confirm before deleting")} />
); } /** * A list of domains, added one at a time and removed by their chip. * * Typed entries are normalized 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 (
{value.length > 0 && (
{value.map((d) => ( {d} ))}
)}
setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(draft); } }} />
{missing.length > 0 && (

{t("Your own:")}{" "} {missing.map((d) => ( ))}

)}

{hint}

); }