Files
NewSzxcn-Email/apps/api/internal/app/settings_handlers.go
T
2026-08-02 15:07:16 +08:00

494 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
enabled := a.cfg.TurnstileEnabled && strings.TrimSpace(a.cfg.TurnstileSiteKey) != "" && strings.TrimSpace(a.cfg.TurnstileSecretKey) != ""
refreshSeconds := a.cfg.MailRefreshSeconds
if refreshSeconds <= 0 {
refreshSeconds = 30
}
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled}
// Include available domains for mailbox creation during registration
if a.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.cfg
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.cfg = 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.cfg
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()
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 {
return SystemSettings{
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
SMTPHost: a.cfg.SMTPHost,
SMTPPort: a.cfg.SMTPPort,
SMTPUsername: a.cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
MaildirRoot: a.cfg.MaildirRoot,
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
SessionTTLHours: a.cfg.SessionTTLHours,
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
OpenRegistration: a.cfg.OpenRegistration,
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
TurnstileEnabled: a.cfg.TurnstileEnabled,
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
CatchAllEnabled: a.cfg.CatchAllEnabled,
MailAutoRefresh: a.cfg.MailAutoRefresh,
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: a.cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: a.cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: a.cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: a.cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPOutlookClientSecret) != "",
}
}
func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
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":
a.cfg.PublicHostname = value
case "publicBaseUrl":
a.cfg.PublicBaseURL = value
case "smtpHost":
a.cfg.SMTPHost = value
case "smtpPort":
a.cfg.SMTPPort = value
case "smtpUsername":
a.cfg.SMTPUsername = value
case "smtpPassword":
a.cfg.SMTPPassword = value
case "smtpRequireTls":
a.cfg.SMTPRequireTLS = value == "true"
case "maildirRoot":
a.cfg.MaildirRoot = value
case "maildirScanSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MaildirScanSeconds = n
}
case "sessionTtlHours":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.SessionTTLHours = n
}
case "allowInsecureHttp":
a.cfg.AllowInsecureHTTP = value == "true"
case "openRegistration":
a.cfg.OpenRegistration = value == "true"
case "twoFactorEnabled":
a.cfg.TwoFactorEnabled = value == "true"
case "turnstileEnabled":
a.cfg.TurnstileEnabled = value == "true"
case "turnstileSiteKey":
a.cfg.TurnstileSiteKey = value
case "turnstileSecretKey":
a.cfg.TurnstileSecretKey = value
case "catchAllEnabled":
a.cfg.CatchAllEnabled = value == "true"
case "mailAutoRefresh":
a.cfg.MailAutoRefresh = value == "true"
case "mailRefreshSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MailRefreshSeconds = n
}
case "userMailboxApplyEnabled":
a.cfg.UserMailboxApplyEnabled = value == "true"
case "userMailboxDomainIds":
a.cfg.UserMailboxDomainIDs = value
case "reservedMailboxPrefixes":
a.cfg.ReservedMailboxPrefixes = value
case "externalImapEnabled":
a.cfg.ExternalIMAPEnabled = value == "true"
case "externalImapSecretKey":
a.cfg.ExternalIMAPSecretKey = value
case "externalImapSyncSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.ExternalIMAPSyncSeconds = n
}
case "externalImapAllowPrivateHosts":
a.cfg.ExternalIMAPAllowPrivateHosts = value == "true"
case "externalImapGmailClientId":
a.cfg.ExternalIMAPGmailClientID = value
case "externalImapGmailClientSecret":
a.cfg.ExternalIMAPGmailClientSecret = value
case "externalImapOutlookClientId":
a.cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret":
a.cfg.ExternalIMAPOutlookClientSecret = value
}
}
return rows.Err()
}
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
}