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') {