// Package config loads and generates settings.ini, mirroring email_server/settings_loader.py. package config import ( "os" "path/filepath" "gopkg.in/ini.v1" ) // defaultKV is one key/value pair with the comment line Python renders above it. type defaultKV struct { Key string Value string Comment string } // defaults mirrors settings_loader.py's DEFAULTS table section-by-section, in order. // The [Attachments] section does not exist in the Python defaults (a bug: it crashes // attachment storage there) — it is added here deliberately, per the approved plan. var defaults = []struct { Section string Keys []defaultKV }{ {"Server", []defaultKV{ {"", "", "Server configuration for SMTP ports and hostname"}, {"", "", "Plain SMTP port for internal/whitelisted IPs"}, {"", "", "(standard port 25 — the process needs CAP_NET_BIND_SERVICE or root to bind"}, {"", "", "it directly; not an issue in the Docker deployment, which runs as root)"}, {"SMTP_PORT", "25", ""}, {"", "", "Direct/implicit-TLS SMTP port for authenticated users (like SMTPS, not STARTTLS)"}, {"SMTP_TLS_PORT", "465", ""}, {"", "", "Server hostname for HELO/EHLO identification"}, {"HOSTNAME", "mail.example.com", ""}, {"", "", "Override HELO hostname"}, {"helo_hostname", "mail.example.com", ""}, {"", "", `IP address to bind to (0.0.0.0 = all interfaces), on Windows must use specific IP`}, {"BIND_IP", "0.0.0.0", ""}, {"", "", "HTTP port for the admin web UI (overridden by the -port flag if given)"}, {"WEB_HTTP_PORT", "5000", ""}, {"", "", "HTTPS port for the admin web UI (self-signed by default, or the Let's Encrypt cert when enabled)"}, {"WEB_HTTPS_PORT", "5001", ""}, {"", "", `Custom server banner (to make it empty use "" must be double quotes)`}, {"server_banner", "", ""}, {"", "", "Time zone for the server"}, {"TIME_ZONE", "Europe/London", ""}, {"", "", `Comma-separated CIDRs and/or the word "cloudflare" -- only these peers'`}, {"", "", "X-Forwarded-For/X-Real-IP/CF-Connecting-IP headers are trusted for the real"}, {"", "", "client IP (audit logs, lockouts). Leave blank if nothing proxies this server."}, {"trusted_proxies", "", ""}, }}, {"Database", []defaultKV{ {"", "", "Database configuration"}, {"DATABASE_URL", "sqlite:///server_data/smtp_server.db", ""}, }}, {"Logging", []defaultKV{ {"", "", "Logging configuration"}, {"", "", "Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL"}, {"LOG_LEVEL", "INFO", ""}, {"", "", "Hide verbose aiosmtpd-equivalent INFO messages when LOG_LEVEL = INFO"}, {"hide_info_aiosmtpd", "true", ""}, }}, {"Relay", []defaultKV{ {"", "", "Timeout in seconds for external SMTP connections"}, {"RELAY_TIMEOUT", "30", ""}, }}, {"TLS", []defaultKV{ {"", "", "TLS/SSL certificate configuration"}, {"TLS_CERT_FILE", "ssl_certs/server.crt", ""}, {"TLS_KEY_FILE", "ssl_certs/server.key", ""}, }}, {"DKIM", []defaultKV{ {"", "", "DKIM signing configuration"}, {"", "", "RSA key size for DKIM keys (1024, 2048, 4096)"}, {"DKIM_KEY_SIZE", "2048", ""}, {"", "", "Provide Public IP address of server, used for SPF in case detection fails"}, {"SPF_SERVER_IP", "192.168.1.1", ""}, }}, {"Attachments", []defaultKV{ {"", "", "Directory where stored message attachments are written (fixed: missing in the Python defaults)"}, {"attachments_path", "server_data/attachments", ""}, }}, {"Auth", []defaultKV{ {"", "", "Admin dashboard login / passkey (WebAuthn) configuration"}, {"", "", "Must match the domain the admin dashboard is actually accessed at — passkeys are bound to this"}, {"rp_id", "localhost", ""}, {"", "", "Display name shown in the authenticator/passkey prompt"}, {"rp_display_name", "mailgoserver", ""}, {"", "", `Full origin (scheme+host+port) the dashboard is served at, e.g. "https://mail.example.com"`}, {"rp_origin", "http://localhost:5000", ""}, {"", "", "Require every admin account (global or domain-scoped) to set up TOTP/passkey MFA"}, {"enforce_admin_mfa", "false", ""}, {"", "", "Require every mailbox's self-service webmail login to have TOTP/passkey MFA"}, {"", "", "(overridable per-domain or per-mailbox — see the Domains/Mailboxes edit pages)"}, {"enforce_mailbox_mfa", "false", ""}, {"", "", "Failed login attempts (for one account, from any IP) allowed within the window below"}, {"", "", "before that account is temporarily locked out, independent of the per-IP rate limit"}, {"login_attempt_limit", "8", ""}, {"login_attempt_window_minutes", "15", ""}, }}, {"Security", []defaultKV{ {"", "", "Generated automatically on first run if this file doesn't exist yet — used to sign"}, {"", "", "CSRF tokens. Back this up like any other secret; don't share it or commit it."}, {"app_secret_path", "server_data/app_secret.key", ""}, {"", "", "Automatically block an IP for a while after too many failed SMTP/IMAP login"}, {"", "", "attempts within the window below (see the Blacklist page in the admin dashboard)"}, {"abuse_detection_enabled", "true", ""}, {"abuse_failure_threshold", "8", ""}, {"abuse_detection_window_minutes", "10", ""}, {"", "", "First-offense block duration; doubles on each repeat offense up to the cap below"}, {"abuse_blacklist_base_hours", "12", ""}, {"abuse_blacklist_max_hours", "168", ""}, }}, {"IMAP", []defaultKV{ {"", "", "IMAP server configuration for mailbox retrieval (Thunderbird, etc.)"}, {"", "", "Plain IMAP port (STARTTLS not offered, matching the SMTP plain-port design)"}, {"IMAP_PORT", "143", ""}, {"", "", "Implicit-TLS IMAP port (IMAPS)"}, {"IMAP_TLS_PORT", "993", ""}, }}, {"Mailstore", []defaultKV{ {"", "", "Local mailbox storage configuration"}, {"", "", "Directory where encrypted message blobs are written"}, {"mailstore_path", "server_data/mailstore", ""}, {"", "", "Path to the server-held master key that wraps every mailbox's encryption key"}, {"", "", "(generated on first run if missing; back this file up separately from the database -"}, {"", "", "losing it makes all stored mail unrecoverable, even for admins)"}, {"master_key_path", "server_data/mailstore_master.key", ""}, {"", "", "Default per-mailbox storage quota in bytes (5 GiB)"}, {"default_quota_bytes", "5368709120", ""}, {"", "", "Minimum length for IMAP/SMTP app passwords (floor of 25 enforced regardless of this value)"}, {"app_password_min_length", "25", ""}, {"", "", "Reject messages scoring at or above this built-in heuristic spam score"}, {"spam_reject_score", "5", ""}, }}, {"Rspamd", []defaultKV{ {"", "", "Optional rspamd integration for spam scoring (off by default; the built-in"}, {"", "", "heuristic score above always runs regardless of this setting)"}, {"enabled", "false", ""}, {"", "", "rspamd controller/worker URL"}, {"url", "http://127.0.0.1:11333", ""}, {"", "", "Reject messages rspamd scores at or above this threshold"}, {"reject_score", "15", ""}, }}, {"LetsEncrypt", []defaultKV{ {"", "", "Let's Encrypt (ACME, DNS-01 only) automatic certificate configuration for the"}, {"", "", "SMTP/IMAP TLS listeners. Leave 'enabled' false to keep using the self-signed cert."}, {"enabled", "false", ""}, {"", "", "Use Let's Encrypt's staging directory (untrusted certs, no rate limits) for testing"}, {"staging", "false", ""}, {"", "", "Contact email for the ACME account"}, {"contact_email", "", ""}, {"", "", "Comma-separated domains to request, e.g. mail.example.com,*.mail.example.com"}, {"domains", "", ""}, {"", "", "DNS provider used to solve the DNS-01 challenge: cloudflare, route53, digitalocean, gcloud"}, {"dns_provider", "", ""}, {"", "", "-- Cloudflare --"}, {"cloudflare_api_token", "", ""}, {"", "", "-- AWS Route53 (leave access key/secret blank to use the host's default AWS credential chain) --"}, {"route53_access_key_id", "", ""}, {"route53_secret_access_key", "", ""}, {"route53_region", "", ""}, {"route53_hosted_zone_id", "", ""}, {"", "", "-- DigitalOcean --"}, {"digitalocean_api_token", "", ""}, {"", "", "-- Google Cloud DNS --"}, {"gcloud_project", "", ""}, {"", "", "Path to an uploaded service-account JSON key; leave blank to use Application Default Credentials"}, {"gcloud_service_account_json_path", "", ""}, }}, } // GenerateSettingsIni writes settings.ini with default values and comments if it does // not already exist. Mirrors settings_loader.generate_settings_ini: never overwrites or // merges into an existing file. func GenerateSettingsIni(path string) error { if _, err := os.Stat(path); err == nil { return nil } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } cfg := ini.Empty() for _, sec := range defaults { section, err := cfg.NewSection(sec.Section) if err != nil { return err } for _, kv := range sec.Keys { if kv.Key == "" { // Comment-only line, e.g. a section header comment. No trailing // newline: ini.v1's writer splits Comment on "\n" and indexes // line[0] unconditionally, so a trailing separator produces an // empty final line and panics. if section.Comment != "" { section.Comment += "\n" } section.Comment += kv.Comment continue } key, err := section.NewKey(kv.Key, kv.Value) if err != nil { return err } if kv.Comment != "" { key.Comment = kv.Comment } } } return cfg.SaveTo(path) } // Load reads settings.ini at path, generating it with defaults first if missing. // Mirrors settings_loader.load_settings: always regenerate-if-missing, then read fresh. func Load(path string) (*ini.File, error) { if err := GenerateSettingsIni(path); err != nil { return nil, err } return ini.Load(path) } // AbsoluteSQLitePath converts a "sqlite:///relative/path" database URL into an absolute // filesystem path resolved against root, mirroring app.py's _get_absolute_database_url. func AbsoluteSQLitePath(databaseURL, root string) string { const prefix = "sqlite:///" if len(databaseURL) < len(prefix) || databaseURL[:len(prefix)] != prefix { return databaseURL } rel := databaseURL[len(prefix):] if filepath.IsAbs(rel) { return rel } return filepath.Join(root, rel) }