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,
|
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);
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user