mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: never send a message when a plugin that checks it before sending fails or does not answer
This commit is contained in:
@@ -147,7 +147,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -158,7 +158,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onBeforeAttachmentUpload: { intercept: hoisted.beforeAttachmentUpload },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
|
||||
@@ -139,7 +139,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { intercept: async () => true },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -141,7 +141,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -139,7 +139,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -144,7 +144,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -150,7 +150,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -143,7 +143,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -145,7 +145,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -145,7 +145,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -146,7 +146,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -162,7 +162,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -148,7 +148,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: hoisted.takeOver },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -145,7 +145,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -145,7 +145,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -152,7 +152,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -150,7 +150,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -139,7 +139,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -151,7 +151,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
onBeforeAttachmentUpload: { intercept: async () => true },
|
||||
|
||||
@@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -2623,11 +2623,14 @@ export function EmailComposer({
|
||||
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
|
||||
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||
};
|
||||
const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
|
||||
if (!sendAllowed) {
|
||||
const sendCheck = await emailHooks.onBeforeEmailSend.interceptOutcome(sendablePreview);
|
||||
if (sendCheck !== 'allowed') {
|
||||
// A plugin vetoed the send (it is expected to show its own UI).
|
||||
// Leave a trace so a silent no-op send is diagnosable (#592).
|
||||
debug.log('email', 'Send aborted by an onBeforeEmailSend plugin handler');
|
||||
debug.log('email', `Send stopped by an onBeforeEmailSend plugin handler (${sendCheck})`);
|
||||
// A guard that crashed or never answered showed nothing; say why
|
||||
// the message is still here.
|
||||
if (sendCheck === 'failed') toast.error(t('send_guard_failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HookBus, pluginErrorTracker, removeAllPluginHooks, clearAllHooks, emailHooks, calendarHooks, isExternalAttachmentResult } from '../plugin-hooks';
|
||||
import { HookBus, pluginErrorTracker, removeAllPluginHooks, clearAllHooks, emailHooks, calendarHooks, isExternalAttachmentResult, setHookTimeoutListener } from '../plugin-hooks';
|
||||
import type { CalendarEventFormView } from '../plugin-types';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -417,3 +417,46 @@ describe('clearAllHooks', () => {
|
||||
expect(calendarHooks.onCalendarEventOpen.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onBeforeEmailSend fails closed', () => {
|
||||
it('does not send when the plugin never answers', async () => {
|
||||
// A plugin that shows a confirm and waits for the user used to let the
|
||||
// mail go out after 60 s, with the dialog still on screen.
|
||||
vi.useFakeTimers();
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
emailHooks.onBeforeEmailSend.register('guard', () => new Promise(() => {}));
|
||||
const result = emailHooks.onBeforeEmailSend.intercept({});
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000 + 100);
|
||||
expect(await result).toBe(false);
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not send when the plugin throws', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
emailHooks.onBeforeEmailSend.register('guard', () => { throw new Error('boom'); });
|
||||
expect(await emailHooks.onBeforeEmailSend.intercept({})).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('closes the dialogs of a plugin whose hook ran out of time', async () => {
|
||||
vi.useFakeTimers();
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const onTimeout = vi.fn();
|
||||
setHookTimeoutListener(onTimeout);
|
||||
try {
|
||||
emailHooks.onBeforeReply.register('slow', () => new Promise(() => {}));
|
||||
const result = emailHooks.onBeforeReply.intercept({});
|
||||
await vi.advanceTimersByTimeAsync(60_000 + 100);
|
||||
expect(await result).toBe(true);
|
||||
expect(onTimeout).toHaveBeenCalledWith('slow');
|
||||
} finally {
|
||||
setHookTimeoutListener(null);
|
||||
consoleSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+47
-9
@@ -69,10 +69,23 @@ const INTERCEPT_TIMEOUT_MS = 60_000;
|
||||
// offloading, and three of those in a minute would auto-disable the plugin.
|
||||
const BLOB_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
class HookTimeoutError extends Error {}
|
||||
|
||||
let timeoutListener: ((pluginId: string) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Called with the plugin id whenever one of its handlers runs out of time.
|
||||
* The loader uses it to close that plugin's open dialogs: the host has
|
||||
* already moved on, so a dialog left on screen would answer nothing.
|
||||
*/
|
||||
export function setHookTimeoutListener(listener: ((pluginId: string) => void) | null): void {
|
||||
timeoutListener = listener;
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: T | Promise<T>, ms: number = DEFAULT_TIMEOUT_MS): Promise<T> {
|
||||
if (!(promise instanceof Promise)) return Promise.resolve(promise);
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms);
|
||||
const timer = setTimeout(() => reject(new HookTimeoutError(`Hook timed out after ${ms}ms`)), ms);
|
||||
promise.then(
|
||||
(val) => { clearTimeout(timer); resolve(val); },
|
||||
(err) => { clearTimeout(timer); reject(err); },
|
||||
@@ -115,8 +128,21 @@ export class HookBus<T extends (...args: any[]) => any> {
|
||||
* Defaults to 5s; a bus whose handlers legitimately do network work sets
|
||||
* its own (see `onBeforeBlobUpload`). `intercept` keeps its own longer
|
||||
* budget because those hooks block on user dialogs.
|
||||
* @param opts.failClosed For intercepts guarding an action that can't be
|
||||
* taken back (sending mail): a handler that throws or runs out of time
|
||||
* cancels the action instead of letting it through.
|
||||
*/
|
||||
constructor(private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
|
||||
constructor(
|
||||
private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
private readonly opts: { failClosed?: boolean } = {},
|
||||
) {}
|
||||
|
||||
private recordFailure(pluginId: string, err: unknown): void {
|
||||
pluginErrorTracker.record(pluginId, err);
|
||||
if (err instanceof HookTimeoutError) {
|
||||
try { timeoutListener?.(pluginId); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
register(pluginId: string, handler: T, order: number = 100): Disposable {
|
||||
const entry: HookEntry<T> = { pluginId, handler, order };
|
||||
@@ -150,7 +176,7 @@ export class HookBus<T extends (...args: any[]) => any> {
|
||||
try {
|
||||
await withTimeout(handler(...args), this.timeoutMs);
|
||||
} catch (err) {
|
||||
pluginErrorTracker.record(pluginId, err);
|
||||
this.recordFailure(pluginId, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,16 +195,26 @@ export class HookBus<T extends (...args: any[]) => any> {
|
||||
|
||||
/** Fire handlers as interceptors - any returning false cancels the operation */
|
||||
async intercept(...args: Parameters<T>): Promise<boolean> {
|
||||
return (await this.interceptOutcome(...args)) === 'allowed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `intercept`, but says why an operation was stopped: `vetoed` when a
|
||||
* handler said no (it is expected to have told the user), `failed` when a
|
||||
* fail-closed bus stopped it because a handler threw or ran out of time.
|
||||
*/
|
||||
async interceptOutcome(...args: Parameters<T>): Promise<'allowed' | 'vetoed' | 'failed'> {
|
||||
for (const { pluginId, handler } of this.handlers) {
|
||||
if (pluginErrorTracker.isDisabled(pluginId)) continue;
|
||||
try {
|
||||
const result = await withTimeout(handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS));
|
||||
if (result === false) return false;
|
||||
if (result === false) return 'vetoed';
|
||||
} catch (err) {
|
||||
pluginErrorTracker.record(pluginId, err);
|
||||
this.recordFailure(pluginId, err);
|
||||
if (this.opts.failClosed) return 'failed';
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +230,7 @@ export class HookBus<T extends (...args: any[]) => any> {
|
||||
try {
|
||||
result = await withTimeout(handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS));
|
||||
} catch (err) {
|
||||
pluginErrorTracker.record(pluginId, err);
|
||||
this.recordFailure(pluginId, err);
|
||||
return 'failed';
|
||||
}
|
||||
if (result === false) return 'handled';
|
||||
@@ -214,7 +250,7 @@ export class HookBus<T extends (...args: any[]) => any> {
|
||||
value = result as V;
|
||||
}
|
||||
} catch (err) {
|
||||
pluginErrorTracker.record(pluginId, err);
|
||||
this.recordFailure(pluginId, err);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
@@ -274,7 +310,9 @@ export const emailHooks = {
|
||||
// Return false to cancel opening the composer.
|
||||
onBeforeCompose: new HookBus(),
|
||||
onComposerOpen: new HookBus(),
|
||||
onBeforeEmailSend: new HookBus(),
|
||||
// Fails closed with the long send budget: a guard that asks the user and
|
||||
// gets no answer must not let the mail go out behind its dialog.
|
||||
onBeforeEmailSend: new HookBus(COMPOSE_SEND_TIMEOUT_MS, { failClosed: true }),
|
||||
onAfterEmailSend: new HookBus(),
|
||||
// Transform hook - fires before the draft is auto-saved to the server.
|
||||
// Receive fields passed to client.createDraft and may mutate fields in place.
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
|
||||
messageListTabHooks,
|
||||
removeAllPluginHooks, pluginErrorTracker,
|
||||
removeAllPluginHooks, pluginErrorTracker, setHookTimeoutListener,
|
||||
} from '../plugin-hooks';
|
||||
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
|
||||
import { verifyBundle } from './bundle-integrity';
|
||||
@@ -262,6 +262,7 @@ export function deactivateAllSandboxed(): void {
|
||||
// ─── Auto-disable ─────────────────────────────────────────────
|
||||
|
||||
export function setupSandboxAutoDisable(): void {
|
||||
setHookTimeoutListener((pluginId) => cancelPluginDialogs(pluginId));
|
||||
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
|
||||
unloadSandboxedPlugin(pluginId);
|
||||
storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors');
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "إزالة لون الخلفية"
|
||||
},
|
||||
"send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة.",
|
||||
"pop_out": "فتح الرد في نافذة منفصلة"
|
||||
"pop_out": "فتح الرد في نافذة منفصلة",
|
||||
"send_guard_failed": "لم يُرسَل - فشلت إضافة تفحص الرسائل قبل الإرسال أو لم تستجب."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "تأكيد",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Elimina el color de fons"
|
||||
},
|
||||
"send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet.",
|
||||
"pop_out": "Obre la resposta en una finestra"
|
||||
"pop_out": "Obre la resposta en una finestra",
|
||||
"send_guard_failed": "No s'ha enviat: un connector que revisa els missatges abans d'enviar-los ha fallat o no ha respost."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirma",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Odebrat barvu pozadí"
|
||||
},
|
||||
"send_filing_warning": "Odesláno - ale následný úklid selhal, může zůstat zastaralý koncept.",
|
||||
"pop_out": "Otevřít odpověď v okně"
|
||||
"pop_out": "Otevřít odpověď v okně",
|
||||
"send_guard_failed": "Neodesláno - plugin, který kontroluje zprávy před odesláním, selhal nebo neodpověděl."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Potvrdit",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Fjern baggrundsfarve"
|
||||
},
|
||||
"send_filing_warning": "Sendt - men oprydningen bagefter mislykkedes, en forældet kladde kan blive stående.",
|
||||
"pop_out": "Åbn svaret i et vindue"
|
||||
"pop_out": "Åbn svaret i et vindue",
|
||||
"send_guard_failed": "Ikke sendt - et plugin, der kontrollerer beskeder før afsendelse, fejlede eller svarede ikke."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Bekræft",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Hintergrundfarbe entfernen"
|
||||
},
|
||||
"send_filing_warning": "Gesendet - aber das Aufräumen danach schlug fehl, evtl. bleibt ein alter Entwurf sichtbar.",
|
||||
"pop_out": "Antwort in eigenem Fenster öffnen"
|
||||
"pop_out": "Antwort in eigenem Fenster öffnen",
|
||||
"send_guard_failed": "Nicht gesendet - ein Plugin, das Nachrichten vor dem Senden prüft, ist fehlgeschlagen oder hat nicht geantwortet."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Bestätigen",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Remove background color"
|
||||
},
|
||||
"send_filing_warning": "Sent - but the post-send cleanup failed, a stale draft may remain.",
|
||||
"pop_out": "Pop out reply"
|
||||
"pop_out": "Pop out reply",
|
||||
"send_guard_failed": "Not sent - a plugin that checks messages before sending failed or did not answer."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirm",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Quitar color de fondo"
|
||||
},
|
||||
"send_filing_warning": "Enviado - pero la limpieza posterior falló, puede quedar un borrador obsoleto.",
|
||||
"pop_out": "Abrir la respuesta en una ventana"
|
||||
"pop_out": "Abrir la respuesta en una ventana",
|
||||
"send_guard_failed": "No enviado: un complemento que revisa los mensajes antes de enviarlos falló o no respondió."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmar",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "حذف رنگ پسزمینه"
|
||||
},
|
||||
"send_filing_warning": "ارسال شد - اما پاکسازی پس از ارسال ناموفق بود، ممکن است پیشنویس قدیمی باقی بماند.",
|
||||
"pop_out": "باز کردن پاسخ در پنجره جدا"
|
||||
"pop_out": "باز کردن پاسخ در پنجره جدا",
|
||||
"send_guard_failed": "ارسال نشد - افزونهای که پیامها را پیش از ارسال بررسی میکند ناموفق بود یا پاسخ نداد."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "تأیید",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Supprimer la couleur d'arrière-plan"
|
||||
},
|
||||
"send_filing_warning": "Envoyé - mais le nettoyage après envoi a échoué, un ancien brouillon peut subsister.",
|
||||
"pop_out": "Ouvrir la réponse dans une fenêtre"
|
||||
"pop_out": "Ouvrir la réponse dans une fenêtre",
|
||||
"send_guard_failed": "Non envoyé - un plugin qui vérifie les messages avant l'envoi a échoué ou n'a pas répondu."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmer",
|
||||
|
||||
@@ -778,7 +778,8 @@
|
||||
"remove_background_color": "הסר צבע רקע"
|
||||
},
|
||||
"send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה.",
|
||||
"pop_out": "פתיחת התשובה בחלון נפרד"
|
||||
"pop_out": "פתיחת התשובה בחלון נפרד",
|
||||
"send_guard_failed": "לא נשלח - תוסף שבודק הודעות לפני השליחה נכשל או לא הגיב."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "אשר",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Háttérszín eltávolítása"
|
||||
},
|
||||
"send_filing_warning": "Elküldve - de az utólagos rendrakás nem sikerült, egy elavult piszkozat megmaradhat.",
|
||||
"pop_out": "Válasz megnyitása külön ablakban"
|
||||
"pop_out": "Válasz megnyitása külön ablakban",
|
||||
"send_guard_failed": "Nem lett elküldve - egy bővítmény, amely küldés előtt ellenőrzi az üzeneteket, hibát jelzett vagy nem válaszolt."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Megerősítés",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Rimuovi colore di sfondo"
|
||||
},
|
||||
"send_filing_warning": "Inviato - ma la pulizia successiva non è riuscita, potrebbe restare una bozza obsoleta.",
|
||||
"pop_out": "Apri la risposta in una finestra"
|
||||
"pop_out": "Apri la risposta in una finestra",
|
||||
"send_guard_failed": "Non inviato - un plugin che controlla i messaggi prima dell'invio non è riuscito o non ha risposto."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Conferma",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "背景色を削除"
|
||||
},
|
||||
"send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。",
|
||||
"pop_out": "返信を別ウィンドウで開く"
|
||||
"pop_out": "返信を別ウィンドウで開く",
|
||||
"send_guard_failed": "送信されませんでした - 送信前にメッセージを確認するプラグインが失敗したか、応答しませんでした。"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "確認",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "배경색 제거"
|
||||
},
|
||||
"send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다.",
|
||||
"pop_out": "답장을 별도 창에서 열기"
|
||||
"pop_out": "답장을 별도 창에서 열기",
|
||||
"send_guard_failed": "보내지 않았습니다 - 보내기 전에 메시지를 확인하는 플러그인이 실패했거나 응답하지 않았습니다."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "확인",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Noņemt fona krāsu"
|
||||
},
|
||||
"send_filing_warning": "Nosūtīts - bet pēcapstrāde neizdevās, var palikt novecojis melnraksts.",
|
||||
"pop_out": "Atvērt atbildi atsevišķā logā"
|
||||
"pop_out": "Atvērt atbildi atsevišķā logā",
|
||||
"send_guard_failed": "Nav nosūtīts - spraudnis, kas pārbauda ziņojumus pirms sūtīšanas, neizdevās vai neatbildēja."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Apstiprināt",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Дэвсгэр өнгийг арилгах"
|
||||
},
|
||||
"send_filing_warning": "Илгээсэн - гэхдээ илгээсний дараах цэвэрлэгээ амжилтгүй болсон тул хуучирсан ноорог үлдэж болзошгүй.",
|
||||
"pop_out": "Хариуг тусдаа цонхонд нээх"
|
||||
"pop_out": "Хариуг тусдаа цонхонд нээх",
|
||||
"send_guard_failed": "Илгээгдсэнгүй - илгээхийн өмнө мессежийг шалгадаг залгаас алдаа гаргасан эсвэл хариу өгсөнгүй."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Баталгаажуулах",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Fjern bakgrunnsfarge"
|
||||
},
|
||||
"send_filing_warning": "Meldingen ble sendt, men oppryddingen etterpå mislyktes. Et gammelt utkast kan bli liggende.",
|
||||
"pop_out": "Åpne svaret i et eget vindu"
|
||||
"pop_out": "Åpne svaret i et eget vindu",
|
||||
"send_guard_failed": "Ikke sendt - et tillegg som sjekker meldinger før sending, feilet eller svarte ikke."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Bekreft",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Achtergrondkleur verwijderen"
|
||||
},
|
||||
"send_filing_warning": "Verzonden - maar het opruimen daarna is mislukt, mogelijk blijft een oud concept staan.",
|
||||
"pop_out": "Antwoord in een apart venster openen"
|
||||
"pop_out": "Antwoord in een apart venster openen",
|
||||
"send_guard_failed": "Niet verzonden - een plug-in die berichten controleert voor het verzenden is mislukt of reageerde niet."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Bevestigen",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Usuń kolor tła"
|
||||
},
|
||||
"send_filing_warning": "Wysłano - ale późniejsze porządkowanie nie powiodło się, może pozostać nieaktualna wersja robocza.",
|
||||
"pop_out": "Otwórz odpowiedź w oknie"
|
||||
"pop_out": "Otwórz odpowiedź w oknie",
|
||||
"send_guard_failed": "Nie wysłano - wtyczka sprawdzająca wiadomości przed wysłaniem zawiodła lub nie odpowiedziała."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Potwierdź",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Remover cor de fundo"
|
||||
},
|
||||
"send_filing_warning": "Enviado - mas a limpeza posterior falhou, um rascunho antigo pode permanecer.",
|
||||
"pop_out": "Abrir a resposta numa janela"
|
||||
"pop_out": "Abrir a resposta numa janela",
|
||||
"send_guard_failed": "Não enviado - um plugin que verifica as mensagens antes do envio falhou ou não respondeu."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmar",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Elimină culoarea fundalului"
|
||||
},
|
||||
"send_filing_warning": "Trimis - dar curățarea ulterioară a eșuat, poate rămâne o ciornă veche.",
|
||||
"pop_out": "Deschide răspunsul într-o fereastră"
|
||||
"pop_out": "Deschide răspunsul într-o fereastră",
|
||||
"send_guard_failed": "Nu s-a trimis - un plugin care verifică mesajele înainte de trimitere a eșuat sau nu a răspuns."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmare",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Убрать цвет фона"
|
||||
},
|
||||
"send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик.",
|
||||
"pop_out": "Открыть ответ в отдельном окне"
|
||||
"pop_out": "Открыть ответ в отдельном окне",
|
||||
"send_guard_failed": "Не отправлено: плагин, проверяющий письма перед отправкой, завершился с ошибкой или не ответил."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Подтвердить",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Odstrániť farbu pozadia"
|
||||
},
|
||||
"send_filing_warning": "Odoslané - ale následné upratovanie zlyhalo, môže zostať zastaraný koncept.",
|
||||
"pop_out": "Otvoriť odpoveď v okne"
|
||||
"pop_out": "Otvoriť odpoveď v okne",
|
||||
"send_guard_failed": "Neodoslané - doplnok, ktorý kontroluje správy pred odoslaním, zlyhal alebo neodpovedal."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Potvrdiť",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Arka plan rengini kaldır"
|
||||
},
|
||||
"send_filing_warning": "Gönderildi - ancak sonrasındaki temizleme başarısız oldu, eski bir taslak kalabilir.",
|
||||
"pop_out": "Yanıtı ayrı pencerede aç"
|
||||
"pop_out": "Yanıtı ayrı pencerede aç",
|
||||
"send_guard_failed": "Gönderilmedi - iletileri göndermeden önce denetleyen bir eklenti başarısız oldu veya yanıt vermedi."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Onayla",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "Прибрати колір фону"
|
||||
},
|
||||
"send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка.",
|
||||
"pop_out": "Відкрити відповідь в окремому вікні"
|
||||
"pop_out": "Відкрити відповідь в окремому вікні",
|
||||
"send_guard_failed": "Не надіслано: плагін, що перевіряє листи перед надсиланням, завершився з помилкою або не відповів."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Підтвердити",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "移除背景顏色"
|
||||
},
|
||||
"send_filing_warning": "郵件已傳送,但寄送後清理失敗,可能仍保留舊草稿。",
|
||||
"pop_out": "在獨立視窗中開啟回覆"
|
||||
"pop_out": "在獨立視窗中開啟回覆",
|
||||
"send_guard_failed": "未傳送 - 傳送前檢查郵件的外掛程式發生錯誤或沒有回應。"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "確認",
|
||||
|
||||
@@ -813,7 +813,8 @@
|
||||
"remove_background_color": "移除背景颜色"
|
||||
},
|
||||
"send_filing_warning": "已发送,但发送后的清理失败,可能会残留旧草稿。",
|
||||
"pop_out": "在单独窗口中打开回复"
|
||||
"pop_out": "在单独窗口中打开回复",
|
||||
"send_guard_failed": "未发送 - 发送前检查邮件的插件出错或没有响应。"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "确认",
|
||||
|
||||
Reference in New Issue
Block a user