fix: report recipients the server rejects at send #1123

This commit is contained in:
Linus Rath
2026-09-29 20:49:40 +02:00
parent be0494cb5c
commit affa999cd0
32 changed files with 509 additions and 13 deletions
+7 -1
View File
@@ -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).
+12 -2
View File
@@ -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
View File
@@ -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 }> {
+12
View File
@@ -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 {
+2
View File
@@ -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": "لديك تغييرات غير محفوظة. هل تريد حفظها كمسودة أم تجاهلها؟",
+2
View File
@@ -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?",
+2
View File
@@ -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?",
+2
View File
@@ -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?",
+2
View File
@@ -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?",
+2
View File
@@ -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?",
+2
View File
@@ -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?",
+2
View File
@@ -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": "تغییرات ذخیره نشده دارید. ذخیره یا دور انداختن؟",
+2
View File
@@ -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 ?",
+2
View File
@@ -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": "יש לך שינויים שלא נשמרו. האם תרצה לשמור את זה כטיוטה או למחוק אותו?",
+2
View File
@@ -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?",
+2
View File
@@ -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?",
+2
View File
@@ -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": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
+2
View File
@@ -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": "저장되지 않은 내용이 있어요. 임시보관함에 저장할까요, 아니면 삭제할까요?",
+2
View File
@@ -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?",
+2
View File
@@ -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": "Танд хадгалагдаагүй өөрчлөлтүүд байна. Та үүнийг ноорог болгон хадгалах уу эсвэл хаях уу?",
+2
View File
@@ -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?",
+2
View File
@@ -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?",
+2
View File
@@ -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ć?",
+2
View File
@@ -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?",
+2
View File
@@ -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?",
+2
View File
@@ -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": "Есть несохранённые изменения. Сохранить как черновик или удалить?",
+2
View File
@@ -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ť?",
+2
View File
@@ -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?",
+2
View File
@@ -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": "У вас є незбережені зміни. Зберегти це як чернетку чи видалити?",
+2
View File
@@ -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": "您有尚未儲存的變更。要將這封郵件儲存為草稿,還是捨棄?",
+2
View File
@@ -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": "您有未保存的更改。要将其保存为草稿还是丢弃?",