mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: stop reporting mail as sent when the server refused its recipients
Stalwart runs the SMTP RCPT stage while it creates a submission and
records a refused recipient as `delivered: "no"` with its reply: a relay
it doesn't allow ("550 5.1.2 Relay not allowed.") or a used-up throttle
("452 4.4.5 Rate limit exceeded, try again later."), on 0.16 and 1.0
alike. The create still succeeds and onSuccessUpdateEmail files the
message in Sent. Bulwark read only the new record's id and sendAt, said
"Email sent", and on Stalwart destroyed the record right after, the only
trace of the refusal. The message was lost without a bounce.
- the send reads the new submission's delivery status in the same
request (`EmailSubmission/get` by its creation id; the create's answer
has none), before the record is destroyed
- every recipient refused: the send fails with the server's reply per
address, the copy filed in Sent is removed and the user's draft kept,
so the address can be fixed in the composer; held sends (undo send,
scheduled) are refused when created, so this covers them too
- some recipients refused: the others get the message, and a warning
names the refused ones with their replies
- "Send now" and "Reschedule" keep the original when every recipient of
the replacement is refused, instead of cancelling it
- the submission prune leaves a shared account that refuses its records
alone for the session, without a warning: 0.16 lists an account that
only shared an address book or a calendar with the submission
capability
This commit is contained in:
@@ -2,7 +2,7 @@ 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';
|
||||
import { SubmissionQuotaError } from '@/lib/jmap/client';
|
||||
import { RecipientsRefusedError, SubmissionQuotaError } from '@/lib/jmap/client';
|
||||
|
||||
// ─── Heavy component mocks (mirrors composer-reopened-draft-signature.test.tsx) ─
|
||||
|
||||
@@ -226,4 +226,13 @@ describe('composer send failures', () => {
|
||||
|
||||
expect(message).toBe('Envelope mailFrom does not match identity email address. [forbiddenFrom]');
|
||||
});
|
||||
|
||||
// Stalwart 0.16 and 1.0 take the submission but refuse the recipient at
|
||||
// RCPT; the send fails and the composer keeps the message.
|
||||
it('says the message was not sent when the server refused every recipient', async () => {
|
||||
const message = await sendAndFail(new RecipientsRefusedError([{ email: 'nobody@example.net', smtpReply: '550 5.1.2 Relay not allowed.' }]));
|
||||
|
||||
expect(message).toBe('recipients_refused');
|
||||
expect(screen.getByDisplayValue('Hello')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore, buildUnifiedAccountClients, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId } from "@/stores/email-store";
|
||||
import { groupSearchScopeFolders } from "@/lib/search-scope-folders";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cancelFailureToast, rescheduleFailureToast } from "@/lib/send-failure-message";
|
||||
import { cancelFailureToast, formatRefusedRecipients, rescheduleFailureToast } from "@/lib/send-failure-message";
|
||||
import { runBatchEmailAction } from "@/lib/email-action-toast";
|
||||
import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog";
|
||||
import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster";
|
||||
@@ -1749,6 +1749,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// and re-sent (#592).
|
||||
toast.warning(t('email_composer.send_filing_warning'));
|
||||
}
|
||||
if (result.refusedRecipients?.length) {
|
||||
// The server refused some recipients outright; nothing else, not
|
||||
// even a bounce, would tell the user they never get the message.
|
||||
toast.warning(t('email_composer.recipients_partly_refused', {
|
||||
recipients: formatRefusedRecipients(result.refusedRecipients),
|
||||
}));
|
||||
}
|
||||
// Mark the original email with $answered or $forwarded keyword. Route the
|
||||
// write to the email's own account so the flag lands on shared/group-mailbox
|
||||
// messages instead of being dropped against the reaching account. (#281)
|
||||
@@ -1770,8 +1777,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delayed sends get the undo toast instead; a filing error already warned.
|
||||
if (!result.filingError) toast.success(t('notifications.email_sent'));
|
||||
// Delayed sends get the undo toast instead; a filing error or refused
|
||||
// recipients already warned.
|
||||
if (!result.filingError && !result.refusedRecipients?.length) toast.success(t('notifications.email_sent'));
|
||||
|
||||
// Refresh the current mailbox to update the UI
|
||||
if (!isScheduledView) {
|
||||
|
||||
@@ -29,7 +29,8 @@ vi.mock('@/components/error', () => ({
|
||||
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {}, warning: () => {} } }));
|
||||
const { toastWarning } = vi.hoisted(() => ({ toastWarning: vi.fn() }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {}, warning: toastWarning } }));
|
||||
|
||||
const { emailState, authState } = vi.hoisted(() => ({
|
||||
emailState: {
|
||||
@@ -171,3 +172,27 @@ describe('Pro compose tab - a draft that lives in a group account', () => {
|
||||
expect(sendEmail.mock.calls[0][16]).toEqual({ localAccountId: undefined, draftAccountId: 'c' });
|
||||
});
|
||||
});
|
||||
|
||||
// Stalwart 0.16 and 1.0 refuse a recipient at RCPT (relay not allowed, a
|
||||
// used-up throttle) while the send goes to the others.
|
||||
describe('Pro compose tab - recipients the server refused', () => {
|
||||
const saved = emailState.sendEmail;
|
||||
|
||||
afterEach(() => {
|
||||
emailState.sendEmail = saved;
|
||||
toastWarning.mockClear();
|
||||
});
|
||||
|
||||
it('says which recipients never get the message', async () => {
|
||||
emailState.sendEmail = vi.fn().mockResolvedValue({
|
||||
scheduled: false,
|
||||
refusedRecipients: [{ email: 'nobody@example.net', smtpReply: '550 5.1.2 Relay not allowed.' }],
|
||||
});
|
||||
render(<ProComposeTabBody tabId="tab-1" data={TAB_DATA} />);
|
||||
const onSend = composerProps.current?.onSend as (data: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await onSend({ to: ['bob@example.org', 'nobody@example.net'], cc: [], bcc: [], subject: 'Hi', body: 'Hi' });
|
||||
|
||||
expect(toastWarning).toHaveBeenCalledWith('email_composer.recipients_partly_refused');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, registerProTabCloseInterceptor, type ProComposeTabData } from "@/stores/pro-tab-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { threadKeyFor } from "@/lib/thread-utils";
|
||||
import { formatRefusedRecipients } from "@/lib/send-failure-message";
|
||||
|
||||
interface ProComposeTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -89,6 +90,12 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
{ localAccountId: sendData.localAccountId, draftAccountId: sendData.draftAccountId },
|
||||
);
|
||||
submitted = true;
|
||||
if (result.refusedRecipients?.length) {
|
||||
// As in the inline composer: nothing else would say who never gets it.
|
||||
toast.warning(t('email_composer.recipients_partly_refused', {
|
||||
recipients: formatRefusedRecipients(result.refusedRecipients),
|
||||
}));
|
||||
}
|
||||
|
||||
if (result.scheduled) {
|
||||
await handleScheduledSendCreated();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JMAPClient, NoLongerScheduledError, SubmissionQuotaError, prunableSubmissionIds } from '../jmap/client';
|
||||
import { JMAPClient, NoLongerScheduledError, RecipientsRefusedError, SubmissionQuotaError, prunableSubmissionIds } from '../jmap/client';
|
||||
import { jmapErrorType } from '../jmap/jmap-error';
|
||||
|
||||
/**
|
||||
@@ -30,8 +30,14 @@ interface FakeSubmission {
|
||||
canceled: boolean;
|
||||
/** Still in the queue: held, or retrying. */
|
||||
queued: boolean;
|
||||
/** What the RCPT stage answered, per recipient (EmailSubmission deliveryStatus). */
|
||||
deliveryStatus?: Record<string, { delivered: string; smtpReply: string; displayed: string }>;
|
||||
}
|
||||
|
||||
// Stalwart's replies to recipients it refuses at RCPT (live, 0.16.23 and 1.0).
|
||||
const RELAY_DENIED = '550 5.1.2 Relay not allowed.';
|
||||
const RATE_LIMITED = '452 4.4.5 Rate limit exceeded, try again later.';
|
||||
|
||||
const OVER_QUOTA_DESCRIPTION = 'There are too many email submissions, please delete some before adding a new one.';
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
@@ -56,12 +62,14 @@ interface FakeOptions {
|
||||
emailCreateError?: SetError;
|
||||
/** Answer a call before the fake does. */
|
||||
intercept?: (call: MethodCall) => Array<[string, Record<string, unknown>, string]> | undefined;
|
||||
/** The SMTP reply the RCPT stage refuses a recipient with; undefined takes it. */
|
||||
refuseRecipient?: (email: string) => string | undefined;
|
||||
}
|
||||
|
||||
function fakeStalwart(options: FakeOptions = {}) {
|
||||
const version = options.version ?? '1.0';
|
||||
const submissions = new Map<string, FakeSubmission>();
|
||||
const emails = new Map<string, { accountId: string; mailboxIds: Record<string, boolean> }>();
|
||||
const emails = new Map<string, { accountId: string; mailboxIds: Record<string, boolean>; recipients?: string[] }>();
|
||||
const requests: MethodCall[][] = [];
|
||||
let next = 1;
|
||||
|
||||
@@ -132,13 +140,14 @@ function fakeStalwart(options: FakeOptions = {}) {
|
||||
if (args.create) {
|
||||
const created: Record<string, { id: string }> = {};
|
||||
const notCreated: Record<string, SetError> = {};
|
||||
for (const [key, value] of Object.entries(args.create as Record<string, { mailboxIds: Record<string, boolean> }>)) {
|
||||
for (const [key, value] of Object.entries(args.create as Record<string, { mailboxIds: Record<string, boolean>; to?: Array<{ email: string }>; cc?: Array<{ email: string }>; bcc?: Array<{ email: string }> }>)) {
|
||||
if (options.emailCreateError) {
|
||||
notCreated[key] = options.emailCreateError;
|
||||
continue;
|
||||
}
|
||||
const id = newId('email');
|
||||
emails.set(id, { accountId, mailboxIds: value.mailboxIds });
|
||||
const recipients = [...(value.to ?? []), ...(value.cc ?? []), ...(value.bcc ?? [])].map((a) => a.email);
|
||||
emails.set(id, { accountId, mailboxIds: value.mailboxIds, recipients });
|
||||
created[key] = { id };
|
||||
}
|
||||
if (Object.keys(created).length) result.created = created;
|
||||
@@ -209,11 +218,22 @@ function fakeStalwart(options: FakeOptions = {}) {
|
||||
}
|
||||
}
|
||||
const holdFor = Number((create.envelope as { mailFrom?: { parameters?: { HOLDFOR?: string } } } | undefined)?.mailFrom?.parameters?.HOLDFOR ?? 0);
|
||||
const submission = add({ accountId, emailId, sendAt: utcDate(Date.now() + holdFor * 1000), queued: true });
|
||||
// The RCPT stage runs while the submission is created, held or
|
||||
// not. A refused recipient is not queued; with none taken, nothing is.
|
||||
const recipients = (create.envelope as { rcptTo?: Array<{ email: string }> } | undefined)?.rcptTo?.map((r) => r.email)
|
||||
?? emails.get(emailId)?.recipients ?? [];
|
||||
const deliveryStatus = Object.fromEntries(recipients.map((email) => {
|
||||
const reply = options.refuseRecipient?.(email);
|
||||
return [email, reply
|
||||
? { delivered: 'no', smtpReply: reply, displayed: 'unknown' }
|
||||
: { delivered: 'queued', smtpReply: '250 2.1.5 Queued', displayed: 'unknown' }];
|
||||
}));
|
||||
const taken = recipients.length === 0 || Object.values(deliveryStatus).some((d) => d.delivered !== 'no');
|
||||
const submission = add({ accountId, emailId, sendAt: utcDate(Date.now() + holdFor * 1000), queued: taken, deliveryStatus });
|
||||
successEmails[key] = emailId;
|
||||
created[key] = version === '0.16.6'
|
||||
? { id: submission.id }
|
||||
: { id: submission.id, sendAt: submission.sendAt, undoStatus: version === '1.0' ? 'pending' : 'final' };
|
||||
: { id: submission.id, sendAt: submission.sendAt, undoStatus: version === '1.0' && taken ? 'pending' : 'final' };
|
||||
}
|
||||
const responses: Array<[string, Record<string, unknown>, string]> = [[name, {
|
||||
accountId,
|
||||
@@ -256,7 +276,15 @@ function fakeStalwart(options: FakeOptions = {}) {
|
||||
}, callId]];
|
||||
}
|
||||
case 'EmailSubmission/get': {
|
||||
const ids = (args.ids as string[] | null) ?? [];
|
||||
// A creation id ("#1") names a submission created earlier in the same
|
||||
// request; unknown, the whole call is refused (as Stalwart does).
|
||||
const created = earlier.find(([method]) => method === 'EmailSubmission/set')?.[1].created as Record<string, { id: string }> | undefined;
|
||||
const requested = (args.ids as string[] | null) ?? [];
|
||||
const unresolved = requested.find((id) => id.startsWith('#') && !created?.[id.slice(1)]);
|
||||
if (unresolved) {
|
||||
return [['error', { type: 'invalidResultReference', description: `Id reference "${unresolved.slice(1)}" not found.` }, callId]];
|
||||
}
|
||||
const ids = requested.map((id) => (id.startsWith('#') ? created![id.slice(1)].id : id));
|
||||
const list: Array<Record<string, unknown>> = [];
|
||||
const notFound: string[] = [];
|
||||
for (const id of ids) {
|
||||
@@ -265,7 +293,10 @@ function fakeStalwart(options: FakeOptions = {}) {
|
||||
notFound.push(id);
|
||||
continue;
|
||||
}
|
||||
list.push({ id: s.id, emailId: s.emailId, identityId: 'identity-1', ...(s.sendAt ? { sendAt: s.sendAt } : {}), undoStatus: computedStatus(s) });
|
||||
list.push({
|
||||
id: s.id, emailId: s.emailId, identityId: 'identity-1', ...(s.sendAt ? { sendAt: s.sendAt } : {}), undoStatus: computedStatus(s),
|
||||
...(s.deliveryStatus ? { deliveryStatus: s.deliveryStatus } : {}),
|
||||
});
|
||||
}
|
||||
return [[name, { accountId, state: 's1', list, notFound }, callId]];
|
||||
}
|
||||
@@ -594,15 +625,16 @@ describe('overQuota on EmailSubmission/set create', () => {
|
||||
const methods = server.requests.map((r) => r.map(([name, args]) => (
|
||||
name === 'EmailSubmission/set' ? `${name} ${Object.keys(args).find((k) => ['create', 'destroy', 'update'].includes(k))}` : name
|
||||
)).join(' + '));
|
||||
// Each create carries the get of its delivery status.
|
||||
expect(methods).toEqual([
|
||||
'Mailbox/get',
|
||||
'Identity/get',
|
||||
'Email/set + EmailSubmission/set create',
|
||||
'Email/set + EmailSubmission/set create + EmailSubmission/get',
|
||||
'EmailSubmission/query + EmailSubmission/get',
|
||||
'EmailSubmission/changes',
|
||||
'EmailSubmission/get',
|
||||
'EmailSubmission/set destroy',
|
||||
'EmailSubmission/set create',
|
||||
'EmailSubmission/set create + EmailSubmission/get',
|
||||
'Email/set + EmailSubmission/set destroy',
|
||||
]);
|
||||
expect(server.calls('EmailSubmission/changes')[0]).toEqual({ accountId: 'account-1', sinceState: 'n' });
|
||||
@@ -1021,3 +1053,153 @@ describe('getScheduledEmails', () => {
|
||||
expect(server.calls('EmailSubmission/query').map((q) => q.position)).toEqual([0, 50]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart runs the SMTP RCPT stage while it creates a submission, held or
|
||||
* not, and records each refused recipient as `delivered: "no"` with its reply:
|
||||
* a relay it doesn't allow ("550 5.1.2 Relay not allowed.") or a throttle that
|
||||
* is used up ("452 4.4.5 Rate limit exceeded, try again later."). The create
|
||||
* still succeeds - undoStatus final when it took nobody - and
|
||||
* onSuccessUpdateEmail files the message in Sent. Seen live on 0.16.23 and
|
||||
* 1.0; 0.16.6 records it the same way (submission/set.rs).
|
||||
*/
|
||||
describe('recipients the server refuses', () => {
|
||||
const relayOff = (email: string) => (email.endsWith('@example.net') ? RELAY_DENIED : undefined);
|
||||
|
||||
function sendTo(client: JMAPClient, to: string[], opts: { draftId?: string; delayedUntil?: string } = {}) {
|
||||
return client.sendEmail(
|
||||
to, 'Subject', 'body',
|
||||
undefined, undefined, 'identity-1', 'user@example.com',
|
||||
opts.draftId,
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
opts.delayedUntil,
|
||||
);
|
||||
}
|
||||
|
||||
it.each(['1.0', '0.16.12', '0.16.6'] as const)('fails a send that every recipient was refused for on Stalwart %s, keeping the draft', async (version) => {
|
||||
const server = fakeStalwart({ version, refuseRecipient: relayOff });
|
||||
|
||||
const error = await sendTo(createClient(), ['nobody@example.net'], { draftId: 'draft-old-1' }).catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(RecipientsRefusedError);
|
||||
expect((error as RecipientsRefusedError).recipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]);
|
||||
// The copy filed in Sent is gone, the user's draft is not ...
|
||||
expect(server.emails.size).toBe(0);
|
||||
const destroyedEmails = server.calls('Email/set').flatMap((args) => (args.destroy as string[] | undefined) ?? []);
|
||||
expect(destroyedEmails).not.toContain('draft-old-1');
|
||||
// ... and so is the record, which can neither go out nor be cancelled.
|
||||
expect(server.submissions.size).toBe(0);
|
||||
});
|
||||
|
||||
it('fails a held send (undo send) the same way on Stalwart 1.0: it is refused when created', async () => {
|
||||
const server = fakeStalwart({ version: '1.0', refuseRecipient: relayOff });
|
||||
|
||||
const error = await sendTo(createClient(), ['nobody@example.net'], { delayedUntil: new Date(Date.now() + 10_000).toISOString() })
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(RecipientsRefusedError);
|
||||
expect(server.emails.size).toBe(0);
|
||||
expect(server.submissions.size).toBe(0);
|
||||
});
|
||||
|
||||
it('takes a used-up throttle for a refusal too on Stalwart 1.0', async () => {
|
||||
fakeStalwart({ version: '1.0', refuseRecipient: () => RATE_LIMITED });
|
||||
|
||||
const error = await sendTo(createClient(), ['bob@example.org']).catch((e: unknown) => e);
|
||||
|
||||
expect((error as RecipientsRefusedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
|
||||
});
|
||||
|
||||
it.each(['1.0', '0.16.12'] as const)('names the refused recipients and sends to the others on Stalwart %s', async (version) => {
|
||||
const server = fakeStalwart({ version, refuseRecipient: relayOff });
|
||||
|
||||
const result = await sendTo(createClient(), ['bob@example.org', 'nobody@example.net'], { draftId: 'draft-old-1' });
|
||||
|
||||
expect(result.refusedRecipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]);
|
||||
expect(result.filingError).toBeUndefined();
|
||||
// Sent as usual: the draft goes, the message stays in Sent.
|
||||
expect(server.calls('Email/set').flatMap((args) => (args.destroy as string[] | undefined) ?? [])).toEqual(['draft-old-1']);
|
||||
expect(server.emails.size).toBe(1);
|
||||
});
|
||||
|
||||
it('reads the delivery status in the send request, before the record is destroyed, on Stalwart 1.0', async () => {
|
||||
const server = fakeStalwart({ version: '1.0', refuseRecipient: relayOff });
|
||||
|
||||
const result = await sendTo(createClient(), ['bob@example.org', 'nobody@example.net']);
|
||||
|
||||
expect(server.requests[2].map(([name]) => name)).toEqual(['Email/set', 'EmailSubmission/set', 'EmailSubmission/get']);
|
||||
expect(server.requests[2][2][1]).toEqual({ accountId: 'account-1', ids: ['#1'], properties: ['id', 'deliveryStatus'] });
|
||||
expect(server.requests.at(-1)).toEqual([['EmailSubmission/set', { accountId: 'account-1', destroy: [result.emailSubmissionId] }, '0']]);
|
||||
});
|
||||
|
||||
it('sends as before when the server tells nothing about the recipients', async () => {
|
||||
fakeStalwart({
|
||||
version: '1.0',
|
||||
refuseRecipient: relayOff,
|
||||
intercept: ([name, , callId]) => (name === 'EmailSubmission/get' ? [['error', { type: 'unknownMethod' }, callId]] : undefined),
|
||||
});
|
||||
|
||||
const result = await sendTo(createClient(), ['nobody@example.net']);
|
||||
|
||||
expect(result.emailSubmissionId).toBeDefined();
|
||||
expect(result.refusedRecipients).toBeUndefined();
|
||||
// The get's own failure is neither a failed send nor a filing problem.
|
||||
expect(result.filingError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a refused create with the create's own reason, not the get's", async () => {
|
||||
fakeStalwart({ version: '1.0', createAnswer: () => ({ type: 'forbiddenToSend', description: 'Sending is disabled for this account.' }) });
|
||||
|
||||
const error = await sendTo(createClient(), ['bob@example.org']).catch((e: unknown) => e);
|
||||
|
||||
expect(jmapErrorType(error)).toBe('forbiddenToSend');
|
||||
expect((error as Error).message).toContain('Sending is disabled for this account.');
|
||||
});
|
||||
|
||||
it('keeps the original when every recipient of a reschedule is refused on Stalwart 1.0', async () => {
|
||||
let throttled = false;
|
||||
const server = fakeStalwart({ version: '1.0', refuseRecipient: () => (throttled ? RATE_LIMITED : undefined) });
|
||||
const original = server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(Date.now() + HOUR), queued: true });
|
||||
throttled = true;
|
||||
|
||||
const error = await createClient()
|
||||
.rescheduleEmailSubmission('original', 'email-x', 'identity-1', new Date(Date.now() + 1000).toISOString(), 'account-1')
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(RecipientsRefusedError);
|
||||
// Still goes at its old time; the dead replacement record is gone.
|
||||
expect(original.canceled).toBe(false);
|
||||
expect(server.calls('EmailSubmission/set').filter((args) => args.update)).toEqual([]);
|
||||
expect([...server.submissions.keys()]).toEqual(['original']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared accounts that refuse the submission prune', () => {
|
||||
const refuseShared = (accountId: string) => ([, args, callId]: MethodCall) => (args.accountId === accountId
|
||||
? [['error', { type: 'forbidden', description: `You are not an owner of account ${accountId}` }, callId]] as Array<[string, Record<string, unknown>, string]>
|
||||
: undefined);
|
||||
|
||||
// 0.16.23 lists an account that shared only an address book or a calendar
|
||||
// with the submission capability, then refuses its records.
|
||||
it('leaves such an account alone for the session, without a warning, on 0.16', async () => {
|
||||
const server = fakeStalwart({ version: '0.16.12', intercept: refuseShared('userb') });
|
||||
const client = createClient({ accounts: ['account-1', 'userb'] });
|
||||
|
||||
await client.pruneEmailSubmissions({ force: true });
|
||||
await client.pruneEmailSubmissions({ force: true });
|
||||
await client.getScheduledEmails();
|
||||
|
||||
expect(server.calls('EmailSubmission/query').map((q) => q.accountId)).toEqual(['account-1', 'userb', 'account-1', 'account-1']);
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps pruning a group account the user belongs to on 1.0', async () => {
|
||||
const server = fakeStalwart({ version: '1.0' });
|
||||
const client = createClient({ accounts: ['account-1', 'team'] });
|
||||
|
||||
await client.pruneEmailSubmissions({ force: true });
|
||||
await client.pruneEmailSubmissions({ force: true });
|
||||
|
||||
expect(server.calls('EmailSubmission/query').map((q) => q.accountId)).toEqual(['account-1', 'team', 'account-1', 'team']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NoLongerScheduledError, RequestTimeoutError, ScheduleTooLateError, SubmissionQuotaError } from '../jmap/client';
|
||||
import { SendCancelledCleanupError, cancelFailureToast, rescheduleFailureToast, sendFailureMessage } from '../send-failure-message';
|
||||
import { NoLongerScheduledError, RecipientsRefusedError, RequestTimeoutError, ScheduleTooLateError, SubmissionQuotaError } from '../jmap/client';
|
||||
import { SendCancelledCleanupError, cancelFailureToast, formatRefusedRecipients, rescheduleFailureToast, sendFailureMessage } from '../send-failure-message';
|
||||
|
||||
// Keys of a namespace, returned with their values so the test sees which
|
||||
// message was chosen and what went into it.
|
||||
@@ -36,6 +36,26 @@ describe('sendFailureMessage', () => {
|
||||
expect(sendFailureMessage(new Error(''), t)).toBe('email_composer.send_failed');
|
||||
expect(sendFailureMessage('nope', t)).toBe('email_composer.send_failed');
|
||||
});
|
||||
|
||||
// Stalwart 0.16 and 1.0 take the submission and refuse the recipients at
|
||||
// RCPT, each with its own reply.
|
||||
it('says the message was not sent and names every refused recipient with its reply', () => {
|
||||
const error = new RecipientsRefusedError([
|
||||
{ email: 'nobody@example.net', smtpReply: '550 5.1.2 Relay not allowed.' },
|
||||
{ email: 'busy@example.org', smtpReply: '452 4.4.5 Rate limit exceeded, try again later.' },
|
||||
]);
|
||||
|
||||
expect(sendFailureMessage(error, t)).toBe(
|
||||
'email_composer.recipients_refused {"recipients":"nobody@example.net (550 5.1.2 Relay not allowed.), busy@example.org (452 4.4.5 Rate limit exceeded, try again later.)"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRefusedRecipients', () => {
|
||||
it('lists an address without its reply when the server gave none', () => {
|
||||
expect(formatRefusedRecipients([{ email: 'a@example.net' }, { email: 'b@example.net', smtpReply: '550 5.1.1 No such user' }]))
|
||||
.toBe('a@example.net, b@example.net (550 5.1.1 No such user)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rescheduleFailureToast', () => {
|
||||
@@ -56,6 +76,15 @@ describe('rescheduleFailureToast', () => {
|
||||
.toEqual({ title: 'email_viewer.no_longer_scheduled' });
|
||||
});
|
||||
|
||||
it('says the message is still scheduled when the server refused every recipient of the replacement', () => {
|
||||
const error = new RecipientsRefusedError([{ email: 'bob@example.org', smtpReply: '452 4.4.5 Rate limit exceeded, try again later.' }]);
|
||||
|
||||
expect(rescheduleFailureToast(error, tViewer, t, 'Sep 26, 10:00')).toEqual({
|
||||
title: 'email_composer.recipients_refused {"recipients":"bob@example.org (452 4.4.5 Rate limit exceeded, try again later.)"}',
|
||||
message: 'email_viewer.still_scheduled_at {"date":"Sep 26, 10:00"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the send failure message otherwise', () => {
|
||||
expect(rescheduleFailureToast(new RequestTimeoutError(30_000), tViewer, t, 'Sep 26, 10:00'))
|
||||
.toEqual({ title: 'email_composer.send_timeout' });
|
||||
|
||||
+107
-6
@@ -1,5 +1,5 @@
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, EmailSubmission, RefusedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, FileNodeListings, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
|
||||
import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet";
|
||||
@@ -230,6 +230,50 @@ export class NoLongerScheduledError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server took a submission but refused every one of its recipients, so
|
||||
* nothing was queued: nothing goes out, and no bounce will say so. Stalwart
|
||||
* answers this way to a relay it doesn't allow ("550 5.1.2 Relay not
|
||||
* allowed.") and to a throttle that is used up ("452 4.4.5 Rate limit
|
||||
* exceeded, try again later."), on 0.16 and 1.0 alike.
|
||||
*/
|
||||
export class RecipientsRefusedError extends Error {
|
||||
constructor(readonly recipients: RefusedRecipient[]) {
|
||||
super(`The server refused ${recipients.map((r) => (r.smtpReply ? `${r.email} (${r.smtpReply})` : r.email)).join(', ')}`);
|
||||
this.name = 'RecipientsRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EmailSubmission/get of the submission a /set creates in the same request,
|
||||
* named by its creation id. The server runs the SMTP RCPT stage while it
|
||||
* creates the submission - for a held send as well, only the delivery waits -
|
||||
* and records a refused recipient right away as `delivered: "no"` with its
|
||||
* reply. The create's own answer carries no deliveryStatus (0.16 and 1.0).
|
||||
*/
|
||||
function deliveryStatusCall(accountId: string, creationId: string, callId: string): JMAPMethodCall {
|
||||
return ['EmailSubmission/get', { accountId, ids: [`#${creationId}`], properties: ['id', 'deliveryStatus'] }, callId];
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipients a new submission's server refused, from the answer to
|
||||
* deliveryStatusCall, and whether it refused every one. Nothing is known when
|
||||
* that get failed or the server keeps no delivery status.
|
||||
*/
|
||||
function refusedRecipients(
|
||||
answer: [string, JMAPResponseResult, string] | undefined,
|
||||
): { recipients: RefusedRecipient[]; all: boolean } {
|
||||
const [name, result] = answer ?? [];
|
||||
const status = name === 'EmailSubmission/get'
|
||||
? (result?.list?.[0] as { deliveryStatus?: Record<string, { delivered?: string; smtpReply?: string }> } | undefined)?.deliveryStatus
|
||||
: undefined;
|
||||
const entries = Object.entries(status ?? {});
|
||||
const recipients = entries
|
||||
.filter(([, delivery]) => delivery?.delivered === 'no')
|
||||
.map(([email, delivery]) => ({ email, ...(delivery.smtpReply ? { smtpReply: delivery.smtpReply } : {}) }));
|
||||
return { recipients, all: recipients.length > 0 && recipients.length === entries.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* How often the background prune may sweep one account's submission records
|
||||
* (see JMAPClient.pruneEmailSubmissions).
|
||||
@@ -1247,6 +1291,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
private learnedMaxDelayedSend: number | null = null;
|
||||
/** When each account's submission records were last pruned (epoch ms). */
|
||||
private submissionPrunedAt = new Map<string, number>();
|
||||
/**
|
||||
* Shared accounts that advertise EmailSubmission but refused this login's
|
||||
* calls with `forbidden` - on 0.16, an account that only shared an address
|
||||
* book or a calendar. Left out of getSubmissionAccountIds for the session.
|
||||
*/
|
||||
private refusedSubmissionAccounts = new Set<string>();
|
||||
/** Accounts where making room found nothing to free, until when (epoch ms). */
|
||||
private submissionRoomRecheckAt = new Map<string, number>();
|
||||
// Consecutive keep-alive failures; failed pings skip upcoming ticks
|
||||
@@ -4315,6 +4365,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: buildSubmissionCreate(`#${emailId}`, firstMailFrom),
|
||||
onSuccessUpdateEmail,
|
||||
}, "1"]);
|
||||
// Which recipients the server took. A refused one is lost without a
|
||||
// bounce while the create succeeds and files the message in Sent.
|
||||
methodCalls.push(deliveryStatusCall(submissionAccountId, "1", "2"));
|
||||
|
||||
let response = await this.request(methodCalls);
|
||||
|
||||
@@ -4350,7 +4403,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
accountId: submissionAccountId,
|
||||
create: buildSubmissionCreate(draftCopyId, mailFrom),
|
||||
onSuccessUpdateEmail,
|
||||
}, "1"]]);
|
||||
}, "1"], deliveryStatusCall(submissionAccountId, "1", "2")]);
|
||||
response = {
|
||||
...retry,
|
||||
methodResponses: [
|
||||
@@ -4360,6 +4413,11 @@ export class JMAPClient implements IJMAPClient {
|
||||
};
|
||||
}
|
||||
|
||||
// The get goes on its own: it fails whenever the create did, and must not
|
||||
// count as a failed send or filing either way.
|
||||
const deliveryAnswer = response.methodResponses?.find(([, , callId]) => callId === "2");
|
||||
response = { ...response, methodResponses: (response.methodResponses ?? []).filter(([, , callId]) => callId !== "2") };
|
||||
|
||||
let createdEmailId: string | undefined;
|
||||
let emailSubmissionId: string | undefined;
|
||||
let serverSendAt: string | undefined;
|
||||
@@ -4440,6 +4498,22 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Every recipient refused: nothing was queued, so nothing goes out, held
|
||||
// or not, and no bounce will tell. That is a failed send. The copy made
|
||||
// for it goes, as after any failed send - onSuccessUpdateEmail filed it in
|
||||
// Sent, where it passes for a sent message - while the user's draft stays
|
||||
// and the composer keeps the text, so the address can be fixed and the
|
||||
// message sent again. The record can neither go out nor be cancelled.
|
||||
const delivery = emailSubmissionId ? refusedRecipients(deliveryAnswer) : { recipients: [], all: false };
|
||||
if (delivery.all) {
|
||||
console.error('[sendEmail] every recipient refused:', JSON.stringify(delivery.recipients));
|
||||
await this.discardUnsentCopy(createdEmailId ?? draftCopyId, targetAccountId);
|
||||
if (this.hasAccountCapability('urn:stalwart:jmap', submissionAccountId)) {
|
||||
await this.destroyEmailSubmissions([emailSubmissionId!], submissionAccountId);
|
||||
}
|
||||
throw new RecipientsRefusedError(delivery.recipients);
|
||||
}
|
||||
|
||||
// An immediate send's submission record has done its job once the
|
||||
// request that created it returned: the message is queued and filed, and
|
||||
// with no hold there is nothing to cancel. Stalwart 1.0 stops accepting
|
||||
@@ -4485,9 +4559,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId);
|
||||
}
|
||||
|
||||
// Some recipients refused: the others get the message, the caller says
|
||||
// who doesn't.
|
||||
const refused = delivery.recipients.length > 0 ? { refusedRecipients: delivery.recipients } : {};
|
||||
return delayedUntil
|
||||
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, filingError }
|
||||
: { scheduled: false, emailId: createdEmailId, emailSubmissionId, submissionAccountId, filingError };
|
||||
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, filingError, ...refused }
|
||||
: { scheduled: false, emailId: createdEmailId, emailSubmissionId, submissionAccountId, filingError, ...refused };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5563,7 +5640,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const primary = this.getSubmissionAccountId();
|
||||
if (primary) ids.push(primary);
|
||||
for (const [accountId, account] of Object.entries(this.session?.accounts ?? {})) {
|
||||
if (ids.includes(accountId)) continue;
|
||||
if (ids.includes(accountId) || this.refusedSubmissionAccounts.has(accountId)) continue;
|
||||
if (account?.accountCapabilities?.['urn:ietf:params:jmap:submission']) ids.push(accountId);
|
||||
}
|
||||
return ids;
|
||||
@@ -10298,6 +10375,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
},
|
||||
} : {}),
|
||||
}, '0'],
|
||||
deliveryStatusCall(submissionAccountId, 'replacement', '1'),
|
||||
]);
|
||||
};
|
||||
let response = await createReplacement(holdForSeconds);
|
||||
@@ -10325,6 +10403,17 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (!replacementId) {
|
||||
throw new Error('Server did not return a replacement scheduled send ID');
|
||||
}
|
||||
// The recipients are checked again for the replacement, and a throttle
|
||||
// used up since the original was taken refuses them all: nothing was
|
||||
// queued, and cancelling the original would lose the message. It goes at
|
||||
// its old time instead; the replacement's record is dead weight.
|
||||
const delivery = refusedRecipients(response.methodResponses?.find(([, , callId]) => callId === '1'));
|
||||
if (delivery.all) {
|
||||
if (this.hasAccountCapability('urn:stalwart:jmap', submissionAccountId)) {
|
||||
await this.destroyEmailSubmissions([replacementId], submissionAccountId);
|
||||
}
|
||||
throw new RecipientsRefusedError(delivery.recipients);
|
||||
}
|
||||
const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId, submissionAccountId);
|
||||
// Either cancel also destroys the record it canceled, so a reschedule
|
||||
// leaves only the replacement behind.
|
||||
@@ -10339,7 +10428,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send';
|
||||
throw new Error(`Reschedule created a replacement but could not cancel the original: ${message}`);
|
||||
}
|
||||
return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt };
|
||||
return {
|
||||
scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt,
|
||||
...(delivery.recipients.length > 0 ? { refusedRecipients: delivery.recipients } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// The third parameter is intentionally unused: this restores an undo-send /
|
||||
@@ -10433,6 +10525,15 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (prunable.length === 0) return 0;
|
||||
return (await this.destroyEmailSubmissions(prunable, accountId)).length;
|
||||
} catch (error) {
|
||||
// Stalwart 0.16 lists an account that shared only an address book or a
|
||||
// calendar with the submission capability, then refuses its records
|
||||
// ("You are not an owner of account ..."). Nothing of this login's is
|
||||
// there, so the account is left alone for the rest of the session.
|
||||
if (jmapErrorType(error) === 'forbidden' && accountId !== this.getSubmissionAccountId()) {
|
||||
this.refusedSubmissionAccounts.add(accountId);
|
||||
debug.log('email', `[EmailSubmission] ${accountId} refuses this login's submissions; not asking again`);
|
||||
return 0;
|
||||
}
|
||||
console.warn(`[EmailSubmission] pruning ${accountId} failed:`, error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,19 @@ export interface SendEmailResult {
|
||||
* old draft). The mail left the server - callers should warn, not fail.
|
||||
*/
|
||||
filingError?: string;
|
||||
/**
|
||||
* Recipients the server refused while it took the submission; the others
|
||||
* get the message. When it refused every one, the send throws
|
||||
* RecipientsRefusedError instead.
|
||||
*/
|
||||
refusedRecipients?: RefusedRecipient[];
|
||||
}
|
||||
|
||||
/** A recipient the server refused to take a message for (EmailSubmission deliveryStatus `delivered: "no"`). */
|
||||
export interface RefusedRecipient {
|
||||
email: string;
|
||||
/** The server's SMTP reply, e.g. "550 5.1.2 Relay not allowed." */
|
||||
smtpReply?: string;
|
||||
}
|
||||
|
||||
export interface ScheduledEmail extends Email {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { NoLongerScheduledError, RequestTimeoutError, ScheduleTooLateError, SubmissionQuotaError } from '@/lib/jmap/client';
|
||||
import { NoLongerScheduledError, RecipientsRefusedError, RequestTimeoutError, ScheduleTooLateError, SubmissionQuotaError } from '@/lib/jmap/client';
|
||||
import type { RefusedRecipient } from '@/lib/jmap/types';
|
||||
|
||||
type Translate = (key: string, values?: Record<string, string>) => string;
|
||||
|
||||
/** "nobody@example.net (550 5.1.2 Relay not allowed.)", one per refused recipient. */
|
||||
export function formatRefusedRecipients(recipients: RefusedRecipient[]): string {
|
||||
return recipients.map((r) => (r.smtpReply ? `${r.email} (${r.smtpReply})` : r.email)).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by "Undo send" and "Cancel and edit" when the cancel went through
|
||||
* but a step after it failed: putting the message back in Drafts
|
||||
@@ -29,7 +35,10 @@ export class SendCancelledCleanupError extends Error {
|
||||
* - a timeout, which is not a clean failure: the submission may have reached
|
||||
* the server and gone out, with only the answer lost. Saying "send failed"
|
||||
* would invite a re-send and a duplicate, so point at Sent instead (#702);
|
||||
* - a hold beyond the server's limit.
|
||||
* - a hold beyond the server's limit;
|
||||
* - every recipient refused: the server took the submission, so nothing
|
||||
* else would say that the message went nowhere. Its reply per address
|
||||
* goes with it.
|
||||
*/
|
||||
export function sendFailureMessage(error: unknown, t: Translate): string {
|
||||
if (error instanceof SubmissionQuotaError) {
|
||||
@@ -39,6 +48,9 @@ export function sendFailureMessage(error: unknown, t: Translate): string {
|
||||
}
|
||||
if (error instanceof RequestTimeoutError) return t('send_timeout');
|
||||
if (error instanceof ScheduleTooLateError) return t('schedule_send_too_late');
|
||||
if (error instanceof RecipientsRefusedError) {
|
||||
return t('recipients_refused', { recipients: formatRefusedRecipients(error.recipients) });
|
||||
}
|
||||
return error instanceof Error && error.message ? error.message : t('send_failed');
|
||||
}
|
||||
|
||||
@@ -47,7 +59,8 @@ export function sendFailureMessage(error: unknown, t: Translate): string {
|
||||
* (`tViewer`) and `email_composer` (`tComposer`) messages. At a full
|
||||
* submission quota no replacement was made, so the message still goes out at
|
||||
* its old time, `scheduledAt` (formatted) when known. Making room takes a
|
||||
* while, and the original may have gone out meanwhile.
|
||||
* while, and the original may have gone out meanwhile. When the server
|
||||
* refused every recipient of the replacement, the original was kept too.
|
||||
*/
|
||||
export function rescheduleFailureToast(
|
||||
error: unknown,
|
||||
@@ -58,11 +71,12 @@ export function rescheduleFailureToast(
|
||||
if (error instanceof NoLongerScheduledError) {
|
||||
return { title: tViewer(error.sent ? 'already_sent' : 'no_longer_scheduled') };
|
||||
}
|
||||
const stillScheduled = scheduledAt ? tViewer('still_scheduled_at', { date: scheduledAt }) : tViewer('still_scheduled');
|
||||
if (error instanceof SubmissionQuotaError) {
|
||||
return {
|
||||
title: scheduledAt ? tViewer('still_scheduled_at', { date: scheduledAt }) : tViewer('still_scheduled'),
|
||||
message: sendFailureMessage(error, tComposer),
|
||||
};
|
||||
return { title: stillScheduled, message: sendFailureMessage(error, tComposer) };
|
||||
}
|
||||
if (error instanceof RecipientsRefusedError) {
|
||||
return { title: sendFailureMessage(error, tComposer), message: stillScheduled };
|
||||
}
|
||||
return { title: sendFailureMessage(error, tComposer) };
|
||||
}
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"send_timeout": "توقف الخادم عن الاستجابة. تحقق من مجلد المُرسَل قبل إعادة الإرسال — فقد تكون الرسالة قد أُرسلت بالفعل.",
|
||||
"submission_quota": "يحتفظ الخادم بعدد كبير جدًا من سجلات الرسائل المرسلة لهذا الحساب. أزال Bulwark ما استطاع منها؛ وإذا تكرر ذلك، فاطلب من المسؤول رفع حد عمليات إرسال البريد.",
|
||||
"submission_limit": "رفض الخادم إرسال الرسالة لبلوغ أحد الحدود: {reason}",
|
||||
"recipients_refused": "لم تُرسل الرسالة: رفض الخادم {recipients}.",
|
||||
"recipients_partly_refused": "رفض الخادم {recipients}. سيتلقى باقي المستلمين الرسالة.",
|
||||
"continue_draft": "متابعة المسودة",
|
||||
"close_draft_title": "حفظ المسودة أم تجاهلها؟",
|
||||
"close_draft_message": "لديك تغييرات غير محفوظة. هل تريد حفظها كمسودة أم تجاهلها؟",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"send_timeout": "El servidor ha deixat de respondre. Comproveu la carpeta Enviats abans de tornar a enviar: el missatge pot haver sortit.",
|
||||
"submission_quota": "El servidor té massa registres de missatges enviats per a aquest compte. Bulwark ha eliminat els que ha pogut; si torna a passar, demaneu a l'administrador que augmenti el límit d'enviaments de correu.",
|
||||
"submission_limit": "El servidor s'ha negat a enviar el missatge perquè s'ha arribat a un límit: {reason}",
|
||||
"recipients_refused": "No s'ha enviat: el servidor ha rebutjat {recipients}.",
|
||||
"recipients_partly_refused": "El servidor ha rebutjat {recipients}. Els altres destinataris reben el missatge.",
|
||||
"continue_draft": "Continua l'esborrany",
|
||||
"close_draft_title": "Voleu desar o descartar l'esborrany?",
|
||||
"close_draft_message": "Teniu canvis sense desar. Voleu desar-los com a esborrany o descartar-los?",
|
||||
|
||||
@@ -676,6 +676,8 @@
|
||||
"send_timeout": "Server přestal odpovídat. Než odešlete znovu, zkontrolujte složku Odeslané – zpráva už mohla odejít.",
|
||||
"submission_quota": "Server má pro tento účet příliš mnoho záznamů o odeslaných zprávách. Bulwark odstranil ty, které mohl; pokud se to bude opakovat, požádejte správce o zvýšení limitu odesílání e-mailů.",
|
||||
"submission_limit": "Server odmítl zprávu odeslat, protože byl dosažen limit: {reason}",
|
||||
"recipients_refused": "Neodesláno: server odmítl {recipients}.",
|
||||
"recipients_partly_refused": "Server odmítl {recipients}. Ostatní příjemci zprávu dostanou.",
|
||||
"continue_draft": "Pokračovat v úpravě konceptu",
|
||||
"close_draft_title": "Uložit nebo zahodit koncept?",
|
||||
"close_draft_message": "Máte neuložené změny. Chcete je uložit jako koncept, nebo zahodit?",
|
||||
|
||||
@@ -678,6 +678,8 @@
|
||||
"send_timeout": "Serveren holdt op med at svare. Tjek mappen Sendt, før du sender igen — beskeden er måske allerede afsendt.",
|
||||
"submission_quota": "Serveren har for mange registreringer af sendte beskeder for denne konto. Bulwark har fjernet dem, den kunne; hvis det bliver ved med at ske, så bed din administrator om at hæve grænsen for afsendelse af e-mails.",
|
||||
"submission_limit": "Serveren afviste at sende beskeden, fordi en grænse er nået: {reason}",
|
||||
"recipients_refused": "Ikke sendt: serveren afviste {recipients}.",
|
||||
"recipients_partly_refused": "Serveren afviste {recipients}. De øvrige modtagere får beskeden.",
|
||||
"continue_draft": "Fortsæt kladde",
|
||||
"close_draft_title": "Gem eller kassér kladde?",
|
||||
"close_draft_message": "Du har ugemte ændringer. Vil du gemme dette som en kladde eller kassere det?",
|
||||
|
||||
@@ -683,6 +683,8 @@
|
||||
"send_timeout": "Der Server antwortet nicht mehr. Prüfen Sie den Ordner „Gesendet“, bevor Sie erneut senden – die Nachricht wurde möglicherweise bereits verschickt.",
|
||||
"submission_quota": "Der Server hat für dieses Konto zu viele Einträge zu gesendeten Nachrichten. Bulwark hat entfernt, was möglich war; wenn das weiterhin vorkommt, bitten Sie Ihren Administrator, das Limit für E-Mail-Übermittlungen zu erhöhen.",
|
||||
"submission_limit": "Der Server hat das Senden abgelehnt, weil ein Limit erreicht ist: {reason}",
|
||||
"recipients_refused": "Nicht gesendet: Der Server hat {recipients} abgelehnt.",
|
||||
"recipients_partly_refused": "Der Server hat {recipients} abgelehnt. Die anderen Empfänger erhalten die Nachricht.",
|
||||
"continue_draft": "Entwurf fortsetzen",
|
||||
"close_draft_title": "Entwurf speichern oder verwerfen?",
|
||||
"close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"send_timeout": "The server stopped responding. Check your Sent folder before sending again — the message may have gone out.",
|
||||
"submission_quota": "The server has too many sent-message records for this account. Bulwark removed the ones it could; if this keeps happening, ask your administrator to raise the email submission limit.",
|
||||
"submission_limit": "The server refused to send the message because a limit was reached: {reason}",
|
||||
"recipients_refused": "Not sent: the server refused {recipients}.",
|
||||
"recipients_partly_refused": "The server refused {recipients}. The other recipients get the message.",
|
||||
"continue_draft": "Continue draft",
|
||||
"close_draft_title": "Save or discard draft?",
|
||||
"close_draft_message": "You have unsaved changes. Would you like to save this as a draft or discard it?",
|
||||
|
||||
@@ -683,6 +683,8 @@
|
||||
"send_timeout": "El servidor dejó de responder. Compruebe la carpeta Enviados antes de volver a enviar: el mensaje puede haber salido.",
|
||||
"submission_quota": "El servidor tiene demasiados registros de mensajes enviados para esta cuenta. Bulwark eliminó los que pudo; si esto sigue ocurriendo, pida a su administrador que aumente el límite de envíos de correo.",
|
||||
"submission_limit": "El servidor se negó a enviar el mensaje porque se alcanzó un límite: {reason}",
|
||||
"recipients_refused": "No enviado: el servidor rechazó {recipients}.",
|
||||
"recipients_partly_refused": "El servidor rechazó {recipients}. Los demás destinatarios reciben el mensaje.",
|
||||
"continue_draft": "Continuar borrador",
|
||||
"close_draft_title": "¿Guardar o descartar borrador?",
|
||||
"close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"send_timeout": "سرور دیگر پاسخ نمیدهد. پیش از ارسال دوباره، پوشهٔ ارسالشدهها را بررسی کنید — ممکن است پیام ارسال شده باشد.",
|
||||
"submission_quota": "سرور برای این حساب سوابق بیش از حدی از پیامهای ارسالشده دارد. Bulwark هر کدام را که میتوانست حذف کرد؛ اگر این وضعیت ادامه یافت، از مدیر سیستم بخواهید سقف ارسال ایمیل را افزایش دهد.",
|
||||
"submission_limit": "سرور از ارسال پیام خودداری کرد، زیرا به یک محدودیت رسیده است: {reason}",
|
||||
"recipients_refused": "ارسال نشد: سرور {recipients} را رد کرد.",
|
||||
"recipients_partly_refused": "سرور {recipients} را رد کرد. سایر گیرندگان پیام را دریافت میکنند.",
|
||||
"continue_draft": "ادامه پیشنویس",
|
||||
"close_draft_title": "ذخیره یا دور انداختن پیشنویس؟",
|
||||
"close_draft_message": "تغییرات ذخیره نشده دارید. ذخیره یا دور انداختن؟",
|
||||
|
||||
@@ -683,6 +683,8 @@
|
||||
"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.",
|
||||
"submission_quota": "Le serveur conserve trop d’enregistrements de messages envoyés pour ce compte. Bulwark a supprimé ceux qu’il pouvait ; si cela se reproduit, demandez à votre administrateur de relever la limite d’envois de courriels.",
|
||||
"submission_limit": "Le serveur a refusé d’envoyer le message, car une limite est atteinte : {reason}",
|
||||
"recipients_refused": "Non envoyé : le serveur a refusé {recipients}.",
|
||||
"recipients_partly_refused": "Le serveur a refusé {recipients}. Les autres destinataires reçoivent le message.",
|
||||
"continue_draft": "Continuer le brouillon",
|
||||
"close_draft_title": "Enregistrer ou supprimer le brouillon ?",
|
||||
"close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?",
|
||||
|
||||
@@ -621,6 +621,8 @@
|
||||
"send_timeout": "השרת הפסיק להגיב. בדקו את תיקיית הנשלחים לפני שליחה חוזרת — ייתכן שההודעה כבר נשלחה.",
|
||||
"submission_quota": "בשרת יש יותר מדי רשומות של הודעות שנשלחו עבור החשבון הזה. Bulwark הסיר את אלה שיכול היה; אם זה ממשיך לקרות, בקשו ממנהל המערכת להגדיל את מגבלת שליחת הדואר.",
|
||||
"submission_limit": "השרת סירב לשלוח את ההודעה כי הושגה מגבלה: {reason}",
|
||||
"recipients_refused": "לא נשלח: השרת דחה את {recipients}.",
|
||||
"recipients_partly_refused": "השרת דחה את {recipients}. שאר הנמענים יקבלו את ההודעה.",
|
||||
"continue_draft": "המשך טיוטה",
|
||||
"close_draft_title": "לשמור או למחוק טיוטה?",
|
||||
"close_draft_message": "יש לך שינויים שלא נשמרו. האם תרצה לשמור את זה כטיוטה או למחוק אותו?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"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.",
|
||||
"submission_quota": "A kiszolgáló túl sok elküldött üzenetről szóló bejegyzést tárol ehhez a fiókhoz. A Bulwark eltávolította, amit tudott; ha ez továbbra is előfordul, kérje meg a rendszergazdát, hogy emelje meg az e-mail-küldési korlátot.",
|
||||
"submission_limit": "A kiszolgáló elutasította az üzenet küldését, mert elért egy korlátot: {reason}",
|
||||
"recipients_refused": "Nincs elküldve: a kiszolgáló elutasította a következőt: {recipients}.",
|
||||
"recipients_partly_refused": "A kiszolgáló elutasította a következőt: {recipients}. A többi címzett megkapja az üzenetet.",
|
||||
"continue_draft": "Piszkozat folytatása",
|
||||
"close_draft_title": "Piszkozat mentése vagy elvetése?",
|
||||
"close_draft_message": "Nem mentett változtatások vannak. Szeretnéd piszkozatként menteni vagy elvetni?",
|
||||
|
||||
@@ -683,6 +683,8 @@
|
||||
"send_timeout": "Il server ha smesso di rispondere. Controlli la cartella Posta inviata prima di inviare di nuovo: il messaggio potrebbe essere già partito.",
|
||||
"submission_quota": "Il server conserva troppi record di messaggi inviati per questo account. Bulwark ha rimosso quelli che poteva; se il problema si ripete, chieda all'amministratore di aumentare il limite di invii di posta.",
|
||||
"submission_limit": "Il server ha rifiutato di inviare il messaggio perché è stato raggiunto un limite: {reason}",
|
||||
"recipients_refused": "Non inviato: il server ha rifiutato {recipients}.",
|
||||
"recipients_partly_refused": "Il server ha rifiutato {recipients}. Gli altri destinatari ricevono il messaggio.",
|
||||
"continue_draft": "Continua bozza",
|
||||
"close_draft_title": "Salvare o eliminare la bozza?",
|
||||
"close_draft_message": "Hai modifiche non salvate. Vuoi salvare come bozza o eliminare?",
|
||||
|
||||
@@ -683,6 +683,8 @@
|
||||
"send_timeout": "サーバーが応答しなくなりました。再送信する前に送信済みフォルダーを確認してください。メッセージはすでに送信されている可能性があります。",
|
||||
"submission_quota": "このアカウントの送信済みメッセージの記録がサーバー上に多すぎます。Bulwark は削除できる記録を削除しました。この状態が続く場合は、メール送信数の上限を引き上げるよう管理者に依頼してください。",
|
||||
"submission_limit": "上限に達したため、サーバーがメッセージの送信を拒否しました: {reason}",
|
||||
"recipients_refused": "送信されませんでした: サーバーが {recipients} を拒否しました。",
|
||||
"recipients_partly_refused": "サーバーが {recipients} を拒否しました。他の宛先にはメッセージが届きます。",
|
||||
"continue_draft": "下書きを続ける",
|
||||
"close_draft_title": "下書きを保存または破棄しますか?",
|
||||
"close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
|
||||
|
||||
@@ -676,6 +676,8 @@
|
||||
"send_timeout": "서버가 응답을 멈췄습니다. 다시 보내기 전에 보낸 편지함을 확인하세요. 메시지가 이미 발송되었을 수 있습니다.",
|
||||
"submission_quota": "서버에 이 계정의 보낸 메시지 기록이 너무 많습니다. Bulwark가 삭제할 수 있는 기록은 삭제했습니다. 이 문제가 계속되면 관리자에게 메일 발송 한도를 늘려 달라고 요청하세요.",
|
||||
"submission_limit": "한도에 도달하여 서버가 메시지 전송을 거부했습니다: {reason}",
|
||||
"recipients_refused": "보내지 못했습니다: 서버가 {recipients}을(를) 거부했습니다.",
|
||||
"recipients_partly_refused": "서버가 {recipients}을(를) 거부했습니다. 다른 받는 사람에게는 메시지가 전달됩니다.",
|
||||
"continue_draft": "이어서 작성하기",
|
||||
"close_draft_title": "임시보관함에 저장할까요?",
|
||||
"close_draft_message": "저장되지 않은 내용이 있어요. 임시보관함에 저장할까요, 아니면 삭제할까요?",
|
||||
|
||||
@@ -676,6 +676,8 @@
|
||||
"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.",
|
||||
"submission_quota": "Serverī šim kontam ir pārāk daudz nosūtīto ziņojumu ierakstu. Bulwark noņēma tos, ko varēja; ja tas atkārtojas, lūdziet administratoram palielināt e-pasta sūtījumu ierobežojumu.",
|
||||
"submission_limit": "Serveris atteicās nosūtīt ziņojumu, jo ir sasniegts ierobežojums: {reason}",
|
||||
"recipients_refused": "Nav nosūtīts: serveris noraidīja {recipients}.",
|
||||
"recipients_partly_refused": "Serveris noraidīja {recipients}. Pārējie adresāti saņem ziņojumu.",
|
||||
"continue_draft": "Turpināt melnrakstu",
|
||||
"close_draft_title": "Saglabāt vai atmest melnrakstu?",
|
||||
"close_draft_message": "Ir nesaglabātas izmaiņas. Vai vēlaties saglabāt kā melnrakstu vai atmest?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"send_timeout": "Сервер хариу өгөхөө больсон. Дахин илгээхийн өмнө \"Илгээсэн\" фолдероо шалгана уу — мессеж унтарсан байж магадгүй.",
|
||||
"submission_quota": "Сервер дээр энэ бүртгэлийн илгээсэн мессежийн бичлэг хэт олон байна. Bulwark боломжтойг нь устгасан; дахин давтагдвал админаасаа имэйл илгээх хязгаарыг нэмэгдүүлэхийг хүснэ үү.",
|
||||
"submission_limit": "Хязгаарт хүрсэн тул сервер мессежийг илгээхээс татгалзлаа: {reason}",
|
||||
"recipients_refused": "Илгээгдсэнгүй: сервер дараах хүлээн авагчдыг татгалзлаа: {recipients}.",
|
||||
"recipients_partly_refused": "Сервер дараах хүлээн авагчдыг татгалзлаа: {recipients}. Бусад хүлээн авагчид мессежийг хүлээн авна.",
|
||||
"continue_draft": "Ноорог үргэлжлүүлэх",
|
||||
"close_draft_title": "Ноорог хадгалах эсвэл устгах уу?",
|
||||
"close_draft_message": "Танд хадгалагдаагүй өөрчлөлтүүд байна. Та үүнийг ноорог болгон хадгалах уу эсвэл хаях уу?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"send_timeout": "Serveren sluttet å svare. Sjekk Sendt-mappen før du sender på nytt – meldingen kan ha blitt sendt.",
|
||||
"submission_quota": "Serveren har for mange registreringer av sendte meldinger for denne kontoen. Bulwark fjernet dem den kunne; hvis dette fortsetter, be administratoren din om å øke grensen for innsending av e-post.",
|
||||
"submission_limit": "Serveren nektet å sende meldingen fordi en grense er nådd: {reason}",
|
||||
"recipients_refused": "Ikke sendt: serveren avviste {recipients}.",
|
||||
"recipients_partly_refused": "Serveren avviste {recipients}. De andre mottakerne får meldingen.",
|
||||
"continue_draft": "Fortsett å skrive",
|
||||
"close_draft_title": "Lagre eller forkast utkast?",
|
||||
"close_draft_message": "Du har ulagrede endringer. Vil du lagre dette som et utkast eller forkaste det?",
|
||||
|
||||
@@ -683,6 +683,8 @@
|
||||
"send_timeout": "De server reageert niet meer. Controleer de map Verzonden voordat u opnieuw verstuurt — het bericht is mogelijk al verzonden.",
|
||||
"submission_quota": "De server bewaart te veel records van verzonden berichten voor dit account. Bulwark heeft verwijderd wat kon; blijft dit gebeuren, vraag uw beheerder dan de limiet voor e-mailverzendingen te verhogen.",
|
||||
"submission_limit": "De server weigerde het bericht te verzenden omdat een limiet is bereikt: {reason}",
|
||||
"recipients_refused": "Niet verzonden: de server heeft {recipients} geweigerd.",
|
||||
"recipients_partly_refused": "De server heeft {recipients} geweigerd. De andere ontvangers krijgen het bericht.",
|
||||
"continue_draft": "Concept voortzetten",
|
||||
"close_draft_title": "Concept opslaan of verwijderen?",
|
||||
"close_draft_message": "U heeft niet-opgeslagen wijzigingen. Wilt u dit als concept opslaan of verwijderen?",
|
||||
|
||||
@@ -676,6 +676,8 @@
|
||||
"send_timeout": "Serwer przestał odpowiadać. Przed ponownym wysłaniem sprawdź folder Wysłane — wiadomość mogła już zostać wysłana.",
|
||||
"submission_quota": "Serwer przechowuje zbyt wiele rekordów wysłanych wiadomości dla tego konta. Bulwark usunął te, które mógł; jeśli to się powtarza, poproś administratora o podniesienie limitu wysyłek e-mail.",
|
||||
"submission_limit": "Serwer odmówił wysłania wiadomości, ponieważ osiągnięto limit: {reason}",
|
||||
"recipients_refused": "Nie wysłano: serwer odrzucił {recipients}.",
|
||||
"recipients_partly_refused": "Serwer odrzucił {recipients}. Pozostali odbiorcy otrzymają wiadomość.",
|
||||
"continue_draft": "Kontynuuj szkic",
|
||||
"close_draft_title": "Zapisać czy odrzucić szkic?",
|
||||
"close_draft_message": "Masz niezapisane zmiany. Czy chcesz zapisać jako szkic czy odrzucić?",
|
||||
|
||||
@@ -683,6 +683,8 @@
|
||||
"send_timeout": "O servidor deixou de responder. Verifique a pasta Enviados antes de enviar novamente — a mensagem pode já ter saído.",
|
||||
"submission_quota": "O servidor tem registros de mensagens enviadas demais para esta conta. O Bulwark removeu os que pôde; se isso continuar acontecendo, peça ao seu administrador para aumentar o limite de envios de e-mail.",
|
||||
"submission_limit": "O servidor se recusou a enviar a mensagem porque um limite foi atingido: {reason}",
|
||||
"recipients_refused": "Não enviado: o servidor recusou {recipients}.",
|
||||
"recipients_partly_refused": "O servidor recusou {recipients}. Os demais destinatários recebem a mensagem.",
|
||||
"continue_draft": "Continuar rascunho",
|
||||
"close_draft_title": "Salvar ou descartar rascunho?",
|
||||
"close_draft_message": "Você tem alterações não salvas. Deseja salvar como rascunho ou descartar?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"send_timeout": "Serverul a încetat să răspundă. Verificați folderul Trimise înainte de a trimite din nou — mesajul poate să fi plecat deja.",
|
||||
"submission_quota": "Serverul păstrează prea multe înregistrări de mesaje trimise pentru acest cont. Bulwark le-a eliminat pe cele pe care a putut; dacă problema persistă, cereți administratorului să mărească limita de trimiteri de e-mail.",
|
||||
"submission_limit": "Serverul a refuzat să trimită mesajul deoarece s-a atins o limită: {reason}",
|
||||
"recipients_refused": "Netrimis: serverul a respins {recipients}.",
|
||||
"recipients_partly_refused": "Serverul a respins {recipients}. Ceilalți destinatari primesc mesajul.",
|
||||
"continue_draft": "Continuă schița",
|
||||
"close_draft_title": "Să salvați sau să renunțați la schiță?",
|
||||
"close_draft_message": "Aveți modificări nesalvate. Doriți să salvați acest mesaj ca schiță sau să îl ignorați?",
|
||||
|
||||
@@ -676,6 +676,8 @@
|
||||
"send_timeout": "Сервер перестал отвечать. Прежде чем отправлять снова, проверьте папку «Отправленные» — письмо могло уже уйти.",
|
||||
"submission_quota": "На сервере слишком много записей об отправленных письмах для этой учётной записи. Bulwark удалил те, что мог; если это повторяется, попросите администратора увеличить лимит отправки писем.",
|
||||
"submission_limit": "Сервер отказался отправить письмо, так как достигнут лимит: {reason}",
|
||||
"recipients_refused": "Не отправлено: сервер отклонил {recipients}.",
|
||||
"recipients_partly_refused": "Сервер отклонил {recipients}. Остальные получатели получат письмо.",
|
||||
"continue_draft": "Продолжить черновик",
|
||||
"close_draft_title": "Сохранить или удалить черновик?",
|
||||
"close_draft_message": "Есть несохранённые изменения. Сохранить как черновик или удалить?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"send_timeout": "Server prestal odpovedať. Pred opätovným odoslaním skontrolujte priečinok Odoslané — správa už mohla odísť.",
|
||||
"submission_quota": "Server má pre tento účet príliš veľa záznamov o odoslaných správach. Bulwark odstránil tie, ktoré mohol; ak sa to bude opakovať, požiadajte správcu o zvýšenie limitu odosielania e-mailov.",
|
||||
"submission_limit": "Server odmietol správu odoslať, pretože sa dosiahol limit: {reason}",
|
||||
"recipients_refused": "Neodoslané: server odmietol {recipients}.",
|
||||
"recipients_partly_refused": "Server odmietol {recipients}. Ostatní príjemcovia správu dostanú.",
|
||||
"continue_draft": "Pokračovať v koncepte",
|
||||
"close_draft_title": "Uložiť alebo zahoditť koncept?",
|
||||
"close_draft_message": "Máte neuložené zmeny. Chcete ich uložiť ako koncept alebo zahoditť?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"send_timeout": "Sunucu yanıt vermeyi bıraktı. Yeniden göndermeden önce Gönderilenler klasörünü kontrol edin — ileti gönderilmiş olabilir.",
|
||||
"submission_quota": "Sunucuda bu hesap için çok fazla gönderilmiş ileti kaydı var. Bulwark kaldırabildiklerini kaldırdı; bu durum sürerse yöneticinizden e-posta gönderim sınırını yükseltmesini isteyin.",
|
||||
"submission_limit": "Bir sınıra ulaşıldığı için sunucu iletiyi göndermeyi reddetti: {reason}",
|
||||
"recipients_refused": "Gönderilmedi: sunucu şu alıcıları reddetti: {recipients}.",
|
||||
"recipients_partly_refused": "Sunucu şu alıcıları reddetti: {recipients}. Diğer alıcılar iletiyi alır.",
|
||||
"continue_draft": "Taslağa devam et",
|
||||
"close_draft_title": "Taslak kaydedilsin mi yoksa silinsin mi?",
|
||||
"close_draft_message": "Kaydedilmemiş değişiklikleriniz var. Bunu taslak olarak kaydetmek ister misiniz yoksa vazgeçmek mi?",
|
||||
|
||||
@@ -676,6 +676,8 @@
|
||||
"send_timeout": "Сервер перестав відповідати. Перш ніж надсилати знову, перевірте теку «Надіслані» — лист міг уже піти.",
|
||||
"submission_quota": "На сервері забагато записів про надіслані листи для цього облікового запису. Bulwark видалив ті, що міг; якщо це повторюється, попросіть адміністратора збільшити ліміт надсилання листів.",
|
||||
"submission_limit": "Сервер відмовився надіслати лист, бо досягнуто ліміту: {reason}",
|
||||
"recipients_refused": "Не надіслано: сервер відхилив {recipients}.",
|
||||
"recipients_partly_refused": "Сервер відхилив {recipients}. Інші одержувачі отримають лист.",
|
||||
"continue_draft": "Продовжити чернетку",
|
||||
"close_draft_title": "Зберегти чи видалити чернетку?",
|
||||
"close_draft_message": "У вас є незбережені зміни. Зберегти це як чернетку чи видалити?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"send_timeout": "伺服器停止回應。再次傳送前,請先檢查「寄件備份」信件匣,因為郵件可能已經寄出。",
|
||||
"submission_quota": "伺服器上此帳戶的已寄出郵件記錄過多。Bulwark 已移除可以移除的記錄;如果問題持續發生,請要求管理員提高郵件提交數量上限。",
|
||||
"submission_limit": "由於達到上限,伺服器拒絕傳送此郵件:{reason}",
|
||||
"recipients_refused": "未傳送:伺服器拒絕了 {recipients}。",
|
||||
"recipients_partly_refused": "伺服器拒絕了 {recipients}。其他收件者會收到此郵件。",
|
||||
"continue_draft": "繼續編輯草稿",
|
||||
"close_draft_title": "要儲存還是捨棄草稿?",
|
||||
"close_draft_message": "您有尚未儲存的變更。要將這封郵件儲存為草稿,還是捨棄?",
|
||||
|
||||
@@ -676,6 +676,8 @@
|
||||
"send_timeout": "服务器已停止响应。再次发送前请检查“已发送”文件夹——邮件可能已经发出。",
|
||||
"submission_quota": "服务器上此账户的已发送邮件记录过多。Bulwark 已删除了能删除的记录;如果问题仍然出现,请让管理员提高邮件提交数量上限。",
|
||||
"submission_limit": "由于达到上限,服务器拒绝发送此邮件:{reason}",
|
||||
"recipients_refused": "未发送:服务器拒绝了 {recipients}。",
|
||||
"recipients_partly_refused": "服务器拒绝了 {recipients}。其他收件人会收到此邮件。",
|
||||
"continue_draft": "继续编辑草稿",
|
||||
"close_draft_title": "保存或丢弃草稿?",
|
||||
"close_draft_message": "您有未保存的更改。要将其保存为草稿还是丢弃?",
|
||||
|
||||
Reference in New Issue
Block a user