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
+308
View File
@@ -0,0 +1,308 @@
// Package config loads gomail.yaml, applies environment variable overrides for
// secrets, and generates a documented example config on first run.
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
TLS TLSConfig `yaml:"tls"`
Database DatabaseConfig `yaml:"database"`
Storage StorageConfig `yaml:"storage"`
RateLimits RateLimitConfig `yaml:"rate_limits"`
Pipeline PipelineConfig `yaml:"pipeline"`
Notify NotifyConfig `yaml:"notify"`
POP3 POP3Config `yaml:"pop3"`
JMAP JMAPConfig `yaml:"jmap"`
OAuth OAuthConfig `yaml:"oauth"`
LinkedAccounts LinkedAccountsConfig `yaml:"linked_accounts"`
Security SecurityConfig `yaml:"-"` // populated entirely from env, never serialized
}
type ServerConfig struct {
Hostname string `yaml:"hostname"`
SMTPAddr string `yaml:"smtp_addr"`
SubmissionAddr string `yaml:"submission_addr"`
SMTPSAddr string `yaml:"smtps_addr"`
IMAPAddr string `yaml:"imap_addr"`
IMAPSAddr string `yaml:"imaps_addr"`
WebmailAddr string `yaml:"webmail_addr"`
AdminAddr string `yaml:"admin_addr"`
DAVAddr string `yaml:"dav_addr"`
ManageSieveAddr string `yaml:"managesieve_addr"`
RealIPHeader string `yaml:"real_ip_header"`
AdminIPAllowlist []string `yaml:"admin_ip_allowlist"`
}
type TLSConfig struct {
Mode string `yaml:"mode"` // acme | file | off
ACMEEmail string `yaml:"acme_email"`
ACMEDomains []string `yaml:"acme_domains"`
ACMEDirectoryURL string `yaml:"acme_directory_url"` // defaults to real Let's Encrypt production; override for staging/testing
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
MinVersion string `yaml:"min_version"`
}
type DatabaseConfig struct {
Driver string `yaml:"driver"` // sqlite | postgres | mysql
DSN string `yaml:"dsn"`
}
type StorageConfig struct {
MaildirRoot string `yaml:"maildir_root"`
RetentionDays int `yaml:"retention_days"`
QuarantineDays int `yaml:"quarantine_days"`
MaxMessageSizeMB int `yaml:"max_message_size_mb"`
}
type RateLimitConfig struct {
SMTPConnPerMin int `yaml:"smtp_conn_per_min"`
SMTPAuthFailures int `yaml:"smtp_auth_failures"`
IMAPConnPerMin int `yaml:"imap_conn_per_min"`
IMAPAuthFailures int `yaml:"imap_auth_failures"`
POP3AuthFailures int `yaml:"pop3_auth_failures"`
HTTPReqPerMin int `yaml:"http_req_per_min"`
}
type PipelineConfig struct {
ScoreFlag float64 `yaml:"score_flag"`
ScoreQuarantine float64 `yaml:"score_quarantine"`
ScoreBlock float64 `yaml:"score_block"`
ClamAVSocket string `yaml:"clamav_socket"`
RspamdURL string `yaml:"rspamd_url"`
LLMURL string `yaml:"llm_url"`
LLMModel string `yaml:"llm_model"`
LLMTimeoutSecs int `yaml:"llm_timeout_secs"`
}
type NotifyConfig struct {
SMTPHost string `yaml:"smtp_host"`
SMTPPort int `yaml:"smtp_port"`
SMTPUser string `yaml:"smtp_user"`
FromAddress string `yaml:"from_address"`
DefaultDigestIntervalMins int `yaml:"default_digest_interval_mins"`
}
type POP3Config struct {
Enabled bool `yaml:"enabled"` // off by default — legacy, opt-in
POP3Addr string `yaml:"pop3_addr"`
POP3SAddr string `yaml:"pop3s_addr"`
}
type JMAPConfig struct {
ExternalEnabled bool `yaml:"external_enabled"` // off by default
ExternalAddr string `yaml:"external_addr"`
}
type OAuthConfig struct {
Google OAuthProviderConfig `yaml:"google"`
Microsoft OAuthProviderConfig `yaml:"microsoft"`
}
type OAuthProviderConfig struct {
Enabled bool `yaml:"enabled"`
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret,omitempty"` // prefer env override
Tenant string `yaml:"tenant,omitempty"` // microsoft only
RedirectURI string `yaml:"redirect_uri"`
}
type LinkedAccountsConfig struct {
DefaultCacheRetention string `yaml:"default_cache_retention"` // e.g. "90d"
MaxCacheRetention string `yaml:"max_cache_retention"` // e.g. "3y"
CacheSweepInterval string `yaml:"cache_sweep_interval"` // e.g. "24h"
SyncPollIntervalSecs int `yaml:"sync_poll_interval_secs"`
}
// SecurityConfig holds every secret. Populated ONLY from environment variables —
// never read from or written to the YAML config file.
type SecurityConfig struct {
MasterKey string // GOMAIL_MASTER_KEY (32-byte hex)
MasterKeyPrev string // GOMAIL_MASTER_KEY_PREV (during rotation)
JWTSecret string // GOMAIL_JWT_SECRET
AdminInitPassword string // GOMAIL_ADMIN_INIT_PASSWORD
NotifySMTPPassword string // GOMAIL_NOTIFY_SMTP_PASSWORD
OAuthGoogleSecret string // GOMAIL_OAUTH_GOOGLE_SECRET
OAuthMicrosoftSecret string // GOMAIL_OAUTH_MICROSOFT_SECRET
DBDSNOverride string // GOMAIL_DB_DSN
BcryptCost int // GOMAIL_BCRYPT_COST (default 12)
}
// Load reads the YAML config at path, auto-generating a default one if it does
// not exist, then applies environment variable overrides for all secrets.
func Load(path string) (*Config, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := writeDefault(path); err != nil {
return nil, fmt.Errorf("generating default config: %w", err)
}
fmt.Printf("No config found — generated default at %s. Review it before production use.\n", path)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
cfg := Default()
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
applyEnvOverrides(cfg)
if err := validate(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func applyEnvOverrides(cfg *Config) {
cfg.Security = SecurityConfig{
MasterKey: os.Getenv("GOMAIL_MASTER_KEY"),
MasterKeyPrev: os.Getenv("GOMAIL_MASTER_KEY_PREV"),
JWTSecret: os.Getenv("GOMAIL_JWT_SECRET"),
AdminInitPassword: os.Getenv("GOMAIL_ADMIN_INIT_PASSWORD"),
NotifySMTPPassword: os.Getenv("GOMAIL_NOTIFY_SMTP_PASSWORD"),
OAuthGoogleSecret: os.Getenv("GOMAIL_OAUTH_GOOGLE_SECRET"),
OAuthMicrosoftSecret: os.Getenv("GOMAIL_OAUTH_MICROSOFT_SECRET"),
DBDSNOverride: os.Getenv("GOMAIL_DB_DSN"),
BcryptCost: 12,
}
if cfg.Security.DBDSNOverride != "" {
cfg.Database.DSN = cfg.Security.DBDSNOverride
}
if cfg.Security.OAuthGoogleSecret != "" {
cfg.OAuth.Google.ClientSecret = cfg.Security.OAuthGoogleSecret
}
if cfg.Security.OAuthMicrosoftSecret != "" {
cfg.OAuth.Microsoft.ClientSecret = cfg.Security.OAuthMicrosoftSecret
}
}
func validate(cfg *Config) error {
if cfg.Security.MasterKey == "" {
return fmt.Errorf("GOMAIL_MASTER_KEY environment variable is required (32-byte hex — generate with: openssl rand -hex 32)")
}
if len(cfg.Security.MasterKey) != 64 {
return fmt.Errorf("GOMAIL_MASTER_KEY must be 64 hex characters (32 bytes), got %d characters", len(cfg.Security.MasterKey))
}
if cfg.Security.JWTSecret == "" {
return fmt.Errorf("GOMAIL_JWT_SECRET environment variable is required (generate with: openssl rand -hex 32)")
}
if len(cfg.Security.JWTSecret) < 32 {
return fmt.Errorf("GOMAIL_JWT_SECRET must be at least 32 characters, got %d (generate with: openssl rand -hex 32)", len(cfg.Security.JWTSecret))
}
if cfg.Server.Hostname == "" {
return fmt.Errorf("server.hostname must be set in config")
}
return nil
}
// Default returns a Config populated with sane defaults (used as the base
// before YAML unmarshal, so any keys missing from the file keep these values).
func Default() *Config {
return &Config{
Server: ServerConfig{
Hostname: "mail.example.com",
SMTPAddr: ":25",
SubmissionAddr: ":587",
SMTPSAddr: ":465",
IMAPAddr: ":143",
IMAPSAddr: ":993",
WebmailAddr: "127.0.0.1:8080",
AdminAddr: "127.0.0.1:9090",
DAVAddr: "127.0.0.1:8443",
ManageSieveAddr: ":4190",
RealIPHeader: "X-Forwarded-For",
AdminIPAllowlist: []string{"127.0.0.1", "::1"},
},
TLS: TLSConfig{
Mode: "acme",
ACMEDirectoryURL: "https://acme-v02.api.letsencrypt.org/directory",
MinVersion: "TLS12",
},
Database: DatabaseConfig{
Driver: "sqlite",
DSN: "file:/var/lib/gomail/gomail.db?_journal_mode=WAL&_foreign_keys=on",
},
Storage: StorageConfig{
MaildirRoot: "/var/mail/gomail",
RetentionDays: 365,
QuarantineDays: 30,
MaxMessageSizeMB: 50,
},
RateLimits: RateLimitConfig{
SMTPConnPerMin: 20,
SMTPAuthFailures: 5,
IMAPConnPerMin: 60,
IMAPAuthFailures: 5,
POP3AuthFailures: 5,
HTTPReqPerMin: 120,
},
Pipeline: PipelineConfig{
ScoreFlag: 20,
ScoreQuarantine: 50,
ScoreBlock: 80,
LLMModel: "llama3.2-3b-instruct",
LLMTimeoutSecs: 30,
},
Notify: NotifyConfig{
SMTPPort: 587,
FromAddress: "noreply@example.com",
DefaultDigestIntervalMins: 60,
},
POP3: POP3Config{
Enabled: false,
POP3Addr: ":110",
POP3SAddr: ":995",
},
JMAP: JMAPConfig{
ExternalEnabled: false,
ExternalAddr: "0.0.0.0:8443",
},
OAuth: OAuthConfig{
Google: OAuthProviderConfig{Enabled: false},
Microsoft: OAuthProviderConfig{Enabled: false, Tenant: "common"},
},
LinkedAccounts: LinkedAccountsConfig{
DefaultCacheRetention: "90d",
MaxCacheRetention: "3y",
CacheSweepInterval: "24h",
SyncPollIntervalSecs: 120,
},
}
}
func writeDefault(path string) error {
cfg := Default()
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
header := `# gomail.yaml — auto-generated. Review before production use.
#
# Secrets are NOT stored here — set these environment variables instead:
# GOMAIL_MASTER_KEY 32-byte hex, message/contact/calendar encryption key
# generate with: openssl rand -hex 32
# GOMAIL_JWT_SECRET 32+ byte random, session signing
# generate with: openssl rand -hex 32
# GOMAIL_ADMIN_INIT_PASSWORD first-run global admin password
# GOMAIL_DB_DSN overrides database.dsn below
# GOMAIL_NOTIFY_SMTP_PASSWORD outbound notification SMTP password
# GOMAIL_OAUTH_GOOGLE_SECRET Google OAuth2 client secret
# GOMAIL_OAUTH_MICROSOFT_SECRET Microsoft OAuth2 client secret
# GOMAIL_MASTER_KEY_PREV previous master key, only during key rotation
`
full := append([]byte(header), data...)
return os.WriteFile(path, full, 0640)
}