551739baa3
- 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>
124 lines
3.8 KiB
Go
124 lines
3.8 KiB
Go
// 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")
|
|
}
|