mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: count storage quota against softLimit when the server sets one #1242
This commit is contained in:
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user