From 50771cd082e42d0287d248bed847f042a5efc618 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:44:26 +0200 Subject: [PATCH] fix: close the composer after a plugin send and keep the draft when it fails #1207 --- .../composer-account-switch.test.tsx | 2 +- .../composer-attach-from-files.test.tsx | 2 +- .../__tests__/composer-close-guard.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-plugin-attachment-source.test.tsx | 2 +- .../__tests__/composer-plugin-send.test.tsx | 249 ++++++++++++++++++ ...composer-reopened-draft-signature.test.tsx | 2 +- .../composer-reply-draft-threading.test.tsx | 2 +- .../__tests__/composer-short-pane.test.tsx | 2 +- .../composer-upload-account.test.tsx | 2 +- .../__tests__/empty-subject-warning.test.tsx | 2 +- components/email/email-composer.tsx | 21 +- lib/__tests__/plugin-hooks.test.ts | 50 ++++ lib/plugin-hooks.ts | 54 +++- locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 45 files changed, 407 insertions(+), 22 deletions(-) create mode 100644 components/email/__tests__/composer-plugin-send.test.tsx diff --git a/components/email/__tests__/composer-account-switch.test.tsx b/components/email/__tests__/composer-account-switch.test.tsx index 738cc9e38..5da9297d0 100644 --- a/components/email/__tests__/composer-account-switch.test.tsx +++ b/components/email/__tests__/composer-account-switch.test.tsx @@ -148,7 +148,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-attach-from-files.test.tsx b/components/email/__tests__/composer-attach-from-files.test.tsx index 13f5232f1..cbfc0111f 100644 --- a/components/email/__tests__/composer-attach-from-files.test.tsx +++ b/components/email/__tests__/composer-attach-from-files.test.tsx @@ -160,7 +160,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, onBeforeAttachmentUpload: { intercept: hoisted.beforeAttachmentUpload }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index aaa115753..9a3c2b68f 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -142,7 +142,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-draft-attachments.test.tsx b/components/email/__tests__/composer-draft-attachments.test.tsx index 482fc5385..259e74132 100644 --- a/components/email/__tests__/composer-draft-attachments.test.tsx +++ b/components/email/__tests__/composer-draft-attachments.test.tsx @@ -140,7 +140,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-format-toggle.test.tsx b/components/email/__tests__/composer-format-toggle.test.tsx index bf91e1886..530412700 100644 --- a/components/email/__tests__/composer-format-toggle.test.tsx +++ b/components/email/__tests__/composer-format-toggle.test.tsx @@ -145,7 +145,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-forward-other-account.test.tsx b/components/email/__tests__/composer-forward-other-account.test.tsx index 5a8778459..4c310c81a 100644 --- a/components/email/__tests__/composer-forward-other-account.test.tsx +++ b/components/email/__tests__/composer-forward-other-account.test.tsx @@ -151,7 +151,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-from-override-envelope.test.tsx b/components/email/__tests__/composer-from-override-envelope.test.tsx index 9add3f877..93b1a94a7 100644 --- a/components/email/__tests__/composer-from-override-envelope.test.tsx +++ b/components/email/__tests__/composer-from-override-envelope.test.tsx @@ -144,7 +144,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx index 77f4662a2..096d7db6e 100644 --- a/components/email/__tests__/composer-mention.test.tsx +++ b/components/email/__tests__/composer-mention.test.tsx @@ -146,7 +146,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-plugin-attachment-source.test.tsx b/components/email/__tests__/composer-plugin-attachment-source.test.tsx index 5d56503b9..f07a6f80a 100644 --- a/components/email/__tests__/composer-plugin-attachment-source.test.tsx +++ b/components/email/__tests__/composer-plugin-attachment-source.test.tsx @@ -163,7 +163,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-plugin-send.test.tsx b/components/email/__tests__/composer-plugin-send.test.tsx new file mode 100644 index 000000000..0d5b95449 --- /dev/null +++ b/components/email/__tests__/composer-plugin-send.test.tsx @@ -0,0 +1,249 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +const hoisted = vi.hoisted(() => ({ + takeOver: vi.fn(), + toastError: vi.fn(), +})); + +// ─── Heavy component mocks (mirrors composer-from-override-envelope.test.tsx) ─ + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [ + { id: 'id-me', email: 'me@example.com', name: 'Me' }, + { id: 'id-sales', email: 'sales@example.com', name: 'Sales' }, + ] as Array>, + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + // The default position: replies/forwards get the signature appended at + // send time rather than embedded above the quote. + signaturePosition: 'below_quote', + signatureSeparatorEnabled: true, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: hoisted.toastError, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true }, + onComposeSend: { takeOver: hoisted.takeOver }, + onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeSignatureHtmlForDisplay: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + sanitizePluginBodyHtml: (v: string) => v, + escapeHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + + +// #1207: after a plugin (S/MIME) sent the message itself the composer stayed +// open and empty, and a plugin that failed or was cancelled still had the +// draft deleted. A throwing plugin let the message go out unsigned. + +const DRAFT = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Signed hello', + body: '

Hi

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +function renderComposer() { + const onSend = vi.fn(); + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + return { onSend, onClose }; +} + +describe('composer send taken over by a plugin (#1207)', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('closes after the plugin sent the message', async () => { + hoisted.takeOver.mockResolvedValue('handled'); + const { onSend, onClose } = renderComposer(); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(onSend).not.toHaveBeenCalled(); + }); + + it('keeps the message when the plugin aborts', async () => { + hoisted.takeOver.mockResolvedValue('aborted'); + const { onSend, onClose } = renderComposer(); + + await waitFor(() => expect(hoisted.takeOver).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getAllByTestId('composer-send')[0]).not.toBeDisabled()); + expect(screen.getByDisplayValue('Signed hello')).toBeInTheDocument(); + expect(onSend).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(hoisted.toastError).not.toHaveBeenCalled(); + }); + + it('does not send unsigned when the plugin fails', async () => { + hoisted.takeOver.mockResolvedValue('failed'); + const { onSend, onClose } = renderComposer(); + + await waitFor(() => expect(hoisted.toastError).toHaveBeenCalledWith('send_plugin_failed')); + expect(screen.getByDisplayValue('Signed hello')).toBeInTheDocument(); + expect(onSend).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('sends normally when no plugin takes the send', async () => { + hoisted.takeOver.mockResolvedValue('default'); + const { onSend } = renderComposer(); + + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/components/email/__tests__/composer-reopened-draft-signature.test.tsx b/components/email/__tests__/composer-reopened-draft-signature.test.tsx index 9bd5baea0..ea61f85dc 100644 --- a/components/email/__tests__/composer-reopened-draft-signature.test.tsx +++ b/components/email/__tests__/composer-reopened-draft-signature.test.tsx @@ -146,7 +146,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-reply-draft-threading.test.tsx b/components/email/__tests__/composer-reply-draft-threading.test.tsx index a7b0c951e..a820821b5 100644 --- a/components/email/__tests__/composer-reply-draft-threading.test.tsx +++ b/components/email/__tests__/composer-reply-draft-threading.test.tsx @@ -146,7 +146,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-short-pane.test.tsx b/components/email/__tests__/composer-short-pane.test.tsx index b742e6201..e37e11aa9 100644 --- a/components/email/__tests__/composer-short-pane.test.tsx +++ b/components/email/__tests__/composer-short-pane.test.tsx @@ -140,7 +140,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/__tests__/composer-upload-account.test.tsx b/components/email/__tests__/composer-upload-account.test.tsx index 17cf83335..3b8baf88a 100644 --- a/components/email/__tests__/composer-upload-account.test.tsx +++ b/components/email/__tests__/composer-upload-account.test.tsx @@ -152,7 +152,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, onBeforeAttachmentUpload: { intercept: async () => true }, onBeforeBlobUpload: { transform: async (fileId: unknown) => fileId }, diff --git a/components/email/__tests__/empty-subject-warning.test.tsx b/components/email/__tests__/empty-subject-warning.test.tsx index 6d4d09496..c5b91070d 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', () => ({ onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, - onComposeSend: { intercept: async () => true }, + onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index ab9f926b0..88f3a8796 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2524,8 +2524,10 @@ export function EmailComposer({ // Hand off to a crypto plugin (S/MIME, PGP, …) if one wants to take over // the send: it builds raw MIME, signs/encrypts, and submits via // api.jmap.sendRaw. A handler returning false means "I sent it" — the - // host then skips its own plaintext submission but still cleans up the - // draft (and fires the scheduled-send callback for a delayed send). + // host then skips its own plaintext submission, cleans up the draft and + // closes (through the scheduled-send callback for a delayed send). An + // aborted or failed handler sent nothing: keep the message and its + // draft, and never fall back to sending it unsigned (#1207). const composeSendRequest = { to: toAddresses.map(r => formatRecipient(r.name, r.email)), cc: ccAddresses.map(r => formatRecipient(r.name, r.email)), @@ -2546,7 +2548,17 @@ export function EmailComposer({ ...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })), ], }; - const sendHandledByPlugin = (await emailHooks.onComposeSend.intercept(composeSendRequest)) === false; + const pluginOutcome = await emailHooks.onComposeSend.takeOver(composeSendRequest); + if (pluginOutcome === 'aborted') { + // The plugin shows its own error, or the user cancelled its prompt. + debug.log('email', 'Send aborted by an onComposeSend plugin handler'); + return; + } + if (pluginOutcome === 'failed') { + toast.error(t('send_plugin_failed')); + return; + } + const sendHandledByPlugin = pluginOutcome === 'handled'; if (sendHandledByPlugin) { if (finalDraftId) { // The draft was created through the identity's owning account @@ -2651,6 +2663,9 @@ export function EmailComposer({ setScheduleError(''); // Clear ref so unmount effect doesn't re-save stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [], plainTextMode }; + // The host closes the composer from onSend or onScheduledSendCreated; + // a send the plugin made itself reaches neither (#1207). + if (sendHandledByPlugin && !effectiveDelayedUntil) cleanClose(); } catch (err) { debug.error('Failed to send email:', err); if (err instanceof RecipientsRejectedError) { diff --git a/lib/__tests__/plugin-hooks.test.ts b/lib/__tests__/plugin-hooks.test.ts index ee5848c3e..a90cc4226 100644 --- a/lib/__tests__/plugin-hooks.test.ts +++ b/lib/__tests__/plugin-hooks.test.ts @@ -200,6 +200,56 @@ describe('HookBus', () => { }); }); +// #1207: a send-takeover handler that failed or was cancelled let the host +// delete the draft, and one that threw let the host send the message unsigned. +describe('onComposeSend takeOver', () => { + it('reports a send the plugin made itself', async () => { + emailHooks.onComposeSend.register('smime', () => false); + expect(await emailHooks.onComposeSend.takeOver({})).toBe('handled'); + }); + + it('reports an abort the plugin asked for', async () => { + emailHooks.onComposeSend.register('smime', () => ({ abort: true })); + expect(await emailHooks.onComposeSend.takeOver({})).toBe('aborted'); + }); + + it('leaves the send to the host when no plugin takes it', async () => { + emailHooks.onComposeSend.register('other', () => undefined); + emailHooks.onComposeSend.register('guard', () => true); + expect(await emailHooks.onComposeSend.takeOver({})).toBe('default'); + }); + + it('fails closed when a handler throws', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const later = vi.fn(() => false); + emailHooks.onComposeSend.register('smime', () => { throw new Error('no key'); }, 10); + emailHooks.onComposeSend.register('later', later, 20); + expect(await emailHooks.onComposeSend.takeOver({})).toBe('failed'); + expect(later).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('waits for a slow passphrase prompt and fails closed after the budget', async () => { + vi.useFakeTimers(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + emailHooks.onComposeSend.register('smime', () => new Promise(resolve => setTimeout(() => resolve(false), 5 * 60_000))); + const slow = emailHooks.onComposeSend.takeOver({}); + await vi.advanceTimersByTimeAsync(5 * 60_000 + 100); + expect(await slow).toBe('handled'); + + emailHooks.onComposeSend.clear(); + emailHooks.onComposeSend.register('stuck', () => new Promise(() => {})); + const stuck = emailHooks.onComposeSend.takeOver({}); + await vi.advanceTimersByTimeAsync(10 * 60_000 + 100); + expect(await stuck).toBe('failed'); + } finally { + consoleSpy.mockRestore(); + vi.useRealTimers(); + } + }); +}); + describe('onBeforeBlobUpload offload budget', () => { it('gives the hook room for a real upload, not the 5s default', async () => { vi.useFakeTimers(); diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index afbd022fe..d9206e978 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -82,6 +82,24 @@ function withTimeout(promise: T | Promise, ms: number = DEFAULT_TIMEOUT_MS // ─── HookBus ───────────────────────────────────────────────── +/** + * What a `takeOver` hook decided: `default` lets the host do its own thing, + * `handled` means a plugin did it, `aborted` means a plugin stopped it on + * purpose (failed, or the user cancelled a prompt), and `failed` means a + * handler threw or timed out. + */ +export type TakeOverOutcome = 'default' | 'handled' | 'aborted' | 'failed'; + +/** A handler returns `{ abort: true }` to stop a `takeOver` operation. */ +export function isAbortResult(value: unknown): value is { abort: true } { + return typeof value === 'object' && value !== null && (value as { abort?: unknown }).abort === true; +} + +// A plugin that takes the send over may wait on a passphrase prompt first. +// When this budget runs out the host keeps the draft, but the plugin may +// still finish the send, so it has to be long enough for a slow user. +const COMPOSE_SEND_TIMEOUT_MS = 10 * 60_000; + interface HookEntry unknown> { pluginId: string; handler: T; @@ -163,6 +181,28 @@ export class HookBus any> { return true; } + /** + * Fire handlers that may take an operation over (see `onComposeSend`). + * Unlike `intercept`, this fails closed: a handler that throws or runs out + * of time stops the operation instead of letting the host's default run, + * which for a send would put an unsigned message on the wire (#1207). + */ + async takeOver(...args: Parameters): Promise { + for (const { pluginId, handler } of this.handlers) { + if (pluginErrorTracker.isDisabled(pluginId)) continue; + let result: unknown; + try { + result = await withTimeout(handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS)); + } catch (err) { + pluginErrorTracker.record(pluginId, err); + return 'failed'; + } + if (result === false) return 'handled'; + if (isAbortResult(result)) return 'aborted'; + } + return 'default'; + } + /** Fire handlers as transforms - each receives the output of the previous */ async transform(initial: V, ...rest: unknown[]): Promise { let value = initial; @@ -324,11 +364,15 @@ export const emailHooks = { // request (draft fields, recipients, identityId, attachments, and the user's // sign/encrypt intent) and may TAKE OVER sending entirely: build a raw MIME // message, sign/encrypt it, and submit it via `api.jmap.sendRaw`. Returning - // false signals "I handled the send" and the host SKIPS its default - // submission. Returning anything else (incl. undefined) lets the host send - // normally. This is the send-takeover hook used by the S/MIME plugin to - // replace the former native sign+encrypt+sendRaw pipeline. - onComposeSend: new HookBus(), + // false signals "I handled the send": the host SKIPS its default submission, + // deletes the draft and closes the composer. Returning { abort: true } means + // "nothing was sent" (signing failed, a passphrase prompt was cancelled): the + // host keeps the composer and the draft as they are. A handler that throws + // or times out counts as an abort, never as permission to send unsigned. + // Returning anything else (incl. undefined) lets the host send normally. + // This is the send-takeover hook used by the S/MIME plugin to replace the + // former native sign+encrypt+sendRaw pipeline. Fired with `takeOver`. + onComposeSend: new HookBus(COMPOSE_SEND_TIMEOUT_MS), // Transform hook - receive Email[] or ScheduledEmail[] just after there are fetched to // lets plugin edit emails before they are shown in row. Used to populate preview // field for encryption plugins. diff --git a/locales/ar/common.json b/locales/ar/common.json index 3bab0e57e..8c4e603f8 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -705,6 +705,7 @@ "send_failed": "فشل إرسال الرسالة", "send_timeout": "توقف الخادم عن الاستجابة. تحقق من مجلد المُرسَل قبل إعادة الإرسال — فقد تكون الرسالة قد أُرسلت بالفعل.", "send_recipients_rejected": "لم تُرسل الرسالة - رفض الخادم جميع المستلمين.", + "send_plugin_failed": "لم يُرسل - فشلت إضافة تتولى الإرسال أو استغرقت وقتًا طويلًا. تحقق من مجلد المرسل قبل الإرسال مرة أخرى.", "send_some_recipients_rejected": "أُرسلت الرسالة، لكن ليس إلى هؤلاء المستلمين - رفضهم الخادم.", "continue_draft": "متابعة المسودة", "close_draft_title": "حفظ المسودة أم تجاهلها؟", diff --git a/locales/ca/common.json b/locales/ca/common.json index 14551986f..703e32714 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -705,6 +705,7 @@ "send_failed": "No s'ha pogut enviar el correu", "send_timeout": "El servidor ha deixat de respondre. Comproveu la carpeta Enviats abans de tornar a enviar: el missatge pot haver sortit.", "send_recipients_rejected": "No s'ha enviat: el servidor ha rebutjat tots els destinataris.", + "send_plugin_failed": "No s'ha enviat: un connector que gestiona l'enviament ha fallat o ha trigat massa. Comprova la carpeta Enviats abans de tornar-ho a enviar.", "send_some_recipients_rejected": "S'ha enviat, però no a aquests destinataris: el servidor els ha rebutjat.", "continue_draft": "Continua l'esborrany", "close_draft_title": "Voleu desar o descartar l'esborrany?", diff --git a/locales/cs/common.json b/locales/cs/common.json index db869e067..473c7d55c 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -702,6 +702,7 @@ "send_failed": "Odeslání zprávy selhalo", "send_timeout": "Server přestal odpovídat. Než odešlete znovu, zkontrolujte složku Odeslané – zpráva už mohla odejít.", "send_recipients_rejected": "Neodesláno – server odmítl všechny příjemce.", + "send_plugin_failed": "Neodesláno – plugin, který zajišťuje odeslání, selhal nebo trval příliš dlouho. Před opětovným odesláním zkontrolujte složku Odeslané.", "send_some_recipients_rejected": "Odesláno, ale ne těmto příjemcům – server je odmítl.", "continue_draft": "Pokračovat v úpravě konceptu", "close_draft_title": "Uložit nebo zahodit koncept?", diff --git a/locales/da/common.json b/locales/da/common.json index e90b6fd7c..3ac8df7e5 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -704,6 +704,7 @@ "send_failed": "Kunne ikke sende e-mail", "send_timeout": "Serveren holdt op med at svare. Tjek mappen Sendt, før du sender igen — beskeden er måske allerede afsendt.", "send_recipients_rejected": "Ikke sendt - serveren afviste alle modtagere.", + "send_plugin_failed": "Ikke sendt - et plugin, der står for afsendelsen, fejlede eller tog for lang tid. Tjek mappen Sendt, før du sender igen.", "send_some_recipients_rejected": "Sendt, men ikke til disse modtagere - serveren afviste dem.", "continue_draft": "Fortsæt kladde", "close_draft_title": "Gem eller kassér kladde?", diff --git a/locales/de/common.json b/locales/de/common.json index 1f1e63f51..9fb9ce1f3 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -709,6 +709,7 @@ "smime_passphrase_placeholder": "S/MIME-Passphrase", "send_timeout": "Der Server antwortet nicht mehr. Prüfen Sie den Ordner „Gesendet“, bevor Sie erneut senden – die Nachricht wurde möglicherweise bereits verschickt.", "send_recipients_rejected": "Nicht gesendet – der Server hat alle Empfänger abgelehnt.", + "send_plugin_failed": "Nicht gesendet – ein Plugin, das den Versand übernimmt, ist fehlgeschlagen oder hat zu lange gebraucht. Prüfen Sie den Ordner „Gesendet“, bevor Sie erneut senden.", "send_some_recipients_rejected": "Gesendet, aber nicht an diese Empfänger – der Server hat sie abgelehnt.", "continue_draft": "Entwurf fortsetzen", "close_draft_title": "Entwurf speichern oder verwerfen?", diff --git a/locales/en/common.json b/locales/en/common.json index ebf3b2679..dd3b04a12 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -705,6 +705,7 @@ "send_failed": "Failed to send email", "send_timeout": "The server stopped responding. Check your Sent folder before sending again — the message may have gone out.", "send_recipients_rejected": "Not sent - the server rejected every recipient.", + "send_plugin_failed": "Not sent - a plugin that handles sending failed or took too long. Check your Sent folder before sending again.", "send_some_recipients_rejected": "Sent, but not to these recipients - the server rejected them.", "continue_draft": "Continue draft", "close_draft_title": "Save or discard draft?", diff --git a/locales/es/common.json b/locales/es/common.json index 2bee7ea1f..b437d5e03 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -709,6 +709,7 @@ "smime_passphrase_placeholder": "Contraseña S/MIME", "send_timeout": "El servidor dejó de responder. Compruebe la carpeta Enviados antes de volver a enviar: el mensaje puede haber salido.", "send_recipients_rejected": "No enviado: el servidor rechazó a todos los destinatarios.", + "send_plugin_failed": "No enviado: un complemento que gestiona el envío falló o tardó demasiado. Revisa la carpeta Enviados antes de volver a enviarlo.", "send_some_recipients_rejected": "Enviado, pero no a estos destinatarios: el servidor los rechazó.", "continue_draft": "Continuar borrador", "close_draft_title": "¿Guardar o descartar borrador?", diff --git a/locales/fa/common.json b/locales/fa/common.json index 7b8af3113..09b36fc18 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -705,6 +705,7 @@ "send_failed": "ارسال ایمیل ناموفق بود", "send_timeout": "سرور دیگر پاسخ نمی‌دهد. پیش از ارسال دوباره، پوشهٔ ارسال‌شده‌ها را بررسی کنید — ممکن است پیام ارسال شده باشد.", "send_recipients_rejected": "ارسال نشد - سرور همه گیرندگان را رد کرد.", + "send_plugin_failed": "ارسال نشد - افزونه‌ای که ارسال را انجام می‌دهد با خطا مواجه شد یا بیش از حد طول کشید. پیش از ارسال دوباره، پوشه ارسال‌شده را بررسی کنید.", "send_some_recipients_rejected": "ارسال شد، اما نه برای این گیرندگان - سرور آنها را رد کرد.", "continue_draft": "ادامه پیش‌نویس", "close_draft_title": "ذخیره یا دور انداختن پیش‌نویس؟", diff --git a/locales/fr/common.json b/locales/fr/common.json index 98534b5a6..6232dfd8d 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -709,6 +709,7 @@ "smime_passphrase_placeholder": "Phrase secrète S/MIME", "send_timeout": "Le serveur ne répond plus. Vérifiez le dossier Envoyés avant de renvoyer : le message est peut-être déjà parti.", "send_recipients_rejected": "Non envoyé : le serveur a refusé tous les destinataires.", + "send_plugin_failed": "Non envoyé : un plugin chargé de l'envoi a échoué ou a pris trop de temps. Vérifiez le dossier Envoyés avant de renvoyer le message.", "send_some_recipients_rejected": "Envoyé, mais pas à ces destinataires : le serveur les a refusés.", "continue_draft": "Continuer le brouillon", "close_draft_title": "Enregistrer ou supprimer le brouillon ?", diff --git a/locales/he/common.json b/locales/he/common.json index 622753e69..595d951e8 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -641,6 +641,7 @@ "send_failed": "שליחת האימייל נכשלה", "send_timeout": "השרת הפסיק להגיב. בדקו את תיקיית הנשלחים לפני שליחה חוזרת — ייתכן שההודעה כבר נשלחה.", "send_recipients_rejected": "לא נשלח - השרת דחה את כל הנמענים.", + "send_plugin_failed": "לא נשלח - תוסף שמטפל בשליחה נכשל או לקח יותר מדי זמן. בדקו את תיקיית דואר יוצא לפני שליחה חוזרת.", "send_some_recipients_rejected": "נשלח, אך לא לנמענים האלה - השרת דחה אותם.", "continue_draft": "המשך טיוטה", "close_draft_title": "לשמור או למחוק טיוטה?", diff --git a/locales/hu/common.json b/locales/hu/common.json index 1eb55c5e6..d50c2f88c 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -705,6 +705,7 @@ "send_failed": "Nem sikerült elküldeni az e-mailt", "send_timeout": "A kiszolgáló nem válaszol többé. Újraküldés előtt ellenőrizze az Elküldött mappát – az üzenet lehet, hogy már kiment.", "send_recipients_rejected": "Nem lett elküldve – a szerver minden címzettet elutasított.", + "send_plugin_failed": "Nem lett elküldve – egy küldést végző bővítmény hibát jelzett vagy túl sokáig tartott. Újraküldés előtt nézd meg az Elküldött mappát.", "send_some_recipients_rejected": "Elküldve, de ezeknek a címzetteknek nem – a szerver elutasította őket.", "continue_draft": "Piszkozat folytatása", "close_draft_title": "Piszkozat mentése vagy elvetése?", diff --git a/locales/it/common.json b/locales/it/common.json index 428d98a19..d357e2dc2 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -709,6 +709,7 @@ "smime_passphrase_placeholder": "Passphrase S/MIME", "send_timeout": "Il server ha smesso di rispondere. Controlli la cartella Posta inviata prima di inviare di nuovo: il messaggio potrebbe essere già partito.", "send_recipients_rejected": "Non inviato: il server ha rifiutato tutti i destinatari.", + "send_plugin_failed": "Non inviato: un plugin che gestisce l'invio non è riuscito o ha impiegato troppo tempo. Controlla la cartella Inviati prima di inviare di nuovo.", "send_some_recipients_rejected": "Inviato, ma non a questi destinatari: il server li ha rifiutati.", "continue_draft": "Continua bozza", "close_draft_title": "Salvare o eliminare la bozza?", diff --git a/locales/ja/common.json b/locales/ja/common.json index fc2757a88..9946e2872 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -709,6 +709,7 @@ "smime_passphrase_placeholder": "S/MIME パスフレーズ", "send_timeout": "サーバーが応答しなくなりました。再送信する前に送信済みフォルダーを確認してください。メッセージはすでに送信されている可能性があります。", "send_recipients_rejected": "送信されませんでした。サーバーがすべての宛先を拒否しました。", + "send_plugin_failed": "送信されませんでした。送信を処理するプラグインが失敗したか、時間がかかりすぎました。もう一度送信する前に送信済みフォルダーを確認してください。", "send_some_recipients_rejected": "送信しましたが、次の宛先には届きません。サーバーが拒否しました。", "continue_draft": "下書きを続ける", "close_draft_title": "下書きを保存または破棄しますか?", diff --git a/locales/ko/common.json b/locales/ko/common.json index 0dd7811dc..69e2f513f 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -702,6 +702,7 @@ "send_failed": "메일 전송에 실패했어요", "send_timeout": "서버가 응답을 멈췄습니다. 다시 보내기 전에 보낸 편지함을 확인하세요. 메시지가 이미 발송되었을 수 있습니다.", "send_recipients_rejected": "보내지 못했습니다. 서버가 모든 수신자를 거부했습니다.", + "send_plugin_failed": "보내지 않았습니다. 전송을 처리하는 플러그인이 실패했거나 너무 오래 걸렸습니다. 다시 보내기 전에 보낸 편지함을 확인하세요.", "send_some_recipients_rejected": "보냈지만 다음 수신자에게는 전달되지 않습니다. 서버가 거부했습니다.", "continue_draft": "이어서 작성하기", "close_draft_title": "임시보관함에 저장할까요?", diff --git a/locales/lv/common.json b/locales/lv/common.json index 44c580576..3919b336d 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -702,6 +702,7 @@ "send_failed": "Neizdevās nosūtīt vēstuli", "send_timeout": "Serveris pārstāja atbildēt. Pirms sūtīt vēlreiz, pārbaudiet mapi Nosūtītie — ziņojums, iespējams, jau ir aizsūtīts.", "send_recipients_rejected": "Nav nosūtīts — serveris noraidīja visus adresātus.", + "send_plugin_failed": "Nav nosūtīts – spraudnis, kas veic sūtīšanu, kļūdījās vai strādāja pārāk ilgi. Pirms sūtīt vēlreiz, pārbaudiet mapi Nosūtītie.", "send_some_recipients_rejected": "Nosūtīts, taču ne šiem adresātiem — serveris tos noraidīja.", "continue_draft": "Turpināt melnrakstu", "close_draft_title": "Saglabāt vai atmest melnrakstu?", diff --git a/locales/mn/common.json b/locales/mn/common.json index 2f3775441..d06a54f49 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -705,6 +705,7 @@ "send_failed": "Имэйл илгээж чадсангүй", "send_timeout": "Сервер хариу өгөхөө больсон. Дахин илгээхийн өмнө \"Илгээсэн\" фолдероо шалгана уу — мессеж унтарсан байж магадгүй.", "send_recipients_rejected": "Илгээгдсэнгүй - сервер бүх хүлээн авагчийг татгалзсан.", + "send_plugin_failed": "Илгээгдсэнгүй - илгээлтийг гүйцэтгэдэг залгаас алдаа гаргасан эсвэл хэт удсан. Дахин илгээхээсээ өмнө Илгээсэн хавтсаа шалгана уу.", "send_some_recipients_rejected": "Илгээсэн, гэхдээ эдгээр хүлээн авагчид хүрэхгүй - сервер тэднийг татгалзсан.", "continue_draft": "Ноорог үргэлжлүүлэх", "close_draft_title": "Ноорог хадгалах эсвэл устгах уу?", diff --git a/locales/nb/common.json b/locales/nb/common.json index a6622753c..7a9760471 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -705,6 +705,7 @@ "send_failed": "Kunne ikke sende e-post", "send_timeout": "Serveren sluttet å svare. Sjekk Sendt-mappen før du sender på nytt – meldingen kan ha blitt sendt.", "send_recipients_rejected": "Ikke sendt – serveren avviste alle mottakerne.", + "send_plugin_failed": "Ikke sendt - et tillegg som står for sendingen, mislyktes eller brukte for lang tid. Sjekk Sendt-mappen før du sender på nytt.", "send_some_recipients_rejected": "Sendt, men ikke til disse mottakerne – serveren avviste dem.", "continue_draft": "Fortsett å skrive", "close_draft_title": "Lagre eller forkast utkast?", diff --git a/locales/nl/common.json b/locales/nl/common.json index bfebceb3d..2fca5a719 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -709,6 +709,7 @@ "smime_passphrase_placeholder": "S/MIME-wachtwoordzin", "send_timeout": "De server reageert niet meer. Controleer de map Verzonden voordat u opnieuw verstuurt — het bericht is mogelijk al verzonden.", "send_recipients_rejected": "Niet verzonden - de server heeft alle ontvangers geweigerd.", + "send_plugin_failed": "Niet verzonden - een plug-in die het verzenden afhandelt, is mislukt of duurde te lang. Controleer je map Verzonden voordat je opnieuw verzendt.", "send_some_recipients_rejected": "Verzonden, maar niet naar deze ontvangers - de server heeft ze geweigerd.", "continue_draft": "Concept voortzetten", "close_draft_title": "Concept opslaan of verwijderen?", diff --git a/locales/pl/common.json b/locales/pl/common.json index 42ebf3ff6..175406ec7 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -702,6 +702,7 @@ "send_failed": "Nie udało się wysłać wiadomości", "send_timeout": "Serwer przestał odpowiadać. Przed ponownym wysłaniem sprawdź folder Wysłane — wiadomość mogła już zostać wysłana.", "send_recipients_rejected": "Nie wysłano – serwer odrzucił wszystkich odbiorców.", + "send_plugin_failed": "Nie wysłano – wtyczka obsługująca wysyłanie zawiodła lub działała zbyt długo. Przed ponownym wysłaniem sprawdź folder Wysłane.", "send_some_recipients_rejected": "Wysłano, ale nie do tych odbiorców – serwer ich odrzucił.", "continue_draft": "Kontynuuj szkic", "close_draft_title": "Zapisać czy odrzucić szkic?", diff --git a/locales/pt/common.json b/locales/pt/common.json index f3236164e..e32d59ef4 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -709,6 +709,7 @@ "smime_passphrase_placeholder": "Frase secreta S/MIME", "send_timeout": "O servidor deixou de responder. Verifique a pasta Enviados antes de enviar novamente — a mensagem pode já ter saído.", "send_recipients_rejected": "Não enviado: o servidor rejeitou todos os destinatários.", + "send_plugin_failed": "Não enviado: um plugin que trata do envio falhou ou demorou demasiado. Verifique a pasta Enviados antes de enviar novamente.", "send_some_recipients_rejected": "Enviado, mas não para estes destinatários, que foram rejeitados pelo servidor.", "continue_draft": "Continuar rascunho", "close_draft_title": "Salvar ou descartar rascunho?", diff --git a/locales/ro/common.json b/locales/ro/common.json index 5192731eb..b31274a36 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -705,6 +705,7 @@ "send_failed": "Nu s-a putut trimite e-mailul", "send_timeout": "Serverul a încetat să răspundă. Verificați folderul Trimise înainte de a trimite din nou — mesajul poate să fi plecat deja.", "send_recipients_rejected": "Netrimis - serverul a respins toți destinatarii.", + "send_plugin_failed": "Nu a fost trimis - un plugin care se ocupă de trimitere a eșuat sau a durat prea mult. Verifică dosarul Trimise înainte de a trimite din nou.", "send_some_recipients_rejected": "Trimis, dar nu și acestor destinatari - serverul i-a respins.", "continue_draft": "Continuă schița", "close_draft_title": "Să salvați sau să renunțați la schiță?", diff --git a/locales/ru/common.json b/locales/ru/common.json index fa98156ee..37a6cf4a9 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -702,6 +702,7 @@ "send_failed": "Не удалось отправить письмо", "send_timeout": "Сервер перестал отвечать. Прежде чем отправлять снова, проверьте папку «Отправленные» — письмо могло уже уйти.", "send_recipients_rejected": "Не отправлено — сервер отклонил всех получателей.", + "send_plugin_failed": "Не отправлено: плагин, который отвечает за отправку, завершился с ошибкой или работал слишком долго. Проверьте папку «Отправленные», прежде чем отправлять снова.", "send_some_recipients_rejected": "Отправлено, но не этим получателям — сервер их отклонил.", "continue_draft": "Продолжить черновик", "close_draft_title": "Сохранить или удалить черновик?", diff --git a/locales/sk/common.json b/locales/sk/common.json index a4b28aa1b..e1b96831e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -705,6 +705,7 @@ "send_failed": "Odoslanie e-mailu zlyhalo", "send_timeout": "Server prestal odpovedať. Pred opätovným odoslaním skontrolujte priečinok Odoslané — správa už mohla odísť.", "send_recipients_rejected": "Neodoslané – server odmietol všetkých príjemcov.", + "send_plugin_failed": "Neodoslané – doplnok, ktorý zabezpečuje odoslanie, zlyhal alebo trval príliš dlho. Pred opätovným odoslaním skontrolujte priečinok Odoslané.", "send_some_recipients_rejected": "Odoslané, ale nie týmto príjemcom – server ich odmietol.", "continue_draft": "Pokračovať v koncepte", "close_draft_title": "Uložiť alebo zahoditť koncept?", diff --git a/locales/tr/common.json b/locales/tr/common.json index 98e44aa6b..20a974252 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -705,6 +705,7 @@ "send_failed": "E-posta gönderilemedi", "send_timeout": "Sunucu yanıt vermeyi bıraktı. Yeniden göndermeden önce Gönderilenler klasörünü kontrol edin — ileti gönderilmiş olabilir.", "send_recipients_rejected": "Gönderilmedi - sunucu tüm alıcıları reddetti.", + "send_plugin_failed": "Gönderilmedi - gönderimi üstlenen bir eklenti başarısız oldu ya da çok uzun sürdü. Yeniden göndermeden önce Gönderilenler klasörünü kontrol edin.", "send_some_recipients_rejected": "Gönderildi, ancak bu alıcılara değil - sunucu onları reddetti.", "continue_draft": "Taslağa devam et", "close_draft_title": "Taslak kaydedilsin mi yoksa silinsin mi?", diff --git a/locales/uk/common.json b/locales/uk/common.json index 47ac491c1..c41908bbf 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -702,6 +702,7 @@ "send_failed": "Не вдалося надіслати електронний лист", "send_timeout": "Сервер перестав відповідати. Перш ніж надсилати знову, перевірте теку «Надіслані» — лист міг уже піти.", "send_recipients_rejected": "Не надіслано — сервер відхилив усіх одержувачів.", + "send_plugin_failed": "Не надіслано: плагін, який відповідає за надсилання, завершився з помилкою або працював задовго. Перевірте папку «Надіслані», перш ніж надсилати знову.", "send_some_recipients_rejected": "Надіслано, але не цим одержувачам — сервер їх відхилив.", "continue_draft": "Продовжити чернетку", "close_draft_title": "Зберегти чи видалити чернетку?", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index d78a7f76a..3110777ae 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -705,6 +705,7 @@ "send_failed": "傳送郵件失敗", "send_timeout": "伺服器停止回應。再次傳送前,請先檢查「寄件備份」信件匣,因為郵件可能已經寄出。", "send_recipients_rejected": "未傳送:伺服器拒絕了所有收件者。", + "send_plugin_failed": "未傳送:負責傳送的外掛程式失敗或耗時過久。再次傳送前,請先檢查「寄件備份」資料夾。", "send_some_recipients_rejected": "已傳送,但未傳送給以下收件者:伺服器拒絕了他們。", "continue_draft": "繼續編輯草稿", "close_draft_title": "要儲存還是捨棄草稿?", diff --git a/locales/zh/common.json b/locales/zh/common.json index 7aa6ce31a..46dc5397f 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -702,6 +702,7 @@ "send_failed": "发送邮件失败", "send_timeout": "服务器已停止响应。再次发送前请检查“已发送”文件夹——邮件可能已经发出。", "send_recipients_rejected": "未发送:服务器拒绝了所有收件人。", + "send_plugin_failed": "未发送:负责发送的插件失败或耗时过长。再次发送前,请先检查“已发送”文件夹。", "send_some_recipients_rejected": "已发送,但未发送给以下收件人:服务器拒绝了他们。", "continue_draft": "继续编辑草稿", "close_draft_title": "保存或丢弃草稿?",