From bab19898d9aaecfb71cd1162cab7e8652befecc5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:31:53 +0200 Subject: [PATCH] fix: cap scheduled send at the 7 days Stalwart actually accepts Stalwart advertises `maxDelayedSend` as a fixed 30 days, but its MTA rejects any hold beyond `futureRelease`, 7 days by default. Scheduling a message 8 to 30 days ahead passed the picker and then failed with "501 5.5.4 Requested hold time exceeds maximum of 604800 seconds". On Stalwart the advertised 30 days now counts as 7. When a submission is still rejected for its hold time, the limit from the error message replaces it for the session, and the composer shows the translated "later than the server allows" message instead of the raw SMTP reply. --- components/email/email-composer.tsx | 6 ++- lib/__tests__/jmap-delayed-send-limit.test.ts | 49 ++++++++++++++++++ lib/jmap/client.ts | 50 ++++++++++++++++++- 3 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 lib/__tests__/jmap-delayed-send-limit.test.ts diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 7311f5a93..ba45ec2a2 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -46,7 +46,7 @@ import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignat import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; -import { RequestTimeoutError } from "@/lib/jmap/client"; +import { RequestTimeoutError, ScheduleTooLateError } from "@/lib/jmap/client"; import { rewriteCidImagesForEditor, replaceInlineImagePlaceholders, @@ -2387,7 +2387,9 @@ export function EmailComposer({ toast.error( err instanceof RequestTimeoutError ? t('send_timeout') - : err instanceof Error ? err.message : t('send_failed') + : err instanceof ScheduleTooLateError + ? t('schedule_send_too_late') + : err instanceof Error ? err.message : t('send_failed') ); } finally { isSendingRef.current = false; diff --git a/lib/__tests__/jmap-delayed-send-limit.test.ts b/lib/__tests__/jmap-delayed-send-limit.test.ts new file mode 100644 index 000000000..34d23e1da --- /dev/null +++ b/lib/__tests__/jmap-delayed-send-limit.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { JMAPClient, ScheduleTooLateError, parseHoldLimit } from '../jmap/client'; + +const THIRTY_DAYS = 30 * 24 * 60 * 60; +const SEVEN_DAYS = 7 * 24 * 60 * 60; + +function clientWith(maxDelayedSend: number, stalwart: boolean): JMAPClient { + const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass'); + const accountCapabilities: Record = { + 'urn:ietf:params:jmap:submission': { maxDelayedSend, submissionExtensions: { FUTURERELEASE: [] } }, + }; + if (stalwart) accountCapabilities['urn:stalwart:jmap'] = {}; + Object.assign(client, { + accountId: 'a1', + capabilities: { 'urn:ietf:params:jmap:submission': {} }, + session: { + primaryAccounts: { 'urn:ietf:params:jmap:submission': 'a1' }, + accounts: { a1: { accountCapabilities } }, + }, + }); + return client; +} + +describe('scheduled send limit', () => { + it('clamps the fixed 30 days Stalwart advertises to its 7-day hold limit', () => { + expect(clientWith(THIRTY_DAYS, true).getMaxDelayedSend()).toBe(SEVEN_DAYS); + }); + + it('trusts the advertised limit on other servers and other values', () => { + expect(clientWith(THIRTY_DAYS, false).getMaxDelayedSend()).toBe(THIRTY_DAYS); + expect(clientWith(3600, true).getMaxDelayedSend()).toBe(3600); + }); + + it('learns the limit from a rejected submission', () => { + const client = clientWith(THIRTY_DAYS, false); + const reject = () => (client as unknown as { throwIfHoldTooLong(e: { description?: string }): void }) + .throwIfHoldTooLong({ + description: 'Server rejected MAIL-FROM: 501 5.5.4 Requested hold time exceeds maximum of 172800 seconds.', + }); + expect(reject).toThrow(ScheduleTooLateError); + expect(client.getMaxDelayedSend()).toBe(172800); + }); + + it('parses only hold-limit rejections', () => { + expect(parseHoldLimit('501 5.5.4 Requested hold time exceeds maximum of 604800 seconds.')).toBe(604800); + expect(parseHoldLimit('Server rejected MAIL-FROM: 550 nope')).toBeNull(); + expect(parseHoldLimit(undefined)).toBeNull(); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 8edc2829e..b23b2ef1e 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -107,6 +107,28 @@ export class RequestTimeoutError extends Error { } } +/** A scheduled send later than the server's hold limit. */ +export class ScheduleTooLateError extends Error { + constructor(readonly maxSeconds?: number) { + super('Scheduled send time is later than the server allows'); + this.name = 'ScheduleTooLateError'; + } +} + +/** + * Stalwart advertises `maxDelayedSend` as a fixed 30 days, while its MTA + * rejects any hold beyond the `futureRelease` limit, 7 days by default, + * with "501 5.5.4 Requested hold time exceeds maximum of N seconds". + */ +const STALWART_ADVERTISED_MAX_DELAYED_SEND = 30 * 24 * 60 * 60; +const STALWART_DEFAULT_MAX_HOLD = 7 * 24 * 60 * 60; + +/** The hold limit named in a rejected submission, if that was the reason. */ +export function parseHoldLimit(description: string | undefined): number | null { + const m = description?.match(/hold time exceeds maximum of (\d+) seconds/i); + return m ? Number(m[1]) : null; +} + // JMAP protocol types - these are intentionally flexible due to server variations interface JMAPSession { // The authenticated login (JMAP spec Session.username) — server-confirmed, @@ -762,6 +784,8 @@ export class JMAPClient implements IJMAPClient { // (keep-alive ping, SSE error handlers) cannot revive timers or // reconnect after an intentional sign-out (#588). private intentionallyDisconnected = false; + /** Hold limit learned from a rejected scheduled send (seconds). */ + private learnedMaxDelayedSend: number | null = null; // Consecutive keep-alive failures; failed pings skip upcoming ticks // (30s -> 1m -> 2m -> ~5m) instead of hammering a down server (#588). private pingFailureCount = 0; @@ -3686,6 +3710,7 @@ export class JMAPClient implements IJMAPClient { `[sendEmail] ${methodName} notCreated:`, JSON.stringify(errors, null, 2), ); + this.throwIfHoldTooLong(firstError); const propsHint = firstError?.properties?.length ? ` (properties: ${firstError.properties.join(', ')})` : ''; @@ -4588,7 +4613,26 @@ export class JMAPClient implements IJMAPClient { getMaxDelayedSend(accountId?: string): number { const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend; - return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0; + if (typeof maxDelayedSend !== 'number' || maxDelayedSend <= 0) return 0; + if (this.learnedMaxDelayedSend !== null) return Math.min(maxDelayedSend, this.learnedMaxDelayedSend); + if ( + maxDelayedSend === STALWART_ADVERTISED_MAX_DELAYED_SEND && + this.hasAccountCapability('urn:stalwart:jmap', accountId) + ) { + return STALWART_DEFAULT_MAX_HOLD; + } + return maxDelayedSend; + } + + /** + * Turn a "hold time exceeds maximum" rejection into ScheduleTooLateError + * and remember the limit, so the picker offers only times that work. + */ + private throwIfHoldTooLong(error: { description?: string } | undefined): void { + const limit = parseHoldLimit(error?.description); + if (limit === null) return; + this.learnedMaxDelayedSend = limit; + throw new ScheduleTooLateError(limit); } hasDelayedSend(accountId?: string): boolean { @@ -4615,7 +4659,7 @@ export class JMAPClient implements IJMAPClient { throw new Error('Scheduled send is not supported for this account'); } if (time > now + maxDelayedSend * 1000) { - throw new Error('Scheduled send time is later than the server allows'); + throw new ScheduleTooLateError(maxDelayedSend); } return Math.ceil((time - now) / 1000); } @@ -8460,6 +8504,7 @@ export class JMAPClient implements IJMAPClient { const r = result as { notCreated?: Record }; if (r.notCreated) { const firstErr = Object.values(r.notCreated)[0]; + this.throwIfHoldTooLong(firstErr); throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email'); } if (methodName === 'Email/import') { @@ -8548,6 +8593,7 @@ export class JMAPClient implements IJMAPClient { const r = result as { notCreated?: Record }; if (r.notCreated) { const firstErr = Object.values(r.notCreated)[0]; + this.throwIfHoldTooLong(firstErr); throw new Error(firstErr?.description || firstErr?.type || 'Failed to submit raw email'); } if (methodName === 'EmailSubmission/set') {