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 {