213 lines
8.4 KiB
Go
213 lines
8.4 KiB
Go
// 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"},
|
|
{"SMTP_PORT", "4025", ""},
|
|
{"", "", "TLS SMTP port for authenticated users"},
|
|
{"SMTP_TLS_PORT", "40465", ""},
|
|
{"", "", "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", ""},
|
|
}},
|
|
{"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", ""},
|
|
}},
|
|
{"IMAP", []defaultKV{
|
|
{"", "", "IMAP server configuration for mailbox retrieval (Thunderbird, etc.)"},
|
|
{"", "", "Plain IMAP port (STARTTLS not offered, matching the SMTP plain-port design)"},
|
|
{"IMAP_PORT", "1143", ""},
|
|
{"", "", "Implicit-TLS IMAP port (IMAPS)"},
|
|
{"IMAP_TLS_PORT", "1993", ""},
|
|
}},
|
|
{"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)
|
|
}
|