From 7930b85cde5c8ce78f5cc6db87a84b8366fd82d3 Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 31 Mar 2026 21:21:11 +0200 Subject: [PATCH] feat(PROJ-29): Tenant-Quotas & Usage-Limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB: max_storage_bytes, max_users, max_emails per Tenant (NULL = unlimited) - storage/quota.go: CheckQuota() mit 60s-Cache, ErrQuotaExceeded - Save() prüft Quota vor dem Schreiben — Ablehnung bei Hard-Limit - tenantstore/quota.go: SetQuota(), GetQuota(), GetUsage() - API: GET/PUT /api/admin/tenant/{id}/quota, GET /api/admin/quotas - QuotaTab: Usage-Balken (Speicher/Nutzer/Mails), Edit-Dialog, Warnung ab 80% - InvalidateQuotaCache() nach Quota-Änderung für sofortige Wirkung Co-Authored-By: Claude Sonnet 4.6 --- internal/api/quota_handlers.go | 119 ++++++++++++ internal/api/server.go | 5 + internal/storage/quota.go | 94 ++++++++++ internal/storage/storage.go | 7 +- internal/tenantstore/quota.go | 73 ++++++++ internal/tenantstore/store.go | 14 +- src/app/admin/page.tsx | 7 + src/components/admin/tabs/QuotaTab.tsx | 244 +++++++++++++++++++++++++ src/lib/api/index.ts | 3 + src/lib/api/tenants.ts | 29 +++ 10 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 internal/api/quota_handlers.go create mode 100644 internal/storage/quota.go create mode 100644 internal/tenantstore/quota.go create mode 100644 src/components/admin/tabs/QuotaTab.tsx diff --git a/internal/api/quota_handlers.go b/internal/api/quota_handlers.go new file mode 100644 index 0000000..f4c4f41 --- /dev/null +++ b/internal/api/quota_handlers.go @@ -0,0 +1,119 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/archivmail/internal/audit" + "github.com/archivmail/internal/storage" + "github.com/archivmail/internal/tenantstore" +) + +// handleGetTenantUsage returns current quota config and usage for a tenant. +// GET /api/admin/tenant/{id}/quota — superadmin only (PROJ-29). +func (s *Server) handleGetTenantUsage(w http.ResponseWriter, r *http.Request) { + tenantID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid tenant id") + return + } + + quota, err := s.tenantStore.GetQuota(r.Context(), tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + usage, err := s.tenantStore.GetUsage(r.Context(), tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "quota": quota, + "usage": usage, + }) +} + +// handleSetTenantQuota sets quota limits for a tenant. +// PUT /api/admin/tenant/{id}/quota — superadmin only (PROJ-29). +func (s *Server) handleSetTenantQuota(w http.ResponseWriter, r *http.Request) { + tenantID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid tenant id") + return + } + + var body tenantstore.TenantQuota + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + + if err := s.tenantStore.SetQuota(r.Context(), tenantID, body); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Invalidate quota cache so the new limit takes effect immediately + storage.InvalidateQuotaCache(tenantID) + + sess := sessionFromCtx(r.Context()) + if s.audlog != nil { + s.audlog.Log(audit.Entry{ + EventType: "tenant_quota_changed", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: true, + Detail: fmt.Sprintf("tenant_id=%d", tenantID), + }) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +// handleGetAllTenantUsage returns quota and usage for all tenants. +// GET /api/admin/quotas — superadmin only (PROJ-29). +func (s *Server) handleGetAllTenantUsage(w http.ResponseWriter, r *http.Request) { + tenants, err := s.tenantStore.List(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + type tenantWithUsage struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + MaxStorageBytes *int64 `json:"max_storage_bytes"` + MaxUsers *int `json:"max_users"` + MaxEmails *int64 `json:"max_emails"` + StorageBytes int64 `json:"storage_bytes"` + UserCount int64 `json:"user_count"` + EmailCount int64 `json:"email_count"` + } + + result := make([]tenantWithUsage, 0, len(tenants)) + for _, t := range tenants { + usage, err := s.tenantStore.GetUsage(r.Context(), t.ID) + if err != nil { + usage = &tenantstore.TenantUsage{} + } + result = append(result, tenantWithUsage{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + MaxStorageBytes: t.MaxStorageBytes, + MaxUsers: t.MaxUsers, + MaxEmails: t.MaxEmails, + StorageBytes: usage.StorageBytes, + UserCount: usage.UserCount, + EmailCount: usage.EmailCount, + }) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"tenants": result}) +} diff --git a/internal/api/server.go b/internal/api/server.go index e863333..d4d30dd 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -181,6 +181,11 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/admin/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetRetention))) s.mux.HandleFunc("PUT /api/admin/tenant/{id}/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantRetention))) + // PROJ-29: Quotas — superadmin only + s.mux.HandleFunc("GET /api/admin/quotas", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetAllTenantUsage))) + s.mux.HandleFunc("GET /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetTenantUsage))) + s.mux.HandleFunc("PUT /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantQuota))) + // PROJ-33: IMAP mode settings — domain_admin only s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode)) s.mux.HandleFunc("PUT /api/admin/settings/imap-mode", s.authAdmin(s.handleSetIMAPMode)) diff --git a/internal/storage/quota.go b/internal/storage/quota.go new file mode 100644 index 0000000..6713f56 --- /dev/null +++ b/internal/storage/quota.go @@ -0,0 +1,94 @@ +package storage + +import ( + "context" + "errors" + "sync" + "time" +) + +// ErrQuotaExceeded is returned when a tenant's storage or email quota is exceeded. +var ErrQuotaExceeded = errors.New("storage: tenant quota exceeded") + +const quotaCacheTTL = 60 * time.Second + +type quotaEntry struct { + exceeded bool + expiry time.Time +} + +type quotaCache struct { + mu sync.Mutex + entries map[int64]quotaEntry +} + +var qCache = "aCache{entries: make(map[int64]quotaEntry)} + +// CheckQuota checks whether a tenant has exceeded its storage or email quota. +// Returns ErrQuotaExceeded if exceeded. Results are cached for 60 seconds. +// If tenantID is nil (no-tenant mail), quota is not enforced. +func (s *Store) CheckQuota(ctx context.Context, tenantID *int64) error { + if s.db == nil || tenantID == nil { + return nil + } + + id := *tenantID + + // Check cache first + qCache.mu.Lock() + if e, ok := qCache.entries[id]; ok && time.Now().Before(e.expiry) { + exceeded := e.exceeded + qCache.mu.Unlock() + if exceeded { + return ErrQuotaExceeded + } + return nil + } + qCache.mu.Unlock() + + // Query quota and current usage in one statement + var maxStorageBytes *int64 + var maxEmails *int64 + var currentBytes int64 + var currentEmails int64 + + err := s.db.QueryRow(ctx, ` + SELECT t.max_storage_bytes, t.max_emails, + COALESCE(SUM(e.size), 0) AS total_bytes, + COUNT(e.id) AS total_emails + FROM tenants t + LEFT JOIN emails e ON e.tenant_id = t.id + WHERE t.id = $1 + GROUP BY t.id + `, id).Scan(&maxStorageBytes, &maxEmails, ¤tBytes, ¤tEmails) + if err != nil { + // Tenant not found or DB error — allow the mail to pass through + return nil + } + + exceeded := false + if maxStorageBytes != nil && currentBytes >= *maxStorageBytes { + exceeded = true + } + if maxEmails != nil && currentEmails >= *maxEmails { + exceeded = true + } + + // Write to cache + qCache.mu.Lock() + qCache.entries[id] = quotaEntry{exceeded: exceeded, expiry: time.Now().Add(quotaCacheTTL)} + qCache.mu.Unlock() + + if exceeded { + return ErrQuotaExceeded + } + return nil +} + +// InvalidateQuotaCache removes the cached quota entry for a tenant. +// Call this after quota changes to ensure fresh values on the next check. +func InvalidateQuotaCache(tenantID int64) { + qCache.mu.Lock() + delete(qCache.entries, tenantID) + qCache.mu.Unlock() +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index dba8def..ddeb729 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -270,7 +270,12 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int } } - // Step 3: SHA-256-based dedup (fallback / no Message-ID) + // Step 3: Quota check (PROJ-29) — reject before writing + if err := s.CheckQuota(ctx, tenantID); err != nil { + return "", err + } + + // Step 4: SHA-256-based dedup (fallback / no Message-ID) sum := sha256.Sum256(raw) id := fmt.Sprintf("%x", sum[:]) // 64 hex chars diff --git a/internal/tenantstore/quota.go b/internal/tenantstore/quota.go new file mode 100644 index 0000000..3190ed8 --- /dev/null +++ b/internal/tenantstore/quota.go @@ -0,0 +1,73 @@ +package tenantstore + +import ( + "context" + "fmt" +) + +// TenantQuota holds the configurable limits for a tenant (nil = unlimited). +type TenantQuota struct { + MaxStorageBytes *int64 `json:"max_storage_bytes"` + MaxUsers *int `json:"max_users"` + MaxEmails *int64 `json:"max_emails"` +} + +// TenantUsage holds the current resource usage for a tenant. +type TenantUsage struct { + StorageBytes int64 `json:"storage_bytes"` + UserCount int64 `json:"user_count"` + EmailCount int64 `json:"email_count"` +} + +// SetQuota updates the quota fields for a tenant. +// Pass nil to remove a limit (unlimited). +func (s *Store) SetQuota(ctx context.Context, tenantID int64, q TenantQuota) error { + tag, err := s.pool.Exec(ctx, + `UPDATE tenants SET max_storage_bytes=$1, max_users=$2, max_emails=$3 WHERE id=$4`, + q.MaxStorageBytes, q.MaxUsers, q.MaxEmails, tenantID, + ) + if err != nil { + return fmt.Errorf("tenantstore: set quota: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("tenantstore: tenant %d not found", tenantID) + } + return nil +} + +// GetQuota returns the quota configuration for a tenant. +func (s *Store) GetQuota(ctx context.Context, tenantID int64) (TenantQuota, error) { + var q TenantQuota + err := s.pool.QueryRow(ctx, + `SELECT max_storage_bytes, max_users, max_emails FROM tenants WHERE id=$1`, tenantID, + ).Scan(&q.MaxStorageBytes, &q.MaxUsers, &q.MaxEmails) + if err != nil { + return q, fmt.Errorf("tenantstore: get quota: %w", err) + } + return q, nil +} + +// GetUsage returns the current resource usage for a tenant. +func (s *Store) GetUsage(ctx context.Context, tenantID int64) (*TenantUsage, error) { + u := &TenantUsage{} + + // Email count and storage bytes from emails table + err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*), COALESCE(SUM(size), 0) + FROM emails + WHERE tenant_id = $1 + `, tenantID).Scan(&u.EmailCount, &u.StorageBytes) + if err != nil { + return nil, fmt.Errorf("tenantstore: get usage emails: %w", err) + } + + // User count from users table + err = s.pool.QueryRow(ctx, + `SELECT COUNT(*) FROM users WHERE tenant_id = $1`, tenantID, + ).Scan(&u.UserCount) + if err != nil { + return nil, fmt.Errorf("tenantstore: get usage users: %w", err) + } + + return u, nil +} diff --git a/internal/tenantstore/store.go b/internal/tenantstore/store.go index ea02945..a832836 100644 --- a/internal/tenantstore/store.go +++ b/internal/tenantstore/store.go @@ -26,6 +26,10 @@ type Tenant struct { LDAPURL string `json:"ldap_url,omitempty"` HasLogo bool `json:"has_logo,omitempty"` RetentionDays int `json:"retention_days"` // 0 = use global config + // Quota fields (PROJ-29) — nil = unlimited + MaxStorageBytes *int64 `json:"max_storage_bytes,omitempty"` + MaxUsers *int `json:"max_users,omitempty"` + MaxEmails *int64 `json:"max_emails,omitempty"` } // TenantDomain is an e-mail domain assigned to a tenant. @@ -77,6 +81,9 @@ ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_data BYTEA; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_content_type VARCHAR(100) NOT NULL DEFAULT ''; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS imap_mode TEXT NOT NULL DEFAULT 'personal'; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS retention_days INT NOT NULL DEFAULT 0; +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS max_storage_bytes BIGINT; +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS max_users INT; +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS max_emails BIGINT; ` // New connects to PostgreSQL and initialises the tenant schema. @@ -128,7 +135,10 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) { tl.enabled AS ldap_enabled, tl.url AS ldap_url, (t.logo_data IS NOT NULL) AS has_logo, - t.retention_days + t.retention_days, + t.max_storage_bytes, + t.max_users, + t.max_emails FROM tenants t LEFT JOIN tenant_domains td ON td.tenant_id = t.id LEFT JOIN users u ON u.tenant_id = t.id @@ -144,7 +154,7 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) { var tenants []Tenant for rows.Next() { var t Tenant - if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo, &t.RetentionDays); err != nil { + if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo, &t.RetentionDays, &t.MaxStorageBytes, &t.MaxUsers, &t.MaxEmails); err != nil { return nil, fmt.Errorf("tenantstore: scan: %w", err) } tenants = append(tenants, t) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3889c0d..07b99fd 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -77,6 +77,7 @@ import { CertTab } from "@/components/admin/tabs/CertTab"; import { ModulesTab } from "@/components/admin/ModulesTab"; import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab"; import { RetentionTab } from "@/components/admin/tabs/RetentionTab"; +import { QuotaTab } from "@/components/admin/tabs/QuotaTab"; import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs"; const AUDIT_PAGE_SIZE = 25; @@ -809,6 +810,7 @@ export default function AdminPage() { {isSuperAdmin && Zertifikat} {isSuperAdmin && Mandanten} {isSuperAdmin && Retention} + {isSuperAdmin && Quotas} {isSuperAdmin && Module} @@ -1087,6 +1089,11 @@ export default function AdminPage() { )} + {isSuperAdmin && ( + + + + )} {isSuperAdmin && ( diff --git a/src/components/admin/tabs/QuotaTab.tsx b/src/components/admin/tabs/QuotaTab.tsx new file mode 100644 index 0000000..ae6042e --- /dev/null +++ b/src/components/admin/tabs/QuotaTab.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { getAllTenantUsage, setTenantQuota, type TenantUsageEntry } from "@/lib/api/tenants"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} + +function usagePercent(used: number, max: number | null): number { + if (!max || max === 0) return 0; + return Math.min(100, Math.round((used / max) * 100)); +} + +function usageColor(pct: number): string { + if (pct >= 100) return "bg-destructive"; + if (pct >= 80) return "bg-yellow-500"; + return "bg-primary"; +} + +function QuotaBadge({ used, max, format }: { used: number; max: number | null; format: (n: number) => string }) { + if (!max) return Unbegrenzt; + const pct = usagePercent(used, max); + return ( +
+
+ {format(used)} + / {format(max)} +
+
+
+
+ {pct >= 80 && {pct}% belegt} +
+ ); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +interface EditState { + tenant: TenantUsageEntry; + maxStorageGB: string; // empty = unlimited + maxUsers: string; + maxEmails: string; +} + +export function QuotaTab() { + const [tenants, setTenants] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [edit, setEdit] = useState(null); + const [saving, setSaving] = useState(false); + + const load = useCallback(() => { + setLoading(true); + getAllTenantUsage() + .then(setTenants) + .catch(() => setError("Quota-Daten konnten nicht geladen werden")) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const handleEditOpen = (t: TenantUsageEntry) => { + setEdit({ + tenant: t, + maxStorageGB: t.max_storage_bytes ? String(Math.round(t.max_storage_bytes / (1024 ** 3))) : "", + maxUsers: t.max_users ? String(t.max_users) : "", + maxEmails: t.max_emails ? String(t.max_emails) : "", + }); + setSaving(false); + }; + + const handleEditSave = async () => { + if (!edit) return; + setSaving(true); + try { + const maxStorageBytes = edit.maxStorageGB.trim() + ? parseInt(edit.maxStorageGB, 10) * 1024 ** 3 + : null; + const maxUsers = edit.maxUsers.trim() ? parseInt(edit.maxUsers, 10) : null; + const maxEmails = edit.maxEmails.trim() ? parseInt(edit.maxEmails, 10) : null; + + await setTenantQuota(edit.tenant.id, { + max_storage_bytes: maxStorageBytes, + max_users: maxUsers, + max_emails: maxEmails, + }); + setEdit(null); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Fehler beim Speichern"); + } finally { + setSaving(false); + } + }; + + return ( +
+ + + Tenant-Quotas & Verbrauch + + +

+ Speicher-, Nutzer- und E-Mail-Limits pro Mandant. + NULL = unbegrenzt.{" "} + Bei Überschreitung werden neue Mails abgewiesen (bestehende Daten bleiben erhalten). + Soft-Warnung ab 80 %. +

+ {error &&

{error}

} + {loading ? ( +

Lädt...

+ ) : ( + + + + Mandant + Speicher + Nutzer + E-Mails + + + + + {tenants.map((t) => ( + + {t.name} + + + + + String(n)} + /> + + + n.toLocaleString("de-DE")} + /> + + + + + + ))} + +
+ )} +
+
+ + {/* Edit Dialog */} + { if (!o) setEdit(null); }}> + + + Quotas für "{edit?.tenant.name}" + + Leer lassen = unbegrenzt. Speicher in GB angeben. + + +
+
+ + setEdit((prev) => prev ? { ...prev, maxStorageGB: e.target.value } : prev)} + /> + {edit?.maxStorageGB && ( +

+ = {formatBytes(parseInt(edit.maxStorageGB, 10) * 1024 ** 3)} +

+ )} +
+
+ + setEdit((prev) => prev ? { ...prev, maxUsers: e.target.value } : prev)} + /> +
+
+ + setEdit((prev) => prev ? { ...prev, maxEmails: e.target.value } : prev)} + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 479da65..38fa59e 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -46,6 +46,7 @@ export type { CreateTenantResponse, TenantLDAPConfig, LDAPSyncResult, + TenantUsageEntry, } from "./tenants"; export { getTenants, @@ -70,6 +71,8 @@ export { deleteAdminTenantLDAPConfig, testAdminTenantLDAPConfig, syncAdminTenantLDAP, + getAllTenantUsage, + setTenantQuota, } from "./tenants"; export type { diff --git a/src/lib/api/tenants.ts b/src/lib/api/tenants.ts index c3eab76..67d72a7 100644 --- a/src/lib/api/tenants.ts +++ b/src/lib/api/tenants.ts @@ -17,6 +17,18 @@ export interface Tenant { has_logo?: boolean; } +export interface TenantUsageEntry { + id: number; + name: string; + slug: string; + max_storage_bytes: number | null; + max_users: number | null; + max_emails: number | null; + storage_bytes: number; + user_count: number; + email_count: number; +} + export interface TenantDomain { id: number; tenant_id: number; @@ -204,3 +216,20 @@ export async function testAdminTenantLDAPConfig( export async function syncAdminTenantLDAP(tenantID: number): Promise { return request(`/api/admin/tenants/${tenantID}/ldap/sync`, { method: "POST", body: "{}" }); } + +// ── Tenant Quotas (PROJ-29) ─────────────────────────────────────────────────── + +export async function getAllTenantUsage(): Promise { + const data = await request<{ tenants: TenantUsageEntry[] }>("/api/admin/quotas"); + return data.tenants; +} + +export async function setTenantQuota( + tenantID: number, + quota: { max_storage_bytes: number | null; max_users: number | null; max_emails: number | null } +): Promise { + await request(`/api/admin/tenant/${tenantID}/quota`, { + method: "PUT", + body: JSON.stringify(quota), + }); +}