diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..183b683 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Binaries +/arcline-email +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of `go env GOPATH` +/go/ + +# Dependency vendor directory +vendor/ + +# Go workspace +go.work +go.work.sum + +# Environment / secrets +*.env +.env* +*.pem +*.key + +# Config (use arcline-email.example.toml as template) +arcline-email.toml + +# Editor +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/arcline-email.example.toml b/arcline-email.example.toml new file mode 100644 index 0000000..3cb74f9 --- /dev/null +++ b/arcline-email.example.toml @@ -0,0 +1,25 @@ +# arcline-email configuration +# Copy to arcline-email.toml and adjust for your environment. + +[server] +hostname = "mail.example.com" + +[smtp] +inbound_addr = ":25" +submission_addr = ":587" +submission_tls_addr = ":465" + +[imap] +addr = ":143" +tls_addr = ":993" + +[tls] +cert_file = "/etc/arcline-email/cert.pem" +key_file = "/etc/arcline-email/key.pem" + +[storage] +maildir_root = "/var/mail" + +[logging] +level = "info" # debug | info | warn | error +format = "json" # json | text diff --git a/cmd/arcline-email/main.go b/cmd/arcline-email/main.go new file mode 100644 index 0000000..a6a2b08 --- /dev/null +++ b/cmd/arcline-email/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "arcline-email/config" +) + +func main() { + cfgPath := flag.String("config", "arcline-email.toml", "path to config file") + flag.Parse() + + cfg, err := config.Load(*cfgPath) + if err != nil { + slog.Error("failed to load config", "err", err) + os.Exit(1) + } + + logger := newLogger(cfg.Logging) + slog.SetDefault(logger) + + slog.Info("arcline-email starting", "hostname", cfg.Server.Hostname) + + // TODO: initialize and start servers (SMTP, IMAP) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + <-ctx.Done() + slog.Info("shutdown signal received") + stop() // release signal resources before shutdown + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // TODO: call server.Shutdown(shutdownCtx) for each listener + _ = shutdownCtx + + slog.Info("shutdown complete") +} + +func newLogger(cfg config.LoggingConfig) *slog.Logger { + var level slog.Level + if err := level.UnmarshalText([]byte(cfg.Level)); err != nil { + level = slog.LevelInfo + } + + opts := &slog.HandlerOptions{Level: level} + + if cfg.Format == "text" { + return slog.New(slog.NewTextHandler(os.Stdout, opts)) + } + return slog.New(slog.NewJSONHandler(os.Stdout, opts)) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..cd2687e --- /dev/null +++ b/config/config.go @@ -0,0 +1,78 @@ +package config + +import ( + "fmt" + + "github.com/BurntSushi/toml" +) + +type Config struct { + Server ServerConfig `toml:"server"` + SMTP SMTPConfig `toml:"smtp"` + IMAP IMAPConfig `toml:"imap"` + TLS TLSConfig `toml:"tls"` + Storage StorageConfig `toml:"storage"` + Logging LoggingConfig `toml:"logging"` +} + +type ServerConfig struct { + Hostname string `toml:"hostname"` +} + +type SMTPConfig struct { + InboundAddr string `toml:"inbound_addr"` + SubmissionAddr string `toml:"submission_addr"` + SubmissionTLSAddr string `toml:"submission_tls_addr"` +} + +type IMAPConfig struct { + Addr string `toml:"addr"` + TLSAddr string `toml:"tls_addr"` +} + +type TLSConfig struct { + CertFile string `toml:"cert_file"` + KeyFile string `toml:"key_file"` +} + +type StorageConfig struct { + MaildirRoot string `toml:"maildir_root"` +} + +type LoggingConfig struct { + Level string `toml:"level"` // debug, info, warn, error + Format string `toml:"format"` // json, text +} + +// Load reads the TOML config file at path, applying defaults for any missing fields. +func Load(path string) (*Config, error) { + cfg := defaults() + if _, err := toml.DecodeFile(path, cfg); err != nil { + return nil, fmt.Errorf("decode %s: %w", path, err) + } + return cfg, nil +} + +func defaults() *Config { + return &Config{ + Server: ServerConfig{ + Hostname: "localhost", + }, + SMTP: SMTPConfig{ + InboundAddr: ":25", + SubmissionAddr: ":587", + SubmissionTLSAddr: ":465", + }, + IMAP: IMAPConfig{ + Addr: ":143", + TLSAddr: ":993", + }, + Storage: StorageConfig{ + MaildirRoot: "/var/mail", + }, + Logging: LoggingConfig{ + Level: "info", + Format: "json", + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a046d60 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module arcline-email + +go 1.25.8 + +require github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/todo.md b/todo.md index 4cb5cd1..7f36058 100644 --- a/todo.md +++ b/todo.md @@ -3,12 +3,12 @@ ## Phase 1: MVP (Core Mail Flow) ### Project Setup -- [ ] Initialize Go module (`go mod init arcline-email`) -- [ ] Set up directory structure (`cmd/`, `internal/`, `config/`) -- [ ] Add `.gitignore` -- [ ] Wire up config parsing (TOML) -- [ ] Structured logging (`log/slog`) -- [ ] Graceful shutdown (signal handling) +- [x] Initialize Go module (`go mod init arcline-email`) +- [x] Set up directory structure (`cmd/`, `internal/`, `config/`) +- [x] Add `.gitignore` +- [x] Wire up config parsing (TOML) +- [x] Structured logging (`log/slog`) +- [x] Graceful shutdown (signal handling) ### SMTP — Inbound (Port 25) - [ ] Basic SMTP listener using `emersion/go-smtp`