From be3debdcb4522d75e8f597198d0acec256ded1e1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:11:59 +0200 Subject: [PATCH] fix: never send a message when a plugin that checks it before sending fails or does not answer --- .../composer-account-switch.test.tsx | 2 +- .../composer-attach-from-files.test.tsx | 2 +- .../composer-attachment-list-scroll.test.tsx | 2 +- .../__tests__/composer-close-guard.test.tsx | 2 +- .../email/__tests__/composer-discard.test.tsx | 2 +- .../composer-draft-attachments.test.tsx | 2 +- .../__tests__/composer-format-toggle.test.tsx | 2 +- .../composer-forward-other-account.test.tsx | 2 +- .../composer-from-override-envelope.test.tsx | 2 +- .../email/__tests__/composer-mention.test.tsx | 2 +- .../composer-mobile-formatting.test.tsx | 2 +- .../__tests__/composer-mv-window.test.tsx | 2 +- ...composer-plugin-attachment-source.test.tsx | 2 +- .../__tests__/composer-plugin-send.test.tsx | 2 +- .../composer-recipient-validation.test.tsx | 2 +- ...composer-reopened-draft-signature.test.tsx | 2 +- .../composer-reply-draft-threading.test.tsx | 2 +- .../__tests__/composer-reply-focus.test.tsx | 2 +- .../composer-schedule-presets.test.tsx | 2 +- .../__tests__/composer-send-errors.test.tsx | 2 +- .../__tests__/composer-send-unmount.test.tsx | 2 +- .../__tests__/composer-short-pane.test.tsx | 2 +- .../email/__tests__/composer-title.test.tsx | 2 +- .../composer-upload-account.test.tsx | 2 +- .../__tests__/empty-subject-warning.test.tsx | 2 +- components/email/email-composer.tsx | 9 ++- lib/__tests__/plugin-hooks.test.ts | 45 ++++++++++++++- lib/plugin-hooks.ts | 56 ++++++++++++++++--- lib/plugin-sandbox/loader.ts | 3 +- locales/ar/common.json | 3 +- locales/ca/common.json | 3 +- locales/cs/common.json | 3 +- locales/da/common.json | 3 +- locales/de/common.json | 3 +- locales/en/common.json | 3 +- locales/es/common.json | 3 +- locales/fa/common.json | 3 +- locales/fr/common.json | 3 +- locales/he/common.json | 3 +- locales/hu/common.json | 3 +- locales/it/common.json | 3 +- locales/ja/common.json | 3 +- locales/ko/common.json | 3 +- locales/lv/common.json | 3 +- locales/mn/common.json | 3 +- locales/nb/common.json | 3 +- locales/nl/common.json | 3 +- locales/pl/common.json | 3 +- locales/pt/common.json | 3 +- locales/ro/common.json | 3 +- locales/ru/common.json | 3 +- locales/sk/common.json | 3 +- locales/tr/common.json | 3 +- locales/uk/common.json | 3 +- locales/zh-TW/common.json | 3 +- locales/zh/common.json | 3 +- 56 files changed, 178 insertions(+), 66 deletions(-) diff --git a/components/email/__tests__/composer-account-switch.test.tsx b/components/email/__tests__/composer-account-switch.test.tsx index 5da9297d0..9914ff30b 100644 --- a/components/email/__tests__/composer-account-switch.test.tsx +++ b/components/email/__tests__/composer-account-switch.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-attach-from-files.test.tsx b/components/email/__tests__/composer-attach-from-files.test.tsx index 938a612f9..db41fe94f 100644 --- a/components/email/__tests__/composer-attach-from-files.test.tsx +++ b/components/email/__tests__/composer-attach-from-files.test.tsx @@ -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 }, diff --git a/components/email/__tests__/composer-attachment-list-scroll.test.tsx b/components/email/__tests__/composer-attachment-list-scroll.test.tsx index 9efbceeb6..76b508546 100644 --- a/components/email/__tests__/composer-attachment-list-scroll.test.tsx +++ b/components/email/__tests__/composer-attachment-list-scroll.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 85cec8f86..7c0b892ff 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-discard.test.tsx b/components/email/__tests__/composer-discard.test.tsx index f391bd06c..9fa0f28dd 100644 --- a/components/email/__tests__/composer-discard.test.tsx +++ b/components/email/__tests__/composer-discard.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-draft-attachments.test.tsx b/components/email/__tests__/composer-draft-attachments.test.tsx index 259e74132..d14cf789a 100644 --- a/components/email/__tests__/composer-draft-attachments.test.tsx +++ b/components/email/__tests__/composer-draft-attachments.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-format-toggle.test.tsx b/components/email/__tests__/composer-format-toggle.test.tsx index 8b64cbf66..b70d7bbac 100644 --- a/components/email/__tests__/composer-format-toggle.test.tsx +++ b/components/email/__tests__/composer-format-toggle.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-forward-other-account.test.tsx b/components/email/__tests__/composer-forward-other-account.test.tsx index 4c310c81a..a0957a0b0 100644 --- a/components/email/__tests__/composer-forward-other-account.test.tsx +++ b/components/email/__tests__/composer-forward-other-account.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-from-override-envelope.test.tsx b/components/email/__tests__/composer-from-override-envelope.test.tsx index 93b1a94a7..b203296ed 100644 --- a/components/email/__tests__/composer-from-override-envelope.test.tsx +++ b/components/email/__tests__/composer-from-override-envelope.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx index 28cceeb20..7b0c84ae1 100644 --- a/components/email/__tests__/composer-mention.test.tsx +++ b/components/email/__tests__/composer-mention.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-mobile-formatting.test.tsx b/components/email/__tests__/composer-mobile-formatting.test.tsx index ca1ffeb69..bc58092c1 100644 --- a/components/email/__tests__/composer-mobile-formatting.test.tsx +++ b/components/email/__tests__/composer-mobile-formatting.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-mv-window.test.tsx b/components/email/__tests__/composer-mv-window.test.tsx index 5b0407095..9039805f8 100644 --- a/components/email/__tests__/composer-mv-window.test.tsx +++ b/components/email/__tests__/composer-mv-window.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-plugin-attachment-source.test.tsx b/components/email/__tests__/composer-plugin-attachment-source.test.tsx index f07a6f80a..55a8cb17d 100644 --- a/components/email/__tests__/composer-plugin-attachment-source.test.tsx +++ b/components/email/__tests__/composer-plugin-attachment-source.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-plugin-send.test.tsx b/components/email/__tests__/composer-plugin-send.test.tsx index 0d5b95449..bd9b1fbf8 100644 --- a/components/email/__tests__/composer-plugin-send.test.tsx +++ b/components/email/__tests__/composer-plugin-send.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-recipient-validation.test.tsx b/components/email/__tests__/composer-recipient-validation.test.tsx index f7815d517..a9bb763f3 100644 --- a/components/email/__tests__/composer-recipient-validation.test.tsx +++ b/components/email/__tests__/composer-recipient-validation.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-reopened-draft-signature.test.tsx b/components/email/__tests__/composer-reopened-draft-signature.test.tsx index ea61f85dc..97f25953e 100644 --- a/components/email/__tests__/composer-reopened-draft-signature.test.tsx +++ b/components/email/__tests__/composer-reopened-draft-signature.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-reply-draft-threading.test.tsx b/components/email/__tests__/composer-reply-draft-threading.test.tsx index a820821b5..3ba0edceb 100644 --- a/components/email/__tests__/composer-reply-draft-threading.test.tsx +++ b/components/email/__tests__/composer-reply-draft-threading.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-reply-focus.test.tsx b/components/email/__tests__/composer-reply-focus.test.tsx index 545440f3d..b7d4ccacc 100644 --- a/components/email/__tests__/composer-reply-focus.test.tsx +++ b/components/email/__tests__/composer-reply-focus.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-schedule-presets.test.tsx b/components/email/__tests__/composer-schedule-presets.test.tsx index 32c9697ea..07bcc2f0b 100644 --- a/components/email/__tests__/composer-schedule-presets.test.tsx +++ b/components/email/__tests__/composer-schedule-presets.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-send-errors.test.tsx b/components/email/__tests__/composer-send-errors.test.tsx index 1f2a4fba0..13e53fd23 100644 --- a/components/email/__tests__/composer-send-errors.test.tsx +++ b/components/email/__tests__/composer-send-errors.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-send-unmount.test.tsx b/components/email/__tests__/composer-send-unmount.test.tsx index d2143cf78..2aac397d1 100644 --- a/components/email/__tests__/composer-send-unmount.test.tsx +++ b/components/email/__tests__/composer-send-unmount.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-short-pane.test.tsx b/components/email/__tests__/composer-short-pane.test.tsx index e37e11aa9..243415f75 100644 --- a/components/email/__tests__/composer-short-pane.test.tsx +++ b/components/email/__tests__/composer-short-pane.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-title.test.tsx b/components/email/__tests__/composer-title.test.tsx index 729a5eace..78fbcbd1d 100644 --- a/components/email/__tests__/composer-title.test.tsx +++ b/components/email/__tests__/composer-title.test.tsx @@ -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 }, }, diff --git a/components/email/__tests__/composer-upload-account.test.tsx b/components/email/__tests__/composer-upload-account.test.tsx index 3b8baf88a..229912d63 100644 --- a/components/email/__tests__/composer-upload-account.test.tsx +++ b/components/email/__tests__/composer-upload-account.test.tsx @@ -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 }, diff --git a/components/email/__tests__/empty-subject-warning.test.tsx b/components/email/__tests__/empty-subject-warning.test.tsx index c8a020628..a62b67891 100644 --- a/components/email/__tests__/empty-subject-warning.test.tsx +++ b/components/email/__tests__/empty-subject-warning.test.tsx @@ -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 }, }, diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index fe3f3160d..664a93611 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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; } diff --git a/lib/__tests__/plugin-hooks.test.ts b/lib/__tests__/plugin-hooks.test.ts index a90cc4226..94f65ceea 100644 --- a/lib/__tests__/plugin-hooks.test.ts +++ b/lib/__tests__/plugin-hooks.test.ts @@ -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(); + } + }); +}); diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index d9206e978..58b2ffd38 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -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(promise: T | Promise, ms: number = DEFAULT_TIMEOUT_MS): Promise { if (!(promise instanceof Promise)) return Promise.resolve(promise); return new Promise((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 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 = { pluginId, handler, order }; @@ -150,7 +176,7 @@ export class HookBus 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 any> { /** Fire handlers as interceptors - any returning false cancels the operation */ async intercept(...args: Parameters): Promise { + 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): 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 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 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. diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 9172915e7..a01b0f6a0 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -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'); diff --git a/locales/ar/common.json b/locales/ar/common.json index 97cc217a3..78a772c82 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -813,7 +813,8 @@ "remove_background_color": "إزالة لون الخلفية" }, "send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة.", - "pop_out": "فتح الرد في نافذة منفصلة" + "pop_out": "فتح الرد في نافذة منفصلة", + "send_guard_failed": "لم يُرسَل - فشلت إضافة تفحص الرسائل قبل الإرسال أو لم تستجب." }, "confirm_dialog": { "confirm": "تأكيد", diff --git a/locales/ca/common.json b/locales/ca/common.json index d4940d31f..43c1a1c50 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index 677cc2e21..39adc31b6 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index e6448e529..da431497a 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 3402006f8..1ea34da40 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index f81c847c7..7f191767f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index fb82b54e5..a3e2fb538 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 4ac2702fe..243b2e1f0 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -813,7 +813,8 @@ "remove_background_color": "حذف رنگ پس‌زمینه" }, "send_filing_warning": "ارسال شد - اما پاک‌سازی پس از ارسال ناموفق بود، ممکن است پیش‌نویس قدیمی باقی بماند.", - "pop_out": "باز کردن پاسخ در پنجره جدا" + "pop_out": "باز کردن پاسخ در پنجره جدا", + "send_guard_failed": "ارسال نشد - افزونه‌ای که پیام‌ها را پیش از ارسال بررسی می‌کند ناموفق بود یا پاسخ نداد." }, "confirm_dialog": { "confirm": "تأیید", diff --git a/locales/fr/common.json b/locales/fr/common.json index b40d92c1f..77f4388f3 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index b7ff75a9b..511925fe4 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -778,7 +778,8 @@ "remove_background_color": "הסר צבע רקע" }, "send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה.", - "pop_out": "פתיחת התשובה בחלון נפרד" + "pop_out": "פתיחת התשובה בחלון נפרד", + "send_guard_failed": "לא נשלח - תוסף שבודק הודעות לפני השליחה נכשל או לא הגיב." }, "confirm_dialog": { "confirm": "אשר", diff --git a/locales/hu/common.json b/locales/hu/common.json index bac4f8302..46d7388b9 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index 48421a212..51fce9613 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0450880d7..b7054e488 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -813,7 +813,8 @@ "remove_background_color": "背景色を削除" }, "send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。", - "pop_out": "返信を別ウィンドウで開く" + "pop_out": "返信を別ウィンドウで開く", + "send_guard_failed": "送信されませんでした - 送信前にメッセージを確認するプラグインが失敗したか、応答しませんでした。" }, "confirm_dialog": { "confirm": "確認", diff --git a/locales/ko/common.json b/locales/ko/common.json index 9dc945c01..87b4831c8 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -813,7 +813,8 @@ "remove_background_color": "배경색 제거" }, "send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다.", - "pop_out": "답장을 별도 창에서 열기" + "pop_out": "답장을 별도 창에서 열기", + "send_guard_failed": "보내지 않았습니다 - 보내기 전에 메시지를 확인하는 플러그인이 실패했거나 응답하지 않았습니다." }, "confirm_dialog": { "confirm": "확인", diff --git a/locales/lv/common.json b/locales/lv/common.json index 87288d927..3c9e7c335 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index 563431c54..f5494b653 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Дэвсгэр өнгийг арилгах" }, "send_filing_warning": "Илгээсэн - гэхдээ илгээсний дараах цэвэрлэгээ амжилтгүй болсон тул хуучирсан ноорог үлдэж болзошгүй.", - "pop_out": "Хариуг тусдаа цонхонд нээх" + "pop_out": "Хариуг тусдаа цонхонд нээх", + "send_guard_failed": "Илгээгдсэнгүй - илгээхийн өмнө мессежийг шалгадаг залгаас алдаа гаргасан эсвэл хариу өгсөнгүй." }, "confirm_dialog": { "confirm": "Баталгаажуулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index c23c8dbbd..c5b3f89d3 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index d801d2f78..26a85faf7 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index f38b2509d..bfb305cae 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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ź", diff --git a/locales/pt/common.json b/locales/pt/common.json index 8d40cd3b1..5297e68f0 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index 9aed8133b..0f15254b7 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index 985279c82..619d030c6 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Убрать цвет фона" }, "send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик.", - "pop_out": "Открыть ответ в отдельном окне" + "pop_out": "Открыть ответ в отдельном окне", + "send_guard_failed": "Не отправлено: плагин, проверяющий письма перед отправкой, завершился с ошибкой или не ответил." }, "confirm_dialog": { "confirm": "Подтвердить", diff --git a/locales/sk/common.json b/locales/sk/common.json index 3e87836d8..1dccdcf4a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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ť", diff --git a/locales/tr/common.json b/locales/tr/common.json index f58996755..6a24a99aa 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index f6d50a2e7..2b7180baa 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Прибрати колір фону" }, "send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка.", - "pop_out": "Відкрити відповідь в окремому вікні" + "pop_out": "Відкрити відповідь в окремому вікні", + "send_guard_failed": "Не надіслано: плагін, що перевіряє листи перед надсиланням, завершився з помилкою або не відповів." }, "confirm_dialog": { "confirm": "Підтвердити", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 4399d8b3a..0c80356c6 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -813,7 +813,8 @@ "remove_background_color": "移除背景顏色" }, "send_filing_warning": "郵件已傳送,但寄送後清理失敗,可能仍保留舊草稿。", - "pop_out": "在獨立視窗中開啟回覆" + "pop_out": "在獨立視窗中開啟回覆", + "send_guard_failed": "未傳送 - 傳送前檢查郵件的外掛程式發生錯誤或沒有回應。" }, "confirm_dialog": { "confirm": "確認", diff --git a/locales/zh/common.json b/locales/zh/common.json index d12bc01c5..c9fb040e5 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -813,7 +813,8 @@ "remove_background_color": "移除背景颜色" }, "send_filing_warning": "已发送,但发送后的清理失败,可能会残留旧草稿。", - "pop_out": "在单独窗口中打开回复" + "pop_out": "在单独窗口中打开回复", + "send_guard_failed": "未发送 - 发送前检查邮件的插件出错或没有响应。" }, "confirm_dialog": { "confirm": "确认",