From e28e8b4d9c38ea2820a1f14b0c980376c422df50 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:32:17 +0200 Subject: [PATCH] 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. --- src/api/__tests__/jmap-client.test.ts | 96 +++++++++++++++++++++++++ src/api/__tests__/live-stalwart.test.ts | 9 +++ src/api/jmap-client.ts | 50 ++++++++++--- src/components/email/QuickReplyBox.tsx | 3 +- 4 files changed, 146 insertions(+), 12 deletions(-) diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index 09307755..3fc4acd6 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -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', () => { it('should fall back to core account', async () => { const session = { diff --git a/src/api/__tests__/live-stalwart.test.ts b/src/api/__tests__/live-stalwart.test.ts index 18f8030b..f37f5c4a 100644 --- a/src/api/__tests__/live-stalwart.test.ts +++ b/src/api/__tests__/live-stalwart.test.ts @@ -91,6 +91,15 @@ live('live Stalwart', () => { 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 | 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', () => { expect(client.getMaxObjectsInGet()).toBeGreaterThan(0); expect(client.getMaxObjectsInSet()).toBeGreaterThan(0); diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 2fb2a5b4..89550c22 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -889,6 +889,20 @@ export class JMAPClient { 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(): | { maxObjectsInGet?: number; maxObjectsInSet?: number; maxCallsInRequest?: number; maxSizeUpload?: number; maxSizeRequest?: number } | undefined { @@ -916,8 +930,8 @@ export class JMAPClient { } /** Per-message attachment total ceiling in bytes (0 = unknown). */ - getMaxSizeAttachmentsPerEmail(): number { - const mail = this.session?.capabilities?.[CAPABILITIES.MAIL] as + getMaxSizeAttachmentsPerEmail(accountId?: string): number { + const mail = this.getAccountCapability(CAPABILITIES.MAIL, accountId) as | { maxSizeAttachmentsPerEmail?: number } | undefined; const max = mail?.maxSizeAttachmentsPerEmail; @@ -967,24 +981,38 @@ export class JMAPClient { // ── Scheduled send (FUTURERELEASE) ──────────────────── // The JMAP submission capability advertises `maxDelayedSend` (max hold in // seconds) and a `submissionExtensions` map; FUTURERELEASE support is what - // lets us defer delivery via the SMTP HOLDFOR parameter. Mirrors the webmail - // implementation so behaviour stays in sync across platforms. + // lets us defer delivery via the SMTP HOLDFOR parameter. Both are account + // 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 } | undefined { - return this.session?.capabilities?.[CAPABILITIES.SUBMISSION] as + return this.getAccountCapability(CAPABILITIES.SUBMISSION, this.submissionAccountId(accountId)) as | { maxDelayedSend?: number; submissionExtensions?: unknown } | undefined; } - getMaxDelayedSend(): number { - const max = this.submissionCapability?.maxDelayedSend; + getMaxDelayedSend(accountId?: string): number { + const max = this.submissionCapability(accountId)?.maxDelayedSend; return typeof max === 'number' ? max : 0; } - hasDelayedSend(): boolean { - const cap = this.submissionCapability; + hasDelayedSend(accountId?: string): boolean { + const cap = this.submissionCapability(accountId); if (!cap) return false; const ext = cap.submissionExtensions; // submissionExtensions is a map of extension name → params. FUTURERELEASE @@ -995,7 +1023,7 @@ export class JMAPClient { Object.keys(ext as Record).some( (k) => k.toUpperCase() === 'FUTURERELEASE', ); - return hasFutureRelease && this.getMaxDelayedSend() > 0; + return hasFutureRelease && this.getMaxDelayedSend(accountId) > 0; } // ── Stored credentials (per-account) ────────────────── diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index 08ebb9e7..0a986403 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -8,6 +8,7 @@ import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; import { useEmailStore } from '../../stores/email-store'; import { sendEmail, patchKeywordsForEmails } from '../../api/email'; +import { jmapClient } from '../../api/jmap-client'; import { buildReplyRecipients } from '../../lib/reply-recipients'; import { buildReplySubject } from '../../lib/subject-prefix'; import { computeReplyThreadingHeaders } from '../../lib/email-threading'; @@ -89,7 +90,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P }, identity.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 }, ); try {