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:
@@ -0,0 +1,4 @@
|
||||
/bin/
|
||||
*.exe
|
||||
.env
|
||||
.DS_Store
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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 ./...
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user