first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user