DLP on send: warnings, blocks and held mail in the composer #29
No files matched your search
@@ -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: "[email protected]",
|
||||
emailRef: "#m",
|
||||
rcpts: [{ email: "[email protected]" }],
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ComposeState>((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<string, unknown>; onSuccessUpdateEmail: Record<string, unknown> } {
|
||||
const scheduled = opts.sendAt !== null;
|
||||
const mailFrom: Record<string, unknown> = { 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<string, unknown> = { 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<void> {
|
||||
/** 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<Sent> {
|
||||
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<void> {
|
||||
draftsId,
|
||||
scheduledId,
|
||||
sendAt: scheduled ? d.sendAt : null,
|
||||
dlpOverride: d.dlpOverride ?? null,
|
||||
});
|
||||
const calls: Array<[string, Record<string, unknown>, 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<void> {
|
||||
// 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> {
|
||||
}
|
||||
void mail.loadMailboxes();
|
||||
void mail.refreshList();
|
||||
return { held: (s.created?.s as { "inbuxa:held"?: boolean } | undefined)?.["inbuxa:held"] === true };
|
||||
}
|
||||
|
||||
export { FULL_PROPS, BODY_PROPS };
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 }) {
|
||||
<button type="button" className="icon-btn sm" aria-label={translate("Dismiss")} onClick={() => patch({ formatOffer: null })}><X size={14} /></button>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
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 && (
|
||||
<div className="composer-notice composer-notice-dlp" role="alert">
|
||||
<AlertTriangle size={14} />
|
||||
<span title={d.dlp.rules.map((r) => r.notice).join(" ")}>
|
||||
{d.dlp.kind === "warning" ? translate("Not sent yet:") : translate("Not sent:")} {d.dlp.rules.map((r) => r.notice).join(" ")}
|
||||
</span>
|
||||
{d.dlp.kind === "warning" && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => void sendAnyway()}>
|
||||
{translate("Send anyway…")}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="icon-btn sm" aria-label={translate("Dismiss")} onClick={() => patch({ dlp: null })}><X size={14} /></button>
|
||||
</div>
|
||||
)}
|
||||
{d.format === "html" ? (
|
||||
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder={translate("Write your message…")} spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} />
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user