From db2c642d748c063bed6d740e821239454360e87b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:35:37 +0200 Subject: [PATCH] fix: storage quota not shown with Stalwart #577 --- app/api/dev-jmap/[...path]/route.ts | 3 ++- components/layout/navigation-rail.tsx | 3 ++- components/settings/account-settings.tsx | 2 +- lib/jmap/client.ts | 13 +++++++++++-- lib/utils.ts | 2 +- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 351c3c72..faec6968 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -1679,7 +1679,8 @@ function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResul } function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult { - return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ resourceType: 'mail', scope: 'mail', used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId]; + // mirroring Stalwart: resourceType "octets", scope "account" + return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'quota-1', resourceType: 'octets', scope: 'account', types: ['Email', 'SieveScript'], used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId]; } function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodResult { diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index cedebd4d..5cc3e869 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -91,7 +91,8 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to return () => document.removeEventListener("mousedown", handleClick); }, [open, updatePosition]); - const free = quota.total - quota.used; + // 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 ? "stroke-destructive" : usagePercent > 70 diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index 0962c4d8..4f34707f 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -53,7 +53,7 @@ export function AccountSettings() { const [dragOverIndex, setDragOverIndex] = useState(null); const draggedIndexRef = useRef(null); - const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0; + const quotaPercentage = quota && quota.total > 0 ? Math.min(Math.round((quota.used / quota.total) * 100), 100) : 0; const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Demo User' : undefined); const email = primaryIdentity?.email || account?.email || username; const max = getMaxAccounts(); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d9773d71..c4c37439 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -45,6 +45,7 @@ interface JMAPAccount { interface JMAPQuota { resourceType?: string; scope?: string; + types?: string[]; used?: number; hardLimit?: number; limit?: number; @@ -878,16 +879,24 @@ export class JMAPClient implements IJMAPClient { } async getQuota(): Promise<{ used: number; total: number } | null> { + if (!this.supportsQuota()) return null; + try { const response = await this.request([ ["Quota/get", { accountId: this.accountId, }, "0"] - ]); + ], ["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 mailQuota = quotas.find((q) => q.resourceType === "mail" || q.scope === "mail"); + 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 { diff --git a/lib/utils.ts b/lib/utils.ts index f49e73ca..677981d4 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -223,7 +223,7 @@ export function truncateText(text: string, maxLength: number): string { } export function formatFileSize(bytes: number): string { - if (bytes === 0) return '0 Bytes'; + if (!Number.isFinite(bytes) || bytes <= 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];