first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+382
View File
@@ -0,0 +1,382 @@
package main
import (
"context"
"crypto/tls"
"flag"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gomail/internal/acme"
"gomail/internal/admin"
"gomail/internal/config"
"gomail/internal/crypto"
"gomail/internal/dav"
"gomail/internal/db"
"gomail/internal/imap"
"gomail/internal/jmap"
"gomail/internal/mailstore"
"gomail/internal/managesieve"
"gomail/internal/oauth2"
"gomail/internal/pipeline"
"gomail/internal/ratelimit"
"gomail/internal/pop3"
"gomail/internal/queue"
"gomail/internal/smtp"
"gomail/internal/tlsutil"
"gomail/internal/webmail"
)
var version = "1.0.0-phase15"
// ipAllowlistMiddleware rejects any request whose client IP (resolved the
// same way ratelimit.HTTPMiddleware does) isn't in allowlist. An empty
// allowlist is a no-op (matches this project's convention elsewhere of an
// unset/zero config value meaning "don't restrict" — see
// ratelimit.New's rate=0 handling).
func ipAllowlistMiddleware(allowlist []string, realIPHeader string, next http.Handler) http.Handler {
if len(allowlist) == 0 {
return next
}
allowed := make(map[string]bool, len(allowlist))
for _, ip := range allowlist {
allowed[ip] = true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !allowed[ratelimit.ClientIP(r, realIPHeader)] {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
var (
cfgPath = flag.String("config", "gomail.yaml", "path to config file")
showVersion = flag.Bool("version", false, "print version and exit")
genKey = flag.Bool("gen-master-key", false, "generate a new master key and exit")
debug = flag.Bool("debug", false, "enable debug logging")
)
flag.Parse()
if *showVersion {
fmt.Printf("gomail %s\n", version)
os.Exit(0)
}
if *genKey {
key, err := crypto.GenerateMasterKeyHex()
if err != nil {
fmt.Fprintf(os.Stderr, "error generating key: %v\n", err)
os.Exit(1)
}
fmt.Printf("GOMAIL_MASTER_KEY=%s\n", key)
os.Exit(0)
}
logLevel := slog.LevelInfo
if *debug {
logLevel = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})))
slog.Info("gomail starting", "version", version)
cfg, err := config.Load(*cfgPath)
if err != nil {
slog.Error("config error", "err", err)
fmt.Fprintln(os.Stderr, "\nHint: generate a master key with: gomail -gen-master-key")
os.Exit(1)
}
slog.Info("config loaded", "hostname", cfg.Server.Hostname, "db_driver", cfg.Database.Driver)
mk, err := crypto.LoadMasterKey(cfg.Security.MasterKey, cfg.Security.MasterKeyPrev)
if err != nil {
slog.Error("master key error", "err", err)
os.Exit(1)
}
slog.Info("master key loaded", "rotation_active", mk.Previous != nil)
database, err := db.Open(cfg.Database.Driver, cfg.Database.DSN)
if err != nil {
slog.Error("database open failed", "err", err)
os.Exit(1)
}
defer database.Close()
if err := database.Migrate(); err != nil {
slog.Error("migration failed", "err", err)
os.Exit(1)
}
if err := database.Bootstrap(cfg.Server.Hostname, cfg.Security.AdminInitPassword, cfg.Security.BcryptCost, mk); err != nil {
slog.Error("bootstrap failed", "err", err)
os.Exit(1)
}
store := mailstore.New(cfg.Storage.MaildirRoot, mk, database)
var tlsConf *tls.Config
var acmeManager *tlsutil.ACMEManager
var acmeChallengeServer *http.Server
if cfg.TLS.Mode == "acme" && len(cfg.TLS.ACMEDomains) > 0 {
// Real ACME issuance — the challenge responder needs to be reachable
// on plain :80 for the CA's HTTP-01 validator to connect to, exactly
// as it would for a real Let's Encrypt issuance.
responder := acme.NewChallengeResponder()
challengeMux := http.NewServeMux()
challengeMux.Handle("/.well-known/acme-challenge/", responder)
acmeChallengeServer = &http.Server{Addr: ":80", Handler: challengeMux}
go func() {
if err := acmeChallengeServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("ACME challenge responder failed to start on :80 — HTTP-01 issuance will fail until this is fixed", "err", err)
}
}()
acmeManager = tlsutil.NewACMEManager(database, mk, cfg.TLS.ACMEDirectoryURL, cfg.TLS.ACMEEmail, responder)
tlsConf = acmeManager.TLSConfig()
slog.Info("TLS: real ACME issuance configured", "directory", cfg.TLS.ACMEDirectoryURL, "domains", cfg.TLS.ACMEDomains)
} else {
var err error
tlsConf, err = tlsutil.LoadOrGenerate(
cfg.TLS.Mode, cfg.Server.Hostname, cfg.TLS.CertFile, cfg.TLS.KeyFile,
tlsutil.ParseMinVersion(cfg.TLS.MinVersion),
)
if err != nil {
slog.Error("TLS setup failed", "err", err)
os.Exit(1)
}
}
orch := pipeline.NewOrchestrator(cfg, pipeline.StagesFromConfig(cfg))
if cfg.Pipeline.ClamAVSocket != "" {
slog.Info("pipeline: ClamAV stage enabled", "addr", cfg.Pipeline.ClamAVSocket)
}
if cfg.Pipeline.RspamdURL != "" {
slog.Info("pipeline: Rspamd stage enabled", "url", cfg.Pipeline.RspamdURL)
}
if cfg.Pipeline.LLMURL != "" {
slog.Info("pipeline: LLM stage enabled", "url", cfg.Pipeline.LLMURL, "model", cfg.Pipeline.LLMModel)
}
smtpServer := smtp.NewServer(cfg, database, store, tlsConf, orch)
// Outbound queue worker — MX delivery with DKIM signing per sending domain.
// The key lookup runs fresh on every send (not cached at startup) so key
// rotation via the admin portal (Phase 8+) takes effect without a restart.
queueWorker := queue.NewWorker(database, store).
WithDeliverer(&queue.MXDeliverer{Hostname: cfg.Server.Hostname}).
WithKeyLookup(
func(fromDomain string) ([]byte, string, bool) {
dom, err := database.LookupDomainByName(fromDomain)
if err != nil || dom == nil || len(dom.DKIMPrivateKeyEnc) == 0 {
return nil, "", false
}
plainKey, err := crypto.Decrypt(mk, dom.ID, "dkim-key", dom.DKIMPrivateKeyEnc)
if err != nil {
slog.Warn("failed to decrypt DKIM key, sending unsigned", "domain", fromDomain, "err", err)
return nil, "", false
}
return plainKey, dom.DKIMSelector, true
},
)
ctx, cancel := context.WithCancel(context.Background())
go func() {
if err := smtpServer.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
slog.Error("SMTP server error", "err", err)
os.Exit(1)
}
}()
go queueWorker.Run()
if acmeManager != nil {
renewalDomains := append([]string{}, cfg.TLS.ACMEDomains...)
go acmeManager.StartRenewalLoop(ctx, renewalDomains, 24*time.Hour)
slog.Info("ACME renewal loop started", "check_interval", "24h", "domains", renewalDomains)
}
imapServer := imap.NewServer(database, store, tlsConf, cfg.Server.Hostname, cfg.RateLimits.IMAPConnPerMin, cfg.RateLimits.IMAPAuthFailures)
go func() {
if err := imapServer.ListenAndServe(ctx, cfg.Server.IMAPAddr, cfg.Server.IMAPSAddr); err != nil && ctx.Err() == nil {
slog.Error("IMAP server error", "err", err)
os.Exit(1)
}
}()
var pop3Server *pop3.Server
if cfg.POP3.Enabled {
pop3Server = pop3.NewServer(database, store, tlsConf, cfg.Server.Hostname, cfg.RateLimits.POP3AuthFailures)
go func() {
if err := pop3Server.ListenAndServe(ctx, cfg.POP3.POP3Addr, cfg.POP3.POP3SAddr); err != nil && ctx.Err() == nil {
slog.Error("POP3 server error", "err", err)
os.Exit(1)
}
}()
slog.Info("POP3 enabled", "addr", cfg.POP3.POP3Addr, "tls_addr", cfg.POP3.POP3SAddr)
} else {
slog.Info("POP3 disabled (pop3.enabled: false in config)")
}
sieveServer := managesieve.NewServer(database, tlsConf, cfg.Server.Hostname)
go func() {
if err := sieveServer.ListenAndServe(ctx, cfg.Server.ManageSieveAddr); err != nil && ctx.Err() == nil {
slog.Error("ManageSieve server error", "err", err)
os.Exit(1)
}
}()
slog.Info("ManageSieve listening", "addr", cfg.Server.ManageSieveAddr)
// Shared across every HTTP-based listener (DAV, webmail, admin, JMAP) —
// one limiter, one per-IP budget across all of them combined, since
// they're all reachable by the same set of clients and a client hitting
// its limit on one shouldn't get a fresh budget by switching to another.
httpLimiter := ratelimit.New(cfg.RateLimits.HTTPReqPerMin)
davHandler := dav.NewHandler(database, mk)
davMux := http.NewServeMux()
davMux.Handle("/dav/", davHandler)
davServer := &http.Server{Addr: cfg.Server.DAVAddr, Handler: httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, davMux), TLSConfig: tlsConf}
go func() {
var err error
if tlsConf != nil {
err = davServer.ListenAndServeTLS("", "")
} else {
err = davServer.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
slog.Error("DAV server error", "err", err)
os.Exit(1)
}
}()
slog.Info("CalDAV/CardDAV listening", "addr", cfg.Server.DAVAddr)
oauthConfigs := buildOAuthConfigs(cfg)
webmailHandler := webmail.NewHandler(database, store, mk, cfg.Security.JWTSecret, oauthConfigs)
webmailMux := http.NewServeMux()
webmailHandler.RegisterRoutes(webmailMux)
// JMAP is always mounted on the webmail address (internal use — the
// webmail SPA itself still calls the Phase 8 REST API, not this, but
// JMAP is independently available here for testing/tooling). Per
// config, it's ADDITIONALLY exposed on its own external address when
// jmap.external_enabled is true — same handler, just a second listener,
// matching the plan's "toggle changes binding, not implementation".
jmapHandler := jmap.NewHandler(database, store, cfg.Server.Hostname)
jmapHandler.RegisterRoutes(webmailMux)
staticFS, _ := fs.Sub(webmail.StaticFS, "static")
webmailMux.Handle("/", http.FileServer(http.FS(staticFS)))
webmailServer := &http.Server{Addr: cfg.Server.WebmailAddr, Handler: httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, webmailMux)}
go func() {
if err := webmailServer.ListenAndServe(); err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
slog.Error("webmail server error", "err", err)
os.Exit(1)
}
}()
slog.Info("webmail listening", "addr", cfg.Server.WebmailAddr)
slog.Info("JMAP listening (internal, mounted on webmail address)", "session_url", cfg.Server.WebmailAddr+"/.well-known/jmap")
var jmapExternalServer *http.Server
if cfg.JMAP.ExternalEnabled {
jmapMux := http.NewServeMux()
jmapHandler.RegisterRoutes(jmapMux)
jmapExternalServer = &http.Server{Addr: cfg.JMAP.ExternalAddr, Handler: httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, jmapMux)}
go func() {
if err := jmapExternalServer.ListenAndServe(); err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
slog.Error("external JMAP server error", "err", err)
}
}()
slog.Info("JMAP externally exposed", "addr", cfg.JMAP.ExternalAddr)
} else {
slog.Info("JMAP external exposure disabled (jmap.external_enabled: false in config)")
}
adminHandler := admin.NewHandler(database, mk, cfg.Security.JWTSecret)
adminMux := http.NewServeMux()
adminHandler.RegisterRoutes(adminMux)
adminStaticFS, _ := fs.Sub(admin.StaticFS, "static")
adminMux.Handle("/", http.FileServer(http.FS(adminStaticFS)))
adminHTTPHandler := httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, adminMux)
adminHTTPHandler = ipAllowlistMiddleware(cfg.Server.AdminIPAllowlist, cfg.Server.RealIPHeader, adminHTTPHandler)
adminServer := &http.Server{Addr: cfg.Server.AdminAddr, Handler: adminHTTPHandler}
go func() {
if err := adminServer.ListenAndServe(); err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
slog.Error("admin server error", "err", err)
os.Exit(1)
}
}()
slog.Info("admin portal listening", "addr", cfg.Server.AdminAddr)
slog.Info("gomail is running",
"smtp", cfg.Server.SMTPAddr,
"submission", cfg.Server.SubmissionAddr,
"smtps", cfg.Server.SMTPSAddr,
"imap", cfg.Server.IMAPAddr,
"imaps", cfg.Server.IMAPSAddr)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
slog.Info("shutdown signal received", "signal", sig)
cancel()
queueWorker.Stop()
smtpServer.Shutdown(30 * time.Second)
imapServer.Shutdown(30 * time.Second)
if pop3Server != nil {
pop3Server.Shutdown(30 * time.Second)
}
davServer.Close()
webmailServer.Close()
if jmapExternalServer != nil {
jmapExternalServer.Close()
}
sieveServer.Shutdown(30 * time.Second)
adminServer.Close()
if acmeChallengeServer != nil {
acmeChallengeServer.Close()
}
slog.Info("shutdown complete")
}
// buildOAuthConfigs turns the operator's configured Client ID/Secret pairs
// into ready-to-use oauth2.Config values, keyed by provider name. A
// provider absent from the map (or with Enabled: false) means webmail's
// "Add Google/Microsoft Account" simply isn't offered — matching the
// plan's "self-hosted operators register their own app" decision; there is
// no GoMail-shared fallback.
func buildOAuthConfigs(cfg *config.Config) map[string]*oauth2.Config {
configs := map[string]*oauth2.Config{}
if cfg.OAuth.Google.Enabled {
authURL, tokenURL, err := oauth2.WellKnownEndpoints("google")
if err == nil {
configs["google"] = &oauth2.Config{
ClientID: cfg.OAuth.Google.ClientID, ClientSecret: cfg.OAuth.Google.ClientSecret,
RedirectURI: cfg.OAuth.Google.RedirectURI, AuthURL: authURL, TokenURL: tokenURL,
Scopes: []string{"https://mail.google.com/", "email"},
}
}
}
if cfg.OAuth.Microsoft.Enabled {
authURL, tokenURL, err := oauth2.WellKnownEndpoints("microsoft")
if err == nil {
configs["microsoft"] = &oauth2.Config{
ClientID: cfg.OAuth.Microsoft.ClientID, ClientSecret: cfg.OAuth.Microsoft.ClientSecret,
RedirectURI: cfg.OAuth.Microsoft.RedirectURI, AuthURL: authURL, TokenURL: tokenURL,
Scopes: []string{"https://outlook.office.com/IMAP.AccessAsUser.All", "offline_access", "email"},
}
}
}
return configs
}