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,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