fix: count storage use against the soft limit when the server sets one (bulwarkmail/webmail#1242)

This commit is contained in:
Linus Rath
2026-10-31 04:35:41 +01:00
parent f5875f87b1
commit af86339bf1
3 changed files with 87 additions and 11 deletions
@@ -65,6 +65,7 @@ import {
TotpLoginError, TotpLoginError,
} from '../../lib/totp-login'; } from '../../lib/totp-login';
import { describeLoginError } from '../../lib/login-errors'; import { describeLoginError } from '../../lib/login-errors';
import { fetchMailQuota, serverSupportsQuota, toMailQuota } from '../quota';
import { discoverOAuthMetadata, loginWithPkce, pkceScopes, revokeRefreshToken } from '../../lib/oauth-native'; import { discoverOAuthMetadata, loginWithPkce, pkceScopes, revokeRefreshToken } from '../../lib/oauth-native';
import { import {
HANDOFF_REDIRECT_URI, HANDOFF_REDIRECT_URI,
@@ -389,6 +390,28 @@ live('live Stalwart sign-in', () => {
expect(copy.detail).toMatch(/password and code were accepted/); expect(copy.detail).toMatch(/password and code were accepted/);
}, 30_000); }, 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<Record<string, number | null>> }];
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 () => { it('account security with the password (Basic) works on both', async () => {
const client = new JMAPClient(); const client = new JMAPClient();
await client.connect(SERVER, USER, PASS); await client.connect(SERVER, USER, PASS);
+30
View File
@@ -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<string, unknown>[] = [];
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 });
});
});
+34 -11
View File
@@ -8,8 +8,8 @@ interface JMAPQuota {
resourceType?: string; resourceType?: string;
used?: number; used?: number;
hardLimit?: number; hardLimit?: number;
warnLimit?: number; warnLimit?: number | null;
softLimit?: number; softLimit?: number | null;
// pre-RFC shape // pre-RFC shape
limit?: number; limit?: number;
scope?: string; scope?: string;
@@ -19,7 +19,38 @@ interface JMAPQuota {
export interface MailQuota { export interface MailQuota {
used: number; 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; 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 { export function serverSupportsQuota(): boolean {
@@ -40,15 +71,7 @@ export async function fetchMailQuota(): Promise<MailQuota | null> {
); );
const [name, body] = res.methodResponses[0] ?? []; const [name, body] = res.methodResponses[0] ?? [];
if (name !== 'Quota/get') return null; if (name !== 'Quota/get') return null;
const quotas = (body?.list ?? []) as JMAPQuota[]; return toMailQuota((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 };
} catch { } catch {
return null; return null;
} }