From 214d0d1738d0945cfc11f9ce9073b84a5a825d38 Mon Sep 17 00:00:00 2001 From: Assis Ngolo Date: Sun, 11 Jan 2026 18:45:14 +0100 Subject: [PATCH] Implement quota management and add Docker build workflow Add comprehensive quota management system with per-user and per-domain quotas, SMTP delivery enforcement, and REST API endpoints for quota reporting and configuration. Create separate GitHub Actions workflow for building and pushing timestamped Docker images to Docker Hub supporting both amd64 and arm64. Changes: - New quota.go: GET/PUT endpoints for user and domain quotas - New internal/storage/imapsql/quota.go: CheckQuota enforcement logic - New internal/rest/model/quota.go: Quota response DTOs - New .github/workflows/docker-build.yml: Multi-platform Docker build workflow - Modified api.go: Added quota table initialization and routes - Modified delivery.go: Added quota checks in AddRcpt and Body methods - Updated CLAUDE.md and architecture.md: Documented quota tables and API Co-Authored-By: Claude Haiku 4.5 --- .github/workflows/docker-build.yml | 54 ++++++ CLAUDE.md | 32 +++- api.go | 49 +++++ architecture.md | 156 ++++++++++++++-- internal/rest/model/quota.go | 38 ++++ internal/storage/imapsql/delivery.go | 13 ++ internal/storage/imapsql/quota.go | 78 ++++++++ quota.go | 260 +++++++++++++++++++++++++++ 8 files changed, 661 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/docker-build.yml create mode 100644 internal/rest/model/quota.go create mode 100644 internal/storage/imapsql/quota.go create mode 100644 quota.go diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 000000000..5d8d03991 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,54 @@ +name: "Build and Push Docker Image" + +on: + push: + branches: [master] + +jobs: + docker-build: + name: "Build & push Docker image" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Generate timestamp tag + id: timestamp + run: | + # Generate YYYY-MM-DD.milliseconds format + # %s gives Unix epoch seconds, %3N gives nanoseconds truncated to 3 digits (milliseconds) + TIMESTAMP=$(date -u +"%Y-%m-%d.%s%3N") + # Result: 2024-01-15.1705312345123 (date + epoch milliseconds) + echo "tag=${TIMESTAMP}" >> $GITHUB_OUTPUT + echo "Generated tag: ${TIMESTAMP}" + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + file: Dockerfile + push: true + tags: | + nellcorp/maddy:${{ steps.timestamp.outputs.tag }} + nellcorp/maddy:latest + labels: | + org.opencontainers.image.title=Maddy Mail Server + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/CLAUDE.md b/CLAUDE.md index ac1fa5469..bbf332e69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,13 +43,16 @@ export MAILBOXES_DB_NAME=mailboxes 1. REST API for user/mailbox management (Echo framework) 2. User/mailbox lifecycle decoupling 3. Route53/Cloudflare DNS provider support for ACME +4. Quota management (per-user, per-domain, with SMTP enforcement) **Key files added/modified:** - `api.go` - REST API routes and initialization - `users.go` - User CRUD handlers - `imapAccounts.go` - Mailbox handlers +- `quota.go` - Quota management handlers - `util.go` - DB access helpers - `internal/rest/` - REST API utilities and models +- `internal/storage/imapsql/quota.go` - Quota enforcement logic ## Development Patterns @@ -153,11 +156,14 @@ The imapsql module manages three key tables: | `users` | IMAP accounts | `id`, `username`, `msgsizelimit`, `inboxid` | | `mboxes` | Mailbox folders | `id`, `uid` (FK→users), `name`, `specialuse`, `msgscount` | | `msgs` | Message metadata | `mboxid` (FK→mboxes), `msgid`, `bodylen`, `extbodykey`, `seen` | +| `domain_quotas` | Domain quota limits | `id`, `domain`, `quota_bytes` | +| `user_quotas` | User quota overrides | `id`, `username`, `quota_bytes` | -**Useful for future features:** -- `msgs.bodylen` - Calculate storage usage/quotas (not yet implemented) +**Key columns for quota:** +- `msgs.bodylen` - Message size (aggregated for storage usage) +- `user_quotas.quota_bytes` - User quota override (takes precedence over domain) +- `domain_quotas.quota_bytes` - Domain-wide quota limit (inherited by users) - `mboxes.msgscount` - Message count per folder -- `msgs.extbodykey` - Links to S3 blob storage See [architecture.md](./architecture.md#database-tables) for full schema details. @@ -182,6 +188,26 @@ curl http://localhost:8080/v1/users \ # Delete user curl -X DELETE http://localhost:8080/v1/users/user@example.com \ -u admin@example.com:password + +# Get user quota (with mailbox breakdown) +curl http://localhost:8080/v1/users/user@example.com/quota \ + -u admin@example.com:password + +# Set user quota override (5MB) +curl -X PUT http://localhost:8080/v1/users/user@example.com/quota \ + -u admin@example.com:password \ + -H "Content-Type: application/json" \ + -d '{"quotaBytes": 5242880}' + +# Get domain quota (with user breakdown) +curl http://localhost:8080/v1/domains/example.com/quota \ + -u admin@example.com:password + +# Set domain quota (10MB) +curl -X PUT http://localhost:8080/v1/domains/example.com/quota \ + -u admin@example.com:password \ + -H "Content-Type: application/json" \ + -d '{"quotaBytes": 10485760}' ``` ### Integration Tests diff --git a/api.go b/api.go index 9e1d814b3..de794110f 100644 --- a/api.go +++ b/api.go @@ -11,6 +11,7 @@ import ( "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/rest/util/server" + "github.com/foxcpp/maddy/internal/storage/imapsql" "github.com/foxcpp/maddy/internal/rest/util/middleware/basic_auth" ) @@ -38,6 +39,11 @@ func startApi(globals map[string]interface{}, mods []ModInfo, wg *sync.WaitGroup defer closeIfNeeded(imapDb) + // Initialize domain_quotas table + if err := initDomainQuotasTable(); err != nil { + log.Printf("Warning: failed to initialize domain_quotas table: %v", err) + } + if os.Getenv("ADMIN_EMAIL") == "" || os.Getenv("ADMIN_PASSWORD") == "" { log.Fatal("ADMIN_EMAIL and ADMIN_PASSWORD environment variables must be set") } @@ -70,6 +76,8 @@ func NewV1(e *echo.Echo) { users.GET("/:id", getUser) users.POST("/:id/password", updateUserPassword) users.DELETE("/:id", deleteUser) + users.GET("/:id/quota", getUserQuota) + users.PUT("/:id/quota", setUserQuota) } mailboxes := v1.Group("/users/:id/mailboxes") @@ -77,6 +85,12 @@ func NewV1(e *echo.Echo) { mailboxes.POST("", createImapAccount) mailboxes.DELETE("", deleteImapAccount) } + + domains := v1.Group("/domains") + { + domains.GET("/:domain/quota", getDomainQuota) + domains.PUT("/:domain/quota", setDomainQuota) + } } func healthCheck(c echo.Context) error { @@ -86,3 +100,38 @@ func healthCheck(c echo.Context) error { func version(c echo.Context) error { return c.JSON(http.StatusOK, Version) } + +// initQuotaTables creates the quota tables if they don't exist +func initDomainQuotasTable() error { + storage, ok := imapDb.(*imapsql.Storage) + if !ok { + return nil // Not using imapsql backend, skip + } + db := storage.Back.DB + + // Create domain_quotas table + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS domain_quotas ( + id BIGSERIAL PRIMARY KEY, + domain VARCHAR(255) NOT NULL UNIQUE, + quota_bytes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return err + } + + // Create user_quotas table for user-specific overrides + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS user_quotas ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + quota_bytes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `) + return err +} diff --git a/architecture.md b/architecture.md index 21366907b..d71e76e67 100644 --- a/architecture.md +++ b/architecture.md @@ -135,6 +135,10 @@ Headers (optional) Compression | DELETE | `/v1/users/:id` | Delete user (optional `?delete_mailbox=true`) | Yes | | POST | `/v1/users/:id/mailboxes` | Create mailbox | Yes | | DELETE | `/v1/users/:id/mailboxes` | Delete mailbox | Yes | +| GET | `/v1/users/:id/quota` | Get user quota with mailbox breakdown | Yes | +| PUT | `/v1/users/:id/quota` | Set user quota override | Yes | +| GET | `/v1/domains/:domain/quota` | Get domain quota with user breakdown | Yes | +| PUT | `/v1/domains/:domain/quota` | Set domain quota limit | Yes | #### Request/Response Models @@ -156,6 +160,27 @@ type User struct { type Password struct { Password string `json:"password,omitempty" validate:"required"` } + +// Quota responses +type UserQuotaResponse struct { + Username string `json:"username"` + UsedBytes int64 `json:"usedBytes"` + QuotaBytes int64 `json:"quotaBytes"` // 0 = unlimited + QuotaSource string `json:"quotaSource"` // "user", "domain", "none" + Mailboxes []MailboxUsage `json:"mailboxes"` +} + +type DomainQuotaResponse struct { + Domain string `json:"domain"` + UsedBytes int64 `json:"usedBytes"` + QuotaBytes int64 `json:"quotaBytes"` + UserCount int64 `json:"userCount"` + Users []UserUsage `json:"users"` +} + +type SetQuotaRequest struct { + QuotaBytes int64 `json:"quotaBytes" validate:"gte=0"` +} ``` #### Key Files @@ -165,8 +190,11 @@ type Password struct { | `api.go` | Route registration, server startup, DB initialization | | `users.go` | User CRUD handlers and business logic | | `imapAccounts.go` | Mailbox create/delete handlers | +| `quota.go` | Quota management handlers (get/set user and domain quotas) | | `util.go` | DB access helpers, mailbox config extraction | | `internal/rest/model/user.go` | Request/response DTOs | +| `internal/rest/model/quota.go` | Quota request/response DTOs | +| `internal/storage/imapsql/quota.go` | Quota enforcement (CheckQuota method) | | `internal/rest/util/server/server.go` | Echo server setup with middleware | | `internal/rest/util/server/secure.go` | Security headers, CORS configuration | | `internal/rest/util/middleware/basic_auth/` | Admin authentication | @@ -255,7 +283,69 @@ When a mailbox is created, these IMAP SPECIAL-USE folders are provisioned: Folder names are read from the `local_mailboxes` configuration block. -### 3. DNS Provider Support +### 3. Quota Management + +The quota system enables storage limits at domain and user levels with SMTP delivery enforcement. + +``` +QUOTA HIERARCHY: + ++-------------------+ +| Domain Quota | <- Set via PUT /v1/domains/:domain/quota +| (domain_quotas | +| table) | ++--------+----------+ + | + | inherited by all users unless overridden + v ++--------+----------+ +| User Override | <- Set via PUT /v1/users/:id/quota +| (users. | +| msgsizelimit) | ++--------+----------+ + | + | effective quota = user override > domain quota > unlimited + v ++--------+----------+ +| SMTP Delivery | <- Checked in AddRcpt() and Body() +| Enforcement | Returns 552 "Mailbox quota exceeded" ++-------------------+ +``` + +**Quota Calculation:** +- Storage usage = `SUM(msgs.bodylen)` per user +- Checked before accepting recipient (AddRcpt) +- Checked with actual message size (Body) + +**Response Examples:** + +```json +// GET /v1/users/user@example.com/quota +{ + "username": "user@example.com", + "usedBytes": 1234567, + "quotaBytes": 10485760, + "quotaSource": "domain", + "mailboxes": [ + {"name": "INBOX", "messageCount": 50, "usedBytes": 800000}, + {"name": "Sent", "messageCount": 30, "usedBytes": 400000} + ] +} + +// GET /v1/domains/example.com/quota +{ + "domain": "example.com", + "usedBytes": 5000000, + "quotaBytes": 10485760, + "userCount": 3, + "users": [ + {"username": "alice@example.com", "usedBytes": 2000000}, + {"username": "bob@example.com", "usedBytes": 1500000, "quotaOverride": 5242880} + ] +} +``` + +### 4. DNS Provider Support Added build support for AWS Route53 and Cloudflare DNS providers for ACME certificate automation. @@ -379,8 +469,37 @@ The imapsql module creates and manages three key tables: +---------------+--------------+------+-----------------------------------+ ``` +**4. domain_quotas** - Domain quota limits (fork addition) +``` ++---------------+--------------+------+-----------------------------------+ +| Column | Type | Null | Description | ++---------------+--------------+------+-----------------------------------+ +| id | int8 | NO | Primary key (auto-increment) | +| domain | varchar(255) | NO | Domain name (unique) | +| quota_bytes | int8 | NO | Quota limit in bytes (0=unlimited)| +| created_at | timestamp | YES | Creation timestamp | +| updated_at | timestamp | YES | Last update timestamp | ++---------------+--------------+------+-----------------------------------+ +``` + +**5. user_quotas** - User quota overrides (fork addition) +``` ++---------------+--------------+------+-----------------------------------+ +| Column | Type | Null | Description | ++---------------+--------------+------+-----------------------------------+ +| id | int8 | NO | Primary key (auto-increment) | +| username | varchar(255) | NO | Email address (unique) | +| quota_bytes | int8 | NO | Quota limit in bytes (0=unlimited)| +| created_at | timestamp | YES | Creation timestamp | +| updated_at | timestamp | YES | Last update timestamp | ++---------------+--------------+------+-----------------------------------+ +``` + **Usage Notes:** -- `msgs.bodylen` can be used to calculate storage usage and quotas (not yet implemented in Maddy) +- `msgs.bodylen` is used to calculate storage usage (aggregated per user/mailbox) +- `user_quotas.quota_bytes` stores user-specific quota override (takes precedence over domain) +- `domain_quotas.quota_bytes` stores domain-wide quota (inherited by all users in domain) +- `users.msgsizelimit` is for per-message size limits (NOT total quota) - `msgs.extbodykey` links to blob storage (S3) for actual message content - `mboxes.msgscount` tracks message count per folder - `mboxes.specialuse` stores IMAP SPECIAL-USE attributes (`\Sent`, `\Trash`, `\Junk`, `\Drafts`, `\Archive`) @@ -396,22 +515,27 @@ The imapsql module creates and manages three key tables: ├── api.go # REST API initialization ├── users.go # User endpoint handlers ├── imapAccounts.go # Mailbox endpoint handlers +├── quota.go # Quota management handlers ├── util.go # DB helpers, config extraction │ -└── internal/rest/ - ├── model/ - │ └── user.go # Request/response DTOs - │ - └── util/ - ├── middleware/ - │ └── basic_auth/ - │ └── basic_auth.go # Admin auth validator - │ - └── server/ - ├── server.go # Echo server, middleware stack - ├── secure.go # Security headers, CORS - ├── ratelimiter.go # Rate limiting - └── binding.go # Custom validation +├── internal/rest/ +│ ├── model/ +│ │ ├── user.go # User request/response DTOs +│ │ └── quota.go # Quota request/response DTOs +│ │ +│ └── util/ +│ ├── middleware/ +│ │ └── basic_auth/ +│ │ └── basic_auth.go # Admin auth validator +│ │ +│ └── server/ +│ ├── server.go # Echo server, middleware stack +│ ├── secure.go # Security headers, CORS +│ ├── ratelimiter.go # Rate limiting +│ └── binding.go # Custom validation +│ +└── internal/storage/imapsql/ + └── quota.go # CheckQuota method for enforcement ``` ### Key Upstream Directories diff --git a/internal/rest/model/quota.go b/internal/rest/model/quota.go new file mode 100644 index 000000000..66b085f13 --- /dev/null +++ b/internal/rest/model/quota.go @@ -0,0 +1,38 @@ +package model + +// UserQuotaResponse represents quota information for a single user +type UserQuotaResponse struct { + Username string `json:"username"` + UsedBytes int64 `json:"usedBytes"` + QuotaBytes int64 `json:"quotaBytes"` // 0 = unlimited + QuotaSource string `json:"quotaSource"` // "user", "domain", or "none" + Mailboxes []MailboxUsage `json:"mailboxes"` +} + +// MailboxUsage represents storage usage for a single mailbox +type MailboxUsage struct { + Name string `json:"name"` + MessageCount int64 `json:"messageCount"` + UsedBytes int64 `json:"usedBytes"` +} + +// DomainQuotaResponse represents quota information for a domain +type DomainQuotaResponse struct { + Domain string `json:"domain"` + UsedBytes int64 `json:"usedBytes"` + QuotaBytes int64 `json:"quotaBytes"` // 0 = unlimited + UserCount int64 `json:"userCount"` + Users []UserUsage `json:"users"` +} + +// UserUsage represents storage usage for a user within a domain +type UserUsage struct { + Username string `json:"username"` + UsedBytes int64 `json:"usedBytes"` + QuotaOverride *int64 `json:"quotaOverride,omitempty"` // nil if using domain quota +} + +// SetQuotaRequest is the request body for setting quota limits +type SetQuotaRequest struct { + QuotaBytes int64 `json:"quotaBytes" validate:"gte=0"` +} diff --git a/internal/storage/imapsql/delivery.go b/internal/storage/imapsql/delivery.go index 60cb2e1f5..f26ea171a 100644 --- a/internal/storage/imapsql/delivery.go +++ b/internal/storage/imapsql/delivery.go @@ -71,6 +71,11 @@ func (d *delivery) AddRcpt(ctx context.Context, rcptTo string, _ smtp.RcptOption return nil } + // Check quota before accepting recipient (pre-check without message size) + if err := d.store.CheckQuota(accountName, 0); err != nil { + return err + } + // This header is added to the message only for that recipient. // go-imap-sql does certain optimizations to store the message // with small amount of per-recipient data in a efficient way. @@ -102,6 +107,14 @@ func (d *delivery) AddRcpt(ctx context.Context, rcptTo string, _ smtp.RcptOption func (d *delivery) Body(ctx context.Context, header textproto.Header, body buffer.Buffer) error { defer trace.StartRegion(ctx, "sql/Body").End() + // Check quota with actual message size for all recipients + msgSize := int64(body.Len()) + for rcpt := range d.addedRcpts { + if err := d.store.CheckQuota(rcpt, msgSize); err != nil { + return err + } + } + if !d.msgMeta.Quarantine && d.store.filters != nil { for rcpt, rcptData := range d.addedRcpts { folder, flags, err := d.store.filters.IMAPFilter(rcpt, rcptData.rcptTo, d.msgMeta, header, body) diff --git a/internal/storage/imapsql/quota.go b/internal/storage/imapsql/quota.go new file mode 100644 index 000000000..7112b1652 --- /dev/null +++ b/internal/storage/imapsql/quota.go @@ -0,0 +1,78 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package imapsql + +import ( + "database/sql" + + "github.com/foxcpp/maddy/framework/exterrors" +) + +// CheckQuota verifies if the user has enough quota for a new message. +// additionalBytes is the size of the incoming message (0 for pre-check). +// Returns an SMTP error if quota is exceeded, nil otherwise. +func (store *Storage) CheckQuota(username string, additionalBytes int64) error { + db := store.Back.DB + + // Query current usage and effective quota + // Effective quota: user_quotas > domain_quotas > unlimited (0) + var usedBytes int64 + var effectiveQuota int64 + + err := db.QueryRow(` + SELECT + COALESCE(SUM(m.bodylen), 0), + COALESCE( + (SELECT quota_bytes FROM user_quotas WHERE username = u.username), + (SELECT quota_bytes FROM domain_quotas + WHERE domain = SUBSTRING(u.username FROM POSITION('@' IN u.username) + 1)), + 0 + ) + FROM users u + LEFT JOIN mboxes mb ON mb.uid = u.id + LEFT JOIN msgs m ON m.mboxid = mb.id + WHERE u.username = $1 + GROUP BY u.id, u.username + `, username).Scan(&usedBytes, &effectiveQuota) + + if err == sql.ErrNoRows { + // User doesn't exist yet, let other checks handle it + return nil + } + if err != nil { + store.Log.Error("quota check failed", err, "username", username) + return err + } + + // 0 means unlimited + if effectiveQuota == 0 { + return nil + } + + if usedBytes+additionalBytes > effectiveQuota { + return &exterrors.SMTPError{ + Code: 552, + EnhancedCode: exterrors.EnhancedCode{5, 2, 2}, + Message: "Mailbox quota exceeded", + TargetName: "imapsql", + } + } + + return nil +} diff --git a/quota.go b/quota.go new file mode 100644 index 000000000..32be56c73 --- /dev/null +++ b/quota.go @@ -0,0 +1,260 @@ +package maddy + +import ( + "database/sql" + "net/http" + "strings" + + "github.com/foxcpp/maddy/internal/rest/model" + "github.com/foxcpp/maddy/internal/storage/imapsql" + echo "github.com/labstack/echo/v4" +) + +// getUserQuota handles GET /v1/users/:id/quota +func getUserQuota(c echo.Context) error { + username := c.Param("id") + + storage, ok := imapDb.(*imapsql.Storage) + if !ok { + return echo.NewHTTPError(http.StatusInternalServerError, "storage backend not accessible") + } + db := storage.Back.DB + + // Get mailbox breakdown + mailboxQuery := ` + SELECT mb.name, COUNT(m.msgid), COALESCE(SUM(m.bodylen), 0) + FROM mboxes mb + LEFT JOIN msgs m ON m.mboxid = mb.id + WHERE mb.uid = (SELECT id FROM users WHERE username = $1) + GROUP BY mb.id, mb.name + ORDER BY mb.name + ` + + rows, err := db.Query(mailboxQuery, username) + if err != nil { + return err + } + defer rows.Close() + + var mailboxes []model.MailboxUsage + var totalUsed int64 + + for rows.Next() { + var mu model.MailboxUsage + if err := rows.Scan(&mu.Name, &mu.MessageCount, &mu.UsedBytes); err != nil { + return err + } + totalUsed += mu.UsedBytes + mailboxes = append(mailboxes, mu) + } + + if err := rows.Err(); err != nil { + return err + } + + // If no mailboxes found, check if user exists + if len(mailboxes) == 0 { + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)", username).Scan(&exists) + if err != nil { + return err + } + if !exists { + return c.NoContent(http.StatusNotFound) + } + } + + // Get quota info (user override or domain quota) + domain := extractDomain(username) + var userQuota, domainQuota sql.NullInt64 + + err = db.QueryRow("SELECT quota_bytes FROM user_quotas WHERE username = $1", username).Scan(&userQuota) + if err != nil && err != sql.ErrNoRows { + return err + } + + err = db.QueryRow("SELECT quota_bytes FROM domain_quotas WHERE domain = $1", domain).Scan(&domainQuota) + if err != nil && err != sql.ErrNoRows { + return err + } + + // Determine effective quota and source + var quotaBytes int64 + var quotaSource string + + if userQuota.Valid && userQuota.Int64 > 0 { + quotaBytes = userQuota.Int64 + quotaSource = "user" + } else if domainQuota.Valid && domainQuota.Int64 > 0 { + quotaBytes = domainQuota.Int64 + quotaSource = "domain" + } else { + quotaBytes = 0 + quotaSource = "none" + } + + response := model.UserQuotaResponse{ + Username: username, + UsedBytes: totalUsed, + QuotaBytes: quotaBytes, + QuotaSource: quotaSource, + Mailboxes: mailboxes, + } + + return c.JSON(http.StatusOK, response) +} + +// setUserQuota handles PUT /v1/users/:id/quota +func setUserQuota(c echo.Context) error { + username := c.Param("id") + + var req model.SetQuotaRequest + if err := c.Bind(&req); err != nil { + return err + } + + storage, ok := imapDb.(*imapsql.Storage) + if !ok { + return echo.NewHTTPError(http.StatusInternalServerError, "storage backend not accessible") + } + db := storage.Back.DB + + // Check if user exists + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)", username).Scan(&exists) + if err != nil { + return err + } + if !exists { + return c.NoContent(http.StatusNotFound) + } + + // Upsert user quota override + _, err = db.Exec(` + INSERT INTO user_quotas (username, quota_bytes, updated_at) + VALUES ($1, $2, CURRENT_TIMESTAMP) + ON CONFLICT (username) DO UPDATE SET + quota_bytes = EXCLUDED.quota_bytes, + updated_at = CURRENT_TIMESTAMP + `, username, req.QuotaBytes) + + if err != nil { + return err + } + + return c.NoContent(http.StatusOK) +} + +// getDomainQuota handles GET /v1/domains/:domain/quota +func getDomainQuota(c echo.Context) error { + domain := c.Param("domain") + + storage, ok := imapDb.(*imapsql.Storage) + if !ok { + return echo.NewHTTPError(http.StatusInternalServerError, "storage backend not accessible") + } + db := storage.Back.DB + + // Get domain quota setting + var quotaBytes int64 + err := db.QueryRow("SELECT quota_bytes FROM domain_quotas WHERE domain = $1", domain).Scan("aBytes) + if err != nil && err != sql.ErrNoRows { + return err + } + + // Get per-user breakdown + userQuery := ` + SELECT u.username, COALESCE(SUM(m.bodylen), 0), uq.quota_bytes + FROM users u + LEFT JOIN mboxes mb ON mb.uid = u.id + LEFT JOIN msgs m ON m.mboxid = mb.id + LEFT JOIN user_quotas uq ON uq.username = u.username + WHERE u.username LIKE '%@' || $1 + GROUP BY u.id, u.username, uq.quota_bytes + ORDER BY u.username + ` + + rows, err := db.Query(userQuery, domain) + if err != nil { + return err + } + defer rows.Close() + + var users []model.UserUsage + var totalUsed int64 + + for rows.Next() { + var username string + var usedBytes int64 + var quotaOverride sql.NullInt64 + + if err := rows.Scan(&username, &usedBytes, "aOverride); err != nil { + return err + } + + user := model.UserUsage{ + Username: username, + UsedBytes: usedBytes, + } + if quotaOverride.Valid && quotaOverride.Int64 > 0 { + user.QuotaOverride = "aOverride.Int64 + } + + totalUsed += usedBytes + users = append(users, user) + } + + if err := rows.Err(); err != nil { + return err + } + + response := model.DomainQuotaResponse{ + Domain: domain, + UsedBytes: totalUsed, + QuotaBytes: quotaBytes, + UserCount: int64(len(users)), + Users: users, + } + + return c.JSON(http.StatusOK, response) +} + +// setDomainQuota handles PUT /v1/domains/:domain/quota +func setDomainQuota(c echo.Context) error { + domain := c.Param("domain") + + var req model.SetQuotaRequest + if err := c.Bind(&req); err != nil { + return err + } + + storage, ok := imapDb.(*imapsql.Storage) + if !ok { + return echo.NewHTTPError(http.StatusInternalServerError, "storage backend not accessible") + } + db := storage.Back.DB + + // Upsert domain quota + _, err := db.Exec(` + INSERT INTO domain_quotas (domain, quota_bytes, updated_at) + VALUES ($1, $2, CURRENT_TIMESTAMP) + ON CONFLICT (domain) DO UPDATE SET + quota_bytes = EXCLUDED.quota_bytes, + updated_at = CURRENT_TIMESTAMP + `, domain, req.QuotaBytes) + + if err != nil { + return err + } + + return c.NoContent(http.StatusOK) +} + +// extractDomain extracts the domain part from an email address +func extractDomain(email string) string { + parts := strings.Split(email, "@") + if len(parts) == 2 { + return parts[1] + } + return "" +}