fix: turn scheduled send and the undo-send delay back on for Stalwart #57

RFC 8621 puts the submission limits (`maxDelayedSend`,
`submissionExtensions`) and the mail limits
(`maxSizeAttachmentsPerEmail`) under each account's
`accountCapabilities`. Stalwart advertises an empty object for them at
session level, which is the only place the client looked, so scheduled
send was refused, the undo-send delay silently fell back to an instant
send, the Scheduled view listed nothing and the per-message attachment
cap was never enforced.

The client now reads these from the account that sends (the account's
own submission capability, else the session's primary submission
account, like the webmail), falling back to the session-level object
for servers that only advertise it there. Quick reply, which can send
from a shared account, now checks that account before holding the
message for the undo-send delay.
This commit is contained in:
Linus Rath
2026-09-24 18:43:56 +02:00
parent ee652d39df
commit e28e8b4d9c
4 changed files with 146 additions and 12 deletions
+96
View File
@@ -256,6 +256,102 @@ describe('JMAPClient', () => {
}); });
}); });
describe('account-level mail and submission limits (#57)', () => {
// Stalwart's shape: the session-level mail/submission objects are empty,
// the limits and SMTP extensions live under the account.
const STALWART_SESSION: JMAPSession = {
...MOCK_SESSION,
primaryAccounts: {
'urn:ietf:params:jmap:mail': 'acc-1',
'urn:ietf:params:jmap:submission': 'acc-1',
},
accounts: {
'acc-1': {
name: 'user@example.com',
isPersonal: true,
isReadOnly: false,
accountCapabilities: {
'urn:ietf:params:jmap:mail': { maxSizeAttachmentsPerEmail: 50_000_000, maxMailboxDepth: 10 },
'urn:ietf:params:jmap:submission': {
maxDelayedSend: 2_592_000,
submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [] },
},
'urn:stalwart:jmap': {},
},
},
'shared-no-send': {
name: 'Team',
isPersonal: false,
isReadOnly: false,
accountCapabilities: { 'urn:ietf:params:jmap:mail': {} },
},
'shared-no-hold': {
name: 'Support',
isPersonal: false,
isReadOnly: false,
accountCapabilities: {
'urn:ietf:params:jmap:submission': { maxDelayedSend: 0, submissionExtensions: {} },
},
},
},
capabilities: {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000 },
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
},
};
it('reads scheduled-send support from the account, not the empty session object', async () => {
global.fetch = mockFetch([{ status: 200, json: STALWART_SESSION }]) as any;
await client.connect('https://mail.example.com', 'user', 'pass');
expect(client.hasDelayedSend()).toBe(true);
expect(client.getMaxDelayedSend()).toBe(2_592_000);
expect(client.getMaxSizeAttachmentsPerEmail()).toBe(50_000_000);
expect(client.getMaxSizeUpload()).toBe(50_000_000);
});
it('uses the capability of the account that sends', async () => {
global.fetch = mockFetch([{ status: 200, json: STALWART_SESSION }]) as any;
await client.connect('https://mail.example.com', 'user', 'pass');
// Its own submission capability says no.
expect(client.hasDelayedSend('shared-no-hold')).toBe(false);
expect(client.getMaxDelayedSend('shared-no-hold')).toBe(0);
// No submission capability of its own: the primary submission account's applies.
expect(client.hasDelayedSend('shared-no-send')).toBe(true);
expect(client.getMaxSizeAttachmentsPerEmail('shared-no-send')).toBe(0);
});
it('falls back to the session-level objects when the account has none', async () => {
const session: JMAPSession = {
...MOCK_SESSION,
capabilities: {
...MOCK_SESSION.capabilities,
'urn:ietf:params:jmap:mail': { maxSizeAttachmentsPerEmail: 1000 },
'urn:ietf:params:jmap:submission': {
maxDelayedSend: 3600,
submissionExtensions: { futurerelease: ['3600', '2026-01-01T00:00:00Z'] },
},
},
};
global.fetch = mockFetch([{ status: 200, json: session }]) as any;
await client.connect('https://mail.example.com', 'user', 'pass');
expect(client.hasDelayedSend()).toBe(true);
expect(client.getMaxDelayedSend()).toBe(3600);
expect(client.getMaxSizeAttachmentsPerEmail()).toBe(1000);
});
it('reports no scheduled send without FUTURERELEASE', async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
await client.connect('https://mail.example.com', 'user', 'pass');
expect(client.hasDelayedSend()).toBe(false);
expect(client.getMaxDelayedSend()).toBe(0);
});
});
describe('accountId resolution', () => { describe('accountId resolution', () => {
it('should fall back to core account', async () => { it('should fall back to core account', async () => {
const session = { const session = {
+9
View File
@@ -91,6 +91,15 @@ live('live Stalwart', () => {
expect(client.hasCapability('urn:stalwart:jmap')).toBe(false); expect(client.hasCapability('urn:stalwart:jmap')).toBe(false);
}); });
it('finds scheduled send in the account submission capability (#57)', () => {
const session = client.currentSession!;
const sessionLevel = session.capabilities?.['urn:ietf:params:jmap:submission'] as Record<string, unknown> | undefined;
expect(sessionLevel?.maxDelayedSend).toBeUndefined();
expect(client.hasDelayedSend()).toBe(true);
expect(client.getMaxDelayedSend()).toBeGreaterThan(0);
expect(client.getMaxSizeAttachmentsPerEmail()).toBeGreaterThan(0);
});
it('reads the advertised request limits', () => { it('reads the advertised request limits', () => {
expect(client.getMaxObjectsInGet()).toBeGreaterThan(0); expect(client.getMaxObjectsInGet()).toBeGreaterThan(0);
expect(client.getMaxObjectsInSet()).toBeGreaterThan(0); expect(client.getMaxObjectsInSet()).toBeGreaterThan(0);
+39 -11
View File
@@ -889,6 +889,20 @@ export class JMAPClient {
return this.hasCapability(urn); return this.hasCapability(urn);
} }
/**
* The value of a capability for an account (RFC 8620 §2
* `accountCapabilities`), falling back to the session-level object. RFC
* 8621 puts the mail and submission limits (`maxSizeAttachmentsPerEmail`,
* `maxDelayedSend`, `submissionExtensions`) under the account; Stalwart
* advertises an empty `{}` for them at session level.
*/
getAccountCapability(urn: string, accountId?: string): unknown {
const id = accountId ?? this._accountId;
const accountCaps = id ? this.session?.accounts?.[id]?.accountCapabilities : undefined;
if (accountCaps && urn in accountCaps) return accountCaps[urn];
return this.session?.capabilities?.[urn];
}
private get coreCapability(): private get coreCapability():
| { maxObjectsInGet?: number; maxObjectsInSet?: number; maxCallsInRequest?: number; maxSizeUpload?: number; maxSizeRequest?: number } | { maxObjectsInGet?: number; maxObjectsInSet?: number; maxCallsInRequest?: number; maxSizeUpload?: number; maxSizeRequest?: number }
| undefined { | undefined {
@@ -916,8 +930,8 @@ export class JMAPClient {
} }
/** Per-message attachment total ceiling in bytes (0 = unknown). */ /** Per-message attachment total ceiling in bytes (0 = unknown). */
getMaxSizeAttachmentsPerEmail(): number { getMaxSizeAttachmentsPerEmail(accountId?: string): number {
const mail = this.session?.capabilities?.[CAPABILITIES.MAIL] as const mail = this.getAccountCapability(CAPABILITIES.MAIL, accountId) as
| { maxSizeAttachmentsPerEmail?: number } | { maxSizeAttachmentsPerEmail?: number }
| undefined; | undefined;
const max = mail?.maxSizeAttachmentsPerEmail; const max = mail?.maxSizeAttachmentsPerEmail;
@@ -967,24 +981,38 @@ export class JMAPClient {
// ── Scheduled send (FUTURERELEASE) ──────────────────── // ── Scheduled send (FUTURERELEASE) ────────────────────
// The JMAP submission capability advertises `maxDelayedSend` (max hold in // The JMAP submission capability advertises `maxDelayedSend` (max hold in
// seconds) and a `submissionExtensions` map; FUTURERELEASE support is what // seconds) and a `submissionExtensions` map; FUTURERELEASE support is what
// lets us defer delivery via the SMTP HOLDFOR parameter. Mirrors the webmail // lets us defer delivery via the SMTP HOLDFOR parameter. Both are account
// implementation so behaviour stays in sync across platforms. // capabilities (RFC 8621 §1.3.2) - Stalwart only advertises them in
// `accountCapabilities`, so a session-level read found nothing and turned
// scheduled send, the undo-send delay and the Scheduled view off (#57).
// Mirrors the webmail implementation so behaviour stays in sync.
private get submissionCapability(): /**
* The account whose submission capability applies to a send from
* `accountId` (default: the primary). An account that doesn't advertise
* submission itself falls back to the session's primary submission account.
*/
private submissionAccountId(accountId?: string): string | undefined {
const id = accountId ?? this._accountId ?? undefined;
if (id && this.session?.accounts?.[id]?.accountCapabilities?.[CAPABILITIES.SUBMISSION]) return id;
return this.session?.primaryAccounts?.[CAPABILITIES.SUBMISSION] || id;
}
private submissionCapability(accountId?: string):
| { maxDelayedSend?: number; submissionExtensions?: unknown } | { maxDelayedSend?: number; submissionExtensions?: unknown }
| undefined { | undefined {
return this.session?.capabilities?.[CAPABILITIES.SUBMISSION] as return this.getAccountCapability(CAPABILITIES.SUBMISSION, this.submissionAccountId(accountId)) as
| { maxDelayedSend?: number; submissionExtensions?: unknown } | { maxDelayedSend?: number; submissionExtensions?: unknown }
| undefined; | undefined;
} }
getMaxDelayedSend(): number { getMaxDelayedSend(accountId?: string): number {
const max = this.submissionCapability?.maxDelayedSend; const max = this.submissionCapability(accountId)?.maxDelayedSend;
return typeof max === 'number' ? max : 0; return typeof max === 'number' ? max : 0;
} }
hasDelayedSend(): boolean { hasDelayedSend(accountId?: string): boolean {
const cap = this.submissionCapability; const cap = this.submissionCapability(accountId);
if (!cap) return false; if (!cap) return false;
const ext = cap.submissionExtensions; const ext = cap.submissionExtensions;
// submissionExtensions is a map of extension name → params. FUTURERELEASE // submissionExtensions is a map of extension name → params. FUTURERELEASE
@@ -995,7 +1023,7 @@ export class JMAPClient {
Object.keys(ext as Record<string, unknown>).some( Object.keys(ext as Record<string, unknown>).some(
(k) => k.toUpperCase() === 'FUTURERELEASE', (k) => k.toUpperCase() === 'FUTURERELEASE',
); );
return hasFutureRelease && this.getMaxDelayedSend() > 0; return hasFutureRelease && this.getMaxDelayedSend(accountId) > 0;
} }
// ── Stored credentials (per-account) ────────────────── // ── Stored credentials (per-account) ──────────────────
+2 -1
View File
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
import { useEmailStore } from '../../stores/email-store'; import { useEmailStore } from '../../stores/email-store';
import { sendEmail, patchKeywordsForEmails } from '../../api/email'; import { sendEmail, patchKeywordsForEmails } from '../../api/email';
import { jmapClient } from '../../api/jmap-client';
import { buildReplyRecipients } from '../../lib/reply-recipients'; import { buildReplyRecipients } from '../../lib/reply-recipients';
import { buildReplySubject } from '../../lib/subject-prefix'; import { buildReplySubject } from '../../lib/subject-prefix';
import { computeReplyThreadingHeaders } from '../../lib/email-threading'; import { computeReplyThreadingHeaders } from '../../lib/email-threading';
@@ -89,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 ? sendDelaySeconds : undefined, sendDelaySeconds > 0 && jmapClient.hasDelayedSend(jmapAccountId) ? sendDelaySeconds : undefined,
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId }, { draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
); );
try { try {