diff --git a/src/api/__tests__/live-stalwart-auth.test.ts b/src/api/__tests__/live-stalwart-auth.test.ts index 7dab439b..7529bf7a 100644 --- a/src/api/__tests__/live-stalwart-auth.test.ts +++ b/src/api/__tests__/live-stalwart-auth.test.ts @@ -65,6 +65,7 @@ import { TotpLoginError, } from '../../lib/totp-login'; import { describeLoginError } from '../../lib/login-errors'; +import { fetchMailQuota, serverSupportsQuota, toMailQuota } from '../quota'; import { discoverOAuthMetadata, loginWithPkce, pkceScopes, revokeRefreshToken } from '../../lib/oauth-native'; import { HANDOFF_REDIRECT_URI, @@ -389,6 +390,28 @@ live('live Stalwart sign-in', () => { expect(copy.detail).toMatch(/password and code were accepted/); }, 30_000); + it('reads the storage quota the way Settings shows it, on both (#1242)', async () => { + const client = new JMAPClient(); + await client.connect(SERVER, USER, PASS); + useSingleton(client); + if (!serverSupportsQuota()) return; + const res = await client.request([['Quota/get', { accountId: client.accountId }, '0']], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:quota', + ]); + const [name, body] = res.methodResponses[0] as unknown as [string, { list: Array> }]; + expect(name).toBe('Quota/get'); + const shown = await fetchMailQuota(); + expect(shown).toEqual(toMailQuota(body.list as never)); + if (shown) { + const raw = body.list.find((q) => q.resourceType === ('octets' as unknown as number))!; + // Never above a softLimit the server set below its hardLimit. + if (typeof raw.softLimit === 'number' && raw.softLimit > 0 && typeof raw.hardLimit === 'number' && raw.softLimit < raw.hardLimit) { + expect(shown.total).toBe(raw.softLimit); + } + } + }); + it('account security with the password (Basic) works on both', async () => { const client = new JMAPClient(); await client.connect(SERVER, USER, PASS); diff --git a/src/api/__tests__/quota.test.ts b/src/api/__tests__/quota.test.ts new file mode 100644 index 00000000..2eca136e --- /dev/null +++ b/src/api/__tests__/quota.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Storage usage counted against hardLimit even where the server set a lower +// softLimit, from which writes already fail (RFC 9425 4.1, webmail #1242). + +const quotas: Record[] = []; +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'a1', + currentSession: { capabilities: { 'urn:ietf:params:jmap:quota': {} } }, + request: vi.fn(async () => ({ methodResponses: [['Quota/get', { list: quotas }, '0']] })), + }, +})); + +import { fetchMailQuota, toMailQuota } from '../quota'; + +describe('mail quota', () => { + it('counts against softLimit when it is below hardLimit (#1242)', async () => { + quotas.splice(0, quotas.length, { + id: 'q', resourceType: 'octets', scope: 'account', used: 900, softLimit: 1000, hardLimit: 1200, warnLimit: 800, + }); + await expect(fetchMailQuota()).resolves.toEqual({ used: 900, total: 1000, hardLimit: 1200, warnAt: 800 }); + }); + + it('keeps hardLimit when there is no lower softLimit', () => { + expect(toMailQuota([{ id: 'q', resourceType: 'octets', used: 1, hardLimit: 10, softLimit: null }])).toEqual({ used: 1, total: 10 }); + expect(toMailQuota([{ id: 'q', resourceType: 'octets', used: 1, hardLimit: 10, softLimit: 20 }])).toEqual({ used: 1, total: 10 }); + expect(toMailQuota([{ id: 'q', resourceType: 'mail', used: 1, limit: 5 }])).toEqual({ used: 1, total: 5 }); + }); +}); diff --git a/src/api/quota.ts b/src/api/quota.ts index 6eb6d870..23c58534 100644 --- a/src/api/quota.ts +++ b/src/api/quota.ts @@ -8,8 +8,8 @@ interface JMAPQuota { resourceType?: string; used?: number; hardLimit?: number; - warnLimit?: number; - softLimit?: number; + warnLimit?: number | null; + softLimit?: number | null; // pre-RFC shape limit?: number; scope?: string; @@ -19,7 +19,38 @@ interface JMAPQuota { export interface MailQuota { used: number; + /** + * The limit the user works against: softLimit when the server sets one + * below hardLimit, since writes start failing there (RFC 9425 4.1, + * webmail #1242). Otherwise hardLimit. + */ total: number; + /** hardLimit, only when it is above `total` (inbound mail still lands up to it). */ + hardLimit?: number; + /** warnLimit, only when it is below `total`. */ + warnAt?: number; +} + +const positive = (n: number | null | undefined): n is number => + typeof n === 'number' && Number.isFinite(n) && n > 0; + +/** Picks the mail storage quota out of a Quota/get list (webmail lib/quota.ts toStorageQuota). */ +export function toMailQuota(quotas: JMAPQuota[]): MailQuota | null { + const coversMail = (q: JMAPQuota) => + !q.types?.length || q.types.some((t) => t === 'Email' || t === 'Mail'); + // Storage quotas use resourceType "octets" (Stalwart, scope "account"); + // older servers used the pre-RFC "mail" shape. + const q = + quotas.find((x) => x.resourceType === 'octets' && coversMail(x)) + || quotas.find((x) => x.resourceType === 'mail' || x.scope === 'mail'); + if (!q) return null; + const hard = q.hardLimit ?? q.limit ?? 0; + const soft = positive(q.softLimit) && (!positive(hard) || q.softLimit < hard) ? q.softLimit : undefined; + const total = soft ?? hard; + const quota: MailQuota = { used: q.used ?? 0, total }; + if (soft !== undefined && positive(hard)) quota.hardLimit = hard; + if (positive(q.warnLimit) && (!positive(total) || q.warnLimit < total)) quota.warnAt = q.warnLimit; + return quota; } export function serverSupportsQuota(): boolean { @@ -40,15 +71,7 @@ export async function fetchMailQuota(): Promise { ); const [name, body] = res.methodResponses[0] ?? []; if (name !== 'Quota/get') return null; - const quotas = (body?.list ?? []) as JMAPQuota[]; - const coversMail = (q: JMAPQuota) => - !q.types?.length || q.types.some((t) => t === 'Email' || t === 'Mail'); - const mailQuota = - quotas.find((q) => q.resourceType === 'octets' && coversMail(q)) - || quotas.find((q) => q.resourceType === 'mail' || q.scope === 'mail'); - if (!mailQuota) return null; - const total = mailQuota.hardLimit ?? mailQuota.limit ?? 0; - return { used: mailQuota.used ?? 0, total }; + return toMailQuota((body?.list ?? []) as JMAPQuota[]); } catch { return null; }