diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index dc9b445e..b67b8c3a 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -409,6 +409,17 @@ describe('JMAPClient', () => { expect(client.undoSendHold(30)).toBeUndefined(); }); + it('gives the latest time a send can be held until, for the date pickers', async () => { + global.fetch = mockFetch([{ status: 200, json: withSubmission(2_592_000, true) }]) as any; + await client.connect('https://mail.example.com', 'user', 'pass'); + const now = Date.UTC(2026, 8, 24, 12, 0, 0); + expect(client.latestHoldDate(undefined, now)?.toISOString()).toBe('2026-10-01T12:00:00.000Z'); + + global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any; + await client.connect('https://mail.example.com', 'user', 'pass'); + expect(client.latestHoldDate(undefined, now)).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(); diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index af923ff7..c334ff0a 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -1087,6 +1087,15 @@ export class JMAPClient { return max; } + /** + * The latest time a send from `accountId` can be held until, or undefined + * when it can't be held. Caps the "Pick date & time" pickers. + */ + latestHoldDate(accountId?: string, now = Date.now()): Date | undefined { + if (!this.hasDelayedSend(accountId)) return undefined; + return new Date(now + this.getMaxDelayedSend(accountId) * 1000); + } + /** * Remember the hold limit a rejected submission named, so the pickers only * offer times the server accepts for the rest of the session. diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 6682176c..5afda5eb 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -1895,7 +1895,9 @@ export default function ComposeScreen({ route, navigation }: Props) { }, [t, scheduleSheetOpen]); const startCustomPicker = () => { - customDraftRef.current = new Date(Date.now() + 3600 * 1000); + // An hour ahead, or the latest time the server can hold it if sooner. + const latest = jmapClient.latestHoldDate()?.getTime() ?? Infinity; + customDraftRef.current = new Date(Math.min(Date.now() + 3600 * 1000, latest)); setScheduleSheetOpen(false); setCustomStage(Platform.OS === 'ios' ? 'datetime' : 'date'); }; @@ -2516,6 +2518,7 @@ export default function ComposeScreen({ route, navigation }: Props) { mode="datetime" display="spinner" minimumDate={new Date()} + maximumDate={jmapClient.latestHoldDate()} onChange={onCustomPickerChange} /> @@ -2542,6 +2545,7 @@ export default function ComposeScreen({ route, navigation }: Props) { mode={customStage === 'time' ? 'time' : 'date'} display="default" minimumDate={customStage === 'date' ? new Date() : undefined} + maximumDate={customStage === 'date' ? jmapClient.latestHoldDate() : undefined} onChange={onCustomPickerChange} /> )} diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index 5b055168..6146b12d 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -201,7 +201,9 @@ export default function ScheduledScreen({ navigation }: Props) { }, [t, rescheduleFor]); const startCustomPicker = () => { - customDraftRef.current = new Date(Date.now() + 3600 * 1000); + // An hour ahead, or the latest time the server can hold it if sooner. + const latest = jmapClient.latestHoldDate(rescheduleFor?.accountId)?.getTime() ?? Infinity; + customDraftRef.current = new Date(Math.min(Date.now() + 3600 * 1000, latest)); setCustomStage(Platform.OS === 'ios' ? 'datetime' : 'date'); }; @@ -375,6 +377,7 @@ export default function ScheduledScreen({ navigation }: Props) { mode="datetime" display="spinner" minimumDate={new Date()} + maximumDate={jmapClient.latestHoldDate(rescheduleFor?.accountId)} onChange={onCustomPickerChange} /> @@ -396,6 +399,7 @@ export default function ScheduledScreen({ navigation }: Props) { mode={customStage === 'time' ? 'time' : 'date'} display="default" minimumDate={customStage === 'date' ? new Date() : undefined} + maximumDate={customStage === 'date' ? jmapClient.latestHoldDate(rescheduleFor?.accountId) : undefined} onChange={onCustomPickerChange} /> )}