mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: close the composer after a plugin send and keep the draft when it fails #1207
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<typeof state>) => 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<Record<string, unknown>>,
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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: '<p>Hi</p>',
|
||||
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(<EmailComposer initialData={DRAFT} onSend={onSend} onClose={onClose} />);
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+49
-5
@@ -82,6 +82,24 @@ function withTimeout<T>(promise: T | Promise<T>, 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<T extends (...args: never[]) => unknown> {
|
||||
pluginId: string;
|
||||
handler: T;
|
||||
@@ -163,6 +181,28 @@ export class HookBus<T extends (...args: any[]) => 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<T>): Promise<TakeOverOutcome> {
|
||||
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<V>(initial: V, ...rest: unknown[]): Promise<V> {
|
||||
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.
|
||||
|
||||
@@ -705,6 +705,7 @@
|
||||
"send_failed": "فشل إرسال الرسالة",
|
||||
"send_timeout": "توقف الخادم عن الاستجابة. تحقق من مجلد المُرسَل قبل إعادة الإرسال — فقد تكون الرسالة قد أُرسلت بالفعل.",
|
||||
"send_recipients_rejected": "لم تُرسل الرسالة - رفض الخادم جميع المستلمين.",
|
||||
"send_plugin_failed": "لم يُرسل - فشلت إضافة تتولى الإرسال أو استغرقت وقتًا طويلًا. تحقق من مجلد المرسل قبل الإرسال مرة أخرى.",
|
||||
"send_some_recipients_rejected": "أُرسلت الرسالة، لكن ليس إلى هؤلاء المستلمين - رفضهم الخادم.",
|
||||
"continue_draft": "متابعة المسودة",
|
||||
"close_draft_title": "حفظ المسودة أم تجاهلها؟",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -705,6 +705,7 @@
|
||||
"send_failed": "ارسال ایمیل ناموفق بود",
|
||||
"send_timeout": "سرور دیگر پاسخ نمیدهد. پیش از ارسال دوباره، پوشهٔ ارسالشدهها را بررسی کنید — ممکن است پیام ارسال شده باشد.",
|
||||
"send_recipients_rejected": "ارسال نشد - سرور همه گیرندگان را رد کرد.",
|
||||
"send_plugin_failed": "ارسال نشد - افزونهای که ارسال را انجام میدهد با خطا مواجه شد یا بیش از حد طول کشید. پیش از ارسال دوباره، پوشه ارسالشده را بررسی کنید.",
|
||||
"send_some_recipients_rejected": "ارسال شد، اما نه برای این گیرندگان - سرور آنها را رد کرد.",
|
||||
"continue_draft": "ادامه پیشنویس",
|
||||
"close_draft_title": "ذخیره یا دور انداختن پیشنویس؟",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -641,6 +641,7 @@
|
||||
"send_failed": "שליחת האימייל נכשלה",
|
||||
"send_timeout": "השרת הפסיק להגיב. בדקו את תיקיית הנשלחים לפני שליחה חוזרת — ייתכן שההודעה כבר נשלחה.",
|
||||
"send_recipients_rejected": "לא נשלח - השרת דחה את כל הנמענים.",
|
||||
"send_plugin_failed": "לא נשלח - תוסף שמטפל בשליחה נכשל או לקח יותר מדי זמן. בדקו את תיקיית דואר יוצא לפני שליחה חוזרת.",
|
||||
"send_some_recipients_rejected": "נשלח, אך לא לנמענים האלה - השרת דחה אותם.",
|
||||
"continue_draft": "המשך טיוטה",
|
||||
"close_draft_title": "לשמור או למחוק טיוטה?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "下書きを保存または破棄しますか?",
|
||||
|
||||
@@ -702,6 +702,7 @@
|
||||
"send_failed": "메일 전송에 실패했어요",
|
||||
"send_timeout": "서버가 응답을 멈췄습니다. 다시 보내기 전에 보낸 편지함을 확인하세요. 메시지가 이미 발송되었을 수 있습니다.",
|
||||
"send_recipients_rejected": "보내지 못했습니다. 서버가 모든 수신자를 거부했습니다.",
|
||||
"send_plugin_failed": "보내지 않았습니다. 전송을 처리하는 플러그인이 실패했거나 너무 오래 걸렸습니다. 다시 보내기 전에 보낸 편지함을 확인하세요.",
|
||||
"send_some_recipients_rejected": "보냈지만 다음 수신자에게는 전달되지 않습니다. 서버가 거부했습니다.",
|
||||
"continue_draft": "이어서 작성하기",
|
||||
"close_draft_title": "임시보관함에 저장할까요?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -705,6 +705,7 @@
|
||||
"send_failed": "Имэйл илгээж чадсангүй",
|
||||
"send_timeout": "Сервер хариу өгөхөө больсон. Дахин илгээхийн өмнө \"Илгээсэн\" фолдероо шалгана уу — мессеж унтарсан байж магадгүй.",
|
||||
"send_recipients_rejected": "Илгээгдсэнгүй - сервер бүх хүлээн авагчийг татгалзсан.",
|
||||
"send_plugin_failed": "Илгээгдсэнгүй - илгээлтийг гүйцэтгэдэг залгаас алдаа гаргасан эсвэл хэт удсан. Дахин илгээхээсээ өмнө Илгээсэн хавтсаа шалгана уу.",
|
||||
"send_some_recipients_rejected": "Илгээсэн, гэхдээ эдгээр хүлээн авагчид хүрэхгүй - сервер тэднийг татгалзсан.",
|
||||
"continue_draft": "Ноорог үргэлжлүүлэх",
|
||||
"close_draft_title": "Ноорог хадгалах эсвэл устгах уу?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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ță?",
|
||||
|
||||
@@ -702,6 +702,7 @@
|
||||
"send_failed": "Не удалось отправить письмо",
|
||||
"send_timeout": "Сервер перестал отвечать. Прежде чем отправлять снова, проверьте папку «Отправленные» — письмо могло уже уйти.",
|
||||
"send_recipients_rejected": "Не отправлено — сервер отклонил всех получателей.",
|
||||
"send_plugin_failed": "Не отправлено: плагин, который отвечает за отправку, завершился с ошибкой или работал слишком долго. Проверьте папку «Отправленные», прежде чем отправлять снова.",
|
||||
"send_some_recipients_rejected": "Отправлено, но не этим получателям — сервер их отклонил.",
|
||||
"continue_draft": "Продолжить черновик",
|
||||
"close_draft_title": "Сохранить или удалить черновик?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -702,6 +702,7 @@
|
||||
"send_failed": "Не вдалося надіслати електронний лист",
|
||||
"send_timeout": "Сервер перестав відповідати. Перш ніж надсилати знову, перевірте теку «Надіслані» — лист міг уже піти.",
|
||||
"send_recipients_rejected": "Не надіслано — сервер відхилив усіх одержувачів.",
|
||||
"send_plugin_failed": "Не надіслано: плагін, який відповідає за надсилання, завершився з помилкою або працював задовго. Перевірте папку «Надіслані», перш ніж надсилати знову.",
|
||||
"send_some_recipients_rejected": "Надіслано, але не цим одержувачам — сервер їх відхилив.",
|
||||
"continue_draft": "Продовжити чернетку",
|
||||
"close_draft_title": "Зберегти чи видалити чернетку?",
|
||||
|
||||
@@ -705,6 +705,7 @@
|
||||
"send_failed": "傳送郵件失敗",
|
||||
"send_timeout": "伺服器停止回應。再次傳送前,請先檢查「寄件備份」信件匣,因為郵件可能已經寄出。",
|
||||
"send_recipients_rejected": "未傳送:伺服器拒絕了所有收件者。",
|
||||
"send_plugin_failed": "未傳送:負責傳送的外掛程式失敗或耗時過久。再次傳送前,請先檢查「寄件備份」資料夾。",
|
||||
"send_some_recipients_rejected": "已傳送,但未傳送給以下收件者:伺服器拒絕了他們。",
|
||||
"continue_draft": "繼續編輯草稿",
|
||||
"close_draft_title": "要儲存還是捨棄草稿?",
|
||||
|
||||
@@ -702,6 +702,7 @@
|
||||
"send_failed": "发送邮件失败",
|
||||
"send_timeout": "服务器已停止响应。再次发送前请检查“已发送”文件夹——邮件可能已经发出。",
|
||||
"send_recipients_rejected": "未发送:服务器拒绝了所有收件人。",
|
||||
"send_plugin_failed": "未发送:负责发送的插件失败或耗时过长。再次发送前,请先检查“已发送”文件夹。",
|
||||
"send_some_recipients_rejected": "已发送,但未发送给以下收件人:服务器拒绝了他们。",
|
||||
"continue_draft": "继续编辑草稿",
|
||||
"close_draft_title": "保存或丢弃草稿?",
|
||||
|
||||
Reference in New Issue
Block a user