From 07cfe1310e7ebc49f8bdafa32ad41943af26dc67 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 18:51:34 -0700 Subject: [PATCH] DLP on send: warnings, blocks and held mail in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webmail half of inbuxa's DLP (dlp-and-mail-flow-rules spec, §2.5, §4): - A send the server's DLP rules refuse (inbuxa:dlpWarning or inbuxa:dlpBlocked) comes back to the composer with the rules' notices instead of a generic "Send failed". A warning offers "Send anyway…", which asks for a reason and sends again with inbuxa:dlpOverride; the server records the reason. A block can only be answered by changing the message. - A message DLP held for review says so on sending ("Held for review: it's sent once a reviewer releases it"), from the submission's inbuxa:held. - Tests: the override travels with the submission only when there's a reason; refusals are told apart from other errors. Nine new English strings (the notice labels, the prompt, the toasts); the other catalogs fall back to English until translated. --- web/src/store/__tests__/compose-dlp.test.ts | 39 ++++++++++++ web/src/store/compose.ts | 67 ++++++++++++++++++--- web/src/styles/app.css | 3 + web/src/views/compose/Composer.tsx | 32 ++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 web/src/store/__tests__/compose-dlp.test.ts diff --git a/web/src/store/__tests__/compose-dlp.test.ts b/web/src/store/__tests__/compose-dlp.test.ts new file mode 100644 index 0000000..7844567 --- /dev/null +++ b/web/src/store/__tests__/compose-dlp.test.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// inbuxa: DLP on send: the override travels with the submission, and the +// server's refusals are told apart from other errors. +import { describe, expect, it } from "vitest"; +import { buildSubmission, dlpRefusalOf } from "../compose"; + +const base = { + identityId: "i1", + fromEmail: "dana@example.com", + emailRef: "#m", + rcpts: [{ email: "x@elsewhere.org" }], + sentId: "sent", + draftsId: "drafts", + scheduledId: null, + sendAt: null, +}; + +describe("DLP on send", () => { + it("sends the override reason only when there is one", () => { + expect(buildSubmission(base).create["inbuxa:dlpOverride"]).toBeUndefined(); + expect(buildSubmission({ ...base, dlpOverride: " " }).create["inbuxa:dlpOverride"]).toBeUndefined(); + expect(buildSubmission({ ...base, dlpOverride: " Client asked " }).create["inbuxa:dlpOverride"]).toEqual({ reason: "Client asked" }); + }); + + it("recognizes the server's refusals", () => { + expect(dlpRefusalOf({ type: "inbuxa:dlpWarning", rules: [{ name: "Cards", notice: "Looks like a card." }] })).toEqual({ + kind: "warning", + rules: [{ name: "Cards", notice: "Looks like a card." }], + }); + expect(dlpRefusalOf({ type: "inbuxa:dlpBlocked" })).toEqual({ kind: "blocked", rules: [] }); + expect(dlpRefusalOf({ type: "forbiddenToSend" })).toBeNull(); + expect(dlpRefusalOf(undefined)).toBeNull(); + }); +}); diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index d48776d..eef8539 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -95,6 +95,37 @@ export interface Draft { mailboxIdOnSend?: Id | null; /** When set, hand the message to the server held until this instant. */ sendAt: number | null; + /** + * The server's DLP rules refused the last send (inbuxa): a warning the + * sender may answer with a reason, or a block. The composer shows it. + */ + dlp?: DlpRefusalInfo | null; + /** The reason to send despite a DLP warning, for the next send only. */ + dlpOverride?: string | null; +} + +export interface DlpRefusalInfo { + kind: "warning" | "blocked"; + rules: { name: string; notice: string }[]; +} + +/** + * A send the server's DLP rules refused (inbuxa: `inbuxa:dlpWarning`, + * `inbuxa:dlpBlocked`): which rules, and what they say. + */ +export class DlpRefusal extends Error { + constructor(public info: DlpRefusalInfo) { + super(info.rules.map((r) => r.notice).join(" ") || translate("This message wasn't sent under the server's rules")); + } +} + +/** The DLP refusal in a submission's set error, if it is one. */ +export function dlpRefusalOf(err: { type?: string; rules?: { name?: string; notice?: string }[] } | undefined): DlpRefusalInfo | null { + if (!err || (err.type !== "inbuxa:dlpWarning" && err.type !== "inbuxa:dlpBlocked")) return null; + return { + kind: err.type === "inbuxa:dlpWarning" ? "warning" : "blocked", + rules: (err.rules ?? []).map((r) => ({ name: r.name ?? "", notice: r.notice ?? "" })), + }; } interface ComposeState { @@ -723,9 +754,21 @@ export const useCompose = create((set, get) => ({ return { pendingSends: rest }; }); try { - await sendInternal(d, get); - toast.success(scheduling ? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) }) : translate("Message sent")); + const sent = await sendInternal(d, get); + toast.success( + sent.held + ? translate("Held for review: it's sent once a reviewer releases it") + : scheduling + ? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) }) + : translate("Message sent"), + ); } catch (err) { + // inbuxa: DLP refused it: the draft comes back with the rules' notices + if (err instanceof DlpRefusal) { + set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: null, dlp: err.info, dlpOverride: null }], activeKey: d.key })); + toast.error(err.info.kind === "warning" ? translate("Not sent yet: check the warning") : translate("Not sent: blocked by the server's rules")); + return; + } toast.error(translate("Send failed: {error}", { error: (err as Error).message }), { action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) }, duration: 15000, @@ -1020,6 +1063,8 @@ export function buildSubmission(opts: { draftsId: Id | null; scheduledId: Id | null; sendAt: number | null; + /** inbuxa: the sender's reason to send despite a DLP warning. */ + dlpOverride?: string | null; }): { create: Record; onSuccessUpdateEmail: Record } { const scheduled = opts.sendAt !== null; const mailFrom: Record = { email: opts.fromEmail }; @@ -1029,13 +1074,17 @@ export function buildSubmission(opts: { if (filedIn) onSuccess[`mailboxIds/${filedIn}`] = true; if (opts.draftsId && opts.draftsId !== filedIn) onSuccess[`mailboxIds/${opts.draftsId}`] = null; if (scheduled && opts.sentId && opts.sentId !== filedIn) onSuccess[`mailboxIds/${opts.sentId}`] = null; - return { - create: { identityId: opts.identityId, emailId: opts.emailRef, envelope: { mailFrom, rcptTo: opts.rcpts } }, - onSuccessUpdateEmail: onSuccess, - }; + const create: Record = { identityId: opts.identityId, emailId: opts.emailRef, envelope: { mailFrom, rcptTo: opts.rcpts } }; + if (opts.dlpOverride?.trim()) create["inbuxa:dlpOverride"] = { reason: opts.dlpOverride.trim() }; + return { create, onSuccessUpdateEmail: onSuccess }; } -async function sendInternal(d: Draft, _get: () => ComposeState): Promise { +/** What the server said of a send: whether DLP held it for review (inbuxa). */ +interface Sent { + held: boolean; +} + +async function sendInternal(d: Draft, _get: () => ComposeState): Promise { const mail = useMail.getState(); const accountId = mail.accountId!; const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0]; @@ -1057,6 +1106,7 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise { draftsId, scheduledId, sendAt: scheduled ? d.sendAt : null, + dlpOverride: d.dlpOverride ?? null, }); const calls: Array<[string, Record, string]> = [ ["Email/set", { accountId, create: { m: email }, ...(d.draftId ? { destroy: [d.draftId] } : {}) }, "e"], @@ -1080,6 +1130,8 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise { // Clean up the created (unsent) email so it doesn't linger in Sent. const created = e.created?.m?.id; if (created) void client.call("Email/set", { accountId, destroy: [created] }); + const dlp = dlpRefusalOf(err as { type?: string; rules?: { name?: string; notice?: string }[] }); + if (dlp) throw new DlpRefusal(dlp); throw new Error(setErrorMessage(err)); } if (d.relatedEmailId && d.relatedKeyword) { @@ -1101,6 +1153,7 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise { } void mail.loadMailboxes(); void mail.refreshList(); + return { held: (s.created?.s as { "inbuxa:held"?: boolean } | undefined)?.["inbuxa:held"] === true }; } export { FULL_PROPS, BODY_PROPS }; diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 55a5eef..54033f7 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1587,6 +1587,9 @@ a.menu-item:hover { color: var(--fg); } /* An offer the draft makes about itself, above the editor: quiet, one line, and dismissible. */ .composer-notice { display: flex; align-items: center; gap: 8px; padding: 6px 10px 6px 14px; background: var(--accent-soft); color: var(--accent-soft-fg); border-bottom: 1px solid var(--border); font-size: .85em; flex: 0 0 auto; } .composer-notice span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* inbuxa: a DLP warning or block on the last send */ +.composer-notice-dlp { background: var(--warn-soft); color: var(--fg); } +.composer-notice-dlp span { white-space: normal; } .composer-fields { flex: 0 0 auto; padding: 0 12px; } .composer-field { display: flex; align-items: center; gap: 8px; min-height: 40px; border-bottom: 1px solid var(--border); padding: 4px 0; } .composer-field > label { color: var(--fg-muted); width: 42px; flex: 0 0 auto; font-size: .92em; } diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index a08f4f7..0ee071f 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -141,6 +141,19 @@ export function Composer({ draft }: { draft: Draft }) { await send(key); }; + // inbuxa: DLP warned: send anyway with a reason the server records + const sendAnyway = async () => { + const reason = await promptDialog({ + title: translate("Send anyway?"), + message: translate("Your reason is recorded with the message."), + placeholder: translate("Why this needs to go"), + confirmLabel: translate("Send anyway"), + }); + if (!reason?.trim()) return; + patch({ dlp: null, dlpOverride: reason.trim() }); + await send(key); + }; + const scheduleFor = (at: Date) => { sendMenu.close(); setScheduleOpen(false); @@ -292,6 +305,25 @@ export function Composer({ draft }: { draft: Draft }) { )} + {/* + inbuxa: the server's DLP rules refused the last send. A warning + can be answered with a reason; a block can't, only by changing + the message. + */} + {d.dlp && ( +
+ + r.notice).join(" ")}> + {d.dlp.kind === "warning" ? translate("Not sent yet:") : translate("Not sent:")} {d.dlp.rules.map((r) => r.notice).join(" ")} + + {d.dlp.kind === "warning" && ( + + )} + +
+ )} {d.format === "html" ? ( addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} /> ) : (