mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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<typeof vi.fn>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+15
-3
@@ -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<string, unknown> {
|
||||
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<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'];
|
||||
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 };
|
||||
}
|
||||
|
||||
+44
-2
@@ -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<string, number>();
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<T>(items: T[], size: number): T[][] {
|
||||
const step = Math.max(1, Math.floor(size));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user