mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
fix: report recipients the server rejects at send #1123
This commit is contained in:
@@ -46,7 +46,7 @@ import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignat
|
||||
import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
|
||||
import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { RequestTimeoutError, ScheduleTooLateError } from "@/lib/jmap/client";
|
||||
import { RecipientsRejectedError, RequestTimeoutError, ScheduleTooLateError, formatRejectedRecipients } from "@/lib/jmap/client";
|
||||
import {
|
||||
rewriteCidImagesForEditor,
|
||||
replaceInlineImagePlaceholders,
|
||||
@@ -2508,6 +2508,12 @@ export function EmailComposer({
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [], plainTextMode };
|
||||
} catch (err) {
|
||||
debug.error('Failed to send email:', err);
|
||||
if (err instanceof RecipientsRejectedError) {
|
||||
// Nothing went out; the message stays open here so the addresses
|
||||
// can be fixed. The SMTP replies say why each one was refused (#1123).
|
||||
toast.error(t('send_recipients_rejected'), { message: formatRejectedRecipients(err.recipients) });
|
||||
return;
|
||||
}
|
||||
// A timeout 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).
|
||||
|
||||
@@ -34,6 +34,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore, buildUnifiedAccountClients, captureViewToken, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId, emptyFolderMovesToTrash } from "@/stores/email-store";
|
||||
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { formatRejectedRecipients } from "@/lib/jmap/client";
|
||||
import { runBatchEmailAction } from "@/lib/email-action-toast";
|
||||
import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog";
|
||||
import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster";
|
||||
@@ -1755,6 +1756,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// and re-sent (#592).
|
||||
toast.warning(t('email_composer.send_filing_warning'));
|
||||
}
|
||||
if (result.rejectedRecipients?.length) {
|
||||
// The server refused some recipients while accepting the rest; no
|
||||
// bounce will follow for these, so this is the only notice (#1123).
|
||||
toast.warning(t('email_composer.send_some_recipients_rejected'), {
|
||||
message: formatRejectedRecipients(result.rejectedRecipients),
|
||||
duration: 15000,
|
||||
});
|
||||
}
|
||||
// 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)
|
||||
@@ -1776,8 +1785,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 a refused
|
||||
// recipient already warned.
|
||||
if (!result.filingError && !result.rejectedRecipients?.length) toast.success(t('notifications.email_sent'));
|
||||
|
||||
// Refresh the current mailbox to update the UI
|
||||
if (!isScheduledView) {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { JMAPClient, RecipientsRejectedError, formatRejectedRecipients, rejectedRecipients } from '../jmap/client';
|
||||
import type { DeliveryStatus } from '../jmap/types';
|
||||
|
||||
// #1123: Stalwart runs RCPT TO while creating the EmailSubmission. A refused
|
||||
// recipient does not fail the create; it is recorded on the submission as
|
||||
// delivered "no", so the send has to read deliveryStatus back to notice.
|
||||
|
||||
type MethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
const MAILBOX_GONE = '550 5.1.2 Mailbox does not exist.';
|
||||
|
||||
function createClient(): JMAPClient {
|
||||
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
||||
Object.assign(client, {
|
||||
apiUrl: 'https://jmap.example.com/api',
|
||||
accountId: 'account-1',
|
||||
username: 'user@example.com',
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
const IDENTITIES = [
|
||||
{ id: 'identity-1', email: 'user@example.com', mayDelete: false },
|
||||
];
|
||||
|
||||
interface ServerOptions {
|
||||
deliveryStatus?: Record<string, DeliveryStatus>;
|
||||
/** Answer EmailSubmission/get the way a server without it would. */
|
||||
noSubmissionGet?: boolean;
|
||||
/** Refuse the submission itself. */
|
||||
refuseSubmission?: boolean;
|
||||
/** Refuse an envelope MAIL FROM other than the identity's, as Stalwart does. */
|
||||
refuseMailFrom?: boolean;
|
||||
}
|
||||
|
||||
function mockServer(options: ServerOptions = {}) {
|
||||
const requests: MethodCall[][] = [];
|
||||
const destroyed: string[] = [];
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
const { methodCalls } = JSON.parse((init as { body: string }).body) as { methodCalls: MethodCall[] };
|
||||
requests.push(methodCalls);
|
||||
const methodResponses: Array<[string, Record<string, unknown>, string]> = [];
|
||||
// Creation id -> server id for this request (RFC 8620 §5.3).
|
||||
const createdIds = new Map<string, string>();
|
||||
for (const [name, args, callId] of methodCalls) {
|
||||
if (name === 'Mailbox/get') {
|
||||
methodResponses.push([name, { list: [{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' }, { id: 'mb-sent', name: 'Sent', role: 'sent' }] }, callId]);
|
||||
} else if (name === 'Identity/get') {
|
||||
methodResponses.push([name, { list: IDENTITIES }, callId]);
|
||||
} else if (name === 'Email/set' || name === 'Email/import') {
|
||||
const ids = Object.keys((args.create ?? args.emails ?? {}) as object);
|
||||
destroyed.push(...((args.destroy ?? []) as string[]));
|
||||
methodResponses.push([name, {
|
||||
created: Object.fromEntries(ids.map((id) => [id, { id: 'email-9' }])),
|
||||
destroyed: args.destroy ?? [],
|
||||
}, callId]);
|
||||
} else if (name === 'EmailSubmission/set') {
|
||||
const [creationId, create] = Object.entries(args.create as Record<string, Record<string, unknown>>)[0];
|
||||
const mailFrom = (create.envelope as { mailFrom?: { email: string } } | undefined)?.mailFrom?.email;
|
||||
if (options.refuseSubmission) {
|
||||
methodResponses.push([name, { notCreated: { [creationId]: { type: 'forbiddenToSend', description: 'Sending is disabled.' } } }, callId]);
|
||||
} else if (options.refuseMailFrom && mailFrom && mailFrom !== 'user@example.com') {
|
||||
methodResponses.push([name, { notCreated: { [creationId]: { type: 'forbiddenFrom' } } }, callId]);
|
||||
} else {
|
||||
createdIds.set(creationId, 'sub-1');
|
||||
methodResponses.push([name, { created: { [creationId]: { id: 'sub-1' } } }, callId]);
|
||||
}
|
||||
} else if (name === 'EmailSubmission/get' && args['#ids']) {
|
||||
// Stalwart evaluates result references into /get, /changes and
|
||||
// /query responses only - a pointer into a /set response fails.
|
||||
methodResponses.push(['error', { type: 'invalidResultReference' }, callId]);
|
||||
} else if (name === 'EmailSubmission/get' && options.noSubmissionGet) {
|
||||
methodResponses.push(['error', { type: 'unknownMethod' }, callId]);
|
||||
} else if (name === 'EmailSubmission/get') {
|
||||
const ids = args.ids as string[];
|
||||
if (!ids[0].startsWith('#')) {
|
||||
methodResponses.push([name, { list: [{ id: ids[0], sendAt: '2026-09-29T12:00:30Z', undoStatus: 'pending' }] }, callId]);
|
||||
continue;
|
||||
}
|
||||
const submissionId = createdIds.get(ids[0].slice(1));
|
||||
methodResponses.push(submissionId
|
||||
? [name, { list: [{ id: submissionId, deliveryStatus: options.deliveryStatus ?? null }], notFound: [] }, callId]
|
||||
: ['error', { type: 'invalidResultReference' }, callId]);
|
||||
} else {
|
||||
methodResponses.push(['error', { type: 'unknownMethod' }, callId]);
|
||||
}
|
||||
}
|
||||
const payload = { methodResponses };
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify(payload)),
|
||||
json: () => Promise.resolve(payload),
|
||||
} as Response;
|
||||
});
|
||||
return { requests, destroyed };
|
||||
}
|
||||
|
||||
function send(client: JMAPClient, options: { draftId?: string; envelopeMailFrom?: string; delayedUntil?: string } = {}) {
|
||||
return client.sendEmail(
|
||||
['kept@example.net', 'gone@example.com'],
|
||||
'Subject',
|
||||
'body',
|
||||
undefined, undefined, 'identity-1', 'user@example.com',
|
||||
options.draftId, undefined, undefined, undefined,
|
||||
undefined, undefined,
|
||||
options.delayedUntil,
|
||||
options.envelopeMailFrom,
|
||||
);
|
||||
}
|
||||
|
||||
describe('rejectedRecipients', () => {
|
||||
it('lists recipients marked delivered "no" with their SMTP reply', () => {
|
||||
expect(rejectedRecipients({
|
||||
'kept@example.net': { delivered: 'queued', smtpReply: '250 2.1.5 Queued', displayed: 'unknown' },
|
||||
'gone@example.com': { delivered: 'no', smtpReply: `${MAILBOX_GONE}\r\n`, displayed: 'unknown' },
|
||||
})).toEqual({ rejected: [{ email: 'gone@example.com', smtpReply: MAILBOX_GONE }], all: false });
|
||||
});
|
||||
|
||||
it('says so when every recipient was refused', () => {
|
||||
expect(rejectedRecipients({
|
||||
'gone@example.com': { delivered: 'no', smtpReply: MAILBOX_GONE, displayed: 'unknown' },
|
||||
}).all).toBe(true);
|
||||
});
|
||||
|
||||
it('reports nothing without a deliveryStatus', () => {
|
||||
expect(rejectedRecipients(null)).toEqual({ rejected: [], all: false });
|
||||
expect(rejectedRecipients({})).toEqual({ rejected: [], all: false });
|
||||
});
|
||||
|
||||
it('formats recipients with their replies', () => {
|
||||
expect(formatRejectedRecipients([
|
||||
{ email: 'gone@example.com', smtpReply: MAILBOX_GONE },
|
||||
{ email: 'other@example.com', smtpReply: '' },
|
||||
])).toBe(`gone@example.com (${MAILBOX_GONE}), other@example.com`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JMAPClient.sendEmail refused recipients (#1123)', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reads the new submission deliveryStatus back in the send request', async () => {
|
||||
const { requests } = mockServer();
|
||||
|
||||
await send(createClient());
|
||||
|
||||
const sendRequest = requests.find((calls) => calls.some(([name]) => name === 'EmailSubmission/set'))!;
|
||||
expect(sendRequest.map(([name]) => name)).toEqual(['Email/set', 'EmailSubmission/set', 'EmailSubmission/get']);
|
||||
expect(sendRequest[2][1]).toEqual({
|
||||
accountId: 'account-1',
|
||||
ids: ['#1'],
|
||||
properties: ['deliveryStatus'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the refused recipients when the others were accepted', async () => {
|
||||
const { destroyed } = mockServer({
|
||||
deliveryStatus: {
|
||||
'kept@example.net': { delivered: 'queued', smtpReply: '250 2.1.5 Queued', displayed: 'unknown' },
|
||||
'gone@example.com': { delivered: 'no', smtpReply: MAILBOX_GONE, displayed: 'unknown' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await send(createClient(), { draftId: 'draft-1' });
|
||||
|
||||
expect(result.rejectedRecipients).toEqual([{ email: 'gone@example.com', smtpReply: MAILBOX_GONE }]);
|
||||
expect(result.filingError).toBeUndefined();
|
||||
// The message went out to the accepted recipient, so the draft is done.
|
||||
expect(destroyed).toEqual(['draft-1']);
|
||||
});
|
||||
|
||||
it('fails the send and keeps the draft when every recipient was refused', async () => {
|
||||
const { destroyed } = mockServer({
|
||||
deliveryStatus: {
|
||||
'kept@example.net': { delivered: 'no', smtpReply: '550 5.7.1 Relaying denied', displayed: 'unknown' },
|
||||
'gone@example.com': { delivered: 'no', smtpReply: MAILBOX_GONE, displayed: 'unknown' },
|
||||
},
|
||||
});
|
||||
|
||||
const error = await send(createClient(), { draftId: 'draft-1' }).catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(RecipientsRejectedError);
|
||||
expect((error as RecipientsRejectedError).recipients.map((r) => r.email)).toEqual(['kept@example.net', 'gone@example.com']);
|
||||
// The copy filed into Sent never left; the draft stays for a retry.
|
||||
expect(destroyed).toEqual(['email-9']);
|
||||
});
|
||||
|
||||
it('also fails a delayed send whose recipients were all refused', async () => {
|
||||
mockServer({
|
||||
deliveryStatus: { 'gone@example.com': { delivered: 'no', smtpReply: MAILBOX_GONE, displayed: 'unknown' } },
|
||||
});
|
||||
const client = createClient();
|
||||
Object.assign(client, {
|
||||
capabilities: {
|
||||
'urn:ietf:params:jmap:core': {},
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
},
|
||||
session: {
|
||||
primaryAccounts: {
|
||||
'urn:ietf:params:jmap:mail': 'account-1',
|
||||
'urn:ietf:params:jmap:submission': 'account-1',
|
||||
},
|
||||
accounts: {
|
||||
'account-1': {
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(send(client, { delayedUntil: new Date(Date.now() + 30_000).toISOString() }))
|
||||
.rejects.toBeInstanceOf(RecipientsRejectedError);
|
||||
});
|
||||
|
||||
it('reports a refused submission by its own error, not the dangling read-back', async () => {
|
||||
mockServer({ refuseSubmission: true });
|
||||
|
||||
await expect(send(createClient())).rejects.toThrow('Sending is disabled.');
|
||||
});
|
||||
|
||||
it('treats a server that cannot answer the read-back as a plain success', async () => {
|
||||
mockServer({ noSubmissionGet: true });
|
||||
|
||||
const result = await send(createClient());
|
||||
|
||||
expect(result).toMatchObject({ emailId: 'email-9', emailSubmissionId: 'sub-1' });
|
||||
expect(result.filingError).toBeUndefined();
|
||||
expect(result.rejectedRecipients).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads the status of the resubmission after a refused MAIL FROM', async () => {
|
||||
const { requests } = mockServer({
|
||||
refuseMailFrom: true,
|
||||
deliveryStatus: {
|
||||
'kept@example.net': { delivered: 'queued', smtpReply: '250 2.1.5 Queued', displayed: 'unknown' },
|
||||
'gone@example.com': { delivered: 'no', smtpReply: MAILBOX_GONE, displayed: 'unknown' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await send(createClient(), { envelopeMailFrom: 'alias@example.com' });
|
||||
|
||||
const submits = requests.filter((calls) => calls.some(([name]) => name === 'EmailSubmission/set'));
|
||||
expect(submits).toHaveLength(2);
|
||||
expect(submits[1].map(([name]) => name)).toEqual(['EmailSubmission/set', 'EmailSubmission/get']);
|
||||
expect(result.rejectedRecipients).toEqual([{ email: 'gone@example.com', smtpReply: MAILBOX_GONE }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JMAPClient raw sends refused recipients (#1123)', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function rawClient(): JMAPClient {
|
||||
const client = createClient();
|
||||
vi.spyOn(client, 'uploadBlob').mockResolvedValue({ blobId: 'blob-1', size: 10, type: 'message/rfc822' } as never);
|
||||
vi.spyOn(client, 'getIdentities').mockResolvedValue(IDENTITIES as never);
|
||||
return client;
|
||||
}
|
||||
|
||||
it('sendRawEmail returns the refused recipients', async () => {
|
||||
mockServer({
|
||||
deliveryStatus: {
|
||||
'kept@example.net': { delivered: 'queued', smtpReply: '250 2.1.5 Queued', displayed: 'unknown' },
|
||||
'gone@example.com': { delivered: 'no', smtpReply: MAILBOX_GONE, displayed: 'unknown' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await rawClient().sendRawEmail(new Blob(['raw']), 'identity-1', 'mb-sent', 'mb-drafts');
|
||||
|
||||
expect(result.rejectedRecipients).toEqual([{ email: 'gone@example.com', smtpReply: MAILBOX_GONE }]);
|
||||
});
|
||||
|
||||
it('sendRawEmail removes the filed copy when every recipient was refused', async () => {
|
||||
const { destroyed } = mockServer({
|
||||
deliveryStatus: { 'gone@example.com': { delivered: 'no', smtpReply: MAILBOX_GONE, displayed: 'unknown' } },
|
||||
});
|
||||
|
||||
await expect(rawClient().sendRawEmail(new Blob(['raw']), 'identity-1', 'mb-sent', 'mb-drafts'))
|
||||
.rejects.toBeInstanceOf(RecipientsRejectedError);
|
||||
expect(destroyed).toEqual(['email-9']);
|
||||
});
|
||||
|
||||
it('submitRawEmail fails when every recipient was refused', async () => {
|
||||
const { requests } = mockServer({
|
||||
deliveryStatus: { 'gone@example.com': { delivered: 'no', smtpReply: MAILBOX_GONE, displayed: 'unknown' } },
|
||||
});
|
||||
|
||||
await expect(rawClient().submitRawEmail(new Blob(['raw']), 'identity-1'))
|
||||
.rejects.toBeInstanceOf(RecipientsRejectedError);
|
||||
const submit = requests.find((calls) => calls.some(([name]) => name === 'EmailSubmission/set'))!;
|
||||
expect(submit[2][1].ids).toEqual(['#raw-submit']);
|
||||
});
|
||||
});
|
||||
+123
-10
@@ -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, 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, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { CalendarEventUpdateOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
|
||||
import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet";
|
||||
@@ -686,6 +686,76 @@ export function sendMethodErrors(
|
||||
return { failure, filing };
|
||||
}
|
||||
|
||||
/** Call id of the deliveryStatus read-back that rides along with a send. */
|
||||
const DELIVERY_STATUS_CALL_ID = 'deliveryStatus';
|
||||
|
||||
/**
|
||||
* Reads back the deliveryStatus of the submission an earlier call of the same
|
||||
* request creates as `creationId`. Stalwart runs RCPT TO while creating the
|
||||
* submission and records a refused recipient on it as delivered "no" instead
|
||||
* of failing the create, so the set response alone reads as a success - even
|
||||
* when every recipient was refused and nothing was queued (#1123).
|
||||
*
|
||||
* A creation-id reference (RFC 8620 §5.3), not a `#ids` result reference:
|
||||
* Stalwart does not evaluate result references into /set responses.
|
||||
*/
|
||||
function deliveryStatusCall(accountId: string, creationId: string): JMAPMethodCall {
|
||||
return ['EmailSubmission/get', {
|
||||
accountId,
|
||||
ids: [`#${creationId}`],
|
||||
properties: ['deliveryStatus'],
|
||||
}, DELIVERY_STATUS_CALL_ID];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the deliveryStatus read-back off a send response. It only reports:
|
||||
* an error from it (a failed set leaves its back-reference dangling) must not
|
||||
* be taken for a failed send or a filing problem by sendMethodErrors.
|
||||
*/
|
||||
function takeDeliveryStatus(response: JMAPResponse): {
|
||||
response: JMAPResponse;
|
||||
deliveryStatus?: Record<string, DeliveryStatus>;
|
||||
} {
|
||||
const entry = response.methodResponses?.find(([, , callId]) => callId === DELIVERY_STATUS_CALL_ID);
|
||||
if (!entry) return { response };
|
||||
const [name, result] = entry;
|
||||
return {
|
||||
response: {
|
||||
...response,
|
||||
methodResponses: response.methodResponses.filter(([, , callId]) => callId !== DELIVERY_STATUS_CALL_ID),
|
||||
},
|
||||
deliveryStatus: name === 'EmailSubmission/get' ? (result?.list?.[0]?.deliveryStatus ?? undefined) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipients a submission's deliveryStatus (RFC 8621 §7) marks as not
|
||||
* delivered, and whether that is all of them - then the message went nowhere.
|
||||
*/
|
||||
export function rejectedRecipients(deliveryStatus: Record<string, DeliveryStatus> | null | undefined): {
|
||||
rejected: RejectedRecipient[];
|
||||
all: boolean;
|
||||
} {
|
||||
const entries = Object.entries(deliveryStatus ?? {});
|
||||
const rejected = entries
|
||||
.filter(([, status]) => status?.delivered === 'no')
|
||||
.map(([email, status]) => ({ email, smtpReply: status.smtpReply?.trim() ?? '' }));
|
||||
return { rejected, all: rejected.length > 0 && rejected.length === entries.length };
|
||||
}
|
||||
|
||||
/** "a@example.com (550 5.1.2 Mailbox does not exist.), b@example.com" */
|
||||
export function formatRejectedRecipients(recipients: RejectedRecipient[]): string {
|
||||
return recipients.map(({ email, smtpReply }) => (smtpReply ? `${email} (${smtpReply})` : email)).join(', ');
|
||||
}
|
||||
|
||||
/** The server refused every recipient of a send, so nothing went out. */
|
||||
export class RecipientsRejectedError extends Error {
|
||||
constructor(readonly recipients: RejectedRecipient[]) {
|
||||
super(`The server rejected every recipient: ${formatRejectedRecipients(recipients)}`);
|
||||
this.name = 'RecipientsRejectedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** "report.pdf" -> "report (2).pdf", the way Stalwart's onExists "rename" names copies. */
|
||||
function numberedFileName(name: string, n: number): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
@@ -3915,6 +3985,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: buildSubmissionCreate(`#${emailId}`, firstMailFrom),
|
||||
onSuccessUpdateEmail,
|
||||
}, "1"]);
|
||||
methodCalls.push(deliveryStatusCall(this.getSubmissionAccountId(targetAccountId), "1"));
|
||||
|
||||
let response = await this.request(methodCalls);
|
||||
|
||||
@@ -3934,7 +4005,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
accountId: this.getSubmissionAccountId(targetAccountId),
|
||||
create: buildSubmissionCreate(draftCopyId, identityMailFrom),
|
||||
onSuccessUpdateEmail,
|
||||
}, "1"]]);
|
||||
}, "1"], deliveryStatusCall(this.getSubmissionAccountId(targetAccountId), "1")]);
|
||||
response = {
|
||||
...retry,
|
||||
methodResponses: [
|
||||
@@ -3950,6 +4021,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
let serverSendAt: string | undefined;
|
||||
let filingError: string | undefined;
|
||||
|
||||
const statusReadBack = takeDeliveryStatus(response);
|
||||
response = statusReadBack.response;
|
||||
|
||||
const { failure, filing } = sendMethodErrors(response.methodResponses);
|
||||
if (failure) {
|
||||
console.error('[sendEmail] JMAP method error:', failure);
|
||||
@@ -4016,6 +4090,21 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// With every recipient refused nothing was queued, yet the submission
|
||||
// exists and its onSuccessUpdateEmail filed the copy into Sent (#1123).
|
||||
// Drop that copy and keep the old draft: the send failed.
|
||||
const { rejected, all: noneAccepted } = rejectedRecipients(statusReadBack.deliveryStatus);
|
||||
if (noneAccepted) {
|
||||
if (createdEmailId) {
|
||||
try {
|
||||
await this.request([["Email/set", { accountId: targetAccountId, destroy: [createdEmailId] }, "0"]]);
|
||||
} catch (err) {
|
||||
console.error('[sendEmail] removing the unsent copy from Sent failed:', err);
|
||||
}
|
||||
}
|
||||
throw new RecipientsRejectedError(rejected);
|
||||
}
|
||||
|
||||
// The message is out (or scheduled) - now it is safe to drop the old
|
||||
// draft. A failure here leaves an orphan in Drafts, which is reported as
|
||||
// a filing warning rather than a failed send (#849).
|
||||
@@ -4045,9 +4134,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId);
|
||||
}
|
||||
|
||||
const rejectedRecipientsResult = rejected.length ? rejected : undefined;
|
||||
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, rejectedRecipients: rejectedRecipientsResult }
|
||||
: { scheduled: false, emailId: createdEmailId, emailSubmissionId, submissionAccountId, filingError, rejectedRecipients: rejectedRecipientsResult };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8949,9 +9039,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
},
|
||||
} : {}),
|
||||
}, '1'],
|
||||
deliveryStatusCall(this.getSubmissionAccountId(), 'raw-submit'),
|
||||
];
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
const { response, deliveryStatus } = takeDeliveryStatus(await this.request(methodCalls));
|
||||
let emailId: string | undefined;
|
||||
let emailSubmissionId: string | undefined;
|
||||
let serverSendAt: string | undefined;
|
||||
@@ -8978,6 +9069,20 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Every recipient refused: nothing was queued, but the imported copy was
|
||||
// filed as sent. Remove it and fail the send (#1123).
|
||||
const { rejected, all: noneAccepted } = rejectedRecipients(deliveryStatus);
|
||||
if (noneAccepted) {
|
||||
if (emailId) {
|
||||
try {
|
||||
await this.request([['Email/set', { accountId: this.accountId, destroy: [emailId] }, '0']]);
|
||||
} catch (err) {
|
||||
console.error('[sendRawEmail] removing the unsent copy failed:', err);
|
||||
}
|
||||
}
|
||||
throw new RecipientsRejectedError(rejected);
|
||||
}
|
||||
|
||||
// These raw/S-MIME paths submit through the primary submission account, so
|
||||
// report that as the owning account rather than leaving it unset.
|
||||
const submissionAccountId = this.getSubmissionAccountId();
|
||||
@@ -8986,9 +9091,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId);
|
||||
}
|
||||
|
||||
const rejectedRecipientsResult = rejected.length ? rejected : undefined;
|
||||
return delayedUntil
|
||||
? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, isSmime: true }
|
||||
: { scheduled: false, emailId, emailSubmissionId, submissionAccountId, isSmime: true };
|
||||
? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, isSmime: true, rejectedRecipients: rejectedRecipientsResult }
|
||||
: { scheduled: false, emailId, emailSubmissionId, submissionAccountId, isSmime: true, rejectedRecipients: rejectedRecipientsResult };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9040,9 +9146,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
//destroy the temporary email after submission to avoid leaving a draft behind.
|
||||
onSuccessDestroyEmail: ['#raw-submit'],
|
||||
}, '1'],
|
||||
deliveryStatusCall(this.getSubmissionAccountId(), 'raw-submit'),
|
||||
];
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
const { response, deliveryStatus } = takeDeliveryStatus(await this.request(methodCalls));
|
||||
let emailSubmissionId: string | undefined;
|
||||
let serverSendAt: string | undefined;
|
||||
|
||||
@@ -9064,15 +9171,21 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// The temporary copy is already gone (onSuccessDestroyEmail), so all that
|
||||
// is left is to report refused recipients (#1123).
|
||||
const { rejected, all: noneAccepted } = rejectedRecipients(deliveryStatus);
|
||||
if (noneAccepted) throw new RecipientsRejectedError(rejected);
|
||||
|
||||
const submissionAccountId = this.getSubmissionAccountId();
|
||||
|
||||
if (delayedUntil && emailSubmissionId && !serverSendAt) {
|
||||
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId);
|
||||
}
|
||||
|
||||
const rejectedRecipientsResult = rejected.length ? rejected : undefined;
|
||||
return delayedUntil
|
||||
? { scheduled: true, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, isSmime: true }
|
||||
: { scheduled: false, emailSubmissionId, submissionAccountId, isSmime: true };
|
||||
? { scheduled: true, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, isSmime: true, rejectedRecipients: rejectedRecipientsResult }
|
||||
: { scheduled: false, emailSubmissionId, submissionAccountId, isSmime: true, rejectedRecipients: rejectedRecipientsResult };
|
||||
}
|
||||
|
||||
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; totalByAccount?: Record<string, number>; nextPosition: number }> {
|
||||
|
||||
@@ -118,6 +118,18 @@ export interface SendEmailResult {
|
||||
* old draft). The mail left the server - callers should warn, not fail.
|
||||
*/
|
||||
filingError?: string;
|
||||
/**
|
||||
* Recipients the server refused while accepting the submission (its
|
||||
* deliveryStatus says delivered "no"). The message went out to the others
|
||||
* only - callers should name these instead of reporting a plain success.
|
||||
*/
|
||||
rejectedRecipients?: RejectedRecipient[];
|
||||
}
|
||||
|
||||
/** A recipient refused at submission, with the SMTP reply the server gave. */
|
||||
export interface RejectedRecipient {
|
||||
email: string;
|
||||
smtpReply: string;
|
||||
}
|
||||
|
||||
export interface ScheduledEmail extends Email {
|
||||
|
||||
@@ -675,6 +675,8 @@
|
||||
"show_less": "إظهار أقل",
|
||||
"send_failed": "فشل إرسال الرسالة",
|
||||
"send_timeout": "توقف الخادم عن الاستجابة. تحقق من مجلد المُرسَل قبل إعادة الإرسال — فقد تكون الرسالة قد أُرسلت بالفعل.",
|
||||
"send_recipients_rejected": "لم تُرسل الرسالة - رفض الخادم جميع المستلمين.",
|
||||
"send_some_recipients_rejected": "أُرسلت الرسالة، لكن ليس إلى هؤلاء المستلمين - رفضهم الخادم.",
|
||||
"continue_draft": "متابعة المسودة",
|
||||
"close_draft_title": "حفظ المسودة أم تجاهلها؟",
|
||||
"close_draft_message": "لديك تغييرات غير محفوظة. هل تريد حفظها كمسودة أم تجاهلها؟",
|
||||
|
||||
@@ -675,6 +675,8 @@
|
||||
"show_less": "Mostra'n menys",
|
||||
"send_failed": "No s'ha pogut enviar el correu",
|
||||
"send_timeout": "El servidor ha deixat de respondre. Comproveu la carpeta Enviats abans de tornar a enviar: el missatge pot haver sortit.",
|
||||
"send_recipients_rejected": "No s'ha enviat: el servidor ha rebutjat tots els destinataris.",
|
||||
"send_some_recipients_rejected": "S'ha enviat, però no a aquests destinataris: el servidor els ha rebutjat.",
|
||||
"continue_draft": "Continua l'esborrany",
|
||||
"close_draft_title": "Voleu desar o descartar l'esborrany?",
|
||||
"close_draft_message": "Teniu canvis sense desar. Voleu desar-los com a esborrany o descartar-los?",
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"show_less": "Zobrazit méně",
|
||||
"send_failed": "Odeslání zprávy selhalo",
|
||||
"send_timeout": "Server přestal odpovídat. Než odešlete znovu, zkontrolujte složku Odeslané – zpráva už mohla odejít.",
|
||||
"send_recipients_rejected": "Neodesláno – server odmítl všechny příjemce.",
|
||||
"send_some_recipients_rejected": "Odesláno, ale ne těmto příjemcům – server je odmítl.",
|
||||
"continue_draft": "Pokračovat v úpravě konceptu",
|
||||
"close_draft_title": "Uložit nebo zahodit koncept?",
|
||||
"close_draft_message": "Máte neuložené změny. Chcete je uložit jako koncept, nebo zahodit?",
|
||||
|
||||
@@ -674,6 +674,8 @@
|
||||
"show_less": "Vis mindre",
|
||||
"send_failed": "Kunne ikke sende e-mail",
|
||||
"send_timeout": "Serveren holdt op med at svare. Tjek mappen Sendt, før du sender igen — beskeden er måske allerede afsendt.",
|
||||
"send_recipients_rejected": "Ikke sendt - serveren afviste alle modtagere.",
|
||||
"send_some_recipients_rejected": "Sendt, men ikke til disse modtagere - serveren afviste dem.",
|
||||
"continue_draft": "Fortsæt kladde",
|
||||
"close_draft_title": "Gem eller kassér kladde?",
|
||||
"close_draft_message": "Du har ugemte ændringer. Vil du gemme dette som en kladde eller kassere det?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"smime_unlock_button": "Entsperren",
|
||||
"smime_passphrase_placeholder": "S/MIME-Passphrase",
|
||||
"send_timeout": "Der Server antwortet nicht mehr. Prüfen Sie den Ordner „Gesendet“, bevor Sie erneut senden – die Nachricht wurde möglicherweise bereits verschickt.",
|
||||
"send_recipients_rejected": "Nicht gesendet – der Server hat alle Empfänger abgelehnt.",
|
||||
"send_some_recipients_rejected": "Gesendet, aber nicht an diese Empfänger – der Server hat sie abgelehnt.",
|
||||
"continue_draft": "Entwurf fortsetzen",
|
||||
"close_draft_title": "Entwurf speichern oder verwerfen?",
|
||||
"close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?",
|
||||
|
||||
@@ -675,6 +675,8 @@
|
||||
"show_less": "Show less",
|
||||
"send_failed": "Failed to send email",
|
||||
"send_timeout": "The server stopped responding. Check your Sent folder before sending again — the message may have gone out.",
|
||||
"send_recipients_rejected": "Not sent - the server rejected every recipient.",
|
||||
"send_some_recipients_rejected": "Sent, but not to these recipients - the server rejected them.",
|
||||
"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?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"smime_unlock_button": "Desbloquear",
|
||||
"smime_passphrase_placeholder": "Contraseña S/MIME",
|
||||
"send_timeout": "El servidor dejó de responder. Compruebe la carpeta Enviados antes de volver a enviar: el mensaje puede haber salido.",
|
||||
"send_recipients_rejected": "No enviado: el servidor rechazó a todos los destinatarios.",
|
||||
"send_some_recipients_rejected": "Enviado, pero no a estos destinatarios: el servidor los rechazó.",
|
||||
"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?",
|
||||
|
||||
@@ -675,6 +675,8 @@
|
||||
"show_less": "نمایش کمتر",
|
||||
"send_failed": "ارسال ایمیل ناموفق بود",
|
||||
"send_timeout": "سرور دیگر پاسخ نمیدهد. پیش از ارسال دوباره، پوشهٔ ارسالشدهها را بررسی کنید — ممکن است پیام ارسال شده باشد.",
|
||||
"send_recipients_rejected": "ارسال نشد - سرور همه گیرندگان را رد کرد.",
|
||||
"send_some_recipients_rejected": "ارسال شد، اما نه برای این گیرندگان - سرور آنها را رد کرد.",
|
||||
"continue_draft": "ادامه پیشنویس",
|
||||
"close_draft_title": "ذخیره یا دور انداختن پیشنویس؟",
|
||||
"close_draft_message": "تغییرات ذخیره نشده دارید. ذخیره یا دور انداختن؟",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"smime_unlock_button": "Déverrouiller",
|
||||
"smime_passphrase_placeholder": "Phrase secrète S/MIME",
|
||||
"send_timeout": "Le serveur ne répond plus. Vérifiez le dossier Envoyés avant de renvoyer : le message est peut-être déjà parti.",
|
||||
"send_recipients_rejected": "Non envoyé : le serveur a refusé tous les destinataires.",
|
||||
"send_some_recipients_rejected": "Envoyé, mais pas à ces destinataires : le serveur les a refusés.",
|
||||
"continue_draft": "Continuer le brouillon",
|
||||
"close_draft_title": "Enregistrer ou supprimer le brouillon ?",
|
||||
"close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?",
|
||||
|
||||
@@ -617,6 +617,8 @@
|
||||
"show_less": "הצג פחות",
|
||||
"send_failed": "שליחת האימייל נכשלה",
|
||||
"send_timeout": "השרת הפסיק להגיב. בדקו את תיקיית הנשלחים לפני שליחה חוזרת — ייתכן שההודעה כבר נשלחה.",
|
||||
"send_recipients_rejected": "לא נשלח - השרת דחה את כל הנמענים.",
|
||||
"send_some_recipients_rejected": "נשלח, אך לא לנמענים האלה - השרת דחה אותם.",
|
||||
"continue_draft": "המשך טיוטה",
|
||||
"close_draft_title": "לשמור או למחוק טיוטה?",
|
||||
"close_draft_message": "יש לך שינויים שלא נשמרו. האם תרצה לשמור את זה כטיוטה או למחוק אותו?",
|
||||
|
||||
@@ -675,6 +675,8 @@
|
||||
"show_less": "Kevesebb mutatása",
|
||||
"send_failed": "Nem sikerült elküldeni az e-mailt",
|
||||
"send_timeout": "A kiszolgáló nem válaszol többé. Újraküldés előtt ellenőrizze az Elküldött mappát – az üzenet lehet, hogy már kiment.",
|
||||
"send_recipients_rejected": "Nem lett elküldve – a szerver minden címzettet elutasított.",
|
||||
"send_some_recipients_rejected": "Elküldve, de ezeknek a címzetteknek nem – a szerver elutasította őket.",
|
||||
"continue_draft": "Piszkozat folytatása",
|
||||
"close_draft_title": "Piszkozat mentése vagy elvetése?",
|
||||
"close_draft_message": "Nem mentett változtatások vannak. Szeretnéd piszkozatként menteni vagy elvetni?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"smime_unlock_button": "Sblocca",
|
||||
"smime_passphrase_placeholder": "Passphrase S/MIME",
|
||||
"send_timeout": "Il server ha smesso di rispondere. Controlli la cartella Posta inviata prima di inviare di nuovo: il messaggio potrebbe essere già partito.",
|
||||
"send_recipients_rejected": "Non inviato: il server ha rifiutato tutti i destinatari.",
|
||||
"send_some_recipients_rejected": "Inviato, ma non a questi destinatari: il server li ha rifiutati.",
|
||||
"continue_draft": "Continua bozza",
|
||||
"close_draft_title": "Salvare o eliminare la bozza?",
|
||||
"close_draft_message": "Hai modifiche non salvate. Vuoi salvare come bozza o eliminare?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"smime_unlock_button": "ロック解除",
|
||||
"smime_passphrase_placeholder": "S/MIME パスフレーズ",
|
||||
"send_timeout": "サーバーが応答しなくなりました。再送信する前に送信済みフォルダーを確認してください。メッセージはすでに送信されている可能性があります。",
|
||||
"send_recipients_rejected": "送信されませんでした。サーバーがすべての宛先を拒否しました。",
|
||||
"send_some_recipients_rejected": "送信しましたが、次の宛先には届きません。サーバーが拒否しました。",
|
||||
"continue_draft": "下書きを続ける",
|
||||
"close_draft_title": "下書きを保存または破棄しますか?",
|
||||
"close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"show_less": "간략히 보기",
|
||||
"send_failed": "메일 전송에 실패했어요",
|
||||
"send_timeout": "서버가 응답을 멈췄습니다. 다시 보내기 전에 보낸 편지함을 확인하세요. 메시지가 이미 발송되었을 수 있습니다.",
|
||||
"send_recipients_rejected": "보내지 못했습니다. 서버가 모든 수신자를 거부했습니다.",
|
||||
"send_some_recipients_rejected": "보냈지만 다음 수신자에게는 전달되지 않습니다. 서버가 거부했습니다.",
|
||||
"continue_draft": "이어서 작성하기",
|
||||
"close_draft_title": "임시보관함에 저장할까요?",
|
||||
"close_draft_message": "저장되지 않은 내용이 있어요. 임시보관함에 저장할까요, 아니면 삭제할까요?",
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"show_less": "Rādīt mazāk",
|
||||
"send_failed": "Neizdevās nosūtīt vēstuli",
|
||||
"send_timeout": "Serveris pārstāja atbildēt. Pirms sūtīt vēlreiz, pārbaudiet mapi Nosūtītie — ziņojums, iespējams, jau ir aizsūtīts.",
|
||||
"send_recipients_rejected": "Nav nosūtīts — serveris noraidīja visus adresātus.",
|
||||
"send_some_recipients_rejected": "Nosūtīts, taču ne šiem adresātiem — serveris tos noraidīja.",
|
||||
"continue_draft": "Turpināt melnrakstu",
|
||||
"close_draft_title": "Saglabāt vai atmest melnrakstu?",
|
||||
"close_draft_message": "Ir nesaglabātas izmaiņas. Vai vēlaties saglabāt kā melnrakstu vai atmest?",
|
||||
|
||||
@@ -675,6 +675,8 @@
|
||||
"show_less": "Бага харуулах",
|
||||
"send_failed": "Имэйл илгээж чадсангүй",
|
||||
"send_timeout": "Сервер хариу өгөхөө больсон. Дахин илгээхийн өмнө \"Илгээсэн\" фолдероо шалгана уу — мессеж унтарсан байж магадгүй.",
|
||||
"send_recipients_rejected": "Илгээгдсэнгүй - сервер бүх хүлээн авагчийг татгалзсан.",
|
||||
"send_some_recipients_rejected": "Илгээсэн, гэхдээ эдгээр хүлээн авагчид хүрэхгүй - сервер тэднийг татгалзсан.",
|
||||
"continue_draft": "Ноорог үргэлжлүүлэх",
|
||||
"close_draft_title": "Ноорог хадгалах эсвэл устгах уу?",
|
||||
"close_draft_message": "Танд хадгалагдаагүй өөрчлөлтүүд байна. Та үүнийг ноорог болгон хадгалах уу эсвэл хаях уу?",
|
||||
|
||||
@@ -675,6 +675,8 @@
|
||||
"show_less": "Vis færre",
|
||||
"send_failed": "Kunne ikke sende e-post",
|
||||
"send_timeout": "Serveren sluttet å svare. Sjekk Sendt-mappen før du sender på nytt – meldingen kan ha blitt sendt.",
|
||||
"send_recipients_rejected": "Ikke sendt – serveren avviste alle mottakerne.",
|
||||
"send_some_recipients_rejected": "Sendt, men ikke til disse mottakerne – serveren avviste dem.",
|
||||
"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?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"smime_unlock_button": "Ontgrendelen",
|
||||
"smime_passphrase_placeholder": "S/MIME-wachtwoordzin",
|
||||
"send_timeout": "De server reageert niet meer. Controleer de map Verzonden voordat u opnieuw verstuurt — het bericht is mogelijk al verzonden.",
|
||||
"send_recipients_rejected": "Niet verzonden - de server heeft alle ontvangers geweigerd.",
|
||||
"send_some_recipients_rejected": "Verzonden, maar niet naar deze ontvangers - de server heeft ze geweigerd.",
|
||||
"continue_draft": "Concept voortzetten",
|
||||
"close_draft_title": "Concept opslaan of verwijderen?",
|
||||
"close_draft_message": "U heeft niet-opgeslagen wijzigingen. Wilt u dit als concept opslaan of verwijderen?",
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"show_less": "Pokaż mniej",
|
||||
"send_failed": "Nie udało się wysłać wiadomości",
|
||||
"send_timeout": "Serwer przestał odpowiadać. Przed ponownym wysłaniem sprawdź folder Wysłane — wiadomość mogła już zostać wysłana.",
|
||||
"send_recipients_rejected": "Nie wysłano – serwer odrzucił wszystkich odbiorców.",
|
||||
"send_some_recipients_rejected": "Wysłano, ale nie do tych odbiorców – serwer ich odrzucił.",
|
||||
"continue_draft": "Kontynuuj szkic",
|
||||
"close_draft_title": "Zapisać czy odrzucić szkic?",
|
||||
"close_draft_message": "Masz niezapisane zmiany. Czy chcesz zapisać jako szkic czy odrzucić?",
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
"smime_unlock_button": "Desbloquear",
|
||||
"smime_passphrase_placeholder": "Frase secreta S/MIME",
|
||||
"send_timeout": "O servidor deixou de responder. Verifique a pasta Enviados antes de enviar novamente — a mensagem pode já ter saído.",
|
||||
"send_recipients_rejected": "Não enviado: o servidor rejeitou todos os destinatários.",
|
||||
"send_some_recipients_rejected": "Enviado, mas não para estes destinatários, que foram rejeitados pelo servidor.",
|
||||
"continue_draft": "Continuar rascunho",
|
||||
"close_draft_title": "Salvar ou descartar rascunho?",
|
||||
"close_draft_message": "Você tem alterações não salvas. Deseja salvar como rascunho ou descartar?",
|
||||
|
||||
@@ -675,6 +675,8 @@
|
||||
"show_less": "Afișați mai puțin",
|
||||
"send_failed": "Nu s-a putut trimite e-mailul",
|
||||
"send_timeout": "Serverul a încetat să răspundă. Verificați folderul Trimise înainte de a trimite din nou — mesajul poate să fi plecat deja.",
|
||||
"send_recipients_rejected": "Netrimis - serverul a respins toți destinatarii.",
|
||||
"send_some_recipients_rejected": "Trimis, dar nu și acestor destinatari - serverul i-a respins.",
|
||||
"continue_draft": "Continuă schița",
|
||||
"close_draft_title": "Să salvați sau să renunțați la schiță?",
|
||||
"close_draft_message": "Aveți modificări nesalvate. Doriți să salvați acest mesaj ca schiță sau să îl ignorați?",
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"show_less": "Свернуть",
|
||||
"send_failed": "Не удалось отправить письмо",
|
||||
"send_timeout": "Сервер перестал отвечать. Прежде чем отправлять снова, проверьте папку «Отправленные» — письмо могло уже уйти.",
|
||||
"send_recipients_rejected": "Не отправлено — сервер отклонил всех получателей.",
|
||||
"send_some_recipients_rejected": "Отправлено, но не этим получателям — сервер их отклонил.",
|
||||
"continue_draft": "Продолжить черновик",
|
||||
"close_draft_title": "Сохранить или удалить черновик?",
|
||||
"close_draft_message": "Есть несохранённые изменения. Сохранить как черновик или удалить?",
|
||||
|
||||
@@ -675,6 +675,8 @@
|
||||
"show_less": "Zobraziť menej",
|
||||
"send_failed": "Odoslanie e-mailu zlyhalo",
|
||||
"send_timeout": "Server prestal odpovedať. Pred opätovným odoslaním skontrolujte priečinok Odoslané — správa už mohla odísť.",
|
||||
"send_recipients_rejected": "Neodoslané – server odmietol všetkých príjemcov.",
|
||||
"send_some_recipients_rejected": "Odoslané, ale nie týmto príjemcom – server ich odmietol.",
|
||||
"continue_draft": "Pokračovať v koncepte",
|
||||
"close_draft_title": "Uložiť alebo zahoditť koncept?",
|
||||
"close_draft_message": "Máte neuložené zmeny. Chcete ich uložiť ako koncept alebo zahoditť?",
|
||||
|
||||
@@ -675,6 +675,8 @@
|
||||
"show_less": "Daha az göster",
|
||||
"send_failed": "E-posta gönderilemedi",
|
||||
"send_timeout": "Sunucu yanıt vermeyi bıraktı. Yeniden göndermeden önce Gönderilenler klasörünü kontrol edin — ileti gönderilmiş olabilir.",
|
||||
"send_recipients_rejected": "Gönderilmedi - sunucu tüm alıcıları reddetti.",
|
||||
"send_some_recipients_rejected": "Gönderildi, ancak bu alıcılara değil - sunucu onları reddetti.",
|
||||
"continue_draft": "Taslağa devam et",
|
||||
"close_draft_title": "Taslak kaydedilsin mi yoksa silinsin mi?",
|
||||
"close_draft_message": "Kaydedilmemiş değişiklikleriniz var. Bunu taslak olarak kaydetmek ister misiniz yoksa vazgeçmek mi?",
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"show_less": "Показувати менше",
|
||||
"send_failed": "Не вдалося надіслати електронний лист",
|
||||
"send_timeout": "Сервер перестав відповідати. Перш ніж надсилати знову, перевірте теку «Надіслані» — лист міг уже піти.",
|
||||
"send_recipients_rejected": "Не надіслано — сервер відхилив усіх одержувачів.",
|
||||
"send_some_recipients_rejected": "Надіслано, але не цим одержувачам — сервер їх відхилив.",
|
||||
"continue_draft": "Продовжити чернетку",
|
||||
"close_draft_title": "Зберегти чи видалити чернетку?",
|
||||
"close_draft_message": "У вас є незбережені зміни. Зберегти це як чернетку чи видалити?",
|
||||
|
||||
@@ -675,6 +675,8 @@
|
||||
"show_less": "顯示較少",
|
||||
"send_failed": "傳送郵件失敗",
|
||||
"send_timeout": "伺服器停止回應。再次傳送前,請先檢查「寄件備份」信件匣,因為郵件可能已經寄出。",
|
||||
"send_recipients_rejected": "未傳送:伺服器拒絕了所有收件者。",
|
||||
"send_some_recipients_rejected": "已傳送,但未傳送給以下收件者:伺服器拒絕了他們。",
|
||||
"continue_draft": "繼續編輯草稿",
|
||||
"close_draft_title": "要儲存還是捨棄草稿?",
|
||||
"close_draft_message": "您有尚未儲存的變更。要將這封郵件儲存為草稿,還是捨棄?",
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"show_less": "收起",
|
||||
"send_failed": "发送邮件失败",
|
||||
"send_timeout": "服务器已停止响应。再次发送前请检查“已发送”文件夹——邮件可能已经发出。",
|
||||
"send_recipients_rejected": "未发送:服务器拒绝了所有收件人。",
|
||||
"send_some_recipients_rejected": "已发送,但未发送给以下收件人:服务器拒绝了他们。",
|
||||
"continue_draft": "继续编辑草稿",
|
||||
"close_draft_title": "保存或丢弃草稿?",
|
||||
"close_draft_message": "您有未保存的更改。要将其保存为草稿还是丢弃?",
|
||||
|
||||
Reference in New Issue
Block a user