Files
NewSzxcn-Email/apps/api/internal/app/settings_handlers.go
T

501 lines
20 KiB
Go
Raw Normal View History

package app
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
)
type SystemSettings struct {
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPasswordSet bool `json:"smtpPasswordSet"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretSet bool `json:"turnstileSecretSet"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
ExternalIMAPSecretSet bool `json:"externalImapSecretSet"`
ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"`
ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"`
ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"`
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
}
type systemSettingsUpdate struct {
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretKey string `json:"turnstileSecretKey"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
ExternalIMAPSecretKey string `json:"externalImapSecretKey"`
ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"`
ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"`
ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"`
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
}
type PublicSettings struct {
OpenRegistration bool `json:"openRegistration"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
PublicHostname string `json:"publicHostname"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshMs int `json:"mailRefreshMs"`
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
}
type PublicDomain struct {
ID string `json:"id"`
Name string `json:"name"`
}
type smtpTestRequest struct {
To string `json:"to"`
}
func (a *App) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
}
func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
cfg := a.config()
enabled := cfg.TurnstileEnabled && strings.TrimSpace(cfg.TurnstileSiteKey) != "" && strings.TrimSpace(cfg.TurnstileSecretKey) != ""
refreshSeconds := cfg.MailRefreshSeconds
if refreshSeconds <= 0 {
refreshSeconds = 30
}
settings := PublicSettings{OpenRegistration: cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: cfg.TurnstileSiteKey, PublicHostname: cfg.PublicHostname, MailAutoRefresh: cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: cfg.ExternalIMAPEnabled}
// Include available domains for mailbox creation during registration
if cfg.OpenRegistration {
rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`)
if err == nil {
defer rows.Close()
for rows.Next() {
var d PublicDomain
if err := rows.Scan(&d.ID, &d.Name); err != nil {
continue
}
settings.MailboxDomains = append(settings.MailboxDomains, d)
}
}
}
respondJSON(w, http.StatusOK, settings)
}
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
var req systemSettingsUpdate
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
next := a.config()
next.PublicHostname = normalizeHostname(req.PublicHostname)
if next.PublicHostname == "" {
badRequest(w, errors.New("publicHostname is required"))
return
}
next.PublicBaseURL = strings.TrimSpace(req.PublicBaseURL)
next.SMTPHost = strings.TrimSpace(req.SMTPHost)
next.SMTPPort = strings.TrimSpace(req.SMTPPort)
if next.SMTPPort == "" {
next.SMTPPort = "25"
}
if _, err := strconv.Atoi(next.SMTPPort); err != nil {
badRequest(w, errors.New("smtpPort must be a number"))
return
}
next.SMTPUsername = strings.TrimSpace(req.SMTPUsername)
if strings.TrimSpace(req.SMTPPassword) != "" {
next.SMTPPassword = req.SMTPPassword
}
next.SMTPRequireTLS = req.SMTPRequireTLS
next.MaildirRoot = strings.TrimSpace(req.MaildirRoot)
if req.MaildirScanSeconds <= 0 {
req.MaildirScanSeconds = 30
}
next.MaildirScanSeconds = req.MaildirScanSeconds
if req.SessionTTLHours <= 0 {
req.SessionTTLHours = 24 * 7
}
next.SessionTTLHours = req.SessionTTLHours
next.AllowInsecureHTTP = req.AllowInsecureHTTP
next.OpenRegistration = req.OpenRegistration
next.TwoFactorEnabled = req.TwoFactorEnabled
next.TurnstileEnabled = req.TurnstileEnabled
next.TurnstileSiteKey = strings.TrimSpace(req.TurnstileSiteKey)
if strings.TrimSpace(req.TurnstileSecretKey) != "" {
next.TurnstileSecretKey = strings.TrimSpace(req.TurnstileSecretKey)
}
if next.TurnstileEnabled && (next.TurnstileSiteKey == "" || next.TurnstileSecretKey == "") {
badRequest(w, errors.New("turnstile keys are required when enabled"))
return
}
next.CatchAllEnabled = req.CatchAllEnabled
next.MailAutoRefresh = req.MailAutoRefresh
if req.MailRefreshSeconds <= 0 {
req.MailRefreshSeconds = 30
}
next.MailRefreshSeconds = req.MailRefreshSeconds
next.UserMailboxApplyEnabled = req.UserMailboxApplyEnabled
next.UserMailboxDomainIDs = strings.Join(cleanIDList(req.UserMailboxDomainIDs), ",")
next.ReservedMailboxPrefixes = strings.Join(parseReservedPrefixes(req.ReservedMailboxPrefixes), ",")
next.ExternalIMAPEnabled = req.ExternalIMAPEnabled
if strings.TrimSpace(req.ExternalIMAPSecretKey) != "" {
next.ExternalIMAPSecretKey = strings.TrimSpace(req.ExternalIMAPSecretKey)
}
if req.ExternalIMAPSyncSeconds <= 0 {
req.ExternalIMAPSyncSeconds = 300
}
next.ExternalIMAPSyncSeconds = req.ExternalIMAPSyncSeconds
next.ExternalIMAPAllowPrivateHosts = req.ExternalIMAPAllowPrivateHosts
next.ExternalIMAPGmailClientID = strings.TrimSpace(req.ExternalIMAPGmailClientID)
if strings.TrimSpace(req.ExternalIMAPGmailClientSecret) != "" {
next.ExternalIMAPGmailClientSecret = strings.TrimSpace(req.ExternalIMAPGmailClientSecret)
}
next.ExternalIMAPOutlookClientID = strings.TrimSpace(req.ExternalIMAPOutlookClientID)
if strings.TrimSpace(req.ExternalIMAPOutlookClientSecret) != "" {
next.ExternalIMAPOutlookClientSecret = strings.TrimSpace(req.ExternalIMAPOutlookClientSecret)
}
if next.ExternalIMAPEnabled && strings.TrimSpace(next.ExternalIMAPSecretKey) == "" {
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
return
}
if err := a.saveSystemSettings(r.Context(), next); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save settings")
return
}
a.setConfig(next)
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
}
func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
var req smtpTestRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
cfg := a.config()
if strings.TrimSpace(cfg.SMTPHost) == "" {
badRequest(w, errors.New("SMTP 主机未设置"))
return
}
if strings.TrimSpace(cfg.SMTPPort) == "" {
cfg.SMTPPort = "25"
}
if _, err := strconv.Atoi(cfg.SMTPPort); err != nil {
badRequest(w, errors.New("SMTP 端口无效"))
return
}
to := normalizeEmail(req.To)
if to == "" || !strings.Contains(to, "@") {
badRequest(w, errors.New("收件邮箱无效"))
return
}
from := cfg.AdminEmail
if user := currentUser(r); user != nil && strings.Contains(user.Email, "@") {
from = user.Email
}
if strings.TrimSpace(from) == "" || !strings.Contains(from, "@") {
badRequest(w, errors.New("发件邮箱无效"))
return
}
domain := cfg.PublicHostname
if parts := strings.SplitN(from, "@", 2); len(parts) == 2 && parts[1] != "" {
domain = parts[1]
}
if domain == "" {
domain = "lanqin.local"
}
now := a.now().UTC()
2026-08-02 15:07:16 +08:00
subject := "NewSzxcn 邮箱 SMTP 测试"
bodyText := "这是一封 SMTP 测试邮件。"
bodyHTML := "<p>这是一封 SMTP 测试邮件。</p>"
if tpl, err := a.mailTemplate(r.Context(), smtpTestTemplateKey); err == nil {
rendered := renderMailTemplate(tpl, templateRenderData{
To: to,
From: from,
PublicHostname: cfg.PublicHostname,
PublicBaseURL: cfg.PublicBaseURL,
Time: now,
})
subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML
}
mimeBytes, err := BuildMIME(MIMEMessage{
From: from,
To: []string{to},
Subject: subject,
Text: bodyText,
HTML: bodyHTML,
MessageID: "<" + newID("msg") + "@" + domain + ">",
Date: now,
})
if err != nil {
badRequest(w, err)
return
}
if err := sendSMTPWithConfig(cfg, from, []string{to}, mimeBytes); err != nil {
respondError(w, http.StatusBadGateway, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) systemSettingsSnapshot() SystemSettings {
cfg := a.config()
return SystemSettings{
PublicHostname: cfg.PublicHostname,
PublicBaseURL: cfg.PublicBaseURL,
SMTPHost: cfg.SMTPHost,
SMTPPort: cfg.SMTPPort,
SMTPUsername: cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(cfg.SMTPPassword) != "",
SMTPRequireTLS: cfg.SMTPRequireTLS,
MaildirRoot: cfg.MaildirRoot,
MaildirScanSeconds: cfg.MaildirScanSeconds,
SessionTTLHours: cfg.SessionTTLHours,
AllowInsecureHTTP: cfg.AllowInsecureHTTP,
OpenRegistration: cfg.OpenRegistration,
TwoFactorEnabled: cfg.TwoFactorEnabled,
TurnstileEnabled: cfg.TurnstileEnabled,
TurnstileSiteKey: cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(cfg.TurnstileSecretKey) != "",
CatchAllEnabled: cfg.CatchAllEnabled,
MailAutoRefresh: cfg.MailAutoRefresh,
MailRefreshSeconds: cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), "\n"),
ExternalIMAPEnabled: cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
}
}
func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
cfg := a.config()
rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return err
}
switch key {
case "publicHostname":
cfg.PublicHostname = value
case "publicBaseUrl":
cfg.PublicBaseURL = value
case "smtpHost":
cfg.SMTPHost = value
case "smtpPort":
cfg.SMTPPort = value
case "smtpUsername":
cfg.SMTPUsername = value
case "smtpPassword":
cfg.SMTPPassword = value
case "smtpRequireTls":
cfg.SMTPRequireTLS = value == "true"
case "maildirRoot":
cfg.MaildirRoot = value
case "maildirScanSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
cfg.MaildirScanSeconds = n
}
case "sessionTtlHours":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
cfg.SessionTTLHours = n
}
case "allowInsecureHttp":
cfg.AllowInsecureHTTP = value == "true"
case "openRegistration":
cfg.OpenRegistration = value == "true"
case "twoFactorEnabled":
cfg.TwoFactorEnabled = value == "true"
case "turnstileEnabled":
cfg.TurnstileEnabled = value == "true"
case "turnstileSiteKey":
cfg.TurnstileSiteKey = value
case "turnstileSecretKey":
cfg.TurnstileSecretKey = value
case "catchAllEnabled":
cfg.CatchAllEnabled = value == "true"
case "mailAutoRefresh":
cfg.MailAutoRefresh = value == "true"
case "mailRefreshSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
cfg.MailRefreshSeconds = n
}
case "userMailboxApplyEnabled":
cfg.UserMailboxApplyEnabled = value == "true"
case "userMailboxDomainIds":
cfg.UserMailboxDomainIDs = value
case "reservedMailboxPrefixes":
cfg.ReservedMailboxPrefixes = value
case "externalImapEnabled":
cfg.ExternalIMAPEnabled = value == "true"
case "externalImapSecretKey":
cfg.ExternalIMAPSecretKey = value
case "externalImapSyncSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
cfg.ExternalIMAPSyncSeconds = n
}
case "externalImapAllowPrivateHosts":
cfg.ExternalIMAPAllowPrivateHosts = value == "true"
case "externalImapGmailClientId":
cfg.ExternalIMAPGmailClientID = value
case "externalImapGmailClientSecret":
cfg.ExternalIMAPGmailClientSecret = value
case "externalImapOutlookClientId":
cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret":
cfg.ExternalIMAPOutlookClientSecret = value
}
}
if err := rows.Err(); err != nil {
return err
}
a.setConfig(cfg)
return nil
}
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
values := map[string]string{
"publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL,
"smtpHost": cfg.SMTPHost,
"smtpPort": cfg.SMTPPort,
"smtpUsername": cfg.SMTPUsername,
"smtpPassword": cfg.SMTPPassword,
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
"maildirRoot": cfg.MaildirRoot,
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
"turnstileSiteKey": cfg.TurnstileSiteKey,
"turnstileSecretKey": cfg.TurnstileSecretKey,
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
"userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled),
"userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","),
"reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","),
"externalImapEnabled": strconv.FormatBool(cfg.ExternalIMAPEnabled),
"externalImapSecretKey": cfg.ExternalIMAPSecretKey,
"externalImapSyncSeconds": strconv.Itoa(cfg.ExternalIMAPSyncSeconds),
"externalImapAllowPrivateHosts": strconv.FormatBool(cfg.ExternalIMAPAllowPrivateHosts),
"externalImapGmailClientId": cfg.ExternalIMAPGmailClientID,
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for key, value := range values {
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, key, value, now); err != nil {
return err
}
}
return tx.Commit()
}
func cleanIDList(items []string) []string {
seen := map[string]bool{}
out := []string{}
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
func parseReservedPrefixes(value string) []string {
value = strings.NewReplacer("\r", "\n", ",", "\n", ";", "\n", "", "\n", "", "\n").Replace(value)
seen := map[string]bool{}
out := []string{}
for _, item := range strings.Split(value, "\n") {
item = normalizeLocalPart(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
func normalizeHostname(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.TrimSuffix(value, ".")
value = strings.TrimPrefix(value, "http://")
value = strings.TrimPrefix(value, "https://")
if i := strings.Index(value, "/"); i >= 0 {
value = value[:i]
}
return value
}