Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
@@ -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
32 changes: 29 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -70,13 +76,21 @@ 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")
{
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 {
Expand All @@ -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
}
Loading
Loading