From 6ef0d979003e096bd320b6344f8d40f185c76a61 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:31:09 +0200 Subject: [PATCH] fix: count storage quota against softLimit when the server sets one #1242 --- components/layout/navigation-rail.tsx | 14 ++++--- lib/__tests__/quota.test.ts | 48 ++++++++++++++++++++++ lib/demo/demo-client.ts | 3 +- lib/jmap/client-interface.ts | 3 +- lib/jmap/client.ts | 28 ++----------- lib/quota.ts | 59 +++++++++++++++++++++++++++ stores/email-store.ts | 5 ++- 7 files changed, 125 insertions(+), 35 deletions(-) create mode 100644 lib/__tests__/quota.test.ts create mode 100644 lib/quota.ts diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index e325f8264..a53b73705 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -21,6 +21,7 @@ import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; import { getMaxAccounts } from "@/lib/account-utils"; import { isDocumentRTL } from "@/i18n/direction"; import { cn, formatFileSize } from "@/lib/utils"; +import { quotaLevel, type StorageQuota } from "@/lib/quota"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useStalwartAdmin } from "@/hooks/use-stalwart-admin"; @@ -44,7 +45,7 @@ interface NavigationRailProps { orientation?: "vertical" | "horizontal"; collapsed?: boolean; className?: string; - quota?: { used: number; total: number } | null; + quota?: StorageQuota | null; isPushConnected?: boolean; onLogout?: () => void; onShowShortcuts?: () => void; @@ -72,7 +73,7 @@ interface NavigationRailProps { onOpenSearch?: () => void; } -function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) { +function StorageQuotaCircle({ quota, usagePercent }: { quota: StorageQuota; usagePercent: number }) { const t = useTranslations("sidebar"); const [open, setOpen] = useState(false); const buttonRef = useRef(null); @@ -119,9 +120,10 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to // Usage can legitimately exceed the quota (e.g. limit lowered after the fact) const free = Math.max(0, quota.total - quota.used); - const strokeColor = usagePercent > 90 + const level = quotaLevel(quota); + const strokeColor = level === "full" ? "stroke-destructive" - : usagePercent > 70 + : level === "warn" ? "stroke-warning" : "stroke-success"; @@ -169,9 +171,9 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
90 + level === "full" ? "bg-destructive" - : usagePercent > 70 + : level === "warn" ? "bg-warning" : "bg-success" )} diff --git a/lib/__tests__/quota.test.ts b/lib/__tests__/quota.test.ts new file mode 100644 index 000000000..9c12ea8d9 --- /dev/null +++ b/lib/__tests__/quota.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { quotaLevel, toStorageQuota } from "../quota"; + +const GB = 1024 ** 3; + +describe("toStorageQuota", () => { + it("uses hardLimit when there is no softLimit", () => { + expect(toStorageQuota([{ resourceType: "octets", used: 1, hardLimit: GB }])).toEqual({ used: 1, total: GB }); + }); + + it("treats a softLimit below hardLimit as 100% and keeps hardLimit aside (#1242)", () => { + const quota = toStorageQuota([{ resourceType: "octets", used: GB, hardLimit: 1.1 * GB, softLimit: GB }]); + expect(quota).toEqual({ used: GB, total: GB, hardLimit: 1.1 * GB }); + expect(quotaLevel(quota!)).toBe("full"); + }); + + it("ignores a softLimit that is null, zero or not below hardLimit", () => { + for (const softLimit of [null, 0, GB, 2 * GB]) { + expect(toStorageQuota([{ resourceType: "octets", used: 0, hardLimit: GB, softLimit }])).toEqual({ used: 0, total: GB }); + } + }); + + it("falls back to the pre-RFC limit field", () => { + expect(toStorageQuota([{ scope: "mail", used: 5, limit: 10 }])).toEqual({ used: 5, total: 10 }); + }); + + it("keeps warnLimit only when it is below the effective limit", () => { + expect(toStorageQuota([{ resourceType: "octets", used: 0, hardLimit: 100, softLimit: 80, warnLimit: 50 }])?.warnAt).toBe(50); + expect(toStorageQuota([{ resourceType: "octets", used: 0, hardLimit: 100, softLimit: 80, warnLimit: 90 }])?.warnAt).toBeUndefined(); + }); + + it("skips quotas that don't cover mail", () => { + expect(toStorageQuota([{ resourceType: "octets", types: ["FileNode"], used: 1, hardLimit: 2 }])).toBeNull(); + }); +}); + +describe("quotaLevel", () => { + it("warns from warnLimit when the server sets one", () => { + expect(quotaLevel({ used: 40, total: 100, warnAt: 40 })).toBe("warn"); + expect(quotaLevel({ used: 39, total: 100, warnAt: 40 })).toBe("ok"); + }); + + it("warns above 70% without a warnLimit and is full above 90%", () => { + expect(quotaLevel({ used: 70, total: 100 })).toBe("ok"); + expect(quotaLevel({ used: 71, total: 100 })).toBe("warn"); + expect(quotaLevel({ used: 91, total: 100, warnAt: 95 })).toBe("full"); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 1096cc907..4b0c3755b 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -6,6 +6,7 @@ import { generateDemoId } from './demo-utils'; import { compareEmails, type SortLevel } from '@/lib/message-list-order'; import { withBasePath } from '@/lib/browser-navigation'; import { toInertBlob } from '@/lib/file-preview'; +import type { StorageQuota } from "@/lib/quota"; // Fixture blobs with real bytes behind them, served from public/demo/. The // photos are CC0 / public domain from Wikimedia Commons: "Wedding couple @@ -131,7 +132,7 @@ export class DemoJMAPClient implements IJMAPClient { // ── Quota ───────────────────────────────────────────────────── - async getQuota(): Promise<{ used: number; total: number } | null> { + async getQuota(): Promise { return { used: 245_366_784, total: 1_073_741_824 }; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 160d300d7..40fcf1b97 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -3,6 +3,7 @@ import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { FileNameRules } from "@/lib/file-name-rules"; import type { SortLevel } from "@/lib/message-list-order"; import type { SearchSnippetResult } from "@/lib/search-snippet"; +import type { StorageQuota } from "@/lib/quota"; /** What `migrateKeyword` managed to do. */ export interface KeywordMigration { @@ -138,7 +139,7 @@ export interface IJMAPClient { destroyPushSubscription(id: string): Promise; // ── Quota ───────────────────────────────────────────────────── - getQuota(): Promise<{ used: number; total: number } | null>; + getQuota(): Promise; // ── Mailboxes ───────────────────────────────────────────────── getMailboxes(accountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index cf3ffea3f..3faac220d 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -21,6 +21,7 @@ import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/f import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone"; import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order"; import { toInertBlob } from "@/lib/file-preview"; +import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota"; // Cap for the follow-up Email/get issued when a displayed body part comes // back truncated at the normal 256000-byte limit (see refetchTruncatedBodyValues @@ -272,15 +273,6 @@ interface JMAPAccount { accountCapabilities?: Record; } -interface JMAPQuota { - resourceType?: string; - scope?: string; - types?: string[]; - used?: number; - hardLimit?: number; - limit?: number; -} - interface JMAPMailbox { id: string; name: string; @@ -1607,7 +1599,7 @@ export class JMAPClient implements IJMAPClient { } } - async getQuota(): Promise<{ used: number; total: number } | null> { + async getQuota(): Promise { if (!this.supportsQuota()) return null; try { @@ -1618,21 +1610,7 @@ export class JMAPClient implements IJMAPClient { ], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:quota"]); if (response.methodResponses?.[0]?.[0] === "Quota/get") { - const quotas = (response.methodResponses[0][1].list || []) as JMAPQuota[]; - const coversMail = (q: JMAPQuota) => - !q.types?.length || q.types.some((t) => t === "Email" || t === "Mail"); - // storage quotas use resourceType "octets" (e.g. Stalwart, with - // scope "account"); fall back to the pre-RFC "mail" shape for older servers. - const mailQuota = - quotas.find((q) => q.resourceType === "octets" && coversMail(q)) || - quotas.find((q) => q.resourceType === "mail" || q.scope === "mail"); - - if (mailQuota) { - return { - used: mailQuota.used ?? 0, - total: mailQuota.hardLimit ?? mailQuota.limit ?? 0 - }; - } + return toStorageQuota((response.methodResponses[0][1].list || []) as JMAPQuota[]); } return null; diff --git a/lib/quota.ts b/lib/quota.ts new file mode 100644 index 000000000..7e944b7e2 --- /dev/null +++ b/lib/quota.ts @@ -0,0 +1,59 @@ +/** Storage quota as the UI shows it. */ +export interface StorageQuota { + used: number; + /** + * The limit the user actually works against: softLimit when the server + * sets one below hardLimit, since writes start failing there (RFC 9425 + * §4.1, #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; +} + +/** A Quota object as RFC 9425 defines it, plus the pre-RFC `limit` some servers send. */ +export interface JMAPQuota { + resourceType?: string; + scope?: string; + types?: string[]; + used?: number; + hardLimit?: number; + softLimit?: number | null; + warnLimit?: number | null; + limit?: 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. */ +export function toStorageQuota(quotas: JMAPQuota[]): StorageQuota | null { + const coversMail = (q: JMAPQuota) => + !q.types?.length || q.types.some((t) => t === "Email" || t === "Mail"); + // storage quotas use resourceType "octets" (e.g. Stalwart, with + // scope "account"); fall back to the pre-RFC "mail" shape for older servers. + const q = + quotas.find((q) => q.resourceType === "octets" && coversMail(q)) || + quotas.find((q) => q.resourceType === "mail" || q.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: StorageQuota = { 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 type QuotaLevel = "ok" | "warn" | "full"; + +/** Colour band for a quota: warnLimit when the server sets one, else 70%; full above 90%. */ +export function quotaLevel(quota: StorageQuota): QuotaLevel { + if (quota.total <= 0) return "ok"; + if (quota.used / quota.total > 0.9) return "full"; + if (quota.warnAt !== undefined ? quota.used >= quota.warnAt : quota.used / quota.total > 0.7) return "warn"; + return "ok"; +} diff --git a/stores/email-store.ts b/stores/email-store.ts index eeb602afe..cabd5ecfc 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -23,6 +23,7 @@ import { folderListKey, forgetFolderLists, rememberFolderList, rememberedFolderL import { keywordPointer } from "@/lib/jmap/patch-pointer"; import { useAccountStore } from "@/stores/account-store"; import { useMessageListTabsStore } from "@/stores/message-list-tabs-store"; +import type { StorageQuota } from "@/lib/quota"; type ScheduledSubmissionMetadata = { submissionId: string; @@ -123,7 +124,7 @@ interface EmailStore { isLoadingMore: boolean; // Track when loading more emails (pagination) error: string | null; searchQuery: string; - quota: { used: number; total: number } | null; + quota: StorageQuota | null; processingReadStatus: Set; // Track emails being marked as read/unread selectedEmailKeys: Set; // Track selected emails for batch operations // Emails the user just read (unread view) or unstarred (starred view) that @@ -275,7 +276,7 @@ interface EmailStore { setLoadingEmail: (loading: boolean) => void; setError: (error: string | null) => void; setSearchQuery: (query: string) => void; - setQuota: (quota: { used: number; total: number } | null) => void; + setQuota: (quota: StorageQuota | null) => void; selectKeyword: (keyword: string | null) => void; fetchTagCounts: (client: IJMAPClient) => Promise; /** Take the email, not its id: the id alone collides across accounts. */