fix: count storage quota against softLimit when the server sets one #1242

This commit is contained in:
Linus Rath
2026-10-15 14:31:09 +02:00
parent d693fbc7c2
commit 6ef0d97900
7 changed files with 125 additions and 35 deletions
+8 -6
View File
@@ -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<HTMLButtonElement>(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
<div
className={cn(
"h-1.5 rounded-full transition-all",
usagePercent > 90
level === "full"
? "bg-destructive"
: usagePercent > 70
: level === "warn"
? "bg-warning"
: "bg-success"
)}
+48
View File
@@ -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");
});
});
+2 -1
View File
@@ -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<StorageQuota | null> {
return { used: 245_366_784, total: 1_073_741_824 };
}
+2 -1
View File
@@ -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<void>;
// ── Quota ─────────────────────────────────────────────────────
getQuota(): Promise<{ used: number; total: number } | null>;
getQuota(): Promise<StorageQuota | null>;
// ── Mailboxes ─────────────────────────────────────────────────
getMailboxes(accountId?: string): Promise<Mailbox[]>;
+3 -25
View File
@@ -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<string, unknown>;
}
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<StorageQuota | null> {
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;
+59
View File
@@ -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";
}
+3 -2
View File
@@ -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<string>; // Track emails being marked as read/unread
selectedEmailKeys: Set<string>; // 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<void>;
/** Take the email, not its id: the id alone collides across accounts. */