309 lines
10 KiB
Go
309 lines
10 KiB
Go
// 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)
|
||
|
|
}
|