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 "" +}