fix: only offer scheduled-send times the server accepts

Stalwart advertises `maxDelayedSend` as a fixed 30 days, but its MTA
rejects any hold beyond its `futureRelease` limit, 7 days by default.
Now that scheduled send works on Stalwart, a time 8 to 30 days ahead
passed the checks and then failed with the raw "501 5.5.4 Requested
hold time exceeds maximum of 604800 seconds" reply.

Ported from the webmail:
- on Stalwart the advertised 30 days count as 7
- a submission or reschedule refused for its hold time throws
  `ScheduleTooLateError`, and the limit it names replaces the advertised
  one for that server
- the composer and the Scheduled screen show "Too far ahead" for it
  instead of the SMTP reply

The composer's and the Scheduled screen's presets now only list times
within the limit, and the undo-send hold, from the composer and from
quick reply, is capped at it.
This commit is contained in:
Linus Rath
2026-09-24 19:00:08 +02:00
parent 48825e919a
commit 4e7ae2fa30
8 changed files with 233 additions and 14 deletions
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
jmapClient: { jmapClient: {
accountId: 'acc-1', accountId: 'acc-1',
request: vi.fn(), request: vi.fn(),
learnHoldLimit: vi.fn(),
getMaxCallsInRequest: vi.fn(() => 16), getMaxCallsInRequest: vi.fn(() => 16),
getMaxObjectsInGet: vi.fn(() => 500), getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500), getMaxObjectsInSet: vi.fn(() => 500),
@@ -12,6 +13,7 @@ vi.mock('../jmap-client', () => ({
import { jmapClient } from '../jmap-client'; import { jmapClient } from '../jmap-client';
import { rescheduleScheduledSend, sendEmail } from '../email'; import { rescheduleScheduledSend, sendEmail } from '../email';
import { ScheduleTooLateError } from '../jmap-result';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>; const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -156,3 +158,68 @@ describe('sendEmail', () => {
await expect(sendEmail(OUTGOING, 'identity-1', 'sent-mb')).rejects.toThrow('Disk full'); 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);
});
});
+60 -2
View File
@@ -8,7 +8,7 @@ vi.mock('expo-secure-store', () => ({
})); }));
import * as SecureStore from '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'; import type { JMAPSession } from '../types';
const MOCK_SESSION: JMAPSession = { const MOCK_SESSION: JMAPSession = {
@@ -306,7 +306,8 @@ describe('JMAPClient', () => {
await client.connect('https://mail.example.com', 'user', 'pass'); await client.connect('https://mail.example.com', 'user', 'pass');
expect(client.hasDelayedSend()).toBe(true); 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.getMaxSizeAttachmentsPerEmail()).toBe(50_000_000);
expect(client.getMaxSizeUpload()).toBe(50_000_000); expect(client.getMaxSizeUpload()).toBe(50_000_000);
}); });
@@ -350,6 +351,63 @@ describe('JMAPClient', () => {
expect(client.hasDelayedSend()).toBe(false); expect(client.hasDelayedSend()).toBe(false);
expect(client.getMaxDelayedSend()).toBe(0); 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', () => { describe('accountId resolution', () => {
+15 -3
View File
@@ -1,5 +1,5 @@
import { jmapClient } from './jmap-client'; 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 { keywordPointer, mailboxPointer } from './patch-pointer';
import { CAPABILITIES } from './types'; import { CAPABILITIES } from './types';
import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types'; import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
@@ -1142,6 +1142,18 @@ function buildEmailCreate(email: OutgoingEmail): Record<string, unknown> {
return emailCreate; 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 { export interface SendEmailOptions {
/** /**
* Drafts folder id. When given, the message is created there with `$draft` * Drafts folder id. When given, the message is created there with `$draft`
@@ -1256,7 +1268,7 @@ export async function sendEmail(
} }
if (methodName === 'EmailSubmission/set') { if (methodName === 'EmailSubmission/set') {
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.['sub-1']; const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).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<string, { id?: string; sendAt?: string }> }).created?.['sub-1']; const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['sub-1'];
emailSubmissionId = created?.id; emailSubmissionId = created?.id;
sendAt = created?.sendAt; sendAt = created?.sendAt;
@@ -1482,7 +1494,7 @@ export async function rescheduleScheduledSend(
const notUpdated = body.notUpdated?.[scheduled.emailSubmissionId] as { description?: string; type?: string } | undefined; 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'); 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; 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; const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
return { emailSubmissionId: created?.id, sendAt: created?.sendAt }; return { emailSubmissionId: created?.id, sendAt: created?.sendAt };
} }
+44 -2
View File
@@ -126,7 +126,16 @@ function canonicalUrl(url: string): string {
// Shared response helpers live in ./jmap-result (dependency-free) and are // Shared response helpers live in ./jmap-result (dependency-free) and are
// re-exported here for convenience. // 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 { export class JMAPClient {
private session: JMAPSession | null = null; private session: JMAPSession | null = null;
@@ -137,6 +146,8 @@ export class JMAPClient {
private authFailureListeners = new Set<(err: AuthenticationError) => void>(); private authFailureListeners = new Set<(err: AuthenticationError) => void>();
private rateLimitListeners = new Set<(retryAfterMs: number) => void>(); private rateLimitListeners = new Set<(retryAfterMs: number) => void>();
private tokenRefreshListeners = new Set<() => void>(); private tokenRefreshListeners = new Set<() => void>();
/** Hold limits learned from rejected scheduled sends, per server (seconds). */
private learnedHoldLimits = new Map<string, number>();
get accountId(): string { get accountId(): string {
if (!this._accountId) { if (!this._accountId) {
@@ -1040,9 +1051,40 @@ export class JMAPClient {
| undefined; | 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 { getMaxDelayedSend(accountId?: string): number {
const max = this.submissionCapability(accountId)?.maxDelayedSend; 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 { hasDelayedSend(accountId?: string): boolean {
+20
View File
@@ -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. */ /** Split `items` into consecutive batches of at most `size` entries. */
export function batched<T>(items: T[], size: number): T[][] { export function batched<T>(items: T[], size: number): T[][] {
const step = Math.max(1, Math.floor(size)); const step = Math.max(1, Math.floor(size));
+1 -1
View File
@@ -90,7 +90,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
}, },
identity.id, identity.id,
sent.originalId ?? sent.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 }, { draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
); );
try { try {
+15 -4
View File
@@ -38,7 +38,7 @@ import { getIdentities } from '../api/identity';
import { import {
sendEmail, createDraft, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail, sendEmail, createDraft, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail,
} from '../api/email'; } 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 { uploadBlob, uploadBytes } from '../api/blob';
import { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients'; import { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients';
import { buildReplySubject, buildForwardSubject } from '../lib/subject-prefix'; 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 // 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 onSend = () => {
const holdFor = sendDelaySeconds > 0 && jmapClient.hasDelayedSend() ? sendDelaySeconds : undefined; const holdFor = jmapClient.undoSendHold(sendDelaySeconds);
void performSend(holdFor); void performSend(holdFor);
}; };
@@ -1883,11 +1883,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
const tomorrowMorning = new Date(now); const tomorrowMorning = new Date(now);
tomorrowMorning.setDate(tomorrowMorning.getDate() + 1); tomorrowMorning.setDate(tomorrowMorning.getDate() + 1);
tomorrowMorning.setHours(8, 0, 0, 0); tomorrowMorning.setHours(8, 0, 0, 0);
// Only offer times within the server's hold limit.
const maxMs = jmapClient.getMaxDelayedSend() * 1000;
return [ return [
{ label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) }, { 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_in_3h', 'In 3 hours'), date: inHours(3) },
{ label: t('email_composer.schedule_tomorrow_morning', 'Tomorrow morning'), date: tomorrowMorning }, { 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, scheduleSheetOpen]); }, [t, scheduleSheetOpen]);
@@ -2046,6 +2048,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
); );
return; 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( Alert.alert(
t('email_composer.send_failed', 'Send failed'), t('email_composer.send_failed', 'Send failed'),
e instanceof Error ? e.message : 'Failed to send email', e instanceof Error ? e.message : 'Failed to send email',
+11 -2
View File
@@ -15,7 +15,7 @@ import {
getFullEmail, getFullEmail,
type ScheduledEmail, type ScheduledEmail,
} from '../api/email'; } from '../api/email';
import { jmapClient } from '../api/jmap-client'; import { jmapClient, ScheduleTooLateError } from '../api/jmap-client';
import { useEmailStore } from '../stores/email-store'; import { useEmailStore } from '../stores/email-store';
import { useLocaleStore } from '../stores/locale-store'; import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store'; import { useSettingsStore } from '../stores/settings-store';
@@ -78,6 +78,13 @@ export default function ScheduledScreen({ navigation }: Props) {
try { try {
await action(); await action();
} catch (e) { } 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)); Alert.alert(failTitle, e instanceof Error ? e.message : String(e));
} finally { } finally {
setBusyId(null); setBusyId(null);
@@ -172,11 +179,13 @@ export default function ScheduledScreen({ navigation }: Props) {
const tomorrowMorning = new Date(now); const tomorrowMorning = new Date(now);
tomorrowMorning.setDate(tomorrowMorning.getDate() + 1); tomorrowMorning.setDate(tomorrowMorning.getDate() + 1);
tomorrowMorning.setHours(8, 0, 0, 0); tomorrowMorning.setHours(8, 0, 0, 0);
// Only offer times within the server's hold limit.
const maxMs = jmapClient.getMaxDelayedSend() * 1000;
return [ return [
{ label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) }, { 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_in_3h', 'In 3 hours'), date: inHours(3) },
{ label: t('email_composer.schedule_tomorrow_morning', 'Tomorrow morning'), date: tomorrowMorning }, { 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, rescheduleFor]); }, [t, rescheduleFor]);