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: {
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);
});
});
+60 -2
View File
@@ -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', () => {