Files
mailgoserver/internal/config/config.go
T
2026-08-12 12:56:22 +01:00

147 lines
4.9 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", ""},
{"", "", `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", ""},
}},
}
// 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)
}