From 551739baa36a6085b41af590d35531b46ac47460 Mon Sep 17 00:00:00 2001 From: karti-ai Date: Sun, 21 Jun 2026 12:16:51 -0700 Subject: [PATCH] Milestone 1 scaffold: binary skeleton, schema, deploy, Mox spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Confirmed core bet via spike/mimecheck: mox/message parses a messy multipart message standalone (envelope, MIME tree, attachment, body) with only an io.ReaderAt — no mox store/config. Option B is viable. - cmd/openmail single binary: serve|migrate|smtpd|sender|version subcommands. - internal/api: chi router, constant-time bearer auth, /healthz, stubbed v0 AgentMail-shaped routes (501 until core services land). - internal/store: pgxpool + embedded, idempotent, tracked SQL migrations. - internal/store/migrations/0001_init.sql: full native schema (pods, inboxes, threads, messages, attachments, drafts, api_keys, webhooks, outbox, events, domains) with FTS + GIN indexes. Validated end-to-end against Postgres 16. - deploy/: docker-compose (postgres+minio+openmail+caddy), Caddyfile, DNS.md (MX/SPF/DKIM/DMARC/DANE/MTA-STS). Makefile, .gitignore. - Verified: go vet clean; build OK; health/auth smoke tests; migrate idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + ARCHITECTURE.md | 5 +- Makefile | 25 ++++ cmd/openmail/main.go | 141 ++++++++++++++++++++++ deploy/Caddyfile | 6 + deploy/DNS.md | 49 ++++++++ deploy/docker-compose.yml | 58 ++++++++++ go.mod | 30 +++++ go.sum | 60 ++++++++++ internal/api/auth.go | 30 +++++ internal/api/server.go | 82 +++++++++++++ internal/config/config.go | 30 +++++ internal/store/migrations/0001_init.sql | 148 ++++++++++++++++++++++++ internal/store/store.go | 100 ++++++++++++++++ spike/mimecheck/main.go | 123 ++++++++++++++++++++ 15 files changed, 889 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/openmail/main.go create mode 100644 deploy/Caddyfile create mode 100644 deploy/DNS.md create mode 100644 deploy/docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/auth.go create mode 100644 internal/api/server.go create mode 100644 internal/config/config.go create mode 100644 internal/store/migrations/0001_init.sql create mode 100644 internal/store/store.go create mode 100644 spike/mimecheck/main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..512b348 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/bin/ +*.exe +.env +.DS_Store diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8f34fd9..e0235a8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -189,11 +189,12 @@ cmd/openmail/ # single binary: `serve`, `smtpd`, `sender`, `mcp` subcomm internal/core/ # inbox/message/thread/draft services internal/smtp/ # go-smtp inbound server; calls mox verify/parse/junk + threading internal/sender/ # Sender interface: smtp (mox smtpclient+dane+mtasts) & relay backends; mox dkim -internal/store/ # sqlc queries, object-store client +internal/store/ # pgx/sqlc queries, object-store client, embedded migrations +internal/store/migrations/ # SQL migrations (go:embed'd into the binary) internal/api/ # chi HTTP handlers (v0 surface) internal/mcp/ # MCP server over core deploy/ # docker-compose.yml, Caddyfile, DNS (MX/SPF/DKIM/DMARC/DANE/MTA-STS) notes -db/migrations/ # SQL migrations +spike/ # throwaway feasibility spikes (mimecheck: mox standalone parse — PASSED) ``` ## 8. Licensing & attribution diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0644dbd --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: build run spike test tidy migrate fmt vet + +build: + go build -o bin/openmail ./cmd/openmail + +run: build + ./bin/openmail serve + +spike: + go run ./spike/mimecheck + +migrate: build + ./bin/openmail migrate + +test: + go test ./... + +tidy: + go mod tidy + +fmt: + go fmt ./... + +vet: + go vet ./... diff --git a/cmd/openmail/main.go b/cmd/openmail/main.go new file mode 100644 index 0000000..66f4b30 --- /dev/null +++ b/cmd/openmail/main.go @@ -0,0 +1,141 @@ +// Command openmail is the single OpenMail binary. Subcommands map to the roles +// in ARCHITECTURE.md §1: serve (HTTP API + MCP), smtpd (inbound :25), sender +// (outbound), migrate (apply DB schema). smtpd/sender are scaffolded for later +// milestones. +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/karti-ai/openmail/internal/api" + "github.com/karti-ai/openmail/internal/config" + "github.com/karti-ai/openmail/internal/store" +) + +const version = "0.0.1-dev" + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + cmd := os.Args[1] + cfg := config.Load() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + var err error + switch cmd { + case "serve": + err = runServe(ctx, cfg) + case "migrate": + err = runMigrate(ctx, cfg) + case "smtpd": + err = fmt.Errorf("smtpd: not yet implemented (milestone 2: go-smtp + mox verify/parse)") + case "sender": + err = fmt.Errorf("sender: not yet implemented (milestone 3: relay; milestone 5: self-host SMTP)") + case "version", "-v", "--version": + fmt.Println("openmail", version) + default: + usage() + os.Exit(2) + } + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `openmail %s — agent-native, self-hosted mail server + +usage: openmail + +commands: + serve start the HTTP API (and MCP) server + migrate apply database migrations + smtpd inbound SMTP listener (milestone 2) + sender outbound delivery worker (milestone 3/5) + version print version + +env: + OPENMAIL_HTTP_ADDR HTTP listen address (default :8080) + DATABASE_URL postgres connection string + OPENMAIL_ADMIN_TOKEN bootstrap bearer token for the API +`, version) +} + +// openStore connects + migrates; returns (nil, nil) when DATABASE_URL is unset +// so `serve` can still boot for health checks during early dev. +func openStore(ctx context.Context, cfg config.Config) (*store.Store, error) { + if cfg.DatabaseURL == "" { + return nil, nil + } + st, err := store.Open(ctx, cfg.DatabaseURL) + if err != nil { + return nil, err + } + if err := st.Migrate(ctx); err != nil { + st.Close() + return nil, err + } + return st, nil +} + +func runMigrate(ctx context.Context, cfg config.Config) error { + if cfg.DatabaseURL == "" { + return errors.New("migrate: DATABASE_URL must be set") + } + st, err := store.Open(ctx, cfg.DatabaseURL) + if err != nil { + return err + } + defer st.Close() + if err := st.Migrate(ctx); err != nil { + return err + } + fmt.Println("migrations applied") + return nil +} + +func runServe(ctx context.Context, cfg config.Config) error { + st, err := openStore(ctx, cfg) + if err != nil { + return err + } + if st != nil { + defer st.Close() + } else { + fmt.Fprintln(os.Stderr, "warning: DATABASE_URL unset — serving health only, API will report db not configured") + } + + srv := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: api.New(cfg, st).Router(), + ReadHeaderTimeout: 10 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + fmt.Fprintf(os.Stderr, "openmail serve: listening on %s\n", cfg.HTTPAddr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) + case err := <-errCh: + return err + } +} diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..82b9115 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,6 @@ +# Caddy terminates TLS for the OpenMail HTTP API and auto-manages certs. +# Replace mail.example.com with your domain; Caddy provisions a Let's Encrypt +# cert automatically on first request. +mail.example.com { + reverse_proxy openmail:8080 +} diff --git a/deploy/DNS.md b/deploy/DNS.md new file mode 100644 index 0000000..9d8f661 --- /dev/null +++ b/deploy/DNS.md @@ -0,0 +1,49 @@ +# DNS for self-hosting OpenMail + +Replace `example.com` with your domain and `` with the server's address. +OpenMail leans on Mox's `dkim`/`spf`/`dmarc`/`dane`/`mtasts` packages, but the +DNS records below are operational and must be published by you. + +## Inbound (receive) — required for milestone 2 + +``` +; route mail for the domain to this server +example.com. MX 10 mail.example.com. +mail.example.com. A +mail.example.com. AAAA ; if available + +; reverse DNS (PTR) — set at your VPS provider, must resolve mail.example.com + -> mail.example.com +``` + +## Outbound (send) — deliverability, milestone 3/5 + +``` +; SPF — authorize this server to send for the domain +example.com. TXT "v=spf1 ip4: -all" + +; DKIM — publish the public key for the selector OpenMail signs with +._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=" + +; DMARC — start at quarantine, tighten to reject after monitoring +_dmarc.example.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com; adkim=s; aspf=s" +``` + +## Secure transport — recommended (Mox supports both) + +``` +; MTA-STS — publish policy + serve https://mta-sts.example.com/.well-known/mta-sts.txt +_mta-sts.example.com. TXT "v=STSv1; id=20260621000000" +mta-sts.example.com. A + +; DANE/TLSA — pin the TLS cert for port 25 (requires DNSSEC on the zone) +_25._tcp.mail.example.com. TLSA 3 1 1 +``` + +## Notes + +- Many cloud providers block outbound port 25 by default — confirm your VPS allows + it (or request an unblock) before milestone 2/5. +- DANE/TLSA requires DNSSEC on the zone; skip it if your DNS host lacks DNSSEC and + rely on MTA-STS instead. +- Warm a new sending IP gradually; reputation is the real deliverability cost. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..b7220b6 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,58 @@ +# OpenMail local/self-host stack. See ARCHITECTURE.md §2. +# `docker compose up` brings up Postgres, MinIO, the openmail binary, and Caddy. +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: openmail + POSTGRES_PASSWORD: openmail + POSTGRES_DB: openmail + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U openmail"] + interval: 5s + timeout: 3s + retries: 10 + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: openmail + MINIO_ROOT_PASSWORD: openmail123 + volumes: + - miniodata:/data + ports: + - "9001:9001" # console (dev only) + + openmail: + build: .. + command: ["serve"] + environment: + OPENMAIL_HTTP_ADDR: ":8080" + DATABASE_URL: "postgres://openmail:openmail@postgres:5432/openmail?sslmode=disable" + OPENMAIL_ADMIN_TOKEN: "dev-change-me" + # S3/MinIO wiring lands with milestone 1 object-store client. + depends_on: + postgres: + condition: service_healthy + # Inbound :25 (milestone 2) — uncomment when smtpd lands: + # ports: + # - "25:25" + + caddy: + image: caddy:2-alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddydata:/data + depends_on: + - openmail + +volumes: + pgdata: + miniodata: + caddydata: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e007131 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/karti-ai/openmail + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.3.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/mjl-/mox v0.0.15 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea // indirect + github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978 // indirect + github.com/prometheus/client_golang v1.18.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.45.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.29.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..198c876 --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea h1:8dftsVL1tHhRksXzFZRhSJ7gSlcy/t87Nvucs3JnTGE= +github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea/go.mod h1:rWZMqGA2HoBm5b5q/A5J8u1sSVuEYh6zBz9tMoVs+RU= +github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978 h1:Eg5DfI3/00URzGErujKus6a3O0kyXzF8vjoDZzH/gig= +github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978/go.mod h1:QBkFtjai3AiQQuUu7pVh6PA06Vd3oa68E+vddf/UBOs= +github.com/mjl-/mox v0.0.15 h1:C3VDXwN33fEI5WTCuBEKZ6KSVh91aNMrFaFLM72ZU4M= +github.com/mjl-/mox v0.0.15/go.mod h1:Ebxm9+lCApzfS1XSmTISluQGjOVhpa7jesEOYGxEVZE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..3034f5a --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1,30 @@ +package api + +import ( + "crypto/subtle" + "net/http" + "strings" +) + +// bearerAuth is a bootstrap bearer-token gate against the configured admin +// token. Milestone 1+: replace with DB-backed api_keys lookup (hash compare, +// per-pod scoping). Until OPENMAIL_ADMIN_TOKEN is set, the API is closed. +func (s *Server) bearerAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.cfg.AdminToken == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "auth_unconfigured", + "message": "OPENMAIL_ADMIN_TOKEN not set; API is closed", + }) + return + } + const prefix = "Bearer " + h := r.Header.Get("Authorization") + if !strings.HasPrefix(h, prefix) || + subtle.ConstantTimeCompare([]byte(strings.TrimPrefix(h, prefix)), []byte(s.cfg.AdminToken)) != 1 { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..93adfc0 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,82 @@ +// Package api is OpenMail's agent-facing HTTP surface: the AgentMail-shaped v0 +// REST API (see ARCHITECTURE.md §4). v0 routes are stubbed pending the core +// services; health and auth are real. +package api + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "github.com/karti-ai/openmail/internal/config" + "github.com/karti-ai/openmail/internal/store" +) + +type Server struct { + cfg config.Config + store *store.Store +} + +func New(cfg config.Config, st *store.Store) *Server { + return &Server{cfg: cfg, store: st} +} + +func (s *Server) Router() http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + + // Unauthenticated liveness/readiness. + r.Get("/healthz", s.handleHealth) + + // Authenticated v0 surface. + r.Group(func(r chi.Router) { + r.Use(s.bearerAuth) + + r.Route("/v0/inboxes", func(r chi.Router) { + r.Post("/", notImplemented) // create inbox + r.Get("/", notImplemented) // list inboxes + r.Get("/{id}", notImplemented) // get inbox + r.Post("/{id}/messages/send", notImplemented) + r.Get("/{id}/messages", notImplemented) + r.Get("/{id}/messages/{msgID}", notImplemented) + r.Post("/{id}/messages/{msgID}/reply", notImplemented) + r.Get("/{id}/threads", notImplemented) + r.Get("/{id}/threads/{threadID}", notImplemented) + }) + }) + + return r +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + status := map[string]any{"status": "ok"} + if s.store != nil { + if err := s.store.Pool.Ping(r.Context()); err != nil { + status["status"] = "degraded" + status["db"] = err.Error() + writeJSON(w, http.StatusServiceUnavailable, status) + return + } + status["db"] = "ok" + } else { + status["db"] = "not configured" + } + writeJSON(w, http.StatusOK, status) +} + +func notImplemented(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusNotImplemented, map[string]string{ + "error": "not_implemented", + "message": "endpoint scaffolded; core service pending (milestone 1)", + }) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8cd3c02 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,30 @@ +// Package config loads OpenMail configuration from the environment. +// Twelve-factor style: everything via env vars, sane defaults for local dev. +package config + +import ( + "os" +) + +type Config struct { + HTTPAddr string // OPENMAIL_HTTP_ADDR, e.g. ":8080" + DatabaseURL string // DATABASE_URL, e.g. postgres://user:pass@host:5432/openmail + SMTPAddr string // OPENMAIL_SMTP_ADDR, inbound :25 listener + AdminToken string // OPENMAIL_ADMIN_TOKEN, bootstrap bearer until DB-backed api_keys land +} + +func Load() Config { + return Config{ + HTTPAddr: envOr("OPENMAIL_HTTP_ADDR", ":8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + SMTPAddr: envOr("OPENMAIL_SMTP_ADDR", ":25"), + AdminToken: os.Getenv("OPENMAIL_ADMIN_TOKEN"), + } +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/store/migrations/0001_init.sql b/internal/store/migrations/0001_init.sql new file mode 100644 index 0000000..d6625a4 --- /dev/null +++ b/internal/store/migrations/0001_init.sql @@ -0,0 +1,148 @@ +-- OpenMail initial schema. See ARCHITECTURE.md §3. +-- Native, agent-shaped data model (not Mox's per-account bbolt index). + +CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid() + +-- Tenant isolation. +CREATE TABLE IF NOT EXISTS pods ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Per-domain DKIM keys + DNS verification state. +CREATE TABLE IF NOT EXISTS domains ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE, + name text NOT NULL, + dkim_selector text, + dkim_privkey_ref text, -- pointer to key in secret store; never the key itself + verified boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (name) +); + +-- An agent-owned address; first-class API resource. +CREATE TABLE IF NOT EXISTS inboxes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE, + address text NOT NULL, + display_name text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (address) +); + +CREATE TABLE IF NOT EXISTS threads ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE, + subject text, + last_message_id uuid, + message_count integer NOT NULL DEFAULT 0, + labels text[] NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE, + thread_id uuid REFERENCES threads(id) ON DELETE SET NULL, + message_id_hdr text, -- RFC 5322 Message-ID + in_reply_to text, + "references" text[] NOT NULL DEFAULT '{}', + from_addr text, + to_addrs text[] NOT NULL DEFAULT '{}', + cc text[] NOT NULL DEFAULT '{}', + bcc text[] NOT NULL DEFAULT '{}', + subject text, + preview text, + text text, + html text, + extracted_text text, -- quoted history stripped + extracted_html text, + raw_object_key text, -- pointer to raw .eml in object store + spf text, -- inbound auth verdicts (from mox pkgs) + dkim text, + dmarc text, + junk_score real, + labels text[] NOT NULL DEFAULT '{}', + size_bytes bigint, + headers jsonb NOT NULL DEFAULT '{}'::jsonb, + ts tsvector, -- full-text search + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS attachments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + filename text, + content_type text, + size_bytes bigint, + object_key text NOT NULL, + inline boolean NOT NULL DEFAULT false, + content_id text +); + +CREATE TABLE IF NOT EXISTS drafts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE, + thread_id uuid REFERENCES threads(id) ON DELETE SET NULL, + to_addrs text[] NOT NULL DEFAULT '{}', + cc text[] NOT NULL DEFAULT '{}', + bcc text[] NOT NULL DEFAULT '{}', + subject text, + text text, + html text, + send_at timestamptz, + client_id text, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Bearer tokens; only the hash is stored. +CREATE TABLE IF NOT EXISTS api_keys ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE, + hash bytea NOT NULL, + scopes text[] NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (hash) +); + +CREATE TABLE IF NOT EXISTS webhooks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE, + url text NOT NULL, + event_types text[] NOT NULL DEFAULT '{}', + secret text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Outbound send queue + retries. +CREATE TABLE IF NOT EXISTS outbox ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + status text NOT NULL DEFAULT 'queued', -- queued|sending|sent|failed + attempts integer NOT NULL DEFAULT 0, + last_error text, + next_attempt_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inbox_id uuid REFERENCES inboxes(id) ON DELETE CASCADE, + type text NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_inboxes_pod ON inboxes(pod_id); +CREATE INDEX IF NOT EXISTS idx_threads_inbox ON threads(inbox_id); +CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id); +CREATE INDEX IF NOT EXISTS idx_messages_inbox_time ON messages(inbox_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages USING gin(ts); +CREATE INDEX IF NOT EXISTS idx_messages_labels ON messages USING gin(labels); +CREATE INDEX IF NOT EXISTS idx_threads_labels ON threads USING gin(labels); +CREATE INDEX IF NOT EXISTS idx_outbox_due ON outbox(next_attempt_at) WHERE status = 'queued'; diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..5a58424 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,100 @@ +// Package store owns OpenMail's Postgres-backed persistence. This is the +// deliberate divergence from Mox: OpenMail keeps its own native, agent-shaped +// data model (see ARCHITECTURE.md §3) rather than embedding Mox's bstore/bbolt +// account store. +package store + +import ( + "context" + "embed" + "fmt" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +type Store struct { + Pool *pgxpool.Pool +} + +// Open connects to Postgres and verifies the connection. +func Open(ctx context.Context, databaseURL string) (*Store, error) { + if databaseURL == "" { + return nil, fmt.Errorf("store: DATABASE_URL is empty") + } + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("store: connect: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("store: ping: %w", err) + } + return &Store{Pool: pool}, nil +} + +func (s *Store) Close() { + if s.Pool != nil { + s.Pool.Close() + } +} + +// Migrate applies any embedded migrations not yet recorded in schema_migrations, +// in filename order. Each migration runs in its own transaction. +func (s *Store) Migrate(ctx context.Context) error { + _, err := s.Pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + )`) + if err != nil { + return fmt.Errorf("migrate: ensure schema_migrations: %w", err) + } + + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + return fmt.Errorf("migrate: read embedded migrations: %w", err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".sql") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + + for _, name := range names { + var exists bool + if err := s.Pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)`, name, + ).Scan(&exists); err != nil { + return fmt.Errorf("migrate: check %s: %w", name, err) + } + if exists { + continue + } + sqlBytes, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + return fmt.Errorf("migrate: read %s: %w", name, err) + } + tx, err := s.Pool.Begin(ctx) + if err != nil { + return fmt.Errorf("migrate: begin %s: %w", name, err) + } + if _, err := tx.Exec(ctx, string(sqlBytes)); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("migrate: apply %s: %w", name, err) + } + if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES($1)`, name); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("migrate: record %s: %w", name, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("migrate: commit %s: %w", name, err) + } + } + return nil +} diff --git a/spike/mimecheck/main.go b/spike/mimecheck/main.go new file mode 100644 index 0000000..d26c53f --- /dev/null +++ b/spike/mimecheck/main.go @@ -0,0 +1,123 @@ +// Command mimecheck is a throwaway feasibility spike for OpenMail's core +// architectural bet (see ARCHITECTURE.md §0, §9): can Mox's `message` package +// parse a raw RFC 5322 message standalone — outside Mox's store/config/global +// state — given only an io.ReaderAt? +// +// If this builds and runs without dragging in mox-/store/config, "embed Mox as +// a library" (Option B) is viable. Run: go run ./spike/mimecheck +package main + +import ( + "bytes" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/mjl-/mox/message" +) + +// A deliberately messy real-world-ish message: multipart/alternative (text+html) +// with a reply quote, threading headers, and an attachment part. +const sampleEML = "From: Alice \r\n" + + "To: agent@openmail.test\r\n" + + "Subject: Re: invoice #42\r\n" + + "Message-ID: \r\n" + + "In-Reply-To: \r\n" + + "References: \r\n" + + "Date: Mon, 21 Jun 2026 12:00:00 +0000\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/mixed; boundary=\"OUTER\"\r\n" + + "\r\n" + + "--OUTER\r\n" + + "Content-Type: multipart/alternative; boundary=\"INNER\"\r\n" + + "\r\n" + + "--INNER\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "Thanks, looks good to me.\r\n" + + "\r\n" + + "On Mon, Alice wrote:\r\n" + + "> here is the invoice\r\n" + + "--INNER\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "\r\n" + + "

Thanks, looks good to me.

\r\n" + + "--INNER--\r\n" + + "--OUTER\r\n" + + "Content-Type: application/pdf; name=\"invoice.pdf\"\r\n" + + "Content-Disposition: attachment; filename=\"invoice.pdf\"\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "\r\n" + + "JVBERi0xLjQK\r\n" + + "--OUTER--\r\n" + +func main() { + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + r := bytes.NewReader([]byte(sampleEML)) + + // EnsurePart fully parses header + walks the MIME tree given the size. + p, err := message.EnsurePart(log, false, r, int64(len(sampleEML))) + if err != nil { + fmt.Fprintf(os.Stderr, "FAIL: parse error: %v\n", err) + os.Exit(1) + } + + fmt.Println("=== mox/message standalone parse OK ===") + if p.Envelope != nil { + e := p.Envelope + fmt.Printf("Subject: %s\n", e.Subject) + fmt.Printf("MessageID: %s\n", e.MessageID) + fmt.Printf("InReplyTo: %s\n", e.InReplyTo) + if len(e.From) > 0 { + fmt.Printf("From: %s@%s\n", e.From[0].User, e.From[0].Host) + } + } + fmt.Printf("Top type: %s/%s\n", p.MediaType, p.MediaSubType) + + // Walk the tree, summarizing each leaf — proves multipart traversal works. + var textBody string + var attachments int + var walk func(parts []message.Part, depth int) + walk = func(parts []message.Part, depth int) { + for i := range parts { + sp := &parts[i] + indent := strings.Repeat(" ", depth) + disp := "" + if sp.ContentDisposition != nil { + disp = " [" + *sp.ContentDisposition + "]" + } + fmt.Printf("%s- %s/%s%s\n", indent, sp.MediaType, sp.MediaSubType, disp) + if sp.ContentDisposition != nil && strings.EqualFold(*sp.ContentDisposition, "attachment") { + attachments++ + } + if sp.MediaType == "TEXT" && sp.MediaSubType == "PLAIN" && textBody == "" { + if buf, rerr := readPart(sp); rerr == nil { + textBody = string(buf) + } + } + walk(sp.Parts, depth+1) + } + } + fmt.Println("Structure:") + walk(p.Parts, 1) + + fmt.Printf("\nAttachments found: %d\n", attachments) + fmt.Printf("text/plain body:\n%s\n", indentBlock(textBody)) + fmt.Println("=== SPIKE PASSED: Mox message parsing is usable standalone ===") +} + +func readPart(p *message.Part) ([]byte, error) { + rd := p.Reader() + var b bytes.Buffer + _, err := b.ReadFrom(rd) + return b.Bytes(), err +} + +func indentBlock(s string) string { + out := []string{} + for _, line := range strings.Split(strings.TrimRight(s, "\r\n"), "\n") { + out = append(out, " | "+strings.TrimRight(line, "\r")) + } + return strings.Join(out, "\n") +}