Milestone 1 scaffold: binary skeleton, schema, deploy, Mox spike

- 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) <noreply@anthropic.com>
This commit is contained in:
karti-ai
2026-06-21 12:16:51 -07:00
parent aab2a8558e
commit 551739baa3
15 changed files with 889 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
/bin/
*.exe
.env
.DS_Store
+3 -2
View File
@@ -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
+25
View File
@@ -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 ./...
+141
View File
@@ -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 <command>
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
}
}
+6
View File
@@ -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
}
+49
View File
@@ -0,0 +1,49 @@
# DNS for self-hosting OpenMail
Replace `example.com` with your domain and `<VPS_IP>` 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 <VPS_IP>
mail.example.com. AAAA <VPS_IPv6> ; if available
; reverse DNS (PTR) — set at your VPS provider, must resolve mail.example.com
<VPS_IP> -> mail.example.com
```
## Outbound (send) — deliverability, milestone 3/5
```
; SPF — authorize this server to send for the domain
example.com. TXT "v=spf1 ip4:<VPS_IP> -all"
; DKIM — publish the public key for the selector OpenMail signs with
<selector>._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=<BASE64_PUBLIC_KEY>"
; 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 <VPS_IP>
; DANE/TLSA — pin the TLS cert for port 25 (requires DNSSEC on the zone)
_25._tcp.mail.example.com. TLSA 3 1 1 <SHA256_OF_CERT_SPKI>
```
## 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.
+58
View File
@@ -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:
+30
View File
@@ -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
)
+60
View File
@@ -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=
+30
View File
@@ -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)
})
}
+82
View File
@@ -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)
}
+30
View File
@@ -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
}
+148
View File
@@ -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';
+100
View File
@@ -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
}
+123
View File
@@ -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 <alice@example.com>\r\n" +
"To: agent@openmail.test\r\n" +
"Subject: Re: invoice #42\r\n" +
"Message-ID: <reply-2@example.com>\r\n" +
"In-Reply-To: <orig-1@openmail.test>\r\n" +
"References: <orig-1@openmail.test>\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" +
"<p>Thanks, looks good to me.</p>\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")
}