fix: never send a message when a plugin that checks it before sending fails or does not answer

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