diff --git a/components/email/__tests__/composer-send-failure.test.tsx b/components/email/__tests__/composer-send-failure.test.tsx index 7b14061fb..8a7524056 100644 --- a/components/email/__tests__/composer-send-failure.test.tsx +++ b/components/email/__tests__/composer-send-failure.test.tsx @@ -209,11 +209,18 @@ describe('composer send failures', () => { }); it('explains a full submission quota on Stalwart 1.0', async () => { - const message = await sendAndFail(new SubmissionQuotaError('There are too many email submissions, please delete some before adding a new one.')); + const message = await sendAndFail(new SubmissionQuotaError('There are too many email submissions, please delete some before adding a new one.', true)); expect(message).toBe('submission_quota'); }); + it("shows another server's own reason for its overQuota, not Stalwart's explanation", async () => { + const message = await sendAndFail(new SubmissionQuotaError('Daily sending limit exceeded', false)); + + // The i18n test mock renders the key; the reason goes in as a value. + expect(message).toBe('submission_limit'); + }); + it("shows the server's reason for other failures", async () => { const message = await sendAndFail(new Error('Envelope mailFrom does not match identity email address. [forbiddenFrom]')); diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx index ea83ebc55..272c86bfb 100644 --- a/components/email/__tests__/viewer-send-failures.test.tsx +++ b/components/email/__tests__/viewer-send-failures.test.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Email, Mailbox } from '@/lib/jmap/types'; -import { SubmissionQuotaError } from '@/lib/jmap/client'; +import { NoLongerScheduledError, SubmissionQuotaError } from '@/lib/jmap/client'; import { toast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useSettingsStore } from '@/stores/settings-store'; @@ -15,6 +15,7 @@ import { useSettingsStore } from '@/stores/settings-store'; vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), useMediaQuery: () => false, + useIsDesktop: () => true, })); vi.mock('@/components/tour/tour-provider', () => ({ @@ -79,7 +80,7 @@ afterEach(() => { describe('viewer send failures', () => { it('reports a failed quick reply and keeps the text on Stalwart 1.0', async () => { - const onQuickReply = vi.fn().mockRejectedValue(new SubmissionQuotaError(QUOTA)); + const onQuickReply = vi.fn().mockRejectedValue(new SubmissionQuotaError(QUOTA, true)); render( , ); @@ -93,18 +94,20 @@ describe('viewer send failures', () => { expect((box as HTMLTextAreaElement).value).toBe('Thanks!'); }); - it('waits for "Send now" and reports its failure on Stalwart 1.0', async () => { - const onRescheduleScheduled = vi.fn().mockRejectedValue(new SubmissionQuotaError(QUOTA)); + const scheduled = () => makeEmail({ + isScheduled: true, + scheduledUndoStatus: 'pending', + scheduledSendAt: new Date(Date.now() + 3_600_000).toISOString(), + emailSubmissionId: 'sub-1', + scheduledIdentityId: 'identity-1', + keywords: { $seen: true }, + }); + + it('waits for "Send now" and says the message is still scheduled at a full quota on Stalwart 1.0', async () => { + const onRescheduleScheduled = vi.fn().mockRejectedValue(new SubmissionQuotaError(QUOTA, true)); render( { fireEvent.click(screen.getAllByText('send_now').at(-1)!); - await waitFor(() => expect(toastError).toHaveBeenCalledWith('submission_quota')); + // The i18n test mock renders keys: "still scheduled for {date}", then why. + await waitFor(() => expect(toastError).toHaveBeenCalledWith('still_scheduled_at', { message: 'submission_quota' })); expect(onRescheduleScheduled).toHaveBeenCalledTimes(1); }); + it('says the message was already sent when it went out while room was made on Stalwart 1.0', async () => { + const onRescheduleScheduled = vi.fn().mockRejectedValue(new NoLongerScheduledError(true)); + render( + , + ); + + fireEvent.click(screen.getAllByText('send_now').at(-1)!); + + await waitFor(() => expect(toastError).toHaveBeenCalledWith('already_sent', undefined)); + }); + + it('explains a full submission quota when the unsubscribe mail cannot go out on Stalwart 1.0', async () => { + const sendEmail = vi.fn().mockRejectedValue(new SubmissionQuotaError(QUOTA, true)); + useAuthStore.setState({ client: { sendEmail } as never }); + try { + render( + ' } })} + mailboxes={mailboxes} + selectedMailbox="inbox" + currentMailboxRole="inbox" + />, + ); + + fireEvent.click(screen.getAllByText('email_viewer.unsubscribe_banner.button').at(-1)!); + fireEvent.click(screen.getAllByText('email_viewer.unsubscribe_banner.confirm_button').at(-1)!); + + await waitFor(() => expect(toastError).toHaveBeenCalledWith('submission_quota')); + expect(sendEmail).toHaveBeenCalledWith(['leave@lists.example.com'], 'unsubscribe', '', undefined, undefined, undefined, undefined, undefined, undefined); + expect(screen.getAllByText('email_viewer.unsubscribe_banner.error').length).toBeGreaterThan(0); + } finally { + useAuthStore.setState({ client: null } as never); + } + }); + describe('automatic read receipts', () => { const MDN_HEADERS = { 'Disposition-Notification-To': 'Alice ' }; let sendReadReceipt: ReturnType; beforeEach(() => { - sendReadReceipt = vi.fn().mockRejectedValue(new SubmissionQuotaError(QUOTA)); + sendReadReceipt = vi.fn().mockRejectedValue(new SubmissionQuotaError(QUOTA, true)); useSettingsStore.setState({ readReceiptResponse: 'always' }); useAuthStore.setState({ client: { sendReadReceipt } as never, diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 1af197b07..0783a85b8 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -90,7 +90,8 @@ import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { toast } from "@/stores/toast-store"; -import { sendFailureMessage } from "@/lib/send-failure-message"; +import { rescheduleFailureToast, sendFailureMessage } from "@/lib/send-failure-message"; +import { SubmissionQuotaError } from "@/lib/jmap/client"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { useIsPaneScoped } from "@/hooks/use-pane-context"; import { useAuthStore } from "@/stores/auth-store"; @@ -785,7 +786,15 @@ export function EmailViewer({ ? undefined : identities.find(i => i.email && recipientAddresses.includes(i.email.toLowerCase())); - await sendClient.sendEmail(fields.to, fields.subject ?? '', fields.body ?? '', undefined, undefined, fromIdentity?.id, fromIdentity?.email, undefined, fromIdentity?.name); + try { + await sendClient.sendEmail(fields.to, fields.subject ?? '', fields.body ?? '', undefined, undefined, fromIdentity?.id, fromIdentity?.email, undefined, fromIdentity?.name); + } catch (error) { + // The banner only says the unsubscribe did not work. A full submission + // quota gets the same explanation as in the composer: retrying will + // not help until records are freed or the limit is raised. + if (error instanceof SubmissionQuotaError) toast.error(sendFailureMessage(error, tComposer)); + throw error; + } }; const promptForRescheduleDelayedUntil = useCallback((): string | null => { const value = window.prompt(t('reschedule_prompt')); @@ -813,14 +822,18 @@ export function EmailViewer({ // "Send now" and "Reschedule" submit a replacement and cancel the original; // either can fail (a full submission quota, the mail already gone), and the // failure used to end up as an unhandled rejection nobody saw. + const scheduledSendAt = email?.scheduledSendAt; const rescheduleScheduled = useCallback(async (delayedUntil: string) => { try { await onRescheduleScheduled?.(delayedUntil); } catch (error) { console.error('Failed to reschedule the scheduled send:', error); - toast.error(sendFailureMessage(error, tComposer)); + const { title, message } = rescheduleFailureToast( + error, t, tComposer, scheduledSendAt ? formatDateTime(scheduledSendAt, timeFormat) : undefined, + ); + toast.error(title, message ? { message } : undefined); } - }, [onRescheduleScheduled, tComposer]); + }, [onRescheduleScheduled, t, tComposer, scheduledSendAt, timeFormat]); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const { startTour } = useTour(); const [showFullHeaders, setShowFullHeaders] = useState(false); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 5a28842ed..fa18369a1 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -34,7 +34,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { useEmailStore, buildUnifiedAccountClients, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId } from "@/stores/email-store"; import { groupSearchScopeFolders } from "@/lib/search-scope-folders"; import { toast } from "@/stores/toast-store"; -import { sendFailureMessage } from "@/lib/send-failure-message"; +import { cancelFailureToast, rescheduleFailureToast } from "@/lib/send-failure-message"; import { runBatchEmailAction } from "@/lib/email-action-toast"; import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog"; import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster"; @@ -54,7 +54,7 @@ import { usePromptDialog } from "@/hooks/use-prompt-dialog"; import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation"; import { debug } from "@/lib/debug"; import { playNotificationSound } from "@/lib/notification-sound"; -import { cn, getMailboxPath } from "@/lib/utils"; +import { cn, formatDateTime, getMailboxPath } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; import { groupEmailsByThread, threadKeyFor, toggleTagKeywords } from "@/lib/thread-utils"; import { resolveThreadRoute } from "@/lib/thread-routing"; @@ -141,6 +141,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const tCommon = useTranslations('common'); const tQuote = useTranslations('quote_header'); const tComposer = useTranslations('email_composer'); + const tViewer = useTranslations('email_viewer'); const { appName } = useConfig(); const mailLayout = useSettingsStore((state) => state.mailLayout); // Phones present search full-screen from the header field rather than @@ -1981,6 +1982,36 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (isMobile) setActiveView('viewer'); }; + /** When a scheduled message goes out, as the user reads times, if known. */ + const scheduledTime = (sendAt: string | undefined) => + sendAt ? formatDateTime(sendAt, useSettingsStore.getState().timeFormat) : undefined; + + const toastCancelFailure = (error: unknown, cancelFailedTitle: string) => { + const { title, message } = cancelFailureToast(error, tViewer, cancelFailedTitle); + toast.error(title, message ? { message } : undefined); + }; + + // The send is cancelled and the message back in Drafts by then. + const reportDraftOpenFailure = (error: unknown) => { + console.error('Cancelled the scheduled send, but opening the draft failed:', error); + }; + + /** + * "Cancel and edit" from the list or the viewer, neither of which waits for + * it: the draft to open (null for S/MIME, which is composed again), or + * undefined once a failure is reported. + */ + const cancelScheduledForEdit = async (email: Email): Promise => { + if (!client) return undefined; + try { + return await cancelScheduledEmailForEdit(client, email); + } catch (error) { + console.error('Failed to cancel the scheduled send for editing:', error); + toastCancelFailure(error, tViewer('cancel_scheduled_failed')); + return undefined; + } + }; + useEffect(() => { if (!pendingUndoSend || !client) return; if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return; @@ -2014,8 +2045,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (isScheduledView) await fetchScheduledEmails(client); } catch (error) { console.error('Failed to send now:', error); - // The message stays scheduled; say why it did not go now. - toast.error(sendFailureMessage(error, tComposer)); + const { title, message } = rescheduleFailureToast(error, tViewer, tComposer, scheduledTime(pending.sendAt)); + toast.error(title, message ? { message } : undefined); } })(); }, @@ -2025,18 +2056,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { label: t('email_viewer.undo_send'), onClick: () => { void (async () => { + let restored: Awaited>; + try { + restored = await cancelUndoSend(undoClient, pending); + } catch (error) { + console.error('Failed to undo scheduled send:', error); + toastCancelFailure(error, t('email_viewer.undo_send_failed')); + return; + } + // The send is undone; opening the draft or refreshing the list + // failing is no reason to say otherwise. try { - const restored = await cancelUndoSend(undoClient, pending); if (restored && !pending.isSmime) { await handleEditDraft(restored); } if (isScheduledView) await fetchScheduledEmails(client); } catch (error) { - console.error('Failed to undo scheduled send:', error); - // Silence here reads as "undone" while the mail may be on its way. - toast.error(t('email_viewer.undo_send_failed'), { - message: error instanceof Error ? error.message : undefined, - }); + console.error('Undid the send, but reopening it failed:', error); } })(); }, @@ -2052,7 +2088,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t, tComposer]); + }, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t, tComposer, tViewer]); const handleReplyAll = async () => { if (selectedEmail) { @@ -4095,12 +4131,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onLoadMoreScheduled={() => client && loadMoreScheduledEmails(client)} onCancelScheduledForEdit={async (email) => { if (!client) return; - const restored = await cancelScheduledEmailForEdit(client, email); + // The context menu does not wait for this; report here. + const restored = await cancelScheduledForEdit(email); + if (restored === undefined) return; if (email.isSmimeScheduled) { setComposerMode('compose'); setPendingDraft(null); } else if (restored) { - await handleEditDraft(restored); + await handleEditDraft(restored).catch(reportDraftOpenFailure); return; } setShowComposer(true); @@ -4114,7 +4152,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { await rescheduleScheduledEmail(client, email.emailSubmissionId, email.id, email.scheduledIdentityId, delayedUntil); } catch (error) { console.error('Failed to reschedule the scheduled send:', error); - toast.error(sendFailureMessage(error, tComposer)); + const { title, message } = rescheduleFailureToast(error, tViewer, tComposer, scheduledTime(email.scheduledSendAt)); + toast.error(title, message ? { message } : undefined); } } }} @@ -4428,13 +4467,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onEditDraft={handleEditDraft} onCancelScheduledForEdit={async () => { if (!client || !selectedEmail) return; - const restored = await cancelScheduledEmailForEdit(client, selectedEmail); + // The button does not wait for this; report here. + const restored = await cancelScheduledForEdit(selectedEmail); + if (restored === undefined) return; if (selectedEmail.isSmimeScheduled) { setComposerMode('compose'); setShowComposer(true); return; } - if (restored) await handleEditDraft(restored); + if (restored) await handleEditDraft(restored).catch(reportDraftOpenFailure); }} onRescheduleScheduled={async (delayedUntil) => { if (client && selectedEmail?.emailSubmissionId && selectedEmail.scheduledIdentityId) { diff --git a/lib/__tests__/jmap-submission-cleanup.test.ts b/lib/__tests__/jmap-submission-cleanup.test.ts index 850781210..09ea77451 100644 --- a/lib/__tests__/jmap-submission-cleanup.test.ts +++ b/lib/__tests__/jmap-submission-cleanup.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { JMAPClient, SubmissionQuotaError, prunableSubmissionIds } from '../jmap/client'; +import { JMAPClient, NoLongerScheduledError, SubmissionQuotaError, prunableSubmissionIds } from '../jmap/client'; import { jmapErrorType } from '../jmap/jmap-error'; /** @@ -628,8 +628,11 @@ describe('overQuota on EmailSubmission/set create', () => { const server = fakeStalwart({ version: '1.0', quota: 1 }); fillWithOldRecords(server, 1); - await expect(send(createClient({ stalwart: false }))).rejects.toBeInstanceOf(SubmissionQuotaError); + const error = await send(createClient({ stalwart: false })).catch((e: unknown) => e); + expect(error).toBeInstanceOf(SubmissionQuotaError); + // Its overQuota need not be about submission records: no Stalwart wording. + expect((error as SubmissionQuotaError).countsRecords).toBe(false); expect(server.calls('EmailSubmission/query')).toHaveLength(1); expect(server.calls('EmailSubmission/changes')).toEqual([]); }); @@ -644,6 +647,7 @@ describe('overQuota on EmailSubmission/set create', () => { expect(error).toBeInstanceOf(SubmissionQuotaError); expect((error as Error).message).toBe(OVER_QUOTA_DESCRIPTION); + expect((error as SubmissionQuotaError).countsRecords).toBe(true); expect(jmapErrorType(error)).toBe('overQuota'); expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(2); // The copy made for this attempt is gone; the user's own draft stays. @@ -682,6 +686,127 @@ describe('overQuota on EmailSubmission/set create', () => { expect(server.calls('EmailSubmission/set').filter((args) => args.update)).toEqual([]); }); + describe('when making room takes a while', () => { + const reschedule = (client: JMAPClient, delayedUntil: string) => + client.rescheduleEmailSubmission('original', 'email-x', 'identity-1', delayedUntil, 'account-1'); + + it('stops without a replacement when the original went out meanwhile on Stalwart 1.0', async () => { + let original!: FakeSubmission; + const server = fakeStalwart({ + version: '1.0', + quota: 2, + // Its sendAt comes while the prune looks for room. + intercept: ([name]) => { + if (name === 'EmailSubmission/query') original.queued = false; + return undefined; + }, + }); + original = server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(Date.now() + 1000), queued: true }); + server.add({ accountId: 'account-1', id: 'held', sendAt: utcDate(Date.now() + DAY), queued: true }); + + // "Send now" + const error = await reschedule(createClient(), new Date(Date.now() + 1000).toISOString()).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(NoLongerScheduledError); + expect((error as NoLongerScheduledError).sent).toBe(true); + // A second create would have sent the message twice. + expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(1); + expect(server.calls('EmailSubmission/set').filter((args) => args.update)).toEqual([]); + }); + + it('stops when the original was cancelled elsewhere meanwhile on Stalwart 1.0', async () => { + let original!: FakeSubmission; + const server = fakeStalwart({ + version: '1.0', + quota: 2, + intercept: ([name]) => { + if (name === 'EmailSubmission/query') { + original.canceled = true; + original.queued = false; + } + return undefined; + }, + }); + original = server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(Date.now() + HOUR), queued: true }); + server.add({ accountId: 'account-1', id: 'held', sendAt: utcDate(Date.now() + DAY), queued: true }); + + const error = await reschedule(createClient(), new Date(Date.now() + 2 * HOUR).toISOString()).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(NoLongerScheduledError); + expect((error as NoLongerScheduledError).sent).toBe(false); + expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(1); + }); + + it('takes the time spent off the hold of the replacement on Stalwart 1.0', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const start = Date.now(); + const server = fakeStalwart({ + version: '1.0', + quota: 2, + // Walking the change log takes ten minutes. + intercept: ([name]) => { + if (name === 'EmailSubmission/changes') vi.setSystemTime(Date.now() + 10 * 60 * 1000); + return undefined; + }, + }); + server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(start + 3 * HOUR), queued: true }); + fillWithOldRecords(server, 1); + + const result = await reschedule(createClient(), new Date(start + 2 * HOUR).toISOString()); + + const holds = server.calls('EmailSubmission/set') + .filter((args) => args.create) + .map((args) => (args.create as Record) + .replacement.envelope.mailFrom.parameters.HOLDFOR); + expect(holds.map(Number)).toEqual([2 * 60 * 60, 2 * 60 * 60 - 10 * 60]); + expect(Math.abs(Date.parse(result.sendAt!) - (start + 2 * HOUR))).toBeLessThan(1000); + }); + }); + + describe('when no room can be made', () => { + function fullOfHeldSends(options: FakeOptions = {}) { + const server = fakeStalwart({ version: '1.0', quota: 2, ...options }); + server.add({ accountId: 'account-1', id: 'held-1', sendAt: utcDate(Date.now() + DAY), queued: true }); + server.add({ accountId: 'account-1', id: 'held-2', sendAt: utcDate(Date.now() + 2 * DAY), queued: true }); + return server; + } + + it('does not search again for a few minutes after finding nothing that can go on Stalwart 1.0', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const server = fullOfHeldSends(); + const client = createClient(); + const searches = () => server.calls('EmailSubmission/query').length + server.calls('EmailSubmission/changes').length; + + await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError); + expect(searches()).toBe(2); + + // Every further attempt - a send, an automatic read receipt - only + // tries the create once more. + await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError); + expect(searches()).toBe(2); + expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(4); + + vi.setSystemTime(Date.now() + 5 * 60 * 1000); + await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError); + expect(searches()).toBe(4); + }); + + it('searches again right away when the search itself failed on Stalwart 1.0', async () => { + let failures = 1; + const server = fullOfHeldSends({ + intercept: ([name, , callId]) => (name === 'EmailSubmission/query' && failures-- > 0 + ? [['error', { type: 'serverFail', description: 'Temporary failure' }, callId]] + : undefined), + }); + const client = createClient(); + + await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError); + await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError); + + expect(server.calls('EmailSubmission/query')).toHaveLength(2); + }); + }); + it('reschedules once the prune made room on Stalwart 1.0', async () => { const server = fakeStalwart({ version: '1.0', quota: 2 }); server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(Date.now() + HOUR), queued: true }); diff --git a/lib/__tests__/send-failure-message.test.ts b/lib/__tests__/send-failure-message.test.ts index 1406a61d7..391267154 100644 --- a/lib/__tests__/send-failure-message.test.ts +++ b/lib/__tests__/send-failure-message.test.ts @@ -1,18 +1,30 @@ import { describe, expect, it } from 'vitest'; -import { RequestTimeoutError, ScheduleTooLateError, SubmissionQuotaError } from '../jmap/client'; -import { sendFailureMessage } from '../send-failure-message'; +import { NoLongerScheduledError, RequestTimeoutError, ScheduleTooLateError, SubmissionQuotaError } from '../jmap/client'; +import { SendCancelledCleanupError, cancelFailureToast, rescheduleFailureToast, sendFailureMessage } from '../send-failure-message'; -// Keys of the `email_composer` namespace, returned as-is so the test sees -// which message was chosen. -const t = (key: string) => `email_composer.${key}`; +// Keys of a namespace, returned with their values so the test sees which +// message was chosen and what went into it. +const translator = (namespace: string) => (key: string, values?: Record) => + values ? `${namespace}.${key} ${JSON.stringify(values)}` : `${namespace}.${key}`; +const t = translator('email_composer'); +const tViewer = translator('email_viewer'); + +const STALWART_QUOTA = 'There are too many email submissions, please delete some before adding a new one.'; describe('sendFailureMessage', () => { it('explains a full submission quota instead of showing the server text on Stalwart 1.0', () => { - const error = new SubmissionQuotaError('There are too many email submissions, please delete some before adding a new one.'); + const error = new SubmissionQuotaError(STALWART_QUOTA, true); expect(sendFailureMessage(error, t)).toBe('email_composer.submission_quota'); }); + it("gives another server's overQuota its own description, not Stalwart's explanation", () => { + expect(sendFailureMessage(new SubmissionQuotaError('Daily sending limit exceeded'), t)) + .toBe('email_composer.submission_limit {"reason":"Daily sending limit exceeded"}'); + expect(sendFailureMessage(new SubmissionQuotaError(undefined, false), t)) + .toBe('email_composer.submission_limit {"reason":"overQuota"}'); + }); + it('points at Sent after a timeout and names the hold limit', () => { expect(sendFailureMessage(new RequestTimeoutError(30_000), t)).toBe('email_composer.send_timeout'); expect(sendFailureMessage(new ScheduleTooLateError(604800), t)).toBe('email_composer.schedule_send_too_late'); @@ -25,3 +37,42 @@ describe('sendFailureMessage', () => { expect(sendFailureMessage('nope', t)).toBe('email_composer.send_failed'); }); }); + +describe('rescheduleFailureToast', () => { + it('says the message is still scheduled, and when, at a full quota on Stalwart 1.0', () => { + const error = new SubmissionQuotaError(STALWART_QUOTA, true); + + expect(rescheduleFailureToast(error, tViewer, t, 'Sep 26, 10:00')).toEqual({ + title: 'email_viewer.still_scheduled_at {"date":"Sep 26, 10:00"}', + message: 'email_composer.submission_quota', + }); + expect(rescheduleFailureToast(error, tViewer, t).title).toBe('email_viewer.still_scheduled'); + }); + + it('says the message went out, or is no longer scheduled, when the original left the queue meanwhile', () => { + expect(rescheduleFailureToast(new NoLongerScheduledError(true), tViewer, t, 'Sep 26, 10:00')) + .toEqual({ title: 'email_viewer.already_sent' }); + expect(rescheduleFailureToast(new NoLongerScheduledError(false), tViewer, t)) + .toEqual({ title: 'email_viewer.no_longer_scheduled' }); + }); + + it('falls back to the send failure message otherwise', () => { + expect(rescheduleFailureToast(new RequestTimeoutError(30_000), tViewer, t, 'Sep 26, 10:00')) + .toEqual({ title: 'email_composer.send_timeout' }); + expect(rescheduleFailureToast(new Error('cannotUnsend'), tViewer, t)).toEqual({ title: 'cannotUnsend' }); + }); +}); + +describe('cancelFailureToast', () => { + it('reports a failed cancel with its reason: the mail may still go out', () => { + expect(cancelFailureToast(new Error('The requested message is no longer in the queue.'), tViewer, 'Undo send failed')) + .toEqual({ title: 'Undo send failed', message: 'The requested message is no longer in the queue.' }); + }); + + it('says the send was cancelled when only a step after the cancel failed', () => { + expect(cancelFailureToast(new SendCancelledCleanupError('restore', new Error('503')), tViewer, 'Undo send failed')) + .toEqual({ title: 'email_viewer.send_cancelled_restore_failed' }); + expect(cancelFailureToast(new SendCancelledCleanupError('remove', new Error('503')), tViewer, 'Undo send failed')) + .toEqual({ title: 'email_viewer.send_cancelled_remove_failed' }); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 8a6264ef2..83342b2fa 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -194,26 +194,51 @@ export class ScheduleTooLateError extends Error { } /** - * The server refused a new EmailSubmission because the account holds too many - * submission records, and removing the ones that can no longer be cancelled - * did not make room. Stalwart 1.0 counts every record, whatever its status or - * age, against `maxSubmissions` (500 by default) and answers the create with - * `overQuota` - not to be confused with Email/set's `overQuota`, which means - * the mailbox storage is full. + * The server refused a new EmailSubmission with `overQuota`, and removing the + * records that can no longer be cancelled did not make room. Stalwart 1.0 + * counts every record, whatever its status or age, against `maxSubmissions` + * (500 by default) - not to be confused with Email/set's `overQuota`, which + * means the mailbox storage is full. */ export class SubmissionQuotaError extends Error { readonly jmapType = 'overQuota'; - constructor(description?: string) { + constructor( + /** The server's own description, if it gave one. */ + readonly description?: string, + /** + * The submission account is on Stalwart (`urn:stalwart:jmap`), whose + * limit is the count of submission records. Another server's overQuota + * may stand for any sending limit. + */ + readonly countsRecords = false, + ) { super(description || 'There are too many email submissions'); this.name = 'SubmissionQuotaError'; } } +/** + * "Send now" or "Reschedule" found the original submission no longer pending + * after making room for its replacement, which can take a while: it went out + * (`sent`), or was cancelled or removed elsewhere. No replacement was made. + */ +export class NoLongerScheduledError extends Error { + constructor(readonly sent: boolean) { + super(sent ? 'The scheduled message was already sent' : 'The message is no longer scheduled'); + this.name = 'NoLongerScheduledError'; + } +} + /** * How often the background prune may sweep one account's submission records * (see JMAPClient.pruneEmailSubmissions). */ const SUBMISSION_PRUNE_INTERVAL_MS = 10 * 60 * 1000; +/** + * How long an account where making room found nothing that could go is not + * searched again; see makeRoomForSubmission. + */ +const SUBMISSION_ROOM_RECHECK_MS = 5 * 60 * 1000; /** Upper bound on the pages one prune or change-log walk reads. */ const SUBMISSION_SCAN_MAX_PAGES = 40; @@ -1214,6 +1239,8 @@ export class JMAPClient implements IJMAPClient { private learnedMaxDelayedSend: number | null = null; /** When each account's submission records were last pruned (epoch ms). */ private submissionPrunedAt = new Map(); + /** Accounts where making room found nothing to free, until when (epoch ms). */ + private submissionRoomRecheckAt = new Map(); // Consecutive keep-alive failures; failed pings skip upcoming ticks // (30s -> 1m -> 2m -> ~5m) instead of hammering a down server (#588). private pingFailureCount = 0; @@ -4301,7 +4328,7 @@ export class JMAPClient implements IJMAPClient { await this.discardUnsentCopy(draftCopyId, targetAccountId); this.throwIfHoldTooLong(firstError); if (methodName === 'EmailSubmission/set' && firstError?.type === 'overQuota') { - throw new SubmissionQuotaError(firstError.description); + throw this.submissionQuotaError(firstError.description, submissionAccountId); } const propsHint = firstError?.properties?.length ? ` (properties: ${firstError.properties.join(', ')})` @@ -5160,7 +5187,7 @@ export class JMAPClient implements IJMAPClient { // Nothing went out: take the receipt back out of Sent, where it would // pass for a sent one. await this.discardUnsentCopy(emailId, targetAccountId); - if (name === 'EmailSubmission/set' && error.type === 'overQuota') throw new SubmissionQuotaError(error.description); + if (name === 'EmailSubmission/set' && error.type === 'overQuota') throw this.submissionQuotaError(error.description, targetAccountId); throw jmapError(`MDN submission failed: ${error.description || error.type || 'unknown'}`, error); } await this.destroyImmediateSubmission(result?.created?.[subId]?.id, targetAccountId); @@ -9698,7 +9725,7 @@ export class JMAPClient implements IJMAPClient { const result = response.methodResponses?.[0]?.[1]; if (result?.notCreated?.['smime-submit']) { const err = result.notCreated['smime-submit']; - if (err.type === 'overQuota') throw new SubmissionQuotaError(err.description); + if (err.type === 'overQuota') throw this.submissionQuotaError(err.description, submissionAccountId); throw new Error(err.description || err.type || 'Failed to submit email'); } await this.destroyImmediateSubmission(result?.created?.['smime-submit']?.id, submissionAccountId); @@ -9888,7 +9915,7 @@ export class JMAPClient implements IJMAPClient { await this.discardUnsentCopy(importedId, this.accountId); this.throwIfHoldTooLong(firstErr); if (methodName === 'EmailSubmission/set' && firstErr?.type === 'overQuota') { - throw new SubmissionQuotaError(firstErr.description); + throw this.submissionQuotaError(firstErr.description, submissionAccountId); } throw jmapError(firstErr?.description || firstErr?.type || failureMessage, firstErr); } @@ -10110,31 +10137,41 @@ export class JMAPClient implements IJMAPClient { const envelopeRecipients = existingEnvelope?.rcptTo?.length ? existingEnvelope.rcptTo : [...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || [])]; - const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients); - const createReplacement = () => this.request([ - ['EmailSubmission/set', { - accountId: submissionAccountId, - create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } }, - ...(draftsMailbox && sentMailbox ? { - onSuccessUpdateEmail: { - '#replacement': { - ...mailboxIdsReplacement(sentMailbox.id), - 'keywords/$draft': null, + const createReplacement = (holdFor: number) => { + const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdFor, envelopeRecipients); + return this.request([ + ['EmailSubmission/set', { + accountId: submissionAccountId, + create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } }, + ...(draftsMailbox && sentMailbox ? { + onSuccessUpdateEmail: { + '#replacement': { + ...mailboxIdsReplacement(sentMailbox.id), + 'keywords/$draft': null, + }, }, - }, - } : {}), - }, '0'], - ]); - let response = await createReplacement(); + } : {}), + }, '0'], + ]); + }; + let response = await createReplacement(holdForSeconds); // The original is still pending, so a prune can't touch it. if (submissionCreateError(response, 'replacement')?.type === 'overQuota') { await this.makeRoomForSubmission(submissionAccountId); - response = await createReplacement(); + // Making room can take a while - a prune, a walk through the change + // log - and meanwhile the original may have gone out: its sendAt came, + // or it was "Send now" of a message due any moment. A replacement then + // would send it a second time. + const original = await this.getSubmissionStatus(submissionId, submissionAccountId); + if (original !== 'pending') throw new NoLongerScheduledError(original === 'final'); + // HOLDFOR counts from when the server takes the request, so take the + // time spent off it; a moment already past goes out at once. + response = await createReplacement(Math.max(1, Math.ceil((Date.parse(delayedUntil) - Date.now()) / 1000))); } const result = response.methodResponses?.[0]?.[1]; const createError = result?.notCreated?.replacement; if (createError) { - if (createError.type === 'overQuota') throw new SubmissionQuotaError(createError.description); + if (createError.type === 'overQuota') throw this.submissionQuotaError(createError.description, submissionAccountId); throw jmapError(createError.description || createError.type || 'Failed to reschedule email', createError); } const replacementId = result?.created?.replacement?.id; @@ -10292,6 +10329,18 @@ export class JMAPClient implements IJMAPClient { return (result?.list ?? []) as Array>; } + /** + * The undoStatus EmailSubmission/get reports for a submission now, which + * Stalwart works out from its queue; undefined once the record is gone. + * Throws when the get fails. + */ + private async getSubmissionStatus(submissionId: string, accountId: string): Promise { + const response = await this.request([ + ['EmailSubmission/get', { accountId, ids: [submissionId], properties: ['id', 'undoStatus'] }, '0'], + ]); + return this.submissionGetList(response, '0').find((submission) => submission.id === submissionId)?.undoStatus; + } + /** * Make room after an EmailSubmission/set create came back overQuota. Prunes * the account at once; when that frees nothing, the records taking up the @@ -10301,24 +10350,52 @@ export class JMAPClient implements IJMAPClient { * are found there and pruned by the same rules. Never rejects: the caller * retries the create either way, since another tab may have made room in * the meantime. + * + * At a full quota every attempt ends up here - each send, reschedule and + * automatic read receipt. When a search found nothing that can go, the + * account is not searched again for a few minutes: what is left is pending + * or out of reach, and looking again right away would cost the prune, the + * change-log walk and the gets once more for the same answer. */ private async makeRoomForSubmission(accountId: string): Promise { - const freed = await this.pruneSubmissionAccount(accountId, true); - if (freed > 0 || !this.hasAccountCapability('urn:stalwart:jmap', accountId)) return; - try { - const ids = await this.submissionIdsFromChangeLog(accountId); - const now = Date.now(); - const prunable: string[] = []; - for (const batch of batched(ids, Math.min(500, this.getMaxObjectsInGet()))) { - const response = await this.request([ - ['EmailSubmission/get', { accountId, ids: batch, properties: ['id', 'sendAt', 'undoStatus'] }, '0'], - ]); - prunable.push(...prunableSubmissionIds(this.submissionGetList(response, '0'), now)); + const recheckAt = this.submissionRoomRecheckAt.get(accountId); + if (recheckAt !== undefined && Date.now() < recheckAt) return; + const now = Date.now(); + this.submissionPrunedAt.set(accountId, now); + let found = 0; + let freed = 0; + let complete = true; + const prune = async (lookUp: () => Promise, what: string) => { + try { + const prunable = await lookUp(); + found += prunable.length; + if (prunable.length > 0) freed += (await this.destroyEmailSubmissions(prunable, accountId)).length; + } catch (error) { + complete = false; + console.warn(`[EmailSubmission] looking up ${what} of ${accountId} failed:`, error); } - if (prunable.length > 0) await this.destroyEmailSubmissions(prunable, accountId); - } catch (error) { - console.warn(`[EmailSubmission] looking up older submissions of ${accountId} failed:`, error); + }; + await prune(() => this.findPrunableSubmissions(accountId, now), 'recent submissions'); + if (freed === 0 && this.hasAccountCapability('urn:stalwart:jmap', accountId)) { + await prune(() => this.findOlderPrunableSubmissions(accountId), 'older submissions'); } + // A failed lookup proves nothing, so only an answer of "nothing" counts. + if (complete && found === 0) this.submissionRoomRecheckAt.set(accountId, Date.now() + SUBMISSION_ROOM_RECHECK_MS); + else this.submissionRoomRecheckAt.delete(accountId); + } + + /** The records in the account's change log that can go (see submissionIdsFromChangeLog). */ + private async findOlderPrunableSubmissions(accountId: string): Promise { + const ids = await this.submissionIdsFromChangeLog(accountId); + const now = Date.now(); + const prunable: string[] = []; + for (const batch of batched(ids, Math.min(500, this.getMaxObjectsInGet()))) { + const response = await this.request([ + ['EmailSubmission/get', { accountId, ids: batch, properties: ['id', 'sendAt', 'undoStatus'] }, '0'], + ]); + prunable.push(...prunableSubmissionIds(this.submissionGetList(response, '0'), now)); + } + return prunable; } /** @@ -10361,6 +10438,16 @@ export class JMAPClient implements IJMAPClient { } } + /** + * The error for a submission create the account refused with overQuota, + * noting whether the account is on Stalwart: its limit is the count of + * submission records, which only an administrator can raise once the + * prune freed nothing. + */ + private submissionQuotaError(description: string | undefined, accountId: string): SubmissionQuotaError { + return new SubmissionQuotaError(description, this.hasAccountCapability('urn:stalwart:jmap', accountId)); + } + // ── PushSubscription (RFC 8620 §7.2) ────────────────────────────── // Used by the PWA Web Push integration. The mobile app does the same dance // through its own JMAP client - keep these in sync. diff --git a/lib/send-failure-message.ts b/lib/send-failure-message.ts index 1f940bd7b..4b065f4fb 100644 --- a/lib/send-failure-message.ts +++ b/lib/send-failure-message.ts @@ -1,20 +1,85 @@ -import { RequestTimeoutError, ScheduleTooLateError, SubmissionQuotaError } from '@/lib/jmap/client'; +import { NoLongerScheduledError, RequestTimeoutError, ScheduleTooLateError, SubmissionQuotaError } from '@/lib/jmap/client'; + +type Translate = (key: string, values?: Record) => string; + +/** + * Thrown by "Undo send" and "Cancel and edit" when the cancel went through + * but a step after it failed: putting the message back in Drafts + * (`restore`), or removing the copy of an S/MIME message, which is composed + * again from scratch (`remove`). Nothing goes out; the message is still in + * Sent. Reported apart from a failed cancel, which may still let it go. + */ +export class SendCancelledCleanupError extends Error { + constructor(readonly step: 'restore' | 'remove', readonly reason: unknown) { + super(step === 'restore' + ? 'Sending was cancelled, but the message could not be put back in Drafts' + : 'Sending was cancelled, but the unsent message could not be removed from Sent'); + this.name = 'SendCancelledCleanupError'; + } +} /** * What to tell the user about a send (or reschedule) that failed, from the * `email_composer` messages. Most errors carry the server's own description; * these are the ones it would misrepresent or leave unexplained: - * - a full submission quota: Stalwart asks the user to "delete some", but - * the client has already removed every record it may, so only an - * administrator can help; + * - a full submission quota on Stalwart: it asks the user to "delete some", + * but the client has already removed every record it may, so only an + * administrator can help. Another server's overQuota may stand for any + * sending limit, so its own description goes with a generic sentence; * - a timeout, which is not a clean failure: the submission may have reached * the server and gone out, with only the answer lost. Saying "send failed" * would invite a re-send and a duplicate, so point at Sent instead (#702); * - a hold beyond the server's limit. */ -export function sendFailureMessage(error: unknown, t: (key: string) => string): string { - if (error instanceof SubmissionQuotaError) return t('submission_quota'); +export function sendFailureMessage(error: unknown, t: Translate): string { + if (error instanceof SubmissionQuotaError) { + return error.countsRecords + ? t('submission_quota') + : t('submission_limit', { reason: error.description || error.jmapType }); + } if (error instanceof RequestTimeoutError) return t('send_timeout'); if (error instanceof ScheduleTooLateError) return t('schedule_send_too_late'); return error instanceof Error && error.message ? error.message : t('send_failed'); } + +/** + * The toast for a failed "Send now" or "Reschedule", from the `email_viewer` + * (`tViewer`) and `email_composer` (`tComposer`) messages. At a full + * submission quota no replacement was made, so the message still goes out at + * its old time, `scheduledAt` (formatted) when known. Making room takes a + * while, and the original may have gone out meanwhile. + */ +export function rescheduleFailureToast( + error: unknown, + tViewer: Translate, + tComposer: Translate, + scheduledAt?: string, +): { title: string; message?: string } { + if (error instanceof NoLongerScheduledError) { + return { title: tViewer(error.sent ? 'already_sent' : 'no_longer_scheduled') }; + } + if (error instanceof SubmissionQuotaError) { + return { + title: scheduledAt ? tViewer('still_scheduled_at', { date: scheduledAt }) : tViewer('still_scheduled'), + message: sendFailureMessage(error, tComposer), + }; + } + return { title: sendFailureMessage(error, tComposer) }; +} + +/** + * The toast for a failed "Undo send" or "Cancel and edit". A failed cancel + * may still let the mail go, so it must never read as done: it gets + * `cancelFailedTitle` and the reason. One that went through while a step + * after it failed says where the message is instead. + */ +export function cancelFailureToast( + error: unknown, + tViewer: Translate, + cancelFailedTitle: string, +): { title: string; message?: string } { + if (error instanceof SendCancelledCleanupError) { + return { title: tViewer(error.step === 'restore' ? 'send_cancelled_restore_failed' : 'send_cancelled_remove_failed') }; + } + return { title: cancelFailedTitle, message: error instanceof Error ? error.message : undefined }; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 5ce838d2e..21bfb88fd 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "تمت جدولة الرسالة للإرسال", "undo_send": "تراجع عن الإرسال", "undo_send_failed": "تعذّر التراجع عن الإرسال", + "send_cancelled_restore_failed": "أُلغي الإرسال، لكن تعذّرت إعادة الرسالة إلى المسودات. لا تزال في المرسل.", + "send_cancelled_remove_failed": "أُلغي الإرسال، لكن تعذّرت إزالة الرسالة غير المرسلة من المرسل.", + "cancel_scheduled_failed": "تعذّر إلغاء الإرسال المجدول", + "still_scheduled_at": "لا تزال الرسالة مجدولة للإرسال في {date}.", + "still_scheduled": "لا تزال الرسالة مجدولة.", + "already_sent": "أُرسلت هذه الرسالة بالفعل.", + "no_longer_scheduled": "لم تعد هذه الرسالة مجدولة.", "contact_sidebar": { "title": "جهة الاتصال", "close": "إغلاق الشريط الجانبي", @@ -671,6 +678,7 @@ "send_failed": "فشل إرسال الرسالة", "send_timeout": "توقف الخادم عن الاستجابة. تحقق من مجلد المُرسَل قبل إعادة الإرسال — فقد تكون الرسالة قد أُرسلت بالفعل.", "submission_quota": "يحتفظ الخادم بعدد كبير جدًا من سجلات الرسائل المرسلة لهذا الحساب. أزال Bulwark ما استطاع منها؛ وإذا تكرر ذلك، فاطلب من المسؤول رفع حد عمليات إرسال البريد.", + "submission_limit": "رفض الخادم إرسال الرسالة لبلوغ أحد الحدود: {reason}", "continue_draft": "متابعة المسودة", "close_draft_title": "حفظ المسودة أم تجاهلها؟", "close_draft_message": "لديك تغييرات غير محفوظة. هل تريد حفظها كمسودة أم تجاهلها؟", diff --git a/locales/ca/common.json b/locales/ca/common.json index d7aa325bf..faa2ba8db 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Missatge programat per enviar-se", "undo_send": "Desfés l'enviament", "undo_send_failed": "No s'ha pogut desfer l'enviament", + "send_cancelled_restore_failed": "S'ha cancel·lat l'enviament, però el missatge no s'ha pogut tornar a Esborranys. Encara és a Enviats.", + "send_cancelled_remove_failed": "S'ha cancel·lat l'enviament, però el missatge no enviat no s'ha pogut treure d'Enviats.", + "cancel_scheduled_failed": "No s'ha pogut cancel·lar l'enviament programat", + "still_scheduled_at": "El missatge encara està programat per al {date}.", + "still_scheduled": "El missatge encara està programat.", + "already_sent": "Aquest missatge ja s'ha enviat.", + "no_longer_scheduled": "Aquest missatge ja no està programat.", "contact_sidebar": { "title": "Contacte", "close": "Tanca la barra lateral", @@ -671,6 +678,7 @@ "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.", "submission_quota": "El servidor té massa registres de missatges enviats per a aquest compte. Bulwark ha eliminat els que ha pogut; si torna a passar, demaneu a l'administrador que augmenti el límit d'enviaments de correu.", + "submission_limit": "El servidor s'ha negat a enviar el missatge perquè s'ha arribat a un límit: {reason}", "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 81d3b9e58..5c121907b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Zpráva je naplánována k odeslání", "undo_send": "Vrátit odeslání", "undo_send_failed": "Odeslání se nepodařilo vrátit", + "send_cancelled_restore_failed": "Odeslání bylo zrušeno, ale zprávu se nepodařilo vrátit do Konceptů. Je stále v Odeslaných.", + "send_cancelled_remove_failed": "Odeslání bylo zrušeno, ale neodeslanou zprávu se nepodařilo odstranit z Odeslaných.", + "cancel_scheduled_failed": "Naplánované odeslání se nepodařilo zrušit", + "still_scheduled_at": "Zpráva je stále naplánována na {date}.", + "still_scheduled": "Zpráva je stále naplánována.", + "already_sent": "Tato zpráva již byla odeslána.", + "no_longer_scheduled": "Tato zpráva už není naplánována.", "contact_sidebar": { "title": "Kontakt", "close": "Zavřít postranní panel", @@ -668,6 +675,7 @@ "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.", "submission_quota": "Server má pro tento účet příliš mnoho záznamů o odeslaných zprávách. Bulwark odstranil ty, které mohl; pokud se to bude opakovat, požádejte správce o zvýšení limitu odesílání e-mailů.", + "submission_limit": "Server odmítl zprávu odeslat, protože byl dosažen limit: {reason}", "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 264645b09..827d3abe9 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Besked planlagt til afsendelse", "undo_send": "Fortryd afsendelse", "undo_send_failed": "Afsendelsen kunne ikke fortrydes", + "send_cancelled_restore_failed": "Afsendelsen blev annulleret, men beskeden kunne ikke lægges tilbage i Kladder. Den ligger stadig i Sendt.", + "send_cancelled_remove_failed": "Afsendelsen blev annulleret, men den ikke-sendte besked kunne ikke fjernes fra Sendt.", + "cancel_scheduled_failed": "Den planlagte afsendelse kunne ikke annulleres", + "still_scheduled_at": "Beskeden er stadig planlagt til {date}.", + "still_scheduled": "Beskeden er stadig planlagt.", + "already_sent": "Denne besked er allerede sendt.", + "no_longer_scheduled": "Denne besked er ikke længere planlagt.", "contact_sidebar": { "title": "Kontakt", "close": "Luk sidepanel", @@ -670,6 +677,7 @@ "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.", "submission_quota": "Serveren har for mange registreringer af sendte beskeder for denne konto. Bulwark har fjernet dem, den kunne; hvis det bliver ved med at ske, så bed din administrator om at hæve grænsen for afsendelse af e-mails.", + "submission_limit": "Serveren afviste at sende beskeden, fordi en grænse er nået: {reason}", "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 7fb7a989c..e4e5c79d8 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Nachricht ist zum Senden geplant", "undo_send": "Senden rückgängig", "undo_send_failed": "Senden konnte nicht rückgängig gemacht werden", + "send_cancelled_restore_failed": "Das Senden wurde abgebrochen, aber die Nachricht konnte nicht zurück in die Entwürfe gelegt werden. Sie liegt noch im Ordner „Gesendet“.", + "send_cancelled_remove_failed": "Das Senden wurde abgebrochen, aber die nicht gesendete Nachricht konnte nicht aus dem Ordner „Gesendet“ entfernt werden.", + "cancel_scheduled_failed": "Das geplante Senden konnte nicht abgebrochen werden", + "still_scheduled_at": "Die Nachricht ist weiterhin für {date} geplant.", + "still_scheduled": "Die Nachricht ist weiterhin geplant.", + "already_sent": "Diese Nachricht wurde bereits gesendet.", + "no_longer_scheduled": "Diese Nachricht ist nicht mehr geplant.", "contact_sidebar": { "title": "Kontakt", "close": "Seitenleiste schließen", @@ -675,6 +682,7 @@ "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.", "submission_quota": "Der Server hat für dieses Konto zu viele Einträge zu gesendeten Nachrichten. Bulwark hat entfernt, was möglich war; wenn das weiterhin vorkommt, bitten Sie Ihren Administrator, das Limit für E-Mail-Übermittlungen zu erhöhen.", + "submission_limit": "Der Server hat das Senden abgelehnt, weil ein Limit erreicht ist: {reason}", "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 a303d1827..907043778 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Message scheduled for sending", "undo_send": "Undo send", "undo_send_failed": "Undo send failed", + "send_cancelled_restore_failed": "Sending was cancelled, but the message could not be put back in Drafts. It is still in Sent.", + "send_cancelled_remove_failed": "Sending was cancelled, but the unsent message could not be removed from Sent.", + "cancel_scheduled_failed": "Couldn't cancel the scheduled send", + "still_scheduled_at": "The message is still scheduled for {date}.", + "still_scheduled": "The message is still scheduled.", + "already_sent": "This message was already sent.", + "no_longer_scheduled": "This message is no longer scheduled.", "contact_sidebar": { "title": "Contact", "close": "Close sidebar", @@ -671,6 +678,7 @@ "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.", "submission_quota": "The server has too many sent-message records for this account. Bulwark removed the ones it could; if this keeps happening, ask your administrator to raise the email submission limit.", + "submission_limit": "The server refused to send the message because a limit was reached: {reason}", "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 a3802d383..8aafd8070 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Mensaje programado para envío", "undo_send": "Deshacer envío", "undo_send_failed": "No se pudo deshacer el envío", + "send_cancelled_restore_failed": "Se canceló el envío, pero el mensaje no se pudo devolver a Borradores. Sigue en Enviados.", + "send_cancelled_remove_failed": "Se canceló el envío, pero el mensaje no enviado no se pudo quitar de Enviados.", + "cancel_scheduled_failed": "No se pudo cancelar el envío programado", + "still_scheduled_at": "El mensaje sigue programado para el {date}.", + "still_scheduled": "El mensaje sigue programado.", + "already_sent": "Este mensaje ya se envió.", + "no_longer_scheduled": "Este mensaje ya no está programado.", "contact_sidebar": { "title": "Contacto", "close": "Cerrar panel", @@ -675,6 +682,7 @@ "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.", "submission_quota": "El servidor tiene demasiados registros de mensajes enviados para esta cuenta. Bulwark eliminó los que pudo; si esto sigue ocurriendo, pida a su administrador que aumente el límite de envíos de correo.", + "submission_limit": "El servidor se negó a enviar el mensaje porque se alcanzó un límite: {reason}", "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 cdcec5d42..bbc529b61 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "پیام برای ارسال زمان‌بندی شد", "undo_send": "بازگردانی ارسال", "undo_send_failed": "بازگردانی ارسال ممکن نشد", + "send_cancelled_restore_failed": "ارسال لغو شد، اما پیام به پیش‌نویس‌ها بازگردانده نشد. هنوز در ارسال شده است.", + "send_cancelled_remove_failed": "ارسال لغو شد، اما پیام ارسال‌نشده از ارسال شده حذف نشد.", + "cancel_scheduled_failed": "لغو ارسال زمان‌بندی‌شده ممکن نشد", + "still_scheduled_at": "پیام همچنان برای {date} زمان‌بندی شده است.", + "still_scheduled": "پیام همچنان زمان‌بندی شده است.", + "already_sent": "این پیام قبلاً ارسال شده است.", + "no_longer_scheduled": "این پیام دیگر زمان‌بندی نشده است.", "contact_sidebar": { "title": "مخاطب", "close": "بستن نوار کناری", @@ -671,6 +678,7 @@ "send_failed": "ارسال ایمیل ناموفق بود", "send_timeout": "سرور دیگر پاسخ نمی‌دهد. پیش از ارسال دوباره، پوشهٔ ارسال‌شده‌ها را بررسی کنید — ممکن است پیام ارسال شده باشد.", "submission_quota": "سرور برای این حساب سوابق بیش از حدی از پیام‌های ارسال‌شده دارد. Bulwark هر کدام را که می‌توانست حذف کرد؛ اگر این وضعیت ادامه یافت، از مدیر سیستم بخواهید سقف ارسال ایمیل را افزایش دهد.", + "submission_limit": "سرور از ارسال پیام خودداری کرد، زیرا به یک محدودیت رسیده است: {reason}", "continue_draft": "ادامه پیش‌نویس", "close_draft_title": "ذخیره یا دور انداختن پیش‌نویس؟", "close_draft_message": "تغییرات ذخیره نشده دارید. ذخیره یا دور انداختن؟", diff --git a/locales/fr/common.json b/locales/fr/common.json index ee42f292f..3bec69b0d 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Message planifié pour envoi", "undo_send": "Annuler l’envoi", "undo_send_failed": "Impossible d’annuler l’envoi", + "send_cancelled_restore_failed": "L’envoi a été annulé, mais le message n’a pas pu être remis dans Brouillons. Il est toujours dans Envoyés.", + "send_cancelled_remove_failed": "L’envoi a été annulé, mais le message non envoyé n’a pas pu être retiré d’Envoyés.", + "cancel_scheduled_failed": "Impossible d’annuler l’envoi planifié", + "still_scheduled_at": "Le message reste planifié pour le {date}.", + "still_scheduled": "Le message reste planifié.", + "already_sent": "Ce message a déjà été envoyé.", + "no_longer_scheduled": "Ce message n’est plus planifié.", "contact_sidebar": { "title": "Contact", "close": "Fermer le panneau", @@ -675,6 +682,7 @@ "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.", "submission_quota": "Le serveur conserve trop d’enregistrements de messages envoyés pour ce compte. Bulwark a supprimé ceux qu’il pouvait ; si cela se reproduit, demandez à votre administrateur de relever la limite d’envois de courriels.", + "submission_limit": "Le serveur a refusé d’envoyer le message, car une limite est atteinte : {reason}", "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 e6647df4a..f45f535cb 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -528,6 +528,13 @@ "undo_send_scheduled": "הודעה תוכננת לשליחה", "undo_send": "בטל שליחה", "undo_send_failed": "לא ניתן היה לבטל את השליחה", + "send_cancelled_restore_failed": "השליחה בוטלה, אך לא ניתן היה להחזיר את ההודעה לטיוטות. היא עדיין בנשלח.", + "send_cancelled_remove_failed": "השליחה בוטלה, אך לא ניתן היה להסיר את ההודעה שלא נשלחה מנשלח.", + "cancel_scheduled_failed": "לא ניתן היה לבטל את השליחה המתוזמנת", + "still_scheduled_at": "ההודעה עדיין מתוזמנת ל־{date}.", + "still_scheduled": "ההודעה עדיין מתוזמנת.", + "already_sent": "ההודעה הזו כבר נשלחה.", + "no_longer_scheduled": "ההודעה הזו כבר אינה מתוזמנת.", "contact_sidebar": { "title": "איש קשר", "close": "סגור סרגל צד", @@ -613,6 +620,7 @@ "send_failed": "שליחת האימייל נכשלה", "send_timeout": "השרת הפסיק להגיב. בדקו את תיקיית הנשלחים לפני שליחה חוזרת — ייתכן שההודעה כבר נשלחה.", "submission_quota": "בשרת יש יותר מדי רשומות של הודעות שנשלחו עבור החשבון הזה. Bulwark הסיר את אלה שיכול היה; אם זה ממשיך לקרות, בקשו ממנהל המערכת להגדיל את מגבלת שליחת הדואר.", + "submission_limit": "השרת סירב לשלוח את ההודעה כי הושגה מגבלה: {reason}", "continue_draft": "המשך טיוטה", "close_draft_title": "לשמור או למחוק טיוטה?", "close_draft_message": "יש לך שינויים שלא נשמרו. האם תרצה לשמור את זה כטיוטה או למחוק אותו?", diff --git a/locales/hu/common.json b/locales/hu/common.json index 33d2f1852..df950411f 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Üzenet ütemezve a küldéshez", "undo_send": "Küldés visszavonása", "undo_send_failed": "A küldést nem sikerült visszavonni", + "send_cancelled_restore_failed": "A küldés megszakadt, de az üzenetet nem sikerült visszatenni a Piszkozatok közé. Még az Elküldött mappában van.", + "send_cancelled_remove_failed": "A küldés megszakadt, de az el nem küldött üzenetet nem sikerült eltávolítani az Elküldött mappából.", + "cancel_scheduled_failed": "Az ütemezett küldést nem sikerült megszakítani", + "still_scheduled_at": "Az üzenet továbbra is ütemezve van: {date}.", + "still_scheduled": "Az üzenet továbbra is ütemezve van.", + "already_sent": "Ez az üzenet már el lett küldve.", + "no_longer_scheduled": "Ez az üzenet már nincs ütemezve.", "contact_sidebar": { "title": "Névjegy", "close": "Oldalsáv bezárása", @@ -671,6 +678,7 @@ "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.", "submission_quota": "A kiszolgáló túl sok elküldött üzenetről szóló bejegyzést tárol ehhez a fiókhoz. A Bulwark eltávolította, amit tudott; ha ez továbbra is előfordul, kérje meg a rendszergazdát, hogy emelje meg az e-mail-küldési korlátot.", + "submission_limit": "A kiszolgáló elutasította az üzenet küldését, mert elért egy korlátot: {reason}", "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 750d8e848..d24f86de3 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Messaggio programmato per l’invio", "undo_send": "Annulla invio", "undo_send_failed": "Impossibile annullare l'invio", + "send_cancelled_restore_failed": "L'invio è stato annullato, ma non è stato possibile riportare il messaggio in Bozze. Si trova ancora in Inviati.", + "send_cancelled_remove_failed": "L'invio è stato annullato, ma non è stato possibile rimuovere il messaggio non inviato da Inviati.", + "cancel_scheduled_failed": "Impossibile annullare l'invio programmato", + "still_scheduled_at": "Il messaggio è ancora programmato per il {date}.", + "still_scheduled": "Il messaggio è ancora programmato.", + "already_sent": "Questo messaggio è già stato inviato.", + "no_longer_scheduled": "Questo messaggio non è più programmato.", "contact_sidebar": { "title": "Contatto", "close": "Chiudi barra laterale", @@ -675,6 +682,7 @@ "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.", "submission_quota": "Il server conserva troppi record di messaggi inviati per questo account. Bulwark ha rimosso quelli che poteva; se il problema si ripete, chieda all'amministratore di aumentare il limite di invii di posta.", + "submission_limit": "Il server ha rifiutato di inviare il messaggio perché è stato raggiunto un limite: {reason}", "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 fb887a21f..bd246e114 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "メッセージは送信予約されています", "undo_send": "送信を取り消す", "undo_send_failed": "送信を取り消せませんでした", + "send_cancelled_restore_failed": "送信はキャンセルされましたが、メッセージを下書きに戻せませんでした。メッセージは送信済みに残っています。", + "send_cancelled_remove_failed": "送信はキャンセルされましたが、未送信のメッセージを送信済みから削除できませんでした。", + "cancel_scheduled_failed": "予約送信をキャンセルできませんでした", + "still_scheduled_at": "メッセージは引き続き {date} に送信予定です。", + "still_scheduled": "メッセージは引き続き送信予定です。", + "already_sent": "このメッセージはすでに送信されています。", + "no_longer_scheduled": "このメッセージはもう送信予定ではありません。", "contact_sidebar": { "title": "連絡先", "close": "サイドバーを閉じる", @@ -675,6 +682,7 @@ "smime_passphrase_placeholder": "S/MIME パスフレーズ", "send_timeout": "サーバーが応答しなくなりました。再送信する前に送信済みフォルダーを確認してください。メッセージはすでに送信されている可能性があります。", "submission_quota": "このアカウントの送信済みメッセージの記録がサーバー上に多すぎます。Bulwark は削除できる記録を削除しました。この状態が続く場合は、メール送信数の上限を引き上げるよう管理者に依頼してください。", + "submission_limit": "上限に達したため、サーバーがメッセージの送信を拒否しました: {reason}", "continue_draft": "下書きを続ける", "close_draft_title": "下書きを保存または破棄しますか?", "close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?", diff --git a/locales/ko/common.json b/locales/ko/common.json index 2c09991c5..8e1b65bf9 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "메시지가 보내기로 예약되었습니다", "undo_send": "보내기 실행 취소", "undo_send_failed": "보내기를 취소하지 못했습니다", + "send_cancelled_restore_failed": "보내기가 취소되었지만 메시지를 임시보관함으로 되돌리지 못했습니다. 메시지는 아직 보낸편지함에 있습니다.", + "send_cancelled_remove_failed": "보내기가 취소되었지만 보내지 않은 메시지를 보낸편지함에서 제거하지 못했습니다.", + "cancel_scheduled_failed": "예약된 보내기를 취소하지 못했습니다", + "still_scheduled_at": "메시지는 여전히 {date}에 보내도록 예약되어 있습니다.", + "still_scheduled": "메시지는 여전히 예약되어 있습니다.", + "already_sent": "이 메시지는 이미 보냈습니다.", + "no_longer_scheduled": "이 메시지는 더 이상 예약되어 있지 않습니다.", "contact_sidebar": { "title": "연락처", "close": "사이드바 닫기", @@ -668,6 +675,7 @@ "send_failed": "메일 전송에 실패했어요", "send_timeout": "서버가 응답을 멈췄습니다. 다시 보내기 전에 보낸 편지함을 확인하세요. 메시지가 이미 발송되었을 수 있습니다.", "submission_quota": "서버에 이 계정의 보낸 메시지 기록이 너무 많습니다. Bulwark가 삭제할 수 있는 기록은 삭제했습니다. 이 문제가 계속되면 관리자에게 메일 발송 한도를 늘려 달라고 요청하세요.", + "submission_limit": "한도에 도달하여 서버가 메시지 전송을 거부했습니다: {reason}", "continue_draft": "이어서 작성하기", "close_draft_title": "임시보관함에 저장할까요?", "close_draft_message": "저장되지 않은 내용이 있어요. 임시보관함에 저장할까요, 아니면 삭제할까요?", diff --git a/locales/lv/common.json b/locales/lv/common.json index a942efd6a..fda93629c 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Ziņojums ir ieplānots nosūtīšanai", "undo_send": "Atsaukt sūtīšanu", "undo_send_failed": "Sūtīšanu neizdevās atsaukt", + "send_cancelled_restore_failed": "Sūtīšana tika atcelta, bet ziņojumu neizdevās atgriezt Melnrakstos. Tas joprojām ir Nosūtītajos.", + "send_cancelled_remove_failed": "Sūtīšana tika atcelta, bet nenosūtīto ziņojumu neizdevās noņemt no Nosūtītajiem.", + "cancel_scheduled_failed": "Ieplānoto sūtīšanu neizdevās atcelt", + "still_scheduled_at": "Ziņojums joprojām ir ieplānots uz {date}.", + "still_scheduled": "Ziņojums joprojām ir ieplānots.", + "already_sent": "Šis ziņojums jau ir nosūtīts.", + "no_longer_scheduled": "Šis ziņojums vairs nav ieplānots.", "contact_sidebar": { "title": "Kontaktpersona", "close": "Aizvērt sānjoslu", @@ -668,6 +675,7 @@ "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.", "submission_quota": "Serverī šim kontam ir pārāk daudz nosūtīto ziņojumu ierakstu. Bulwark noņēma tos, ko varēja; ja tas atkārtojas, lūdziet administratoram palielināt e-pasta sūtījumu ierobežojumu.", + "submission_limit": "Serveris atteicās nosūtīt ziņojumu, jo ir sasniegts ierobežojums: {reason}", "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 c7b1445e4..a7493809a 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Илгээхээр товлосон мессеж", "undo_send": "Илгээхийг буцаах", "undo_send_failed": "Илгээхийг буцааж чадсангүй", + "send_cancelled_restore_failed": "Илгээлтийг цуцалсан ч мессежийг Ноорог руу буцааж чадсангүй. Энэ одоо ч Илгээсэн хэсэгт байна.", + "send_cancelled_remove_failed": "Илгээлтийг цуцалсан ч илгээгээгүй мессежийг Илгээсэн хэсгээс устгаж чадсангүй.", + "cancel_scheduled_failed": "Товлосон илгээлтийг цуцалж чадсангүй", + "still_scheduled_at": "Мессеж {date}-нд илгээгдэхээр товлогдсон хэвээр байна.", + "still_scheduled": "Мессеж товлогдсон хэвээр байна.", + "already_sent": "Энэ мессеж аль хэдийн илгээгдсэн.", + "no_longer_scheduled": "Энэ мессеж товлогдоогүй болсон.", "contact_sidebar": { "title": "Холбоо барих", "close": "Хажуугийн самбарыг хаах", @@ -671,6 +678,7 @@ "send_failed": "Имэйл илгээж чадсангүй", "send_timeout": "Сервер хариу өгөхөө больсон. Дахин илгээхийн өмнө \"Илгээсэн\" фолдероо шалгана уу — мессеж унтарсан байж магадгүй.", "submission_quota": "Сервер дээр энэ бүртгэлийн илгээсэн мессежийн бичлэг хэт олон байна. Bulwark боломжтойг нь устгасан; дахин давтагдвал админаасаа имэйл илгээх хязгаарыг нэмэгдүүлэхийг хүснэ үү.", + "submission_limit": "Хязгаарт хүрсэн тул сервер мессежийг илгээхээс татгалзлаа: {reason}", "continue_draft": "Ноорог үргэлжлүүлэх", "close_draft_title": "Ноорог хадгалах эсвэл устгах уу?", "close_draft_message": "Танд хадгалагдаагүй өөрчлөлтүүд байна. Та үүнийг ноорог болгон хадгалах уу эсвэл хаях уу?", diff --git a/locales/nb/common.json b/locales/nb/common.json index 737481c44..f71f0ea9a 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Meldingen er planlagt for sending", "undo_send": "Angre sending", "undo_send_failed": "Sendingen kunne ikke angres", + "send_cancelled_restore_failed": "Sendingen ble avbrutt, men meldingen kunne ikke legges tilbake i Utkast. Den ligger fortsatt i Sendt.", + "send_cancelled_remove_failed": "Sendingen ble avbrutt, men den usendte meldingen kunne ikke fjernes fra Sendt.", + "cancel_scheduled_failed": "Den planlagte sendingen kunne ikke avbrytes", + "still_scheduled_at": "Meldingen er fortsatt planlagt til {date}.", + "still_scheduled": "Meldingen er fortsatt planlagt.", + "already_sent": "Denne meldingen er allerede sendt.", + "no_longer_scheduled": "Denne meldingen er ikke lenger planlagt.", "contact_sidebar": { "title": "Kontakt", "close": "Lukk sidefeltet", @@ -671,6 +678,7 @@ "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.", "submission_quota": "Serveren har for mange registreringer av sendte meldinger for denne kontoen. Bulwark fjernet dem den kunne; hvis dette fortsetter, be administratoren din om å øke grensen for innsending av e-post.", + "submission_limit": "Serveren nektet å sende meldingen fordi en grense er nådd: {reason}", "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 32faa08ee..4de1c66d4 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Bericht gepland voor verzending", "undo_send": "Verzenden ongedaan maken", "undo_send_failed": "Verzenden kon niet ongedaan worden gemaakt", + "send_cancelled_restore_failed": "Het verzenden is geannuleerd, maar het bericht kon niet terug naar Concepten. Het staat nog in Verzonden.", + "send_cancelled_remove_failed": "Het verzenden is geannuleerd, maar het niet-verzonden bericht kon niet uit Verzonden worden verwijderd.", + "cancel_scheduled_failed": "De geplande verzending kon niet worden geannuleerd", + "still_scheduled_at": "Het bericht staat nog steeds gepland voor {date}.", + "still_scheduled": "Het bericht staat nog steeds gepland.", + "already_sent": "Dit bericht is al verzonden.", + "no_longer_scheduled": "Dit bericht staat niet meer gepland.", "contact_sidebar": { "title": "Contact", "close": "Zijbalk sluiten", @@ -675,6 +682,7 @@ "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.", "submission_quota": "De server bewaart te veel records van verzonden berichten voor dit account. Bulwark heeft verwijderd wat kon; blijft dit gebeuren, vraag uw beheerder dan de limiet voor e-mailverzendingen te verhogen.", + "submission_limit": "De server weigerde het bericht te verzenden omdat een limiet is bereikt: {reason}", "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 dac181fe5..8cd972fe0 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Wiadomość zaplanowana do wysłania", "undo_send": "Cofnij wysyłkę", "undo_send_failed": "Nie udało się cofnąć wysyłki", + "send_cancelled_restore_failed": "Wysyłka została anulowana, ale nie udało się przenieść wiadomości z powrotem do Szkiców. Nadal jest w Wysłanych.", + "send_cancelled_remove_failed": "Wysyłka została anulowana, ale nie udało się usunąć niewysłanej wiadomości z Wysłanych.", + "cancel_scheduled_failed": "Nie udało się anulować zaplanowanej wysyłki", + "still_scheduled_at": "Wiadomość jest nadal zaplanowana na {date}.", + "still_scheduled": "Wiadomość jest nadal zaplanowana.", + "already_sent": "Ta wiadomość została już wysłana.", + "no_longer_scheduled": "Ta wiadomość nie jest już zaplanowana.", "contact_sidebar": { "title": "Kontakt", "close": "Zamknij panel boczny", @@ -668,6 +675,7 @@ "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.", "submission_quota": "Serwer przechowuje zbyt wiele rekordów wysłanych wiadomości dla tego konta. Bulwark usunął te, które mógł; jeśli to się powtarza, poproś administratora o podniesienie limitu wysyłek e-mail.", + "submission_limit": "Serwer odmówił wysłania wiadomości, ponieważ osiągnięto limit: {reason}", "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 01f6bfb9c..e5303b5d3 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Mensagem agendada para envio", "undo_send": "Desfazer envio", "undo_send_failed": "Não foi possível desfazer o envio", + "send_cancelled_restore_failed": "O envio foi cancelado, mas não foi possível devolver a mensagem para Rascunhos. Ela ainda está em Enviados.", + "send_cancelled_remove_failed": "O envio foi cancelado, mas não foi possível remover a mensagem não enviada de Enviados.", + "cancel_scheduled_failed": "Não foi possível cancelar o envio agendado", + "still_scheduled_at": "A mensagem continua agendada para {date}.", + "still_scheduled": "A mensagem continua agendada.", + "already_sent": "Esta mensagem já foi enviada.", + "no_longer_scheduled": "Esta mensagem não está mais agendada.", "contact_sidebar": { "title": "Contato", "close": "Fechar barra lateral", @@ -675,6 +682,7 @@ "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.", "submission_quota": "O servidor tem registros de mensagens enviadas demais para esta conta. O Bulwark removeu os que pôde; se isso continuar acontecendo, peça ao seu administrador para aumentar o limite de envios de e-mail.", + "submission_limit": "O servidor se recusou a enviar a mensagem porque um limite foi atingido: {reason}", "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 c57bbc0c7..b651ebedb 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Mesaj programat pentru trimitere", "undo_send": "Anulează trimiterea", "undo_send_failed": "Trimiterea nu a putut fi anulată", + "send_cancelled_restore_failed": "Trimiterea a fost anulată, dar mesajul nu a putut fi mutat înapoi în Ciorne. Se află încă în Trimise.", + "send_cancelled_remove_failed": "Trimiterea a fost anulată, dar mesajul netrimis nu a putut fi eliminat din Trimise.", + "cancel_scheduled_failed": "Trimiterea programată nu a putut fi anulată", + "still_scheduled_at": "Mesajul este încă programat pentru {date}.", + "still_scheduled": "Mesajul este încă programat.", + "already_sent": "Acest mesaj a fost deja trimis.", + "no_longer_scheduled": "Acest mesaj nu mai este programat.", "contact_sidebar": { "title": "Contact", "close": "Închide bara laterală", @@ -671,6 +678,7 @@ "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.", "submission_quota": "Serverul păstrează prea multe înregistrări de mesaje trimise pentru acest cont. Bulwark le-a eliminat pe cele pe care a putut; dacă problema persistă, cereți administratorului să mărească limita de trimiteri de e-mail.", + "submission_limit": "Serverul a refuzat să trimită mesajul deoarece s-a atins o limită: {reason}", "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 bc9bc2b0f..977f583d1 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Сообщение запланировано к отправке", "undo_send": "Отменить отправку", "undo_send_failed": "Не удалось отменить отправку", + "send_cancelled_restore_failed": "Отправка отменена, но вернуть письмо в Черновики не удалось. Оно по-прежнему в Отправленных.", + "send_cancelled_remove_failed": "Отправка отменена, но удалить неотправленное письмо из Отправленных не удалось.", + "cancel_scheduled_failed": "Не удалось отменить запланированную отправку", + "still_scheduled_at": "Письмо по-прежнему запланировано на {date}.", + "still_scheduled": "Письмо по-прежнему запланировано.", + "already_sent": "Это письмо уже отправлено.", + "no_longer_scheduled": "Это письмо больше не запланировано.", "contact_sidebar": { "title": "Контакт", "close": "Закрыть боковую панель", @@ -668,6 +675,7 @@ "send_failed": "Не удалось отправить письмо", "send_timeout": "Сервер перестал отвечать. Прежде чем отправлять снова, проверьте папку «Отправленные» — письмо могло уже уйти.", "submission_quota": "На сервере слишком много записей об отправленных письмах для этой учётной записи. Bulwark удалил те, что мог; если это повторяется, попросите администратора увеличить лимит отправки писем.", + "submission_limit": "Сервер отказался отправить письмо, так как достигнут лимит: {reason}", "continue_draft": "Продолжить черновик", "close_draft_title": "Сохранить или удалить черновик?", "close_draft_message": "Есть несохранённые изменения. Сохранить как черновик или удалить?", diff --git a/locales/sk/common.json b/locales/sk/common.json index 8f5646d18..1467c4b7a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Správa je naplánovaná na odoslanie", "undo_send": "Vrátiť odoslanie", "undo_send_failed": "Odoslanie sa nepodarilo vrátiť", + "send_cancelled_restore_failed": "Odoslanie bolo zrušené, ale správu sa nepodarilo vrátiť do Konceptov. Stále je v Odoslaných.", + "send_cancelled_remove_failed": "Odoslanie bolo zrušené, ale neodoslanú správu sa nepodarilo odstrániť z Odoslaných.", + "cancel_scheduled_failed": "Naplánované odoslanie sa nepodarilo zrušiť", + "still_scheduled_at": "Správa je stále naplánovaná na {date}.", + "still_scheduled": "Správa je stále naplánovaná.", + "already_sent": "Táto správa už bola odoslaná.", + "no_longer_scheduled": "Táto správa už nie je naplánovaná.", "contact_sidebar": { "title": "Kontakt", "close": "Zavrieť postranný panel", @@ -671,6 +678,7 @@ "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ť.", "submission_quota": "Server má pre tento účet príliš veľa záznamov o odoslaných správach. Bulwark odstránil tie, ktoré mohol; ak sa to bude opakovať, požiadajte správcu o zvýšenie limitu odosielania e-mailov.", + "submission_limit": "Server odmietol správu odoslať, pretože sa dosiahol limit: {reason}", "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 9e9c80e4f..8a5912828 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "İleti gönderim için zamanlandı", "undo_send": "Göndermeyi geri al", "undo_send_failed": "Gönderme geri alınamadı", + "send_cancelled_restore_failed": "Gönderim iptal edildi, ancak ileti Taslaklar'a geri taşınamadı. Hâlâ Gönderilenler'de.", + "send_cancelled_remove_failed": "Gönderim iptal edildi, ancak gönderilmemiş ileti Gönderilenler'den kaldırılamadı.", + "cancel_scheduled_failed": "Zamanlanmış gönderim iptal edilemedi", + "still_scheduled_at": "İleti hâlâ {date} için zamanlanmış durumda.", + "still_scheduled": "İleti hâlâ zamanlanmış durumda.", + "already_sent": "Bu ileti zaten gönderildi.", + "no_longer_scheduled": "Bu ileti artık zamanlanmış değil.", "contact_sidebar": { "title": "Kişi", "close": "Kenar çubuğunu kapat", @@ -671,6 +678,7 @@ "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.", "submission_quota": "Sunucuda bu hesap için çok fazla gönderilmiş ileti kaydı var. Bulwark kaldırabildiklerini kaldırdı; bu durum sürerse yöneticinizden e-posta gönderim sınırını yükseltmesini isteyin.", + "submission_limit": "Bir sınıra ulaşıldığı için sunucu iletiyi göndermeyi reddetti: {reason}", "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 66510e8a0..e29d426fc 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "Повідомлення заплановано до надсилання", "undo_send": "Скасувати надсилання", "undo_send_failed": "Не вдалося скасувати надсилання", + "send_cancelled_restore_failed": "Надсилання скасовано, але повернути лист до Чернеток не вдалося. Він досі в Надісланих.", + "send_cancelled_remove_failed": "Надсилання скасовано, але видалити ненадісланий лист із Надісланих не вдалося.", + "cancel_scheduled_failed": "Не вдалося скасувати заплановане надсилання", + "still_scheduled_at": "Лист і далі заплановано на {date}.", + "still_scheduled": "Лист і далі заплановано.", + "already_sent": "Цей лист уже надіслано.", + "no_longer_scheduled": "Цей лист більше не заплановано.", "contact_sidebar": { "title": "Контакт", "close": "Закрити бічну панель", @@ -668,6 +675,7 @@ "send_failed": "Не вдалося надіслати електронний лист", "send_timeout": "Сервер перестав відповідати. Перш ніж надсилати знову, перевірте теку «Надіслані» — лист міг уже піти.", "submission_quota": "На сервері забагато записів про надіслані листи для цього облікового запису. Bulwark видалив ті, що міг; якщо це повторюється, попросіть адміністратора збільшити ліміт надсилання листів.", + "submission_limit": "Сервер відмовився надіслати лист, бо досягнуто ліміту: {reason}", "continue_draft": "Продовжити чернетку", "close_draft_title": "Зберегти чи видалити чернетку?", "close_draft_message": "У вас є незбережені зміни. Зберегти це як чернетку чи видалити?", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b3c6ec732..4671a7d2e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "郵件已排程寄送", "undo_send": "取消傳送", "undo_send_failed": "無法取消傳送", + "send_cancelled_restore_failed": "已取消傳送,但無法將郵件放回草稿。郵件仍在「寄件備份」中。", + "send_cancelled_remove_failed": "已取消傳送,但無法從「寄件備份」移除未寄出的郵件。", + "cancel_scheduled_failed": "無法取消排定的傳送", + "still_scheduled_at": "郵件仍排定於 {date} 傳送。", + "still_scheduled": "郵件仍在排定傳送中。", + "already_sent": "此郵件已寄出。", + "no_longer_scheduled": "此郵件已不在排定傳送中。", "contact_sidebar": { "title": "聯絡人", "close": "關閉側邊欄", @@ -671,6 +678,7 @@ "send_failed": "傳送郵件失敗", "send_timeout": "伺服器停止回應。再次傳送前,請先檢查「寄件備份」信件匣,因為郵件可能已經寄出。", "submission_quota": "伺服器上此帳戶的已寄出郵件記錄過多。Bulwark 已移除可以移除的記錄;如果問題持續發生,請要求管理員提高郵件提交數量上限。", + "submission_limit": "由於達到上限,伺服器拒絕傳送此郵件:{reason}", "continue_draft": "繼續編輯草稿", "close_draft_title": "要儲存還是捨棄草稿?", "close_draft_message": "您有尚未儲存的變更。要將這封郵件儲存為草稿,還是捨棄?", diff --git a/locales/zh/common.json b/locales/zh/common.json index da43867c5..ff1417c59 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -563,6 +563,13 @@ "undo_send_scheduled": "邮件已计划发送", "undo_send": "撤销发送", "undo_send_failed": "无法撤销发送", + "send_cancelled_restore_failed": "已取消发送,但无法将邮件放回草稿。它仍在“已发送”中。", + "send_cancelled_remove_failed": "已取消发送,但无法从“已发送”中移除未发送的邮件。", + "cancel_scheduled_failed": "无法取消定时发送", + "still_scheduled_at": "邮件仍计划于 {date} 发送。", + "still_scheduled": "邮件仍处于定时发送状态。", + "already_sent": "此邮件已发送。", + "no_longer_scheduled": "此邮件已不在定时发送中。", "contact_sidebar": { "title": "联系人", "close": "关闭侧边栏", @@ -668,6 +675,7 @@ "send_failed": "发送邮件失败", "send_timeout": "服务器已停止响应。再次发送前请检查“已发送”文件夹——邮件可能已经发出。", "submission_quota": "服务器上此账户的已发送邮件记录过多。Bulwark 已删除了能删除的记录;如果问题仍然出现,请让管理员提高邮件提交数量上限。", + "submission_limit": "由于达到上限,服务器拒绝发送此邮件:{reason}", "continue_draft": "继续编辑草稿", "close_draft_title": "保存或丢弃草稿?", "close_draft_message": "您有未保存的更改。要将其保存为草稿还是丢弃?", diff --git a/stores/__tests__/email-store-undo-send.test.ts b/stores/__tests__/email-store-undo-send.test.ts new file mode 100644 index 000000000..609bb3dea --- /dev/null +++ b/stores/__tests__/email-store-undo-send.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { SendCancelledCleanupError } from '@/lib/send-failure-message'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// "Undo send" and "Cancel and edit" cancel the submission, then put the +// message back in Drafts (or remove an S/MIME copy). Only a failed cancel may +// read as "undo failed": after a cancel that went through, the mail no longer +// goes out, whatever fails next. + +function mailbox(id: string, role: string): Mailbox { + return { id, name: role, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true } as Mailbox; +} + +const pending = { + submissionId: 'sub-1', + emailId: 'sent-1', + identityId: 'identity-1', + sendAt: new Date(Date.now() + 10_000).toISOString(), + isSmime: false, +}; + +const scheduled = { + id: 'sent-1', + emailSubmissionId: 'sub-1', + scheduledAccountId: 'account-1', + isSmimeScheduled: false, +} as unknown as Email; + +describe('cancelling a send and restoring the draft', () => { + let client: IJMAPClient; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + client = { + pruneEmailSubmissions: vi.fn(() => new Promise(() => {})), + getScheduledEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0, nextPosition: 0 }), + cancelEmailSubmission: vi.fn().mockResolvedValue(undefined), + restoreEmailToDraft: vi.fn().mockResolvedValue(undefined), + deleteEmail: vi.fn().mockResolvedValue(undefined), + getEmail: vi.fn().mockResolvedValue({ id: 'sent-1' }), + getMailboxes: vi.fn().mockResolvedValue([]), + } as unknown as IJMAPClient; + useEmailStore.setState({ + emails: [], + mailboxes: [mailbox('mb-drafts', 'drafts'), mailbox('mb-sent', 'sent')], + pendingUndoSend: pending, + }); + }); + + it('undoes the send and returns the draft to open', async () => { + const restored = await useEmailStore.getState().cancelUndoSend(client, pending); + + expect(client.cancelEmailSubmission).toHaveBeenCalledWith('sub-1', undefined); + expect(client.restoreEmailToDraft).toHaveBeenCalledWith('sent-1', 'mb-drafts', 'mb-sent'); + expect(restored).toEqual({ id: 'sent-1' }); + expect(useEmailStore.getState().pendingUndoSend).toBeNull(); + }); + + it('lets a failed cancel through as it is: the mail may still go out', async () => { + vi.mocked(client.cancelEmailSubmission).mockRejectedValueOnce(new Error('The requested message is no longer in the queue.')); + + const error = await useEmailStore.getState().cancelUndoSend(client, pending).catch((e: unknown) => e); + + expect(error).not.toBeInstanceOf(SendCancelledCleanupError); + expect((error as Error).message).toBe('The requested message is no longer in the queue.'); + expect(client.restoreEmailToDraft).not.toHaveBeenCalled(); + }); + + it('says the send was cancelled when only putting the message back in Drafts failed', async () => { + vi.mocked(client.restoreEmailToDraft).mockRejectedValueOnce(new Error('Request failed: 503')); + + const error = await useEmailStore.getState().cancelUndoSend(client, pending).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(SendCancelledCleanupError); + expect((error as SendCancelledCleanupError).step).toBe('restore'); + // The undo toast is done with: the mail is not going out. + expect(useEmailStore.getState().pendingUndoSend).toBeNull(); + }); + + it('says the send was cancelled when only removing an S/MIME copy failed', async () => { + vi.mocked(client.deleteEmail).mockRejectedValueOnce(new Error('Request failed: 503')); + + const error = await useEmailStore.getState().cancelUndoSend(client, { ...pending, isSmime: true }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(SendCancelledCleanupError); + expect((error as SendCancelledCleanupError).step).toBe('remove'); + }); + + it('counts the undo as done when only reading the restored draft back failed', async () => { + vi.mocked(client.getEmail).mockRejectedValueOnce(new Error('Request failed: 503')); + + await expect(useEmailStore.getState().cancelUndoSend(client, pending)).resolves.toBeNull(); + expect(client.restoreEmailToDraft).toHaveBeenCalled(); + }); + + it('reports "Cancel and edit" the same way', async () => { + vi.mocked(client.restoreEmailToDraft).mockRejectedValueOnce(new Error('Request failed: 503')); + const restoreFailed = await useEmailStore.getState().cancelScheduledEmailForEdit(client, scheduled).catch((e: unknown) => e); + expect(restoreFailed).toBeInstanceOf(SendCancelledCleanupError); + expect(client.cancelEmailSubmission).toHaveBeenCalledWith('sub-1', 'account-1'); + + vi.mocked(client.cancelEmailSubmission).mockRejectedValueOnce(new Error('cannotUnsend')); + const cancelFailed = await useEmailStore.getState().cancelScheduledEmailForEdit(client, scheduled).catch((e: unknown) => e); + expect(cancelFailed).not.toBeInstanceOf(SendCancelledCleanupError); + + await expect(useEmailStore.getState().cancelScheduledEmailForEdit(client, scheduled)).resolves.toEqual({ id: 'sent-1' }); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index aa9a9ce00..ed83c333c 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -14,6 +14,7 @@ import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, adv import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useMessageListTabsStore } from "@/stores/message-list-tabs-store"; +import { SendCancelledCleanupError } from "@/lib/send-failure-message"; type ScheduledSubmissionMetadata = { submissionId: string; @@ -613,6 +614,19 @@ export class ArchiveMailboxNotFoundError extends Error { } } +/** + * Puts a message whose send was cancelled back in Drafts, out of Sent, where + * the send filed it as soon as the submission was created. + */ +async function restoreSentToDraft(client: IJMAPClient, known: Mailbox[], emailId: string): Promise { + const mailboxes = known.length > 0 ? known : await client.getMailboxes(); + const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); + const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); + if (draftsMailbox) { + await client.restoreEmailToDraft(emailId, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id); + } +} + /** * Archive folder for a single-email or batch archive. * @@ -5075,24 +5089,28 @@ export const useEmailStore = create((set, get) => ({ if (get().pendingUndoSend?.submissionId === submissionId) { set({ pendingUndoSend: null }); } - if (email.isSmimeScheduled) { - await client.deleteEmail(email.id); - set(state => ({ - selectedEmail: state.selectedEmail?.id === email.id ? null : state.selectedEmail, - selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== email.id)), - })); - await get().fetchScheduledEmails(client); - return null; - } - const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes(); - const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); - const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); - if (draftsMailbox) { - await client.restoreEmailToDraft(email.id, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id); + // Nothing goes out any more; a failure from here on is not a failed cancel. + try { + if (email.isSmimeScheduled) { + await client.deleteEmail(email.id); + set(state => ({ + selectedEmail: state.selectedEmail?.id === email.id ? null : state.selectedEmail, + selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== email.id)), + })); + } else { + await restoreSentToDraft(client, get().mailboxes, email.id); + } + } catch (error) { + void get().fetchScheduledEmails(client); + throw new SendCancelledCleanupError(email.isSmimeScheduled ? 'remove' : 'restore', error); } await get().fetchScheduledEmails(client); - const restored = await client.getEmail(email.id); - return restored; + if (email.isSmimeScheduled) return null; + // Back in Drafts either way; the caller opens it when it can. + return client.getEmail(email.id).catch((error) => { + console.error('Failed to load the restored draft:', error); + return null; + }); }, rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, delayedUntil) => { @@ -5125,23 +5143,30 @@ export const useEmailStore = create((set, get) => ({ cancelUndoSend: async (client, pending) => { await client.cancelEmailSubmission(pending.submissionId, pending.submissionAccountId); - if (pending.emailId && pending.isSmime) { - await client.deleteEmail(pending.emailId); - set(state => ({ - selectedEmail: state.selectedEmail?.id === pending.emailId ? null : state.selectedEmail, - selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== pending.emailId)), - })); - } else if (pending.emailId) { - const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes(); - const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); - const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); - if (draftsMailbox) { - await client.restoreEmailToDraft(pending.emailId, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id); + // Nothing goes out any more. A failure from here on must not read as a + // failed undo, which would tell the user the mail may still leave. + set({ pendingUndoSend: null }); + try { + if (pending.emailId && pending.isSmime) { + await client.deleteEmail(pending.emailId); + set(state => ({ + selectedEmail: state.selectedEmail?.id === pending.emailId ? null : state.selectedEmail, + selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== pending.emailId)), + })); + } else if (pending.emailId) { + await restoreSentToDraft(client, get().mailboxes, pending.emailId); } + } catch (error) { + void get().refreshScheduledMetadata(client); + throw new SendCancelledCleanupError(pending.isSmime ? 'remove' : 'restore', error); } await get().refreshScheduledMetadata(client); - set({ pendingUndoSend: null }); - return pending.emailId && !pending.isSmime ? client.getEmail(pending.emailId) : null; + if (!pending.emailId || pending.isSmime) return null; + // Back in Drafts either way; the caller opens it when it can. + return client.getEmail(pending.emailId).catch((error) => { + console.error('Failed to load the restored draft:', error); + return null; + }); }, loadMockData: () => {