mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
fix: count storage use against the soft limit when the server sets one (bulwarkmail/webmail#1242)
This commit is contained in:
@@ -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<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 () => {
|
||||
const client = new JMAPClient();
|
||||
await client.connect(SERVER, USER, PASS);
|
||||
|
||||
@@ -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
@@ -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<MailQuota | null> {
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user