diff --git a/src/api/__tests__/email-submission.test.ts b/src/api/__tests__/email-submission.test.ts index e77d8881..b3d65d19 100644 --- a/src/api/__tests__/email-submission.test.ts +++ b/src/api/__tests__/email-submission.test.ts @@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', request: vi.fn(), + learnHoldLimit: vi.fn(), getMaxCallsInRequest: vi.fn(() => 16), getMaxObjectsInGet: vi.fn(() => 500), getMaxObjectsInSet: vi.fn(() => 500), @@ -12,6 +13,7 @@ vi.mock('../jmap-client', () => ({ import { jmapClient } from '../jmap-client'; import { rescheduleScheduledSend, sendEmail } from '../email'; +import { ScheduleTooLateError } from '../jmap-result'; const mockRequest = jmapClient.request as ReturnType; @@ -156,3 +158,68 @@ describe('sendEmail', () => { await expect(sendEmail(OUTGOING, 'identity-1', 'sent-mb')).rejects.toThrow('Disk full'); }); }); + +describe('hold-limit rejections', () => { + const HOLD_REJECTED = { + type: 'forbiddenMailFrom', + description: 'Server rejected MAIL-FROM: 501 5.5.4 Requested hold time exceeds maximum of 172800 seconds.', + }; + + it('turns a refused send-later into ScheduleTooLateError and learns the limit', async () => { + mockRequest.mockResolvedValueOnce({ + methodResponses: [ + ['Email/set', { created: { draft: { id: 'e-new' } } }, '0'], + ['EmailSubmission/set', { notCreated: { 'sub-1': HOLD_REJECTED } }, '1'], + ], + }); + + const err = await sendEmail( + { from: [{ email: 'me@example.com' }], to: [{ email: 'you@example.com' }], subject: 'Later', textBody: 'x' }, + 'identity-1', + 'sent-mb', + 5 * 24 * 3600, + { draftsMailboxId: 'drafts-mb' }, + ).catch((e) => e); + + expect(err).toBeInstanceOf(ScheduleTooLateError); + expect(err.maxSeconds).toBe(172_800); + expect(jmapClient.learnHoldLimit).toHaveBeenCalledWith(172_800); + }); + + it('keeps other submission errors as they are', async () => { + mockRequest.mockResolvedValueOnce({ + methodResponses: [ + ['Email/set', { created: { draft: { id: 'e-new' } } }, '0'], + ['EmailSubmission/set', { notCreated: { 'sub-1': { type: 'forbiddenFrom', description: 'Not your address' } } }, '1'], + ], + }); + + const err = await sendEmail( + { from: [{ email: 'me@example.com' }], to: [{ email: 'you@example.com' }], subject: 'Now', textBody: 'x' }, + 'identity-1', + 'sent-mb', + ).catch((e) => e); + + expect(err).not.toBeInstanceOf(ScheduleTooLateError); + expect(err.message).toBe('Not your address'); + expect(jmapClient.learnHoldLimit).not.toHaveBeenCalled(); + }); + + it('turns a refused reschedule into ScheduleTooLateError', async () => { + mockRequest + .mockResolvedValueOnce({ + methodResponses: [ + ['EmailSubmission/get', { list: [{ id: 'sub-1', envelope: { mailFrom: { email: 'me@example.com' }, rcptTo: [{ email: 'to@example.com' }] } }] }, '0'], + ['Email/get', { list: [] }, '1'], + ], + }) + .mockResolvedValueOnce({ + methodResponses: [['EmailSubmission/set', { updated: { 'sub-1': null }, notCreated: { replacement: HOLD_REJECTED } }, '0']], + }); + + const err = await rescheduleScheduledSend(SCHEDULED, 5 * 24 * 3600).catch((e) => e); + + expect(err).toBeInstanceOf(ScheduleTooLateError); + expect(jmapClient.learnHoldLimit).toHaveBeenCalledWith(172_800); + }); +}); diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index 3fc4acd6..3f9988d3 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -8,7 +8,7 @@ vi.mock('expo-secure-store', () => ({ })); import * as SecureStore from 'expo-secure-store'; -import { JMAPClient, AuthenticationError, RateLimitError } from '../jmap-client'; +import { JMAPClient, AuthenticationError, RateLimitError, parseHoldLimit } from '../jmap-client'; import type { JMAPSession } from '../types'; const MOCK_SESSION: JMAPSession = { @@ -306,7 +306,8 @@ describe('JMAPClient', () => { await client.connect('https://mail.example.com', 'user', 'pass'); expect(client.hasDelayedSend()).toBe(true); - expect(client.getMaxDelayedSend()).toBe(2_592_000); + // Stalwart's advertised 30 days count as the 7 its MTA accepts. + expect(client.getMaxDelayedSend()).toBe(604_800); expect(client.getMaxSizeAttachmentsPerEmail()).toBe(50_000_000); expect(client.getMaxSizeUpload()).toBe(50_000_000); }); @@ -350,6 +351,63 @@ describe('JMAPClient', () => { expect(client.hasDelayedSend()).toBe(false); expect(client.getMaxDelayedSend()).toBe(0); }); + + describe('hold limit', () => { + const withSubmission = (maxDelayedSend: number, stalwart: boolean): JMAPSession => ({ + ...STALWART_SESSION, + accounts: { + 'acc-1': { + name: 'user@example.com', + isPersonal: true, + isReadOnly: false, + accountCapabilities: { + 'urn:ietf:params:jmap:submission': { maxDelayedSend, submissionExtensions: { FUTURERELEASE: [] } }, + ...(stalwart ? { 'urn:stalwart:jmap': {} } : {}), + }, + }, + }, + }); + + it('trusts the advertised limit on other servers and other values', async () => { + global.fetch = mockFetch([{ status: 200, json: withSubmission(2_592_000, false) }]) as any; + await client.connect('https://mail.example.com', 'user', 'pass'); + expect(client.getMaxDelayedSend()).toBe(2_592_000); + + const other = new JMAPClient(); + global.fetch = mockFetch([{ status: 200, json: withSubmission(3600, true) }]) as any; + await other.connect('https://mail.example.com', 'user', 'pass'); + expect(other.getMaxDelayedSend()).toBe(3600); + }); + + it('learns the limit from a rejection, per server', async () => { + global.fetch = mockFetch([{ status: 200, json: withSubmission(2_592_000, false) }]) as any; + await client.connect('https://mail.example.com', 'user', 'pass'); + client.learnHoldLimit(172_800); + expect(client.getMaxDelayedSend()).toBe(172_800); + + global.fetch = mockFetch([{ status: 200, json: withSubmission(2_592_000, false) }]) as any; + await client.connect('https://other.example.com', 'user', 'pass'); + expect(client.getMaxDelayedSend()).toBe(2_592_000); + }); + + it('caps the undo-send hold and drops it where holding is unsupported', async () => { + global.fetch = mockFetch([{ status: 200, json: withSubmission(20, false) }]) as any; + await client.connect('https://mail.example.com', 'user', 'pass'); + expect(client.undoSendHold(10)).toBe(10); + expect(client.undoSendHold(30)).toBe(20); + expect(client.undoSendHold(0)).toBeUndefined(); + + global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any; + await client.connect('https://mail.example.com', 'user', 'pass'); + expect(client.undoSendHold(30)).toBeUndefined(); + }); + + it('parses only hold-limit rejections', () => { + expect(parseHoldLimit('Server rejected MAIL-FROM: 501 5.5.4 Requested hold time exceeds maximum of 604800 seconds.')).toBe(604_800); + expect(parseHoldLimit('Server rejected MAIL-FROM: 550 nope')).toBeNull(); + expect(parseHoldLimit(undefined)).toBeNull(); + }); + }); }); describe('accountId resolution', () => { diff --git a/src/api/email.ts b/src/api/email.ts index 85b48f5b..1897c3ea 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1,5 +1,5 @@ import { jmapClient } from './jmap-client'; -import { assertSetResult, batched, requireMethodResult } from './jmap-result'; +import { assertSetResult, batched, parseHoldLimit, requireMethodResult, ScheduleTooLateError } from './jmap-result'; import { keywordPointer, mailboxPointer } from './patch-pointer'; import { CAPABILITIES } from './types'; import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types'; @@ -1142,6 +1142,18 @@ function buildEmailCreate(email: OutgoingEmail): Record { return emailCreate; } +/** + * The error for a submission the server refused. A hold beyond the MTA's + * limit becomes `ScheduleTooLateError`, and the limit it names is remembered + * so the pickers stop offering such times (webmail parity). + */ +function submissionError(err: { description?: string; type?: string }, fallback: string): Error { + const limit = parseHoldLimit(err.description); + if (limit === null) return new Error(err.description ?? err.type ?? fallback); + jmapClient.learnHoldLimit(limit); + return new ScheduleTooLateError(limit); +} + export interface SendEmailOptions { /** * Drafts folder id. When given, the message is created there with `$draft` @@ -1256,7 +1268,7 @@ export async function sendEmail( } if (methodName === 'EmailSubmission/set') { const notCreated = (result as { notCreated?: Record }).notCreated?.['sub-1']; - if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to submit message'); + if (notCreated) throw submissionError(notCreated, 'Failed to submit message'); const created = (result as { created?: Record }).created?.['sub-1']; emailSubmissionId = created?.id; sendAt = created?.sendAt; @@ -1482,7 +1494,7 @@ export async function rescheduleScheduledSend( const notUpdated = body.notUpdated?.[scheduled.emailSubmissionId] as { description?: string; type?: string } | undefined; if (notUpdated) throw new Error(notUpdated.description ?? notUpdated.type ?? 'Failed to cancel the previous schedule'); const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined; - if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to reschedule'); + if (notCreated) throw submissionError(notCreated, 'Failed to reschedule'); const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined; return { emailSubmissionId: created?.id, sendAt: created?.sendAt }; } diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 6177c1dc..73823a36 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -126,7 +126,16 @@ function canonicalUrl(url: string): string { // Shared response helpers live in ./jmap-result (dependency-free) and are // re-exported here for convenience. -export { JMAPMethodError, requireMethodResult, assertSetResult, batched } from './jmap-result'; +export { + JMAPMethodError, requireMethodResult, assertSetResult, batched, ScheduleTooLateError, parseHoldLimit, +} from './jmap-result'; + +/** + * Stalwart advertises `maxDelayedSend` as a fixed 30 days, while its MTA + * rejects any hold beyond the `futureRelease` limit, 7 days by default. + */ +const STALWART_ADVERTISED_MAX_DELAYED_SEND = 30 * 24 * 60 * 60; +const STALWART_DEFAULT_MAX_HOLD = 7 * 24 * 60 * 60; export class JMAPClient { private session: JMAPSession | null = null; @@ -137,6 +146,8 @@ export class JMAPClient { private authFailureListeners = new Set<(err: AuthenticationError) => void>(); private rateLimitListeners = new Set<(retryAfterMs: number) => void>(); private tokenRefreshListeners = new Set<() => void>(); + /** Hold limits learned from rejected scheduled sends, per server (seconds). */ + private learnedHoldLimits = new Map(); get accountId(): string { if (!this._accountId) { @@ -1040,9 +1051,40 @@ export class JMAPClient { | undefined; } + /** + * The longest hold (seconds) a send from `accountId` may ask for. On + * Stalwart the advertised 30 days counts as the 7 its MTA accepts, and a + * limit learned from a rejection wins over both (webmail parity). + */ getMaxDelayedSend(accountId?: string): number { const max = this.submissionCapability(accountId)?.maxDelayedSend; - return typeof max === 'number' ? max : 0; + if (typeof max !== 'number' || max <= 0) return 0; + const learned = this.serverUrl ? this.learnedHoldLimits.get(this.serverUrl) : undefined; + if (learned !== undefined) return Math.min(max, learned); + if ( + max === STALWART_ADVERTISED_MAX_DELAYED_SEND && + this.hasAccountCapability('urn:stalwart:jmap', this.submissionAccountId(accountId)) + ) { + return STALWART_DEFAULT_MAX_HOLD; + } + return max; + } + + /** + * Remember the hold limit a rejected submission named, so the pickers only + * offer times the server accepts for the rest of the session. + */ + learnHoldLimit(seconds: number): void { + if (this.serverUrl && seconds > 0) this.learnedHoldLimits.set(this.serverUrl, seconds); + } + + /** + * The HOLDFOR for the undo-send delay: none when the account can't hold + * mail, and never longer than the server allows. + */ + undoSendHold(delaySeconds: number, accountId?: string): number | undefined { + if (delaySeconds <= 0 || !this.hasDelayedSend(accountId)) return undefined; + return Math.min(delaySeconds, this.getMaxDelayedSend(accountId)); } hasDelayedSend(accountId?: string): boolean { diff --git a/src/api/jmap-result.ts b/src/api/jmap-result.ts index d860b859..9398583c 100644 --- a/src/api/jmap-result.ts +++ b/src/api/jmap-result.ts @@ -80,6 +80,26 @@ export function assertSetResult( } } +/** A scheduled send later than the server's hold limit. */ +export class ScheduleTooLateError extends Error { + maxSeconds?: number; + constructor(maxSeconds?: number) { + super('Scheduled send time is later than the server allows'); + this.name = 'ScheduleTooLateError'; + this.maxSeconds = maxSeconds; + } +} + +/** + * The hold limit named in a rejected submission, if that was the reason: + * Stalwart's MTA refuses a HOLDFOR beyond its `futureRelease` limit with + * "501 5.5.4 Requested hold time exceeds maximum of N seconds". + */ +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; +} + /** Split `items` into consecutive batches of at most `size` entries. */ export function batched(items: T[], size: number): T[][] { const step = Math.max(1, Math.floor(size)); diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index 0a986403..9f448392 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -90,7 +90,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P }, identity.id, sent.originalId ?? sent.id, - sendDelaySeconds > 0 && jmapClient.hasDelayedSend(jmapAccountId) ? sendDelaySeconds : undefined, + jmapClient.undoSendHold(sendDelaySeconds, jmapAccountId), { draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId }, ); try { diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 27d16725..4e203975 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -38,7 +38,7 @@ import { getIdentities } from '../api/identity'; import { sendEmail, createDraft, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail, } from '../api/email'; -import { jmapClient, RequestTimeoutError } from '../api/jmap-client'; +import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client'; import { uploadBlob, uploadBytes } from '../api/blob'; import { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients'; import { buildReplySubject, buildForwardSubject } from '../lib/subject-prefix'; @@ -1862,9 +1862,9 @@ export default function ComposeScreen({ route, navigation }: Props) { }; // The Send button: applies the global undo-send delay when the server - // supports it, otherwise sends immediately. + // supports it (capped at its hold limit), otherwise sends immediately. const onSend = () => { - const holdFor = sendDelaySeconds > 0 && jmapClient.hasDelayedSend() ? sendDelaySeconds : undefined; + const holdFor = jmapClient.undoSendHold(sendDelaySeconds); void performSend(holdFor); }; @@ -1883,11 +1883,13 @@ export default function ComposeScreen({ route, navigation }: Props) { const tomorrowMorning = new Date(now); tomorrowMorning.setDate(tomorrowMorning.getDate() + 1); tomorrowMorning.setHours(8, 0, 0, 0); + // Only offer times within the server's hold limit. + const maxMs = jmapClient.getMaxDelayedSend() * 1000; return [ { label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) }, { label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) }, { label: t('email_composer.schedule_tomorrow_morning', 'Tomorrow morning'), date: tomorrowMorning }, - ]; + ].filter((preset) => preset.date.getTime() - now.getTime() <= maxMs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [t, scheduleSheetOpen]); @@ -2046,6 +2048,15 @@ export default function ComposeScreen({ route, navigation }: Props) { ); return; } + if (e instanceof ScheduleTooLateError) { + // The server refused the hold; the pickers now only offer times + // within the limit it named. + Alert.alert( + t('email_composer.schedule_too_late_title', 'Too far ahead'), + t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.'), + ); + return; + } Alert.alert( t('email_composer.send_failed', 'Send failed'), e instanceof Error ? e.message : 'Failed to send email', diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index 65c24b0f..3602b217 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -15,7 +15,7 @@ import { getFullEmail, type ScheduledEmail, } from '../api/email'; -import { jmapClient } from '../api/jmap-client'; +import { jmapClient, ScheduleTooLateError } from '../api/jmap-client'; import { useEmailStore } from '../stores/email-store'; import { useLocaleStore } from '../stores/locale-store'; import { useSettingsStore } from '../stores/settings-store'; @@ -78,6 +78,13 @@ export default function ScheduledScreen({ navigation }: Props) { try { await action(); } catch (e) { + if (e instanceof ScheduleTooLateError) { + Alert.alert( + t('email_composer.schedule_too_late_title', 'Too far ahead'), + t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.'), + ); + return; + } Alert.alert(failTitle, e instanceof Error ? e.message : String(e)); } finally { setBusyId(null); @@ -172,11 +179,13 @@ export default function ScheduledScreen({ navigation }: Props) { const tomorrowMorning = new Date(now); tomorrowMorning.setDate(tomorrowMorning.getDate() + 1); tomorrowMorning.setHours(8, 0, 0, 0); + // Only offer times within the server's hold limit. + const maxMs = jmapClient.getMaxDelayedSend() * 1000; return [ { label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) }, { label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) }, { label: t('email_composer.schedule_tomorrow_morning', 'Tomorrow morning'), date: tomorrowMorning }, - ]; + ].filter((preset) => preset.date.getTime() - now.getTime() <= maxMs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [t, rescheduleFor]);