824 lines
27 KiB
Go
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
|
|
}
|