diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 95d8d8974..ca7d7b11f 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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). diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 1b6c6c6e7..94a6d3e9a 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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) { diff --git a/lib/__tests__/jmap-send-rejected-recipients.test.ts b/lib/__tests__/jmap-send-rejected-recipients.test.ts new file mode 100644 index 000000000..f3aa06710 --- /dev/null +++ b/lib/__tests__/jmap-send-rejected-recipients.test.ts @@ -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]; + +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; + /** 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]> = []; + // Creation id -> server id for this request (RFC 8620 §5.3). + const createdIds = new Map(); + 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>)[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']); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index fb2d117da..abbb93ef5 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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; +} { + 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 | 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; nextPosition: number }> { diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 8e76bb6d5..44a78a29d 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -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 { diff --git a/locales/ar/common.json b/locales/ar/common.json index ff0b7740c..9ef279ff4 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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": "لديك تغييرات غير محفوظة. هل تريد حفظها كمسودة أم تجاهلها؟", diff --git a/locales/ca/common.json b/locales/ca/common.json index 9c9826d50..aa7883070 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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?", diff --git a/locales/cs/common.json b/locales/cs/common.json index a190a912c..8f956a352 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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?", diff --git a/locales/da/common.json b/locales/da/common.json index f7e7d9836..554724505 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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?", diff --git a/locales/de/common.json b/locales/de/common.json index 42758af44..d64090f9d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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?", diff --git a/locales/en/common.json b/locales/en/common.json index 0592e0bd9..2809d04cc 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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?", diff --git a/locales/es/common.json b/locales/es/common.json index d106c1b74..a0b739895 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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?", diff --git a/locales/fa/common.json b/locales/fa/common.json index bbfef6ae7..3bb08736d 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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": "تغییرات ذخیره نشده دارید. ذخیره یا دور انداختن؟", diff --git a/locales/fr/common.json b/locales/fr/common.json index 96a0c4a77..6a019896c 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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 ?", diff --git a/locales/he/common.json b/locales/he/common.json index b8d851da1..fc76b08dc 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "יש לך שינויים שלא נשמרו. האם תרצה לשמור את זה כטיוטה או למחוק אותו?", diff --git a/locales/hu/common.json b/locales/hu/common.json index d230d683d..dd02e3e8d 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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?", diff --git a/locales/it/common.json b/locales/it/common.json index 544a42066..1341bb3bb 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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?", diff --git a/locales/ja/common.json b/locales/ja/common.json index a4e7ab888..3ec22af17 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?", diff --git a/locales/ko/common.json b/locales/ko/common.json index 9f0eccd33..8f7b1099b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "저장되지 않은 내용이 있어요. 임시보관함에 저장할까요, 아니면 삭제할까요?", diff --git a/locales/lv/common.json b/locales/lv/common.json index b214a00bd..4a4fb86be 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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?", diff --git a/locales/mn/common.json b/locales/mn/common.json index a0077ec1b..bb0f5f776 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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": "Танд хадгалагдаагүй өөрчлөлтүүд байна. Та үүнийг ноорог болгон хадгалах уу эсвэл хаях уу?", diff --git a/locales/nb/common.json b/locales/nb/common.json index b33e80e06..38af63e16 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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?", diff --git a/locales/nl/common.json b/locales/nl/common.json index e8079ab9f..ae19c61a2 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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?", diff --git a/locales/pl/common.json b/locales/pl/common.json index 79b679c0e..c33c3ab5a 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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ć?", diff --git a/locales/pt/common.json b/locales/pt/common.json index 83e551db9..9f9f80462 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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?", diff --git a/locales/ro/common.json b/locales/ro/common.json index ca79e5cca..d68f480cd 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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?", diff --git a/locales/ru/common.json b/locales/ru/common.json index 34c088b74..3ed1b4ff7 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "Есть несохранённые изменения. Сохранить как черновик или удалить?", diff --git a/locales/sk/common.json b/locales/sk/common.json index 9725708ae..d8417d1e0 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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ť?", diff --git a/locales/tr/common.json b/locales/tr/common.json index f12a42e76..dd3d29f14 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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?", diff --git a/locales/uk/common.json b/locales/uk/common.json index 9659baae5..30ed763f0 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "У вас є незбережені зміни. Зберегти це як чернетку чи видалити?", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 55e488afb..90254617a 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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": "您有尚未儲存的變更。要將這封郵件儲存為草稿,還是捨棄?", diff --git a/locales/zh/common.json b/locales/zh/common.json index 4b6120dbd..642ca25ca 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "您有未保存的更改。要将其保存为草稿还是丢弃?",