Files
mailgosend/internal/config/config.go
T
2026-05-24 17:15:48 +00:00

824 lines
27 KiB
Go

// Package config loads and auto-generates app_config.conf.
// INI-style: KEY = value, # comments, blank lines ignored.
// Missing keys appended on each startup — existing values preserved.
// Env var MAILGO_<KEY> overrides file value.
package config
import (
"bufio"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"os"
"strconv"
"strings"
)
const ConfigPath = "./app_config.conf"
// Config holds all runtime configuration.
type Config struct {
// Identity
Hostname string // FQDN for SMTP HELO, TLS SNI, URL building
DefaultDomain string // Primary mail domain
// Network — SMTP
SMTPIface string
SMTPPort int
SubmitIface string
SubmitPort int // 587 STARTTLS
SMTPSPort int // 465 implicit TLS
SMTPEnabled bool
SubmitEnabled bool
SMTPSEnabled bool
// Network — IMAP
IMAPIface string
IMAPPort int // 143 STARTTLS
IMAPSPort int // 993 implicit TLS
IMAPEnabled bool
IMAPSEnabled bool
// Network — Web
WebClientIface string
WebClientPort int
WebAdminIface string
WebAdminPort int
CalDAVIface string
CalDAVPort int
// TLS
TLSMode string // dns01 | http01 | manual | off
TLSCert string // manual: path to cert.pem
TLSKey string // manual: path to key.pem
// Per-service overrides (empty = use global TLS)
SMTPTLSCert string
SMTPTLSKey string
IMAPTLSCert string
IMAPTLSKey string
WebTLSCert string
WebTLSKey string
// ACME (dns01 / http01)
ACMEEmail string
ACMECacheDir string
ACMEStaging bool
ACMEDomains []string // domains to certify; empty = just Hostname
// DNS-01 provider
ACMEDNSProvider string // cloudflare | route53 | digitalocean | hetzner | ...
// Cloudflare
CFDNSAPIToken string
CFAPIKey string
CFAPIEmail string
// Route53
AWSRegion string
AWSAccessKeyID string
AWSSecretAccessKey string
AWSHostedZoneID string
// DigitalOcean
DOAuthToken string
// Hetzner
HetznerAPIKey string
// Generic: any additional lego env vars should be exported before starting.
// Secrets (auto-generated on first run — BACK UP app_config.conf)
EncryptionKey []byte // 32 bytes, AES-256 master key
SessionSecret []byte // session cookie signing
// Database
DBDriver string // sqlite | postgres | mysql | mssql
DBPath string // SQLite path
DBDSN string // PostgreSQL/MySQL/MSSQL DSN
// Storage
StorageBackend string // db | fs
StorageFSPath string // base path for fs storage
// Security
MaxMessageSize int64
SessionMaxAge int // seconds
BruteMaxTries int
BruteWindowMin int
BruteBanHours int
TrustedProxies []net.IPNet
SecureCookie bool
BruteWhitelist []net.IP
// SMTP server
SMTPHostname string // override for SMTP HELO (defaults to Hostname)
MaxRcptPer int // max recipients per message
QueueMaxAgeHours int
QueueRetryMins []int // backoff schedule
DNSPrimary string
DNSSecondary string
// DKIM
DKIMSelector string
DKIMAlgo string // rsa2048 | ed25519
// Spam
SpamThreshold int
SpamDNSBL []string
SpamCheckSPF bool
SpamCheckDKIM bool
SpamCheckDMARC bool
// OAuth2 (external accounts)
GoogleClientID string
GoogleClientSecret string
MicrosoftClientID string
MicrosoftClientSecret string
MicrosoftTenantID string
// Debug
Debug bool
LogFile string
LogLevel string // debug | info | warn | error
}
// field drives both config file generation and value parsing.
type field struct {
key string
defVal string
comments []string
secret bool // true = never shown in logs
}
var allFields = []field{
// --- Identity ---
{key: "HOSTNAME", defVal: "mail.example.com", comments: []string{
"--- Server Identity ---",
"FQDN used for SMTP HELO/EHLO, TLS SNI, and URL building.",
"Must resolve in DNS if using TLS_MODE=autocert/dns01/http01.",
}},
{key: "DEFAULT_DOMAIN", defVal: "example.com", comments: []string{
"Primary mail domain served by this instance.",
}},
// --- SMTP ---
{key: "SMTP_IFACE", defVal: "0.0.0.0", comments: []string{
"--- SMTP Server (Inbound MTA) ---",
"Network interface to bind SMTP port 25.",
}},
{key: "SMTP_PORT", defVal: "25"},
{key: "SMTP_ENABLED", defVal: "true"},
{key: "SUBMIT_IFACE", defVal: "0.0.0.0", comments: []string{
"--- SMTP Submission (Authenticated Send) ---",
}},
{key: "SUBMIT_PORT", defVal: "587", comments: []string{"STARTTLS mandatory on this port."}},
{key: "SUBMIT_ENABLED", defVal: "true"},
{key: "SMTPS_PORT", defVal: "465", comments: []string{"Implicit TLS SMTP submission."}},
{key: "SMTPS_ENABLED", defVal: "true"},
// --- IMAP ---
{key: "IMAP_IFACE", defVal: "0.0.0.0", comments: []string{"--- IMAP Server ---"}},
{key: "IMAP_PORT", defVal: "143"},
{key: "IMAP_ENABLED", defVal: "true"},
{key: "IMAPS_PORT", defVal: "993"},
{key: "IMAPS_ENABLED", defVal: "true"},
// --- Web ---
{key: "WEBCLIENT_IFACE", defVal: "0.0.0.0", comments: []string{"--- Web Client ---"}},
{key: "WEBCLIENT_PORT", defVal: "8080"},
{key: "WEBADMIN_IFACE", defVal: "127.0.0.1", comments: []string{
"--- Web Admin ---",
"Default: loopback only. Change to 0.0.0.0 only behind a reverse proxy with auth.",
}},
{key: "WEBADMIN_PORT", defVal: "8081"},
{key: "CALDAV_IFACE", defVal: "0.0.0.0", comments: []string{"--- CalDAV / CardDAV ---"}},
{key: "CALDAV_PORT", defVal: "5232"},
// --- TLS ---
{key: "TLS_MODE", defVal: "dns01", comments: []string{
"--- TLS Configuration ---",
" dns01 = Let's Encrypt via DNS-01 (no open ports, wildcard support) [RECOMMENDED]",
" http01 = Let's Encrypt via HTTP-01 (port 80 must be reachable, no wildcards)",
" manual = Provide TLS_CERT + TLS_KEY paths",
" off = No TLS (use ONLY behind a TLS-terminating reverse proxy)",
}},
{key: "TLS_CERT", defVal: "./certs/cert.pem", comments: []string{
"Path to certificate file (PEM). Used when TLS_MODE=manual.",
"Also used as per-service fallback if SMTP_TLS_CERT etc. are not set.",
}},
{key: "TLS_KEY", defVal: "./certs/key.pem"},
{key: "SMTP_TLS_CERT", defVal: "", comments: []string{"Override TLS cert/key for SMTP services (blank = use global TLS)."}},
{key: "SMTP_TLS_KEY", defVal: ""},
{key: "IMAP_TLS_CERT", defVal: "", comments: []string{"Override TLS cert/key for IMAP services."}},
{key: "IMAP_TLS_KEY", defVal: ""},
{key: "WEB_TLS_CERT", defVal: "", comments: []string{"Override TLS cert/key for web/CalDAV ports."}},
{key: "WEB_TLS_KEY", defVal: ""},
// --- ACME ---
{key: "ACME_EMAIL", defVal: "", comments: []string{
"--- ACME / Let's Encrypt ---",
"Email for Let's Encrypt account registration and renewal notices. Required.",
}},
{key: "ACME_CACHE_DIR", defVal: "./acme-cache", comments: []string{
"Directory to cache ACME account data and certificates.",
}},
{key: "ACME_STAGING", defVal: "false", comments: []string{
"Use Let's Encrypt staging server (rate-limit-free testing). Set false for production.",
}},
{key: "ACME_DOMAINS", defVal: "", comments: []string{
"Comma-separated domains to include in the certificate.",
"Example: example.com,*.example.com,mail.example.com",
"Blank = use HOSTNAME only. Include wildcard for full coverage.",
}},
{key: "ACME_DNS_PROVIDER", defVal: "cloudflare", comments: []string{
"DNS provider for DNS-01 challenge (TLS_MODE=dns01).",
"Supported: cloudflare | route53 | digitalocean | hetzner | ovh | porkbun |",
" namecheap | gandi | desec | acmedns | godaddy | ... (90+ providers)",
"Full list: https://go-acme.github.io/lego/dns/",
}},
// --- Cloudflare ---
{key: "CF_DNS_API_TOKEN", defVal: "", secret: true, comments: []string{
"--- Cloudflare DNS-01 (ACME_DNS_PROVIDER=cloudflare) ---",
"API Token with Zone.DNS:Edit permission on target zone(s).",
"Preferred over CF_API_KEY. Create at: https://dash.cloudflare.com/profile/api-tokens",
}},
{key: "CF_API_KEY", defVal: "", secret: true, comments: []string{"Global API Key (alternative to CF_DNS_API_TOKEN)."}},
{key: "CF_API_EMAIL", defVal: "", comments: []string{"Account email (required with CF_API_KEY, not needed with CF_DNS_API_TOKEN)."}},
// --- Route53 ---
{key: "AWS_REGION", defVal: "", comments: []string{"--- AWS Route53 (ACME_DNS_PROVIDER=route53) ---"}},
{key: "AWS_ACCESS_KEY_ID", defVal: "", secret: true},
{key: "AWS_SECRET_ACCESS_KEY", defVal: "", secret: true},
{key: "AWS_HOSTED_ZONE_ID", defVal: "", comments: []string{"Optional: skip auto-detection."}},
// --- DigitalOcean ---
{key: "DO_AUTH_TOKEN", defVal: "", secret: true, comments: []string{"--- DigitalOcean (ACME_DNS_PROVIDER=digitalocean) ---"}},
// --- Hetzner ---
{key: "HETZNER_API_KEY", defVal: "", secret: true, comments: []string{"--- Hetzner DNS (ACME_DNS_PROVIDER=hetzner) ---"}},
// --- Secrets ---
{key: "ENCRYPTION_KEY", defVal: "", secret: true, comments: []string{
"--- Secrets (auto-generated — BACK UP this file!) ---",
"AES-256 master key for all data at rest (emails, tokens, keys, contacts, calendar).",
"64 hex characters = 32 bytes. Losing this key = permanent data loss.",
}},
{key: "SESSION_SECRET", defVal: "", secret: true, comments: []string{
"Session cookie signing secret. Changing this logs out all users.",
}},
// --- Database ---
{key: "DB_DRIVER", defVal: "sqlite", comments: []string{
"--- Database ---",
"Database driver: sqlite | postgres | mysql | mssql",
}},
{key: "DB_PATH", defVal: "./data/mail.db", comments: []string{"SQLite database path (DB_DRIVER=sqlite)."}},
{key: "DB_DSN", defVal: "", secret: true, comments: []string{
"Connection string for PostgreSQL/MySQL/MSSQL.",
" PostgreSQL: host=localhost port=5432 user=mail password=secret dbname=mail sslmode=require",
" MySQL: mail:secret@tcp(localhost:3306)/mail?tls=true",
" MSSQL: sqlserver://mail:secret@localhost?database=mail",
}},
// --- Storage ---
{key: "STORAGE_BACKEND", defVal: "db", comments: []string{
"--- Email Storage ---",
" db = Store encrypted message blobs in database (simple, single backup file)",
" fs = Store encrypted files on filesystem, metadata in DB (better for large attachments)",
}},
{key: "STORAGE_FS_PATH", defVal: "./data/messages", comments: []string{
"Base directory for filesystem storage (STORAGE_BACKEND=fs).",
}},
// --- Security ---
{key: "MAX_MESSAGE_SIZE", defVal: "52428800", comments: []string{
"--- Security ---",
"Maximum accepted email size in bytes (default 50 MB).",
}},
{key: "SESSION_MAX_AGE", defVal: "604800", comments: []string{"Session lifetime in seconds (default 7 days)."}},
{key: "BRUTE_MAX_TRIES", defVal: "5"},
{key: "BRUTE_WINDOW_MIN", defVal: "30"},
{key: "BRUTE_BAN_HOURS", defVal: "24"},
{key: "BRUTE_WHITELIST_IPS", defVal: "", comments: []string{"Comma-separated IPs exempt from brute-force banning."}},
{key: "TRUSTED_PROXIES", defVal: "", comments: []string{
"Comma-separated CIDR ranges of trusted reverse proxies.",
"Only these may set X-Forwarded-For / X-Forwarded-Proto headers.",
}},
{key: "SECURE_COOKIE", defVal: "false", comments: []string{
"Mark session cookies Secure. Set true when serving over HTTPS.",
"Auto-enabled when BASE_URL starts with https://",
}},
// --- SMTP tuning ---
{key: "SMTP_HOSTNAME", defVal: "", comments: []string{
"--- SMTP Tuning ---",
"SMTP HELO/EHLO hostname override. Blank = use HOSTNAME.",
}},
{key: "MAX_RCPT_PER", defVal: "100", comments: []string{"Maximum recipients per message."}},
{key: "QUEUE_MAX_AGE_HOURS", defVal: "72", comments: []string{"Queue age before bounce (hours)."}},
{key: "QUEUE_RETRY_MINS", defVal: "5,15,60,240,480", comments: []string{"Retry backoff schedule (minutes between attempts)."}},
{key: "DNS_PRIMARY", defVal: "1.1.1.1"},
{key: "DNS_SECONDARY", defVal: "8.8.8.8"},
// --- DKIM ---
{key: "DKIM_SELECTOR", defVal: "godkim", comments: []string{
"--- DKIM ---",
"Default DKIM selector for new domains.",
}},
{key: "DKIM_ALGO", defVal: "rsa2048", comments: []string{"Key algorithm: rsa2048 | ed25519"}},
// --- Spam ---
{key: "SPAM_THRESHOLD", defVal: "10", comments: []string{
"--- Spam Filtering ---",
"Messages with spam score >= threshold delivered to Spam folder.",
}},
{key: "SPAM_DNSBL", defVal: "zen.spamhaus.org,bl.spamcop.net"},
{key: "SPAM_CHECK_SPF", defVal: "true"},
{key: "SPAM_CHECK_DKIM", defVal: "true"},
{key: "SPAM_CHECK_DMARC", defVal: "true"},
// --- OAuth2 ---
{key: "GOOGLE_CLIENT_ID", defVal: "", comments: []string{
"--- Google OAuth2 (external Gmail accounts) ---",
"Create at: https://console.cloud.google.com/apis/credentials",
"Required scope: https://mail.google.com/",
}},
{key: "GOOGLE_CLIENT_SECRET", defVal: "", secret: true},
{key: "MICROSOFT_CLIENT_ID", defVal: "", comments: []string{
"--- Microsoft OAuth2 (external Outlook accounts) ---",
"Register at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps",
}},
{key: "MICROSOFT_CLIENT_SECRET", defVal: "", secret: true},
{key: "MICROSOFT_TENANT_ID", defVal: "consumers"},
// --- Debug ---
{key: "DEBUG", defVal: "false", comments: []string{"--- Debug ---"}},
{key: "LOG_FILE", defVal: "./logs/mail.log"},
{key: "LOG_LEVEL", defVal: "info", comments: []string{"Log level: debug | info | warn | error"}},
}
// Load reads app_config.conf, generates it if missing, returns populated Config.
func Load() (*Config, error) {
if err := os.MkdirAll("./data", 0700); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
if err := os.MkdirAll("./logs", 0700); err != nil {
return nil, fmt.Errorf("create logs dir: %w", err)
}
existing, err := readFile(ConfigPath)
if err != nil {
return nil, err
}
// Auto-generate secrets if absent.
if existing["ENCRYPTION_KEY"] == "" {
existing["ENCRYPTION_KEY"] = mustHex(32)
fmt.Fprintln(os.Stderr, "[mailgosend] WARNING: Generated new ENCRYPTION_KEY — back up app_config.conf immediately!")
}
if existing["SESSION_SECRET"] == "" {
existing["SESSION_SECRET"] = mustHex(32)
}
if err := writeFile(ConfigPath, existing); err != nil {
return nil, fmt.Errorf("write config: %w", err)
}
get := func(key string) string {
if v := os.Getenv("MAILGO_" + key); v != "" {
return v
}
return existing[key]
}
// Decode master encryption key.
encHex := get("ENCRYPTION_KEY")
encKey, err := hex.DecodeString(encHex)
if err != nil || len(encKey) != 32 {
return nil, fmt.Errorf("ENCRYPTION_KEY must be 64 hex chars (32 bytes), got %d chars", len(encHex))
}
sessSecret := get("SESSION_SECRET")
if sessSecret == "" {
return nil, fmt.Errorf("SESSION_SECRET missing")
}
trustedProxies, err := parseCIDRs(get("TRUSTED_PROXIES"))
if err != nil {
return nil, fmt.Errorf("TRUSTED_PROXIES: %w", err)
}
acmeDomains := splitTrim(get("ACME_DOMAINS"), ",")
retryMins := parseIntList(get("QUEUE_RETRY_MINS"), []int{5, 15, 60, 240, 480})
dnsbl := splitTrim(get("SPAM_DNSBL"), ",")
smtpHostname := get("SMTP_HOSTNAME")
if smtpHostname == "" {
smtpHostname = get("HOSTNAME")
}
if smtpHostname == "" {
smtpHostname = "localhost"
}
cfg := &Config{
Hostname: orDefault(get("HOSTNAME"), "mail.example.com"),
DefaultDomain: orDefault(get("DEFAULT_DOMAIN"), "example.com"),
SMTPIface: orDefault(get("SMTP_IFACE"), "0.0.0.0"),
SMTPPort: atoi(get("SMTP_PORT"), 25),
SMTPEnabled: atobool(get("SMTP_ENABLED"), true),
SubmitIface: orDefault(get("SUBMIT_IFACE"), "0.0.0.0"),
SubmitPort: atoi(get("SUBMIT_PORT"), 587),
SubmitEnabled: atobool(get("SUBMIT_ENABLED"), true),
SMTPSPort: atoi(get("SMTPS_PORT"), 465),
SMTPSEnabled: atobool(get("SMTPS_ENABLED"), true),
IMAPIface: orDefault(get("IMAP_IFACE"), "0.0.0.0"),
IMAPPort: atoi(get("IMAP_PORT"), 143),
IMAPEnabled: atobool(get("IMAP_ENABLED"), true),
IMAPSPort: atoi(get("IMAPS_PORT"), 993),
IMAPSEnabled: atobool(get("IMAPS_ENABLED"), true),
WebClientIface: orDefault(get("WEBCLIENT_IFACE"), "0.0.0.0"),
WebClientPort: atoi(get("WEBCLIENT_PORT"), 8080),
WebAdminIface: orDefault(get("WEBADMIN_IFACE"), "127.0.0.1"),
WebAdminPort: atoi(get("WEBADMIN_PORT"), 8081),
CalDAVIface: orDefault(get("CALDAV_IFACE"), "0.0.0.0"),
CalDAVPort: atoi(get("CALDAV_PORT"), 5232),
TLSMode: orDefault(get("TLS_MODE"), "dns01"),
TLSCert: get("TLS_CERT"),
TLSKey: get("TLS_KEY"),
SMTPTLSCert: get("SMTP_TLS_CERT"),
SMTPTLSKey: get("SMTP_TLS_KEY"),
IMAPTLSCert: get("IMAP_TLS_CERT"),
IMAPTLSKey: get("IMAP_TLS_KEY"),
WebTLSCert: get("WEB_TLS_CERT"),
WebTLSKey: get("WEB_TLS_KEY"),
ACMEEmail: get("ACME_EMAIL"),
ACMECacheDir: orDefault(get("ACME_CACHE_DIR"), "./acme-cache"),
ACMEStaging: atobool(get("ACME_STAGING"), false),
ACMEDomains: acmeDomains,
ACMEDNSProvider: orDefault(get("ACME_DNS_PROVIDER"), "cloudflare"),
CFDNSAPIToken: get("CF_DNS_API_TOKEN"),
CFAPIKey: get("CF_API_KEY"),
CFAPIEmail: get("CF_API_EMAIL"),
AWSRegion: get("AWS_REGION"),
AWSAccessKeyID: get("AWS_ACCESS_KEY_ID"),
AWSSecretAccessKey: get("AWS_SECRET_ACCESS_KEY"),
AWSHostedZoneID: get("AWS_HOSTED_ZONE_ID"),
DOAuthToken: get("DO_AUTH_TOKEN"),
HetznerAPIKey: get("HETZNER_API_KEY"),
EncryptionKey: encKey,
SessionSecret: []byte(sessSecret),
DBDriver: orDefault(get("DB_DRIVER"), "sqlite"),
DBPath: orDefault(get("DB_PATH"), "./data/mail.db"),
DBDSN: get("DB_DSN"),
StorageBackend: orDefault(get("STORAGE_BACKEND"), "db"),
StorageFSPath: orDefault(get("STORAGE_FS_PATH"), "./data/messages"),
MaxMessageSize: int64(atoi(get("MAX_MESSAGE_SIZE"), 52428800)),
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
BruteMaxTries: atoi(get("BRUTE_MAX_TRIES"), 5),
BruteWindowMin: atoi(get("BRUTE_WINDOW_MIN"), 30),
BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 24),
BruteWhitelist: parseIPs(get("BRUTE_WHITELIST_IPS")),
TrustedProxies: trustedProxies,
SecureCookie: atobool(get("SECURE_COOKIE"), false) || strings.HasPrefix(strings.ToLower(get("BASE_URL")), "https://"),
SMTPHostname: smtpHostname,
MaxRcptPer: atoi(get("MAX_RCPT_PER"), 100),
QueueMaxAgeHours: atoi(get("QUEUE_MAX_AGE_HOURS"), 72),
QueueRetryMins: retryMins,
DNSPrimary: orDefault(get("DNS_PRIMARY"), "1.1.1.1"),
DNSSecondary: orDefault(get("DNS_SECONDARY"), "8.8.8.8"),
DKIMSelector: orDefault(get("DKIM_SELECTOR"), "godkim"),
DKIMAlgo: orDefault(get("DKIM_ALGO"), "rsa2048"),
SpamThreshold: atoi(get("SPAM_THRESHOLD"), 10),
SpamDNSBL: dnsbl,
SpamCheckSPF: atobool(get("SPAM_CHECK_SPF"), true),
SpamCheckDKIM: atobool(get("SPAM_CHECK_DKIM"), true),
SpamCheckDMARC: atobool(get("SPAM_CHECK_DMARC"), true),
GoogleClientID: get("GOOGLE_CLIENT_ID"),
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"),
Debug: atobool(get("DEBUG"), false),
LogFile: orDefault(get("LOG_FILE"), "./logs/mail.log"),
LogLevel: orDefault(get("LOG_LEVEL"), "info"),
}
// Export provider-specific env vars so lego can pick them up.
cfg.exportProviderEnv()
logStartup(cfg)
return cfg, nil
}
// exportProviderEnv sets standard lego env vars from config values.
// This allows using app_config.conf as the single source of truth.
func (c *Config) exportProviderEnv() {
setenv := func(key, val string) {
if val != "" && os.Getenv(key) == "" {
os.Setenv(key, val) //nolint:errcheck
}
}
setenv("CF_DNS_API_TOKEN", c.CFDNSAPIToken)
setenv("CF_API_KEY", c.CFAPIKey)
setenv("CF_API_EMAIL", c.CFAPIEmail)
setenv("AWS_REGION", c.AWSRegion)
setenv("AWS_ACCESS_KEY_ID", c.AWSAccessKeyID)
setenv("AWS_SECRET_ACCESS_KEY", c.AWSSecretAccessKey)
setenv("AWS_HOSTED_ZONE_ID", c.AWSHostedZoneID)
setenv("DO_AUTH_TOKEN", c.DOAuthToken)
setenv("HETZNER_API_KEY", c.HetznerAPIKey)
}
// ACMEDomainList returns ACME_DOMAINS, falling back to Hostname.
func (c *Config) ACMEDomainList() []string {
if len(c.ACMEDomains) > 0 {
return c.ACMEDomains
}
return []string{c.Hostname}
}
// RealIP extracts the client IP, honouring X-Forwarded-For from trusted proxies.
func (c *Config) RealIP(remoteAddr, xForwardedFor string) string {
remoteIP, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
remoteIP = remoteAddr
}
if xForwardedFor == "" || !c.isTrustedProxy(remoteIP) {
return remoteIP
}
parts := strings.Split(xForwardedFor, ",")
if len(parts) > 0 {
if ip := strings.TrimSpace(parts[0]); net.ParseIP(ip) != nil {
return ip
}
}
return remoteIP
}
func (c *Config) isTrustedProxy(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, cidr := range c.TrustedProxies {
if cidr.Contains(ip) {
return true
}
}
return false
}
func (c *Config) IsIPWhitelisted(ip string) bool {
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
for _, w := range c.BruteWhitelist {
if w.Equal(parsed) {
return true
}
}
return false
}
// ---- Config file I/O ----
func readFile(path string) (map[string]string, error) {
vals := make(map[string]string)
f, err := os.Open(path)
if os.IsNotExist(err) {
return vals, nil
}
if err != nil {
return nil, fmt.Errorf("open config: %w", err)
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
continue
}
k := strings.TrimSpace(line[:idx])
v := strings.TrimSpace(line[idx+1:])
vals[k] = v
}
return vals, sc.Err()
}
func writeFile(path string, existing map[string]string) error {
var sb strings.Builder
sb.WriteString("# mailgosend Configuration\n")
sb.WriteString("# =========================\n")
sb.WriteString("# Auto-generated and updated on each startup.\n")
sb.WriteString("# Edit freely — your values are always preserved.\n")
sb.WriteString("# Override any key via env var: MAILGO_<KEY>=value\n")
sb.WriteString("#\n\n")
for _, f := range allFields {
for _, c := range f.comments {
if c == "" {
sb.WriteString("#\n")
} else {
sb.WriteString("# " + c + "\n")
}
}
v := existing[f.key]
if v == "" {
v = f.defVal
}
sb.WriteString(f.key + " = " + v + "\n\n")
}
return os.WriteFile(path, []byte(sb.String()), 0600)
}
// ---- Startup log ----
func logStartup(c *Config) {
fmt.Printf("mailgosend starting\n")
fmt.Printf(" Hostname : %s\n", c.Hostname)
fmt.Printf(" Default domain: %s\n", c.DefaultDomain)
fmt.Printf(" TLS mode : %s\n", c.TLSMode)
if c.TLSMode == "dns01" || c.TLSMode == "http01" {
fmt.Printf(" ACME provider: %s\n", c.ACMEDNSProvider)
fmt.Printf(" ACME domains : %v\n", c.ACMEDomainList())
}
fmt.Printf(" DB driver : %s\n", c.DBDriver)
if c.SMTPEnabled {
fmt.Printf(" SMTP : %s:%d\n", c.SMTPIface, c.SMTPPort)
}
if c.SubmitEnabled {
fmt.Printf(" Submission : %s:%d (STARTTLS)\n", c.SubmitIface, c.SubmitPort)
}
if c.SMTPSEnabled {
fmt.Printf(" SMTPS : %s:%d (TLS)\n", c.SMTPIface, c.SMTPSPort)
}
if c.IMAPEnabled {
fmt.Printf(" IMAP : %s:%d (STARTTLS)\n", c.IMAPIface, c.IMAPPort)
}
if c.IMAPSEnabled {
fmt.Printf(" IMAPS : %s:%d (TLS)\n", c.IMAPIface, c.IMAPSPort)
}
fmt.Printf(" Web client : %s:%d\n", c.WebClientIface, c.WebClientPort)
fmt.Printf(" Web admin : %s:%d\n", c.WebAdminIface, c.WebAdminPort)
fmt.Printf(" CalDAV : %s:%d\n", c.CalDAVIface, c.CalDAVPort)
}
// ---- Validation ----
// Validate checks the configuration for missing required fields and insecure
// defaults, printing warnings to stdout. Returns a non-nil error only for
// truly fatal conditions (no listening ports enabled).
func (c *Config) Validate() error {
warn := func(msg string) { fmt.Printf("[config] WARNING: %s\n", msg) }
// Secrets
if len(c.SessionSecret) < 32 {
warn("SESSION_SECRET is missing or too short (< 32 bytes). Regenerate it.")
}
if len(c.EncryptionKey) == 0 {
warn("ENCRYPTION_KEY is missing. Emails will not be encrypted at rest.")
}
// Hostname / domain
if c.Hostname == "" || c.Hostname == "mail.example.com" {
warn("HOSTNAME is not set to a real FQDN. SMTP HELO will be rejected by strict servers.")
}
if c.DefaultDomain == "" || c.DefaultDomain == "example.com" {
warn("DEFAULT_DOMAIN is not set. Email delivery may fail.")
}
// ACME
if (c.TLSMode == "dns01" || c.TLSMode == "http01") && c.ACMEEmail == "" {
warn("ACME_EMAIL is required for automatic TLS certificate provisioning.")
}
// Ports — at least one service should be listening.
anyPort := c.SMTPEnabled || c.SubmitEnabled || c.SMTPSEnabled ||
c.IMAPEnabled || c.IMAPSEnabled ||
c.WebClientPort > 0 || c.WebAdminPort > 0
if !anyPort {
return fmt.Errorf("no services enabled — check SMTP_ENABLED, IMAP_ENABLED, WEB_CLIENT_PORT, WEB_ADMIN_PORT")
}
// Admin binding — warn if admin panel is exposed on non-loopback.
if c.WebAdminPort > 0 && c.WebAdminIface != "127.0.0.1" && c.WebAdminIface != "::1" {
warn(fmt.Sprintf("WEB_ADMIN_IFACE=%q exposes the admin panel on a public interface. Consider restricting to 127.0.0.1.", c.WebAdminIface))
}
// Queue
if c.QueueMaxAgeHours <= 0 {
warn("QUEUE_MAX_AGE_HOURS is 0 or negative. Queued messages will never expire.")
}
return nil
}
// ---- Helpers ----
func mustHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand unavailable: " + err.Error())
}
return hex.EncodeToString(b)
}
func atoi(s string, fallback int) int {
if v, err := strconv.Atoi(s); err == nil {
return v
}
return fallback
}
func atobool(s string, fallback bool) bool {
if v, err := strconv.ParseBool(s); err == nil {
return v
}
return fallback
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
func splitTrim(s, sep string) []string {
var out []string
for _, p := range strings.Split(s, sep) {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func parseIntList(s string, fallback []int) []int {
parts := splitTrim(s, ",")
if len(parts) == 0 {
return fallback
}
out := make([]int, 0, len(parts))
for _, p := range parts {
if v, err := strconv.Atoi(p); err == nil {
out = append(out, v)
}
}
if len(out) == 0 {
return fallback
}
return out
}
func parseCIDRs(s string) ([]net.IPNet, error) {
var nets []net.IPNet
for _, raw := range splitTrim(s, ",") {
if !strings.Contains(raw, "/") {
ip := net.ParseIP(raw)
if ip == nil {
return nil, fmt.Errorf("invalid IP %q", raw)
}
bits := 32
if ip.To4() == nil {
bits = 128
}
raw = fmt.Sprintf("%s/%d", ip.String(), bits)
}
_, ipNet, err := net.ParseCIDR(raw)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", raw, err)
}
nets = append(nets, *ipNet)
}
return nets, nil
}
func parseIPs(s string) []net.IP {
var ips []net.IP
for _, raw := range splitTrim(s, ",") {
if ip := net.ParseIP(raw); ip != nil {
ips = append(ips, ip)
}
}
return ips
}