Files
webmail/lib/outgoing/plugin-send-check.ts
T
Linus Rath 0c8d4ad93e fix: hold sends until required send checks have started, and check what really goes out
- The message for send checks is built after waiting for required
  checks that are still starting: a send right after sign-in gave them
  no external recipients and no file types.
- A plugin's forward is held too while a required check is down, also
  with no plugin listening.
- A plugin the admin forces on that hasn't reached this browser yet (a
  new device, a failed plugin sync) counts as starting.
- A check that stopped working says to reload or tell the admin.
- The files that were checked are the ones sent, and recipients a
  plugin adds after the check are checked again.
- A file Bulwark couldn't read is marked unreadable; a text file
  starting with "MZ" is text, Java and Android apps are told from ZIPs.

Changed assertions: runPluginSendCheck takes a builder and reports
guard-loading or guard-failed instead of guard-down; the composer test
of an auto-disabled guard expects the stopped_working text; the sniff
tests give programs a zero byte, as real ones have, and expect
unreadable on a file that can't be read.
2026-11-01 10:02:58 +01:00

66 lines
3.0 KiB
TypeScript

/**
* Plugins' say before a user's mail goes out (onBeforeEmailSend), the same
* for every surface that sends it: the composer (inline, pop-out, Pro tab),
* quick reply and a plugin's forward (api.email.forward). The bus fails
* closed: a handler that throws or runs out of time stops the send.
*
* Not run for mail Bulwark sends on its own (calendar invitations and RSVP
* replies, read receipts, List-Unsubscribe mails, phishing reports) nor for
* a crypto plugin's raw send, which happens after the composer's check.
*/
import { emailHooks } from '@/lib/plugin-hooks';
import type { OutgoingEmail } from '@/lib/plugin-types';
import { awaitSendGuards } from '@/lib/plugin-send-guards';
import { debug } from '@/lib/debug';
/**
* allowed: send. vetoed: a plugin said no and told the user. failed: a
* handler threw or ran out of time. guard-loading / guard-failed: a send
* check the admin requires is still starting, or failed to start or was
* turned off after errors (lib/plugin-send-guards.ts). Nobody told the user
* about the last three yet.
*/
export type PluginSendCheckOutcome = 'allowed' | 'vetoed' | 'failed' | 'guard-loading' | 'guard-failed';
/**
* Waits for the send checks the admin requires, then asks the plugins.
* `build` makes the message they get; it runs only after that wait and only
* while a plugin listens, so a check that was still starting at the click
* gets the whole message (external recipients, attachment types), never the
* cut-down one built for nobody.
*/
export async function runPluginSendCheck(build: () => OutgoingEmail | Promise<OutgoingEmail>): Promise<PluginSendCheckOutcome> {
const down = await awaitSendGuards();
if (down.length > 0) {
debug.warn('email', `Send held: required send checks not running: ${down.map((g) => `${g.id} (${g.state})`).join(', ')}`);
return down.some((g) => g.state === 'failed') ? 'guard-failed' : 'guard-loading';
}
if (emailHooks.onBeforeEmailSend.size === 0) return 'allowed';
return emailHooks.onBeforeEmailSend.interceptOutcome(await build());
}
/** The message key in the `send_guards` namespace for a held send, or null. */
export function sendGuardMessageKey(outcome: PluginSendCheckOutcome): 'not_running' | 'stopped_working' | null {
if (outcome === 'guard-loading') return 'not_running';
if (outcome === 'guard-failed') return 'stopped_working';
return null;
}
/** Bare lowercase addresses of a recipient list ("Name <a@b>" or "a@b"). */
function addressSet(list: readonly string[]): string {
return list
.map((entry) => (/<([^>]+)>\s*$/.exec(entry)?.[1] ?? entry).trim().toLowerCase())
.filter(Boolean)
.sort()
.join(',');
}
/** Whether a plugin's onTransformOutgoingEmail changed who the message goes to. */
export function recipientsChanged(
before: Pick<OutgoingEmail, 'to' | 'cc' | 'bcc'>,
after: Pick<OutgoingEmail, 'to' | 'cc' | 'bcc'>,
): boolean {
return addressSet([...before.to, ...before.cc, ...before.bcc]) !== addressSet([...(after.to ?? []), ...(after.cc ?? []), ...(after.bcc ?? [])]);
}