From 9a489992ed0f6fdebe9b70cb1b721232ddab8546 Mon Sep 17 00:00:00 2001
From: zxyszx <299979470+zxyszx@users.noreply.github.com>
Date: Mon, 3 Aug 2026 19:51:27 +0800
Subject: [PATCH] fix: harden runtime and remove placeholder features
---
apps/api/go.mod | 16 +-
apps/api/go.sum | 16 +-
apps/api/internal/app/app.go | 46 +-
apps/api/internal/app/app_test.go | 154 ++--
apps/api/internal/app/auth_handlers.go | 10 +-
apps/api/internal/app/dns_handlers.go | 4 +-
apps/api/internal/app/external_imap.go | 35 +-
apps/api/internal/app/forwarding_delivery.go | 4 +-
apps/api/internal/app/forwarding_handlers.go | 6 +-
apps/api/internal/app/imap_metadata.go | 19 -
apps/api/internal/app/mail_handlers.go | 8 +-
.../internal/app/mail_transfer_handlers.go | 2 +-
apps/api/internal/app/mail_translate.go | 8 +-
apps/api/internal/app/maildir_health.go | 2 +-
apps/api/internal/app/maildir_sync.go | 27 +-
apps/api/internal/app/maildir_write.go | 18 +-
apps/api/internal/app/mime.go | 2 +-
apps/api/internal/app/open_api_extended.go | 2 +-
apps/api/internal/app/open_api_handlers.go | 12 -
apps/api/internal/app/permissions.go | 27 +-
apps/api/internal/app/personal_handlers.go | 12 +-
apps/api/internal/app/router_auth.go | 4 +-
apps/api/internal/app/send_queue.go | 6 +-
apps/api/internal/app/session.go | 6 +-
apps/api/internal/app/settings_handlers.go | 143 ++--
apps/api/internal/app/status_webhook.go | 18 +-
apps/api/internal/app/submission.go | 8 +-
.../internal/app/system_update_handlers.go | 14 +-
apps/api/internal/app/turnstile.go | 4 +-
apps/api/internal/app/two_factor.go | 4 +-
apps/web/package.json | 11 +-
apps/web/src/components/confirm-dialog.tsx | 1 -
apps/web/src/components/ui/resizable.tsx | 43 -
apps/web/src/lib/api.ts | 2 +-
apps/web/src/main.tsx | 17 +-
apps/web/src/pages/admin.tsx | 11 +-
apps/web/src/pages/mail.tsx | 24 +-
apps/web/src/pages/profile.tsx | 784 ++----------------
apps/web/tsconfig.json | 2 +
pnpm-lock.yaml | 168 ++--
40 files changed, 447 insertions(+), 1253 deletions(-)
delete mode 100644 apps/web/src/components/ui/resizable.tsx
diff --git a/apps/api/go.mod b/apps/api/go.mod
index 62fda36..d6d1648 100644
--- a/apps/api/go.mod
+++ b/apps/api/go.mod
@@ -3,9 +3,14 @@ module lanqin-email-api
go 1.25.0
require (
- github.com/go-chi/chi/v5 v5.1.0
+ github.com/emersion/go-imap/v2 v2.0.0-beta.8
+ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
+ github.com/emersion/go-smtp v0.24.0
+ github.com/go-chi/chi/v5 v5.3.0
github.com/microcosm-cc/bluemonday v1.0.27
- golang.org/x/crypto v0.26.0
+ golang.org/x/crypto v0.51.0
+ golang.org/x/net v0.55.0
+ golang.org/x/oauth2 v0.36.0
golang.org/x/text v0.38.0
modernc.org/sqlite v1.31.1
)
@@ -13,19 +18,14 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
github.com/emersion/go-message v0.18.2 // indirect
- github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
- github.com/emersion/go-smtp v0.24.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/net v0.26.0 // indirect
- golang.org/x/oauth2 v0.36.0 // indirect
- golang.org/x/sys v0.23.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
diff --git a/apps/api/go.sum b/apps/api/go.sum
index 1f7e27c..e715baa 100644
--- a/apps/api/go.sum
+++ b/apps/api/go.sum
@@ -10,8 +10,8 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
-github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
-github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
+github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -33,8 +33,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
-golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
@@ -43,8 +43,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
-golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -59,8 +59,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
-golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go
index 7c6b0c0..b7206e4 100644
--- a/apps/api/internal/app/app.go
+++ b/apps/api/internal/app/app.go
@@ -24,6 +24,7 @@ import (
type App struct {
cfg Config
+ cfgMu sync.RWMutex
db *sql.DB
log *slog.Logger
now func() time.Time
@@ -34,6 +35,24 @@ type App struct {
externalIMAP externalIMAPClientFactory
}
+func (a *App) config() Config {
+ a.cfgMu.RLock()
+ defer a.cfgMu.RUnlock()
+ return a.cfg
+}
+
+func (a *App) setConfig(cfg Config) {
+ a.cfgMu.Lock()
+ a.cfg = cfg
+ a.cfgMu.Unlock()
+}
+
+func (a *App) updateConfig(update func(*Config)) {
+ a.cfgMu.Lock()
+ defer a.cfgMu.Unlock()
+ update(&a.cfg)
+}
+
func New(cfg Config, logger *slog.Logger) (*App, error) {
if logger == nil {
logger = slog.Default()
@@ -76,7 +95,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
workerCtx, cancel := context.WithCancel(context.Background())
a.workerCancel = cancel
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
- if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
+ if strings.TrimSpace(a.config().MaildirRoot) != "" {
a.startWorker(func() { a.maildirWorker(workerCtx) })
}
a.startWorker(func() { a.sendQueueWorker(workerCtx) })
@@ -925,7 +944,7 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
// Current seed() creates mailboxes with display_name = admin email, so this migration
// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema.
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
- adminEmail := normalizeEmail(a.cfg.AdminEmail)
+ adminEmail := normalizeEmail(a.config().AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
}
@@ -1387,6 +1406,7 @@ func messageIndexes() []string {
}
func (a *App) seed(ctx context.Context) error {
+ cfg := a.config()
var count int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return err
@@ -1395,7 +1415,7 @@ func (a *App) seed(ctx context.Context) error {
return a.ensureConfiguredAdminSuperAdmin(ctx)
}
- adminPassword := a.cfg.AdminPassword
+ adminPassword := cfg.AdminPassword
if adminPassword == "" {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
@@ -1410,8 +1430,8 @@ func (a *App) seed(ctx context.Context) error {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
- if strings.TrimSpace(a.cfg.AdminUsername) != "" {
- adminUsername, err := cleanUsername(a.cfg.AdminUsername)
+ if strings.TrimSpace(cfg.AdminUsername) != "" {
+ adminUsername, err := cleanUsername(cfg.AdminUsername)
if err != nil {
return fmt.Errorf("invalid admin username: %w", err)
}
@@ -1422,7 +1442,7 @@ func (a *App) seed(ctx context.Context) error {
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
return nil
}
- adminEmail := normalizeEmail(a.cfg.AdminEmail)
+ adminEmail := normalizeEmail(cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return errors.New("invalid admin email")
}
@@ -1463,12 +1483,13 @@ func (a *App) seed(ctx context.Context) error {
}
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
- if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
+ cfg := a.config()
+ if adminUsername := normalizeLoginName(cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`,
a.now().UTC().Format(time.RFC3339Nano), adminUsername)
return err
}
- adminEmail := normalizeEmail(a.cfg.AdminEmail)
+ adminEmail := normalizeEmail(cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
}
@@ -1589,6 +1610,7 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
}
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
+ cfg := a.config()
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
if err != nil {
return err
@@ -1599,10 +1621,10 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
bodyHTML := "
你的自建邮箱 Webmail 已经初始化完成。
请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。
"
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
rendered := renderMailTemplate(tpl, templateRenderData{
- To: a.cfg.AdminEmail,
+ To: cfg.AdminEmail,
From: "system@lanqin.local",
- PublicHostname: a.cfg.PublicHostname,
- PublicBaseURL: a.cfg.PublicBaseURL,
+ PublicHostname: cfg.PublicHostname,
+ PublicBaseURL: cfg.PublicBaseURL,
Time: now,
})
subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML
@@ -1615,7 +1637,7 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
Subject: subject,
From: "system@lanqin.local",
FromName: "NewSzxcn 邮箱",
- To: []string{a.cfg.AdminEmail},
+ To: []string{cfg.AdminEmail},
SentAt: now,
ReceivedAt: now,
Snippet: snippetFrom(bodyText, bodyHTML),
diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go
index e358634..306b495 100644
--- a/apps/api/internal/app/app_test.go
+++ b/apps/api/internal/app/app_test.go
@@ -650,7 +650,7 @@ func TestExternalIMAPDisabledByDefaultAndAdminSettings(t *testing.T) {
if settings.ExternalIMAPGmailClientID != "gmail-client" || settings.ExternalIMAPOutlookClientID != "outlook-client" {
t.Fatalf("oauth client ids not saved: %+v", settings)
}
- if a.cfg.ExternalIMAPSecretKey != "test-secret" || a.cfg.ExternalIMAPGmailClientSecret != "gmail-secret" || a.cfg.ExternalIMAPOutlookClientSecret != "outlook-secret" {
+ if a.config().ExternalIMAPSecretKey != "test-secret" || a.config().ExternalIMAPGmailClientSecret != "gmail-secret" || a.config().ExternalIMAPOutlookClientSecret != "outlook-secret" {
t.Fatalf("secret settings not persisted in config")
}
if code := admin.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || !public.ExternalIMAPEnabled {
@@ -660,8 +660,8 @@ func TestExternalIMAPDisabledByDefaultAndAdminSettings(t *testing.T) {
func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) {
a := newTestApp(t)
- a.cfg.ExternalIMAPEnabled = true
- a.cfg.ExternalIMAPSecretKey = "test-secret"
+ a.updateConfig(func(cfg *Config) { cfg.ExternalIMAPEnabled = true })
+ a.updateConfig(func(cfg *Config) { cfg.ExternalIMAPSecretKey = "test-secret" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -958,7 +958,7 @@ func TestMailRulesConditionGroupsAndActions(t *testing.T) {
func TestMailRulesForwardingAction(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
- a.cfg.SMTPHost = "127.0.0.1"
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -1344,7 +1344,7 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
t.Fatalf("closed registration code=%d body=%v", code, out)
}
- a.cfg.OpenRegistration = true
+ a.updateConfig(func(cfg *Config) { cfg.OpenRegistration = true })
var registered struct {
User User `json:"user"`
}
@@ -1968,8 +1968,8 @@ func TestHTMLPolicyPreservesEmailLayoutStyles(t *testing.T) {
func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) {
a := newTestApp(t)
- a.cfg.SMTPHost = "127.0.0.1"
- a.cfg.SMTPPort = "1"
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2010,8 +2010,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 8)
- a.cfg.SMTPHost = host
- a.cfg.SMTPPort = port
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2187,8 +2187,8 @@ func TestMailSendRejectsUnauthorizedFrom(t *testing.T) {
func TestMailSendRollsBackSentCopyWhenQueueInsertFails(t *testing.T) {
a := newTestApp(t)
- a.cfg.SMTPHost = "postfix"
- a.cfg.SMTPPort = "25"
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "postfix" })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
user, mb := defaultAdminUserAndMailbox(t, a)
if _, err := a.db.ExecContext(context.Background(), `DROP TABLE send_queue`); err != nil {
t.Fatal(err)
@@ -2387,8 +2387,8 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
- a.cfg.SMTPHost = "127.0.0.1"
- a.cfg.SMTPPort = "25"
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2496,9 +2496,9 @@ func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) {
func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
- a.cfg.SMTPHost = "127.0.0.1"
- a.cfg.SMTPPort = "25"
- a.cfg.DeliveryWebhookSecret = "delivery-test-secret"
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
+ a.updateConfig(func(cfg *Config) { cfg.DeliveryWebhookSecret = "delivery-test-secret" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
@@ -2580,7 +2580,7 @@ func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) {
}{Events: []deliveryWebhookEvent{{ID: "provider-event-1", Provider: "test-provider", MessageID: first.MessageID, Recipient: recipient.Address, Status: "bounced", Reason: "550 mailbox unavailable", OccurredAt: a.now().UTC().Format(time.RFC3339Nano)}}}
body, _ := json.Marshal(eventPayload)
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
- mac := hmac.New(sha256.New, []byte(a.cfg.DeliveryWebhookSecret))
+ mac := hmac.New(sha256.New, []byte(a.config().DeliveryWebhookSecret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(body)
webhookHeaders := map[string]string{"X-LanQin-Timestamp": timestamp, "X-LanQin-Signature": "sha256=" + hex.EncodeToString(mac.Sum(nil))}
@@ -2589,7 +2589,7 @@ func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) {
t.Fatalf("invalid delivery webhook signature code=%d", code)
}
oldTimestamp := strconv.FormatInt(a.now().UTC().Add(-10*time.Minute).Unix(), 10)
- oldMAC := hmac.New(sha256.New, []byte(a.cfg.DeliveryWebhookSecret))
+ oldMAC := hmac.New(sha256.New, []byte(a.config().DeliveryWebhookSecret))
_, _ = oldMAC.Write([]byte(oldTimestamp + "."))
_, _ = oldMAC.Write(body)
oldHeaders := map[string]string{"X-LanQin-Timestamp": oldTimestamp, "X-LanQin-Signature": "sha256=" + hex.EncodeToString(oldMAC.Sum(nil))}
@@ -2769,9 +2769,9 @@ func TestStatusWebhookOutboxDeliveryRetryAndSSRFProtection(t *testing.T) {
w.WriteHeader(http.StatusNoContent)
}))
defer receiver.Close()
- a.cfg.StatusWebhookURL = receiver.URL
- a.cfg.StatusWebhookSecret = "outbound-test-secret"
- a.cfg.StatusWebhookAllowPrivateHosts = true
+ a.updateConfig(func(cfg *Config) { cfg.StatusWebhookURL = receiver.URL })
+ a.updateConfig(func(cfg *Config) { cfg.StatusWebhookSecret = "outbound-test-secret" })
+ a.updateConfig(func(cfg *Config) { cfg.StatusWebhookAllowPrivateHosts = true })
user, mb := defaultAdminUserAndMailbox(t, a)
a.recordSendAudit(context.Background(), sendAuditFailed, sendQueueStatusFailed, sendAuditInput{QueueID: "snd_test", UserID: user.ID, MailboxID: mb.ID, SentMessageID: "mail_test", Source: sendSourceOpenAPI, MailFrom: mb.Address, Recipients: []string{"recipient@example.test"}, Error: "test failure"})
@@ -2807,8 +2807,8 @@ func TestStatusWebhookOutboxDeliveryRetryAndSSRFProtection(t *testing.T) {
privateTLS := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer privateTLS.Close()
- a.cfg.StatusWebhookURL = privateTLS.URL
- a.cfg.StatusWebhookAllowPrivateHosts = false
+ a.updateConfig(func(cfg *Config) { cfg.StatusWebhookURL = privateTLS.URL })
+ a.updateConfig(func(cfg *Config) { cfg.StatusWebhookAllowPrivateHosts = false })
if _, err := a.validatedStatusWebhookURL(context.Background()); err == nil || !strings.Contains(err.Error(), "private or local") {
t.Fatalf("private webhook target should be rejected, err=%v", err)
}
@@ -2818,8 +2818,8 @@ func TestSendQueueRecoversStaleSendingItems(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 1)
- a.cfg.SMTPHost = host
- a.cfg.SMTPPort = port
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb := defaultAdminUserAndMailbox(t, a)
now := a.now().UTC()
mimeBytes := []byte("From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: stale\r\n\r\nbody")
@@ -2868,8 +2868,8 @@ func TestSendQueueStaleDeliveredMarkerDoesNotRedeliver(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 1)
- a.cfg.SMTPHost = host
- a.cfg.SMTPPort = port
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb := defaultAdminUserAndMailbox(t, a)
now := a.now().UTC()
mimeBytes := []byte("From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: marker\r\n\r\nbody")
@@ -2922,8 +2922,8 @@ func TestSendQueueStaleDeliveredMarkerDoesNotRedeliver(t *testing.T) {
func TestSendQueueAPIPermissionIsolation(t *testing.T) {
a := newTestApp(t)
- a.cfg.SMTPHost = "127.0.0.1"
- a.cfg.SMTPPort = "25"
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2997,8 +2997,8 @@ func TestSendQueueAPIPermissionIsolation(t *testing.T) {
func TestSendQueueAPIFiltersStableCursorAndMessageDetailLink(t *testing.T) {
a := newTestApp(t)
- a.cfg.SMTPHost = "127.0.0.1"
- a.cfg.SMTPPort = "25"
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
@@ -3129,8 +3129,8 @@ func TestSendQueueAPIFiltersStableCursorAndMessageDetailLink(t *testing.T) {
func TestSendQueueAPIRetryAndCancel(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1)
- a.cfg.SMTPHost = host
- a.cfg.SMTPPort = port
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
@@ -3384,8 +3384,8 @@ func TestSubmissionAuthRequiresMailboxPasswordAndSendPermission(t *testing.T) {
func TestSubmissionSendsRelayAndStoresSentCopy(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
- a.cfg.SMTPHost = host
- a.cfg.SMTPPort = port
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
raw := strings.Join([]string{
"From: Admin ",
"To: person@example.com",
@@ -3481,8 +3481,8 @@ func TestSerializeMessageUsesStableHeaderOrder(t *testing.T) {
func TestSubmissionRelayFailureKeepsSentCopyAndRetries(t *testing.T) {
a := newTestApp(t)
- a.cfg.SMTPHost = "127.0.0.1"
- a.cfg.SMTPPort = "1"
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3513,8 +3513,8 @@ func TestSubmissionRelayFailureKeepsSentCopyAndRetries(t *testing.T) {
func TestSubmissionSentCopyDedupesByMessageID(t *testing.T) {
a := newTestApp(t)
host, port, _ := startCapturingSMTP(t, 4)
- a.cfg.SMTPHost = host
- a.cfg.SMTPPort = port
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3587,8 +3587,8 @@ func TestInsertSentMessageOnceFailsWhenDedupeKeyHasNoMessage(t *testing.T) {
func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
a := newTestApp(t)
- a.cfg.SMTPHost = "127.0.0.1"
- a.cfg.SMTPPort = "1"
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3602,8 +3602,8 @@ func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
}
host, port, received := startCapturingSMTP(t, 1)
- a.cfg.SMTPHost = host
- a.cfg.SMTPPort = port
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
t.Fatal(err)
}
@@ -3628,8 +3628,8 @@ func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
- a.cfg.SMTPHost = host
- a.cfg.SMTPPort = port
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3670,8 +3670,8 @@ func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) {
func TestSubmissionRequeuesCanceledDuplicateMessageID(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1)
- a.cfg.SMTPHost = host
- a.cfg.SMTPPort = port
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3821,9 +3821,9 @@ func TestSendQueueMessageIDMigrationDropsDuplicatesBeforeUniqueIndex(t *testing.
func TestSubmissionTLSConfigRequiresCertificateFiles(t *testing.T) {
a := newTestApp(t)
- a.cfg.SubmissionAddr = ":587"
- a.cfg.SubmissionTLSAddr = ":465"
- if _, err := LoadServerTLSConfig(a.cfg); err == nil {
+ a.updateConfig(func(cfg *Config) { cfg.SubmissionAddr = ":587" })
+ a.updateConfig(func(cfg *Config) { cfg.SubmissionTLSAddr = ":465" })
+ if _, err := LoadServerTLSConfig(a.config()); err == nil {
t.Fatal("submission TLS config should require certificate files")
}
}
@@ -3831,9 +3831,9 @@ func TestSubmissionTLSConfigRequiresCertificateFiles(t *testing.T) {
func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
a := newTestApp(t)
certPath, keyPath := writeTestCertificateFiles(t, "first.example.test")
- a.cfg.TLSCertFile = certPath
- a.cfg.TLSKeyFile = keyPath
- tlsConfig, err := LoadServerTLSConfig(a.cfg)
+ a.updateConfig(func(cfg *Config) { cfg.TLSCertFile = certPath })
+ a.updateConfig(func(cfg *Config) { cfg.TLSKeyFile = keyPath })
+ tlsConfig, err := LoadServerTLSConfig(a.config())
if err != nil {
t.Fatal(err)
}
@@ -3876,12 +3876,12 @@ func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
- a.cfg.SMTPHost = host
- a.cfg.SMTPPort = port
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
certPath, keyPath := writeTestCertificateFiles(t, "mail.example.test")
- a.cfg.TLSCertFile = certPath
- a.cfg.TLSKeyFile = keyPath
- tlsConfig, err := LoadServerTLSConfig(a.cfg)
+ a.updateConfig(func(cfg *Config) { cfg.TLSCertFile = certPath })
+ a.updateConfig(func(cfg *Config) { cfg.TLSKeyFile = keyPath })
+ tlsConfig, err := LoadServerTLSConfig(a.config())
if err != nil {
t.Fatal(err)
}
@@ -3952,8 +3952,8 @@ func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
func TestAdminSMTPTestEndpoint(t *testing.T) {
a := newTestApp(t)
host, port, received := startFakeSMTP(t)
- a.cfg.SMTPHost = host
- a.cfg.SMTPPort = port
+ a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
+ a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -4102,7 +4102,7 @@ func TestUserMailSignaturesDefaultResolution(t *testing.T) {
func TestUserTwoFactorSetupAndLogin(t *testing.T) {
a := newTestApp(t)
- a.cfg.TwoFactorEnabled = true
+ a.updateConfig(func(cfg *Config) { cfg.TwoFactorEnabled = true })
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
@@ -4569,7 +4569,7 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
- a.cfg.MaildirRoot = root
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
var domainID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil {
t.Fatal(err)
@@ -4650,7 +4650,7 @@ func TestMaildirImportStoresAuthenticationResults(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
- a.cfg.MaildirRoot = root
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
ts := httptest.NewServer(a.Router())
defer ts.Close()
@@ -4753,8 +4753,8 @@ func TestMaildirSyncHealthAfterTrackedSync(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
- a.cfg.MaildirRoot = root
- a.cfg.MaildirScanSeconds = 45
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
+ a.updateConfig(func(cfg *Config) { cfg.MaildirScanSeconds = 45 })
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
@@ -4806,7 +4806,7 @@ func TestMaildirSyncHealthAfterTrackedSync(t *testing.T) {
if counts.Imported != 1 || counts.FilesScanned != 1 {
t.Fatalf("counts=%+v, want imported=1 filesScanned=1", counts)
}
- health := a.maildirHealth.snapshot(a.cfg)
+ health := a.maildirHealth.snapshot(a.config())
if !health.Configured || !health.Enabled {
t.Fatalf("configured health=%+v, want enabled", health)
}
@@ -4828,7 +4828,7 @@ func TestMaildirSyncImportsSentFolder(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
- a.cfg.MaildirRoot = root
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
@@ -4901,7 +4901,7 @@ func TestMaildirSyncImportsSentFolder(t *testing.T) {
func TestWebmailSentWritesMaildirSent(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
- a.cfg.MaildirRoot = t.TempDir()
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -4941,7 +4941,7 @@ func TestWebmailSentWritesMaildirSent(t *testing.T) {
func TestMaildirSyncBackfillsSQLiteOnlySent(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
- a.cfg.MaildirRoot = t.TempDir()
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -4984,7 +4984,7 @@ func TestMaildirSyncBackfillsSQLiteOnlySent(t *testing.T) {
func TestDraftWritesAndUpdatesMaildirDrafts(t *testing.T) {
a := newTestApp(t)
- a.cfg.MaildirRoot = t.TempDir()
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5037,7 +5037,7 @@ func TestDraftWritesAndUpdatesMaildirDrafts(t *testing.T) {
func TestMoveAndDeleteMessageUpdateMaildir(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
- a.cfg.MaildirRoot = t.TempDir()
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5090,7 +5090,7 @@ func TestMoveAndDeleteMessageUpdateMaildir(t *testing.T) {
func TestMessageFlagsUpdateMaildir(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
- a.cfg.MaildirRoot = t.TempDir()
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5136,7 +5136,7 @@ func TestMessageFlagsUpdateMaildir(t *testing.T) {
func TestIMAPUIDAndModSeqProgression(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
- a.cfg.MaildirRoot = t.TempDir()
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5229,7 +5229,7 @@ func TestIMAPUIDAndModSeqProgression(t *testing.T) {
func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
- a.cfg.MaildirRoot = t.TempDir()
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5275,7 +5275,7 @@ func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) {
func TestMaildirSyncKeepsDistinctCopiesWithSameMessageID(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
- a.cfg.MaildirRoot = t.TempDir()
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5304,7 +5304,7 @@ func TestMaildirSyncKeepsDistinctCopiesWithSameMessageID(t *testing.T) {
func TestMaildirSyncUpdatesFlagsFromIMAP(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
- a.cfg.MaildirRoot = t.TempDir()
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5345,7 +5345,7 @@ func TestMaildirSyncUpdatesFlagsFromIMAP(t *testing.T) {
func TestMaildirSyncDeletesMissingMessage(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
- a.cfg.MaildirRoot = t.TempDir()
+ a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
diff --git a/apps/api/internal/app/auth_handlers.go b/apps/api/internal/app/auth_handlers.go
index 55eaf7e..67d249f 100644
--- a/apps/api/internal/app/auth_handlers.go
+++ b/apps/api/internal/app/auth_handlers.go
@@ -70,7 +70,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
- if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
+ if a.config().TwoFactorEnabled && user.TwoFactorEnabled {
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "验证码生成失败,请稍后重试")
@@ -87,7 +87,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
- if !a.cfg.OpenRegistration {
+ if !a.config().OpenRegistration {
respondError(w, http.StatusForbidden, "当前未开放注册")
return
}
@@ -176,7 +176,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
if mailboxDomainID != "" && mailboxLocalPart != "" {
// Check reserved prefixes
reserved := map[string]bool{}
- for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
+ for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[mailboxLocalPart] {
@@ -192,10 +192,10 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
- if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
+ if cookie, err := r.Cookie(a.config().CookieName); err == nil {
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
}
- http.SetCookie(w, &http.Cookie{Name: a.cfg.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
+ http.SetCookie(w, &http.Cookie{Name: a.config().CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
diff --git a/apps/api/internal/app/dns_handlers.go b/apps/api/internal/app/dns_handlers.go
index fcdb97b..412d91b 100644
--- a/apps/api/internal/app/dns_handlers.go
+++ b/apps/api/internal/app/dns_handlers.go
@@ -34,7 +34,7 @@ func (a *App) handleDNSCheck(w http.ResponseWriter, r *http.Request) {
func (a *App) dnsRecordsFor(d *Domain) []DNSRecord {
name := strings.TrimSuffix(d.Name, ".")
- host := strings.TrimSuffix(a.cfg.PublicHostname, ".") + "."
+ host := strings.TrimSuffix(a.config().PublicHostname, ".") + "."
return []DNSRecord{
{Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300},
{Type: "TXT", Name: name, Value: "v=spf1 mx -all", TTL: 300},
@@ -58,7 +58,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
for _, item := range mx {
entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, "."))
found = append(found, entry)
- if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.cfg.PublicHostname, ".")) {
+ if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.config().PublicHostname, ".")) {
ok = true
}
}
diff --git a/apps/api/internal/app/external_imap.go b/apps/api/internal/app/external_imap.go
index 11a9cac..83bd7ad 100644
--- a/apps/api/internal/app/external_imap.go
+++ b/apps/api/internal/app/external_imap.go
@@ -131,7 +131,7 @@ type externalIMAPOAuthState struct {
}
func (a *App) externalIMAPWorker(ctx context.Context) {
- interval := time.Duration(a.cfg.ExternalIMAPSyncSeconds) * time.Second
+ interval := time.Duration(a.config().ExternalIMAPSyncSeconds) * time.Second
if interval <= 0 {
interval = 5 * time.Minute
}
@@ -148,7 +148,7 @@ func (a *App) externalIMAPWorker(ctx context.Context) {
}
func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
- if !a.cfg.ExternalIMAPEnabled {
+ if !a.config().ExternalIMAPEnabled {
return
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM external_imap_accounts WHERE enabled=1 AND storage_mode=? ORDER BY COALESCE(last_sync_at, created_at) ASC LIMIT 10`, externalIMAPStorageLocal)
@@ -170,7 +170,7 @@ func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
func (a *App) requireExternalIMAPEnabled(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !a.cfg.ExternalIMAPEnabled {
+ if !a.config().ExternalIMAPEnabled {
respondError(w, http.StatusForbidden, "external imap is disabled")
return
}
@@ -540,7 +540,7 @@ func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Req
respondError(w, http.StatusInternalServerError, "failed to save oauth account")
return
}
- http.Redirect(w, r, strings.TrimRight(a.cfg.PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
+ http.Redirect(w, r, strings.TrimRight(a.config().PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
}
func (a *App) handleMailExternalAccounts(w http.ResponseWriter, r *http.Request) {
@@ -789,7 +789,7 @@ func (a *App) normalizeExternalIMAPPayload(ctx context.Context, req externalIMAP
}
func (a *App) validateExternalIMAPHost(ctx context.Context, host string) error {
- if a.cfg.ExternalIMAPAllowPrivateHosts {
+ if a.config().ExternalIMAPAllowPrivateHosts {
return nil
}
if strings.EqualFold(host, "localhost") {
@@ -868,7 +868,7 @@ func (a *App) decryptExternalIMAPPassword(ciphertext string) (string, error) {
}
func (a *App) externalIMAPKey() ([]byte, error) {
- secret := strings.TrimSpace(a.cfg.ExternalIMAPSecretKey)
+ secret := strings.TrimSpace(a.config().ExternalIMAPSecretKey)
if secret == "" {
return nil, errors.New("LANQIN_EXTERNAL_IMAP_SECRET_KEY is required")
}
@@ -883,15 +883,15 @@ type externalIMAPOAuthProvider struct {
}
func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, externalIMAPOAuthProvider, error) {
- callback := strings.TrimRight(a.cfg.PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
+ callback := strings.TrimRight(a.config().PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
switch provider {
case externalIMAPOAuthGmail:
- if a.cfg.ExternalIMAPGmailClientID == "" || a.cfg.ExternalIMAPGmailClientSecret == "" {
+ if a.config().ExternalIMAPGmailClientID == "" || a.config().ExternalIMAPGmailClientSecret == "" {
return nil, externalIMAPOAuthProvider{}, errors.New("gmail oauth is not configured")
}
return &oauth2.Config{
- ClientID: a.cfg.ExternalIMAPGmailClientID,
- ClientSecret: a.cfg.ExternalIMAPGmailClientSecret,
+ ClientID: a.config().ExternalIMAPGmailClientID,
+ ClientSecret: a.config().ExternalIMAPGmailClientSecret,
RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"},
Endpoint: oauth2.Endpoint{
@@ -900,12 +900,12 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external
},
}, externalIMAPOAuthProvider{Name: "Gmail", Host: "imap.gmail.com", Port: 993}, nil
case externalIMAPOAuthOutlook:
- if a.cfg.ExternalIMAPOutlookClientID == "" || a.cfg.ExternalIMAPOutlookClientSecret == "" {
+ if a.config().ExternalIMAPOutlookClientID == "" || a.config().ExternalIMAPOutlookClientSecret == "" {
return nil, externalIMAPOAuthProvider{}, errors.New("outlook oauth is not configured")
}
return &oauth2.Config{
- ClientID: a.cfg.ExternalIMAPOutlookClientID,
- ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret,
+ ClientID: a.config().ExternalIMAPOutlookClientID,
+ ClientSecret: a.config().ExternalIMAPOutlookClientSecret,
RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
Endpoint: oauth2.Endpoint{
@@ -1376,15 +1376,6 @@ func safeExternalEMLFilename(subject string) string {
return name + ".eml"
}
-func externalIMAPAttachmentsFromBodyStructure(body imap.BodyStructure) []Attachment {
- parts := externalIMAPAttachmentPartsFromBodyStructure(body)
- items := make([]Attachment, 0, len(parts))
- for _, part := range parts {
- items = append(items, part.Attachment)
- }
- return items
-}
-
func externalIMAPAttachmentPartsFromBodyStructure(body imap.BodyStructure) []externalIMAPAttachmentPart {
now := time.Now().UTC()
items := []externalIMAPAttachmentPart{}
diff --git a/apps/api/internal/app/forwarding_delivery.go b/apps/api/internal/app/forwarding_delivery.go
index 8b56a72..a6d607d 100644
--- a/apps/api/internal/app/forwarding_delivery.go
+++ b/apps/api/internal/app/forwarding_delivery.go
@@ -45,7 +45,7 @@ func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID
a.log.Warn("skip forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return
}
- forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
+ forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" {
@@ -101,7 +101,7 @@ func (a *App) processRuleForwarding(ctx context.Context, messageID, mailboxID st
a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return nil
}
- forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
+ forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" {
diff --git a/apps/api/internal/app/forwarding_handlers.go b/apps/api/internal/app/forwarding_handlers.go
index b800872..b8ea04b 100644
--- a/apps/api/internal/app/forwarding_handlers.go
+++ b/apps/api/internal/app/forwarding_handlers.go
@@ -389,7 +389,7 @@ func (a *App) issueForwardingVerification(ctx context.Context, userID, id, email
}
func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targetEmail, token string, now time.Time) (string, error) {
- if strings.TrimSpace(a.cfg.SMTPHost) == "" {
+ if strings.TrimSpace(a.config().SMTPHost) == "" {
return "", errors.New("SMTP 未配置,无法发送验证邮件")
}
mb, err := a.primaryMailboxForUser(ctx, userID)
@@ -428,9 +428,9 @@ func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targe
}
func (a *App) forwardingVerificationURL(token string) string {
- base := strings.TrimRight(strings.TrimSpace(a.cfg.PublicBaseURL), "/")
+ base := strings.TrimRight(strings.TrimSpace(a.config().PublicBaseURL), "/")
if base == "" {
- base = "https://" + strings.Trim(strings.TrimSpace(a.cfg.PublicHostname), "/")
+ base = "https://" + strings.Trim(strings.TrimSpace(a.config().PublicHostname), "/")
}
return base + "/api/verify-email?token=" + url.QueryEscape(token)
}
diff --git a/apps/api/internal/app/imap_metadata.go b/apps/api/internal/app/imap_metadata.go
index cb1079a..d349b01 100644
--- a/apps/api/internal/app/imap_metadata.go
+++ b/apps/api/internal/app/imap_metadata.go
@@ -232,25 +232,6 @@ func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderI
return next, nil
}
-func (a *App) touchMessageIMAPModSeq(ctx context.Context, messageID string) error {
- var folderID sql.NullString
- if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID); err != nil {
- return err
- }
- if !folderID.Valid || folderID.String == "" {
- return nil
- }
- modSeq, err := a.bumpFolderModSeq(ctx, folderID.String)
- if err != nil {
- return err
- }
- if modSeq == 0 {
- return nil
- }
- _, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
- return err
-}
-
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
if folderID == "" {
var dbFolderID sql.NullString
diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go
index df78faf..0877ef0 100644
--- a/apps/api/internal/app/mail_handlers.go
+++ b/apps/api/internal/app/mail_handlers.go
@@ -971,7 +971,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
for _, rcpt := range localRecipients {
rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
if err != nil {
- if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
+ if !a.config().CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
continue
}
copyMsg := base
@@ -986,7 +986,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
continue
}
if rcptMailbox.Status != "active" {
- if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
+ if a.config().CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
copyMsg := base
copyMsg.MailboxID = ""
copyMsg.FolderID = ""
@@ -2345,7 +2345,7 @@ func (a *App) storeAttachmentWithDB(ctx context.Context, db dbExecutor, messageI
if err != nil {
return err
}
- dir := filepath.Join(a.cfg.DataDir, "attachments", messageID)
+ dir := filepath.Join(a.config().DataDir, "attachments", messageID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@@ -2550,7 +2550,7 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
_ = os.Remove(p)
}
}
- _ = os.RemoveAll(filepath.Join(a.cfg.DataDir, "attachments", messageID))
+ _ = os.RemoveAll(filepath.Join(a.config().DataDir, "attachments", messageID))
}
func (a *App) deleteMessage(ctx context.Context, messageID string) {
diff --git a/apps/api/internal/app/mail_transfer_handlers.go b/apps/api/internal/app/mail_transfer_handlers.go
index c439b26..26ad258 100644
--- a/apps/api/internal/app/mail_transfer_handlers.go
+++ b/apps/api/internal/app/mail_transfer_handlers.go
@@ -216,7 +216,7 @@ func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
imported, skipped := 0, 0
problems := []string{}
- maxMessageBytes := int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
+ maxMessageBytes := int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
if maxMessageBytes <= 0 {
maxMessageBytes = 35 * 1024 * 1024
}
diff --git a/apps/api/internal/app/mail_translate.go b/apps/api/internal/app/mail_translate.go
index 8b8ac83..f2dde79 100644
--- a/apps/api/internal/app/mail_translate.go
+++ b/apps/api/internal/app/mail_translate.go
@@ -32,7 +32,7 @@ type translateMailMessageResponse struct {
}
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) {
- if !a.cfg.MailTranslateEnabled {
+ if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
@@ -59,7 +59,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
- maxChars := a.cfg.MailTranslateMaxChars
+ maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
@@ -78,7 +78,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
}
func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) {
- if !a.cfg.MailTranslateEnabled {
+ if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
@@ -126,7 +126,7 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
- maxChars := a.cfg.MailTranslateMaxChars
+ maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
diff --git a/apps/api/internal/app/maildir_health.go b/apps/api/internal/app/maildir_health.go
index d6c098d..36e0a14 100644
--- a/apps/api/internal/app/maildir_health.go
+++ b/apps/api/internal/app/maildir_health.go
@@ -193,5 +193,5 @@ func cloneTimePtr(in *time.Time) *time.Time {
}
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
- respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
+ respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.config()))
}
diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go
index 0ef292b..c35bfe7 100644
--- a/apps/api/internal/app/maildir_sync.go
+++ b/apps/api/internal/app/maildir_sync.go
@@ -45,13 +45,13 @@ type parsedMail struct {
}
func (a *App) maildirWorker(ctx context.Context) {
- interval := time.Duration(a.cfg.MaildirScanSeconds) * time.Second
+ interval := time.Duration(a.config().MaildirScanSeconds) * time.Second
if interval <= 0 {
interval = 30 * time.Second
}
nextRunAt := a.now().UTC()
a.maildirHealth.markWorkerStarted(&nextRunAt)
- a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
+ a.log.Info("maildir sync worker started", "root", a.config().MaildirRoot, "interval", interval.String())
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
a.log.Warn("initial maildir sync failed", "error", err)
} else if n := counts.total(); n > 0 {
@@ -98,7 +98,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
}
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
- root := strings.TrimSpace(a.cfg.MaildirRoot)
+ root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" {
return maildirSyncCounts{}, nil
}
@@ -190,7 +190,7 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
if err := rows.Err(); err != nil {
return nil, err
}
- if a.cfg.CatchAllEnabled {
+ if a.config().CatchAllEnabled {
domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
if err != nil {
return nil, err
@@ -216,13 +216,8 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
return out, nil
}
-func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
- counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
- return counts.Imported, err
-}
-
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
- base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
+ base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
counts := maildirSyncCounts{}
for _, sub := range []string{"new", "cur"} {
select {
@@ -394,16 +389,6 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
return count > 0, nil
}
-func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID string) {
- if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
- return
- }
- if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
- rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
- a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
- }
-}
-
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
now := a.now().UTC().Format(time.RFC3339Nano)
var samePathID, oldFolderID string
@@ -514,7 +499,7 @@ func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailbo
}
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
- if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
+ if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
diff --git a/apps/api/internal/app/maildir_write.go b/apps/api/internal/app/maildir_write.go
index 4b7fba3..8ae37f1 100644
--- a/apps/api/internal/app/maildir_write.go
+++ b/apps/api/internal/app/maildir_write.go
@@ -14,7 +14,7 @@ import (
)
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
- if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
+ if strings.TrimSpace(a.config().MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
return nil
}
raw, err := BuildMIME(MIMEMessage{
@@ -37,7 +37,7 @@ func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string,
}
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
- if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
+ if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
msg, err := a.storedMessageByID(ctx, messageID)
@@ -76,7 +76,7 @@ func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, ra
}
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
- if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
+ if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
@@ -114,7 +114,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
if err != nil {
return err
}
- base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
+ base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
subdir := "cur"
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
@@ -166,7 +166,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
}
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
- if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
+ if strings.TrimSpace(a.config().MaildirRoot) == "" {
state, stateErr := a.maildirMessageState(ctx, messageID)
if stateErr != nil {
return stateErr
@@ -215,7 +215,7 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
if err != nil {
return err
}
- base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
+ base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
return err
@@ -284,7 +284,7 @@ func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
}
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
- if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
+ if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
@@ -354,7 +354,7 @@ func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
}
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
- if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
+ if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
@@ -472,7 +472,7 @@ func (a *App) folderNameByID(ctx context.Context, folderID string) (string, erro
}
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
- root := strings.TrimSpace(a.cfg.MaildirRoot)
+ root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" || strings.TrimSpace(path) == "" {
return false, nil
}
diff --git a/apps/api/internal/app/mime.go b/apps/api/internal/app/mime.go
index c9859de..f698fce 100644
--- a/apps/api/internal/app/mime.go
+++ b/apps/api/internal/app/mime.go
@@ -136,7 +136,7 @@ func writeBase64(w io.Writer, data []byte) {
}
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error {
- return sendSMTPWithConfig(a.cfg, from, recipients, mimeBytes)
+ return sendSMTPWithConfig(a.config(), from, recipients, mimeBytes)
}
func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
diff --git a/apps/api/internal/app/open_api_extended.go b/apps/api/internal/app/open_api_extended.go
index ca835fd..7531147 100644
--- a/apps/api/internal/app/open_api_extended.go
+++ b/apps/api/internal/app/open_api_extended.go
@@ -31,7 +31,7 @@ type deliveryWebhookEvent struct {
}
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
- secret := strings.TrimSpace(a.cfg.DeliveryWebhookSecret)
+ secret := strings.TrimSpace(a.config().DeliveryWebhookSecret)
if secret == "" {
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
return
diff --git a/apps/api/internal/app/open_api_handlers.go b/apps/api/internal/app/open_api_handlers.go
index fc30ecb..8a45290 100644
--- a/apps/api/internal/app/open_api_handlers.go
+++ b/apps/api/internal/app/open_api_handlers.go
@@ -923,15 +923,3 @@ func parseOpenAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
}
return limit
}
-
-func parseOpenAPIOffset(r *http.Request) int {
- cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
- if cursor == "" {
- return 0
- }
- offset, err := strconv.Atoi(cursor)
- if err != nil || offset < 0 {
- return 0
- }
- return offset
-}
diff --git a/apps/api/internal/app/permissions.go b/apps/api/internal/app/permissions.go
index 6985ee4..d6d6710 100644
--- a/apps/api/internal/app/permissions.go
+++ b/apps/api/internal/app/permissions.go
@@ -490,21 +490,6 @@ func regularUserDefaultPermissions() []string {
}
}
-func fixedPermissionGroupIDs() map[string]bool {
- out := map[string]bool{}
- for _, group := range defaultPermissionGroups() {
- out[group.ID] = true
- }
- return out
-}
-
-func assignablePermissionGroupIDs() map[string]bool {
- out := fixedPermissionGroupIDs()
- delete(out, PermissionGroupSuperAdmin)
- delete(out, PermissionGroupRegular)
- return out
-}
-
func isAssignablePermissionGroupID(groupID string) bool {
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
}
@@ -517,14 +502,6 @@ func permissionGroupOrder() map[string]int {
return out
}
-func permissionGroupNames() map[string]string {
- out := map[string]string{}
- for _, group := range defaultPermissionGroups() {
- out[group.ID] = group.Name
- }
- return out
-}
-
func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
for _, item := range defaultPermissionGroups() {
@@ -1057,10 +1034,10 @@ func (a *App) isDefaultAdminUser(u *User) bool {
if u == nil {
return false
}
- if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
+ if adminUsername := normalizeLoginName(a.config().AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
}
- adminEmail := normalizeEmail(a.cfg.AdminEmail)
+ adminEmail := normalizeEmail(a.config().AdminEmail)
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
}
diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go
index f5fb7b6..c7940af 100644
--- a/apps/api/internal/app/personal_handlers.go
+++ b/apps/api/internal/app/personal_handlers.go
@@ -27,14 +27,14 @@ func (a *App) handleMailboxApplyOptions(w http.ResponseWriter, r *http.Request)
return
}
respondJSON(w, http.StatusOK, MailboxApplyOptions{
- Enabled: a.cfg.UserMailboxApplyEnabled,
+ Enabled: a.config().UserMailboxApplyEnabled,
Domains: domains,
- ReservedPrefixes: parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes),
+ ReservedPrefixes: parseReservedPrefixes(a.config().ReservedMailboxPrefixes),
})
}
func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
- if !a.cfg.UserMailboxApplyEnabled {
+ if !a.config().UserMailboxApplyEnabled {
respondError(w, http.StatusForbidden, "当前未开放邮箱申请")
return
}
@@ -73,7 +73,7 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
return
}
reserved := map[string]bool{}
- for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
+ for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[localPart] {
@@ -129,10 +129,10 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
}
func (a *App) mailboxApplyDomains(ctx context.Context) ([]Domain, error) {
- if !a.cfg.UserMailboxApplyEnabled {
+ if !a.config().UserMailboxApplyEnabled {
return []Domain{}, nil
}
- ids := cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ","))
+ ids := cleanIDList(strings.Split(a.config().UserMailboxDomainIDs, ","))
if len(ids) == 0 {
return []Domain{}, nil
}
diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go
index fb0db53..f40f314 100644
--- a/apps/api/internal/app/router_auth.go
+++ b/apps/api/internal/app/router_auth.go
@@ -215,7 +215,7 @@ func (a *App) registerOpenAPIRoutes(r chi.Router) {
func (a *App) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
- if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.cfg.PublicBaseURL) {
+ if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.config().PublicBaseURL) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
@@ -273,7 +273,7 @@ func currentUser(r *http.Request) *User {
}
func (a *App) authenticateRequest(r *http.Request) (*User, error) {
- cookie, err := r.Cookie(a.cfg.CookieName)
+ cookie, err := r.Cookie(a.config().CookieName)
if err != nil || cookie.Value == "" {
return nil, errors.New("no session")
}
diff --git a/apps/api/internal/app/send_queue.go b/apps/api/internal/app/send_queue.go
index 3bc28d3..bc444fd 100644
--- a/apps/api/internal/app/send_queue.go
+++ b/apps/api/internal/app/send_queue.go
@@ -67,7 +67,7 @@ type sendQueueItem struct {
}
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
- if strings.TrimSpace(a.cfg.SMTPHost) == "" {
+ if strings.TrimSpace(a.config().SMTPHost) == "" {
return "", nil
}
now := in.Now.UTC()
@@ -149,7 +149,7 @@ func (a *App) sendQueueWorker(ctx context.Context) {
}
func (a *App) processDueSendQueue(ctx context.Context) error {
- if strings.TrimSpace(a.cfg.SMTPHost) == "" {
+ if strings.TrimSpace(a.config().SMTPHost) == "" {
return nil
}
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
@@ -396,7 +396,7 @@ func (a *App) sendQueueDeliveredMarkerPath(id string) string {
if safeID == "" || safeID == "." {
safeID = "unknown"
}
- return filepath.Join(a.cfg.DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
+ return filepath.Join(a.config().DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
}
func (a *App) writeSendQueueDeliveredMarker(id string) error {
diff --git a/apps/api/internal/app/session.go b/apps/api/internal/app/session.go
index d6f4702..ad0d33f 100644
--- a/apps/api/internal/app/session.go
+++ b/apps/api/internal/app/session.go
@@ -8,20 +8,20 @@ import (
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
token := randomToken()
sessionID := newID("ses")
- expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
+ expires := a.now().UTC().Add(time.Duration(a.config().SessionTTLHours) * time.Hour)
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
- Name: a.cfg.CookieName,
+ Name: a.config().CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
- Secure: !a.cfg.AllowInsecureHTTP,
+ Secure: !a.config().AllowInsecureHTTP,
})
return nil
}
diff --git a/apps/api/internal/app/settings_handlers.go b/apps/api/internal/app/settings_handlers.go
index f18d1ee..8269c84 100644
--- a/apps/api/internal/app/settings_handlers.go
+++ b/apps/api/internal/app/settings_handlers.go
@@ -100,15 +100,16 @@ func (a *App) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
}
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
+ 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: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled}
+ 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 a.cfg.OpenRegistration {
+ 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()
@@ -131,7 +132,7 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
badRequest(w, err)
return
}
- next := a.cfg
+ next := a.config()
next.PublicHostname = normalizeHostname(req.PublicHostname)
if next.PublicHostname == "" {
badRequest(w, errors.New("publicHostname is required"))
@@ -208,7 +209,7 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusInternalServerError, "failed to save settings")
return
}
- a.cfg = next
+ a.setConfig(next)
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
}
@@ -218,7 +219,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
- cfg := a.cfg
+ cfg := a.config()
if strings.TrimSpace(cfg.SMTPHost) == "" {
badRequest(w, errors.New("SMTP 主机未设置"))
return
@@ -285,41 +286,43 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
}
func (a *App) systemSettingsSnapshot() SystemSettings {
+ cfg := a.config()
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) != "",
+ 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
@@ -332,76 +335,80 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
}
switch key {
case "publicHostname":
- a.cfg.PublicHostname = value
+ cfg.PublicHostname = value
case "publicBaseUrl":
- a.cfg.PublicBaseURL = value
+ cfg.PublicBaseURL = value
case "smtpHost":
- a.cfg.SMTPHost = value
+ cfg.SMTPHost = value
case "smtpPort":
- a.cfg.SMTPPort = value
+ cfg.SMTPPort = value
case "smtpUsername":
- a.cfg.SMTPUsername = value
+ cfg.SMTPUsername = value
case "smtpPassword":
- a.cfg.SMTPPassword = value
+ cfg.SMTPPassword = value
case "smtpRequireTls":
- a.cfg.SMTPRequireTLS = value == "true"
+ cfg.SMTPRequireTLS = value == "true"
case "maildirRoot":
- a.cfg.MaildirRoot = value
+ cfg.MaildirRoot = value
case "maildirScanSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
- a.cfg.MaildirScanSeconds = n
+ cfg.MaildirScanSeconds = n
}
case "sessionTtlHours":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
- a.cfg.SessionTTLHours = n
+ cfg.SessionTTLHours = n
}
case "allowInsecureHttp":
- a.cfg.AllowInsecureHTTP = value == "true"
+ cfg.AllowInsecureHTTP = value == "true"
case "openRegistration":
- a.cfg.OpenRegistration = value == "true"
+ cfg.OpenRegistration = value == "true"
case "twoFactorEnabled":
- a.cfg.TwoFactorEnabled = value == "true"
+ cfg.TwoFactorEnabled = value == "true"
case "turnstileEnabled":
- a.cfg.TurnstileEnabled = value == "true"
+ cfg.TurnstileEnabled = value == "true"
case "turnstileSiteKey":
- a.cfg.TurnstileSiteKey = value
+ cfg.TurnstileSiteKey = value
case "turnstileSecretKey":
- a.cfg.TurnstileSecretKey = value
+ cfg.TurnstileSecretKey = value
case "catchAllEnabled":
- a.cfg.CatchAllEnabled = value == "true"
+ cfg.CatchAllEnabled = value == "true"
case "mailAutoRefresh":
- a.cfg.MailAutoRefresh = value == "true"
+ cfg.MailAutoRefresh = value == "true"
case "mailRefreshSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
- a.cfg.MailRefreshSeconds = n
+ cfg.MailRefreshSeconds = n
}
case "userMailboxApplyEnabled":
- a.cfg.UserMailboxApplyEnabled = value == "true"
+ cfg.UserMailboxApplyEnabled = value == "true"
case "userMailboxDomainIds":
- a.cfg.UserMailboxDomainIDs = value
+ cfg.UserMailboxDomainIDs = value
case "reservedMailboxPrefixes":
- a.cfg.ReservedMailboxPrefixes = value
+ cfg.ReservedMailboxPrefixes = value
case "externalImapEnabled":
- a.cfg.ExternalIMAPEnabled = value == "true"
+ cfg.ExternalIMAPEnabled = value == "true"
case "externalImapSecretKey":
- a.cfg.ExternalIMAPSecretKey = value
+ cfg.ExternalIMAPSecretKey = value
case "externalImapSyncSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
- a.cfg.ExternalIMAPSyncSeconds = n
+ cfg.ExternalIMAPSyncSeconds = n
}
case "externalImapAllowPrivateHosts":
- a.cfg.ExternalIMAPAllowPrivateHosts = value == "true"
+ cfg.ExternalIMAPAllowPrivateHosts = value == "true"
case "externalImapGmailClientId":
- a.cfg.ExternalIMAPGmailClientID = value
+ cfg.ExternalIMAPGmailClientID = value
case "externalImapGmailClientSecret":
- a.cfg.ExternalIMAPGmailClientSecret = value
+ cfg.ExternalIMAPGmailClientSecret = value
case "externalImapOutlookClientId":
- a.cfg.ExternalIMAPOutlookClientID = value
+ cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret":
- a.cfg.ExternalIMAPOutlookClientSecret = value
+ cfg.ExternalIMAPOutlookClientSecret = value
}
}
- return rows.Err()
+ if err := rows.Err(); err != nil {
+ return err
+ }
+ a.setConfig(cfg)
+ return nil
}
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
diff --git a/apps/api/internal/app/status_webhook.go b/apps/api/internal/app/status_webhook.go
index db670b9..dadc9c3 100644
--- a/apps/api/internal/app/status_webhook.go
+++ b/apps/api/internal/app/status_webhook.go
@@ -27,7 +27,7 @@ type statusWebhookEnvelope struct {
}
func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey, eventType, mailboxID string, data any) error {
- if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
+ if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return nil
}
now := a.now().UTC()
@@ -39,7 +39,7 @@ func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey,
}
func (a *App) statusWebhookWorker(ctx context.Context) {
- if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
+ if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return
}
a.log.Info("status webhook worker started")
@@ -59,7 +59,7 @@ func (a *App) statusWebhookWorker(ctx context.Context) {
}
func (a *App) processDueStatusWebhooks(ctx context.Context) error {
- if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
+ if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return nil
}
_, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox
@@ -104,7 +104,7 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
return err
}
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
- mac := hmac.New(sha256.New, []byte(a.cfg.StatusWebhookSecret))
+ mac := hmac.New(sha256.New, []byte(a.config().StatusWebhookSecret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
@@ -134,17 +134,17 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
}
func (a *App) validatedStatusWebhookURL(ctx context.Context) (*url.URL, error) {
- if strings.TrimSpace(a.cfg.StatusWebhookSecret) == "" {
+ if strings.TrimSpace(a.config().StatusWebhookSecret) == "" {
return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required")
}
- target, err := url.Parse(strings.TrimSpace(a.cfg.StatusWebhookURL))
+ target, err := url.Parse(strings.TrimSpace(a.config().StatusWebhookURL))
if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
return nil, errors.New("invalid status webhook URL")
}
- if target.Scheme != "https" && !(a.cfg.StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
+ if target.Scheme != "https" && !(a.config().StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
return nil, errors.New("status webhook URL must use HTTPS")
}
- if !a.cfg.StatusWebhookAllowPrivateHosts {
+ if !a.config().StatusWebhookAllowPrivateHosts {
if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
return nil, err
}
@@ -157,7 +157,7 @@ func (a *App) statusWebhookDialContext(ctx context.Context, network, address str
if err != nil {
return nil, err
}
- if a.cfg.StatusWebhookAllowPrivateHosts {
+ if a.config().StatusWebhookAllowPrivateHosts {
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
diff --git a/apps/api/internal/app/submission.go b/apps/api/internal/app/submission.go
index c8fa2a4..acf89a1 100644
--- a/apps/api/internal/app/submission.go
+++ b/apps/api/internal/app/submission.go
@@ -49,8 +49,8 @@ func (s *SubmissionServers) Shutdown(ctx context.Context) error {
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
return &SubmissionServers{
- Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
- TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
+ Plain: a.newSubmissionServer(a.config().SubmissionAddr, tlsConfig),
+ TLS: a.newSubmissionServer(a.config().SubmissionTLSAddr, tlsConfig),
}
}
@@ -61,11 +61,11 @@ func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserve
}
s := smtpserver.NewServer(submissionBackend{app: a})
s.Addr = addr
- s.Domain = a.cfg.PublicHostname
+ s.Domain = a.config().PublicHostname
s.TLSConfig = tlsConfig
s.AllowInsecureAuth = false
s.MaxRecipients = defaultSubmissionMaxRecipients
- s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
+ s.MaxMessageBytes = int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
s.ReadTimeout = smtpSessionTimeout
s.WriteTimeout = smtpSessionTimeout
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
diff --git a/apps/api/internal/app/system_update_handlers.go b/apps/api/internal/app/system_update_handlers.go
index 1700baf..405160b 100644
--- a/apps/api/internal/app/system_update_handlers.go
+++ b/apps/api/internal/app/system_update_handlers.go
@@ -97,7 +97,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
}
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
- current := strings.TrimSpace(a.cfg.AppVersion)
+ current := strings.TrimSpace(a.config().AppVersion)
if current == "" {
current = BuildVersion
}
@@ -124,7 +124,7 @@ func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
}
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
- endpoint := strings.TrimSpace(a.cfg.ReleaseAPIURL)
+ endpoint := strings.TrimSpace(a.config().ReleaseAPIURL)
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return githubRelease{}, errors.New("invalid release API URL")
@@ -134,7 +134,7 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
return githubRelease{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
- req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.cfg.AppVersion, "v"))
+ req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.config().AppVersion, "v"))
client := &http.Client{
Timeout: 8 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
@@ -161,11 +161,11 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
}
func (a *App) updateEnabled() bool {
- return strings.TrimSpace(a.cfg.UpdateServiceURL) != "" && strings.TrimSpace(a.cfg.UpdateServiceToken) != ""
+ return strings.TrimSpace(a.config().UpdateServiceURL) != "" && strings.TrimSpace(a.config().UpdateServiceToken) != ""
}
func (a *App) triggerUpdateService(ctx context.Context) error {
- parsed, err := url.Parse(strings.TrimSpace(a.cfg.UpdateServiceURL))
+ parsed, err := url.Parse(strings.TrimSpace(a.config().UpdateServiceURL))
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("invalid update service URL")
}
@@ -173,7 +173,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
if err != nil {
return err
}
- req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.cfg.UpdateServiceToken))
+ req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
@@ -193,7 +193,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
}
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
- backupDir := filepath.Join(a.cfg.DataDir, "backups")
+ backupDir := filepath.Join(a.config().DataDir, "backups")
if err := os.MkdirAll(backupDir, 0o700); err != nil {
return "", err
}
diff --git a/apps/api/internal/app/turnstile.go b/apps/api/internal/app/turnstile.go
index 2cdc95b..2992864 100644
--- a/apps/api/internal/app/turnstile.go
+++ b/apps/api/internal/app/turnstile.go
@@ -17,11 +17,11 @@ type turnstileVerifyResponse struct {
}
func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error {
- if !a.cfg.TurnstileEnabled {
+ if !a.config().TurnstileEnabled {
return nil
}
token = strings.TrimSpace(token)
- secret := strings.TrimSpace(a.cfg.TurnstileSecretKey)
+ secret := strings.TrimSpace(a.config().TurnstileSecretKey)
if secret == "" || token == "" {
return errors.New("turnstile verification required")
}
diff --git a/apps/api/internal/app/two_factor.go b/apps/api/internal/app/two_factor.go
index 6ff8f2d..fc6d0d1 100644
--- a/apps/api/internal/app/two_factor.go
+++ b/apps/api/internal/app/two_factor.go
@@ -144,7 +144,7 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
}
func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
- if !a.cfg.TwoFactorEnabled {
+ if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return
}
@@ -179,7 +179,7 @@ func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
- if !a.cfg.TwoFactorEnabled {
+ if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return
}
diff --git a/apps/web/package.json b/apps/web/package.json
index 8e92ffb..6b07435 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -26,27 +26,22 @@
"@radix-ui/react-tooltip": "^1.2.9",
"@tanstack/react-query": "5.59.16",
"@tiptap/core": "^3.27.0",
- "@tiptap/extension-color": "^3.27.0",
- "@tiptap/extension-font-family": "^3.27.0",
- "@tiptap/extension-highlight": "^3.27.0",
"@tiptap/extension-image": "^3.27.0",
"@tiptap/extension-link": "^3.27.0",
"@tiptap/extension-placeholder": "^3.27.0",
"@tiptap/extension-text-align": "^3.27.0",
"@tiptap/extension-text-style": "^3.27.0",
- "@tiptap/extension-underline": "^3.27.0",
"@tiptap/pm": "^3.27.0",
"@tiptap/react": "^3.27.0",
"@tiptap/starter-kit": "^3.27.0",
"class-variance-authority": "^0.7.0",
"clsx": "2.1.1",
- "dompurify": "3.4.10",
+ "dompurify": "3.4.12",
"lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
- "react-resizable-panels": "^2.1.7",
- "react-router-dom": "6.30.4",
+ "react-router-dom": "7.18.2",
"tailwind-merge": "2.5.4"
},
"devDependencies": {
@@ -55,7 +50,7 @@
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
"autoprefixer": "10.4.20",
- "postcss": "8.5.15",
+ "postcss": "8.5.25",
"tailwindcss": "3.4.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.6.3",
diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx
index 6da7eea..fe1c0c3 100644
--- a/apps/web/src/components/confirm-dialog.tsx
+++ b/apps/web/src/components/confirm-dialog.tsx
@@ -1,4 +1,3 @@
-import * as React from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
diff --git a/apps/web/src/components/ui/resizable.tsx b/apps/web/src/components/ui/resizable.tsx
deleted file mode 100644
index cd3cb0e..0000000
--- a/apps/web/src/components/ui/resizable.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { GripVertical } from "lucide-react"
-import * as ResizablePrimitive from "react-resizable-panels"
-
-import { cn } from "@/lib/utils"
-
-const ResizablePanelGroup = ({
- className,
- ...props
-}: React.ComponentProps) => (
-
-)
-
-const ResizablePanel = ResizablePrimitive.Panel
-
-const ResizableHandle = ({
- withHandle,
- className,
- ...props
-}: React.ComponentProps & {
- withHandle?: boolean
-}) => (
- div]:rotate-90",
- className
- )}
- {...props}
- >
- {withHandle && (
-
-
-
- )}
-
-)
-
-export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 9327e42..8d19b37 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -1,4 +1,4 @@
-import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
+import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 787c4e1..6ea77ae 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -6,14 +6,15 @@ import { Toaster } from "@/components/ui/toaster"
import { LanguageDomSync } from "@/lib/language"
import { ProtectedLayout } from "@/components/protected-layout"
import { AdminOnly } from "@/components/admin-only"
-import { LoginPage } from "@/pages/login"
-import { RegisterPage } from "@/pages/register"
-import { MailPage } from "@/pages/mail"
-import { AdminPage } from "@/pages/admin"
-import { ProfilePage } from "@/pages/profile"
-import { NotFoundPage } from "@/pages/not-found"
import "./index.css"
+const LoginPage = React.lazy(() => import("@/pages/login").then((module) => ({ default: module.LoginPage })))
+const RegisterPage = React.lazy(() => import("@/pages/register").then((module) => ({ default: module.RegisterPage })))
+const MailPage = React.lazy(() => import("@/pages/mail").then((module) => ({ default: module.MailPage })))
+const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ default: module.AdminPage })))
+const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
+const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
+
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
const router = createBrowserRouter([
{ path: "/login", element: },
@@ -31,7 +32,9 @@ const router = createBrowserRouter([
ReactDOM.createRoot(document.getElementById("root")!).render(
-
+ 加载中...}>
+
+
diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx
index 948164d..38c63c8 100644
--- a/apps/web/src/pages/admin.tsx
+++ b/apps/web/src/pages/admin.tsx
@@ -422,13 +422,13 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen })
const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
const [permissions, setPermissions] = React.useState(group?.permissions || [])
- const [limits, setLimits] = React.useState(group?.limits || defaultPermissionLimits)
+ const [limits, setLimits] = React.useState(group?.limits || defaultLimits)
React.useEffect(() => {
if (dialogOpen) {
setPermissions(group?.permissions || [])
- setLimits(group?.limits || defaultPermissionLimits)
+ setLimits(group?.limits || defaultLimits)
}
- }, [dialogOpen, group])
+ }, [defaultLimits, dialogOpen, group])
const mutation = useMutation({
mutationFn: (form: FormData) => {
const payload = {
@@ -1979,11 +1979,6 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
return {header}{content}
}
-const dnsDescriptions: Record = {
- MX: "指定收件服务器。把邮件投递到该地址指向的服务器。",
- TXT: "", // 具体含义根据内容区分
-}
-
function dnsDescription(record: DNSRecord): string {
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx
index c2f289e..374aa1f 100644
--- a/apps/web/src/pages/mail.tsx
+++ b/apps/web/src/pages/mail.tsx
@@ -11,8 +11,8 @@ import TextAlign from "@tiptap/extension-text-align"
import Placeholder from "@tiptap/extension-placeholder"
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
import { useNavigate } from "react-router-dom"
-import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
-import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
+import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
+import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { useDisplayMode } from "@/lib/display-mode"
@@ -140,7 +140,7 @@ export function MailPage() {
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
const [exportingMail, setExportingMail] = React.useState(false)
const [importingMail, setImportingMail] = React.useState(false)
- const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState(null)
+ const [, setLastAutoRefreshAt] = React.useState(null)
const [bulkPending, setBulkPending] = React.useState(false)
const [pendingConfirm, setPendingConfirm] = React.useState(null)
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
@@ -4728,24 +4728,6 @@ function toDateTimeLocalValue(date: Date) {
function normalizeSchedule(schedule: ScheduleDraft): ScheduleDraft {
return { ...schedule, title: schedule.title.trim(), location: schedule.location.trim(), description: schedule.description.trim() }
}
-function scheduleToHtml(schedule: ScheduleDraft) {
- const start = parseScheduleStart(schedule)
- const end = schedule.allDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : new Date(start.getTime() + schedule.durationMinutes * 60 * 1000)
- const rows = [
- ["时间", schedule.allDay ? formatDate(start.toISOString()) : `${formatDateTime(start.toISOString())} - ${formatTimeOnly(end)}`],
- ["持续", schedule.allDay ? "全天" : durationLabel(schedule.durationMinutes)],
- ["提醒", reminderLabel(schedule.reminderMinutes)],
- ["重复", repeatLabel(schedule.repeat)],
- schedule.location ? ["位置", schedule.location] : undefined,
- schedule.description ? ["描述", schedule.description] : undefined,
- ].filter(Boolean) as string[][]
- return DOMPurify.sanitize(`
-
-
${escapeHtml(schedule.title)}
- ${rows.map(([label, value]) => `
${label}:${escapeHtml(value)}
`).join("")}
-
- `)
-}
function scheduleToFile(schedule: ScheduleDraft) {
const ics = scheduleToIcs(schedule)
const filename = `${safeFilename(schedule.title || "schedule")}.ics`
diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx
index 6dd06a9..f3a6e67 100644
--- a/apps/web/src/pages/profile.tsx
+++ b/apps/web/src/pages/profile.tsx
@@ -1,7 +1,7 @@
import * as React from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate, useSearchParams } from "react-router-dom"
-import { ArrowLeft, BarChart3, Ban, Bell, BellOff, BookOpen, ChevronDown, ChevronUp, Clock3, Code2, Contact, Copy, HardDrive, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, PlayCircle, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, Users, X } from "lucide-react"
+import { ArrowLeft, BarChart3, Ban, Bell, BellOff, BookOpen, ChevronDown, ChevronUp, Clock3, Code2, Contact, Copy, HardDrive, Image, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftOpen, PencilLine, PlayCircle, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, Users, X } from "lucide-react"
import { QRCodeSVG } from "qrcode.react"
import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
import { cn, formatBytes } from "@/lib/utils"
@@ -24,13 +24,12 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area"
import { ConfirmDialog } from "@/components/confirm-dialog"
import { useToast } from "@/hooks/use-toast"
-type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "feedback" | "apiTokens"
+type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens"
type AccountSettingsTab = "account" | "mail" | "clients" | "security"
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
const tabs: Record = {
@@ -42,7 +41,6 @@ const tabs: Record = {
rules: { label: "收信规则", icon: },
blocked: { label: "被拦截邮件", icon: },
stats: { label: "数据统计", icon: },
- feedback: { label: "反馈", icon: },
apiTokens: { label: "开发者", icon: },
}
const tabKeys = Object.keys(tabs) as Tab[]
@@ -52,7 +50,6 @@ const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [
{ key: "clients", label: "通知与客户端" },
{ key: "security", label: "安全" },
]
-const actionLabels: Record = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到", forward: "邮件转发" }
export function ProfilePage() {
const me = useMe()
const qc = useQueryClient()
@@ -94,7 +91,6 @@ export function ProfilePage() {
if (key === "rules") return canManageRules
if (key === "blocked") return canManageBlocked
if (key === "stats") return canViewStats
- if (key === "feedback") return true
if (key === "apiTokens") return true
return false
})
@@ -370,7 +366,7 @@ export function ProfilePage() {
)
- const pageTitle = tab === "feedback" ? "反馈与工单" : tabs[tab].label
+ const pageTitle = tabs[tab].label
const pageSubtitle = tab === "stats" ? "查看邮件收发趋势、分布情况和常用联系人。" : undefined
const pageAction = tab === "stats"
?
@@ -431,7 +427,6 @@ export function ProfilePage() {
setupTwoFactor={setupTwoFactor}
enableTwoFactor={enableTwoFactor}
disableTwoFactor={disableTwoFactor}
- onCopy={copy}
mailboxes={mailboxes.data?.items || []}
selectedMailboxId={mailboxId}
selectedMailbox={selectedMailbox}
@@ -448,6 +443,7 @@ export function ProfilePage() {
onSetDefaultSignature={(id) => setDefaultSignature.mutate(id)}
onDeleteSignature={(id) => deleteSignature.mutate(id)}
clientHostname={publicSettings.data?.publicHostname}
+ onCopy={copy}
onSelectMailbox={setMailboxId}
onOpenCleanup={() => setTab("cleanup")}
/>
@@ -466,7 +462,6 @@ export function ProfilePage() {
externalSyncRuns={externalSyncRuns.data?.items || []}
onSelectExternalRunAccount={setExternalRunAccountId}
onSelect={setMailboxId}
- onCopy={copy}
onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }}
onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)}
onCreateExternal={(payload) => createExternalImap.mutate(payload)}
@@ -484,7 +479,6 @@ export function ProfilePage() {
if (tab === "rules") return createRule.mutate(payload)} onUpdate={(id, payload) => updateRule.mutate({ id, payload })} onToggle={(item) => updateRule.mutate({ id: item.id, payload: { enabled: !item.enabled } })} onMove={(id, direction) => moveRule.mutate({ id, direction })} onApply={(id) => applyRule.mutate(id)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending || updateRule.isPending || moveRule.isPending || applyRule.isPending} />
if (tab === "blocked") return f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
if (tab === "stats") return stats.refetch()} />
- if (tab === "feedback") return
if (tab === "apiTokens") return createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} />
return null
}
@@ -704,20 +698,6 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
-
-
- {["NewSzxcn 邮箱 v3 风格设置页", "智能搜索与邮件列表", "自建邮箱管理能力"].map((title, index) => (
-
-
- v{3 - index}.0.0
- ·
- {title}
-
-
持续完善邮箱体验、账号管理和私有化部署功能。
-
- ))}
-
-
)
}
@@ -781,15 +761,6 @@ function MailPreferencesSection({
const [signatureDefault, setSignatureDefault] = React.useState(false)
const [editingSignature, setEditingSignature] = React.useState(null)
const [pendingConfirm, setPendingConfirm] = React.useState(null)
- const [whitelist, setWhitelist] = React.useState(() => readLocalStringList("lanqin:mail-whitelist"))
- const [imageKey, setImageKey] = React.useState(() => readLocalString("lanqin:image-api-key"))
- const [autoReplyEnabled, setAutoReplyEnabled] = React.useState(() => readLocalString("lanqin:auto-reply-enabled") === "1")
- const [autoReplyText, setAutoReplyText] = React.useState(() => readLocalString("lanqin:auto-reply-text"))
-
- React.useEffect(() => { writeLocalStringList("lanqin:mail-whitelist", whitelist) }, [whitelist])
- React.useEffect(() => { writeLocalString("lanqin:image-api-key", imageKey) }, [imageKey])
- React.useEffect(() => { writeLocalString("lanqin:auto-reply-enabled", autoReplyEnabled ? "1" : "0") }, [autoReplyEnabled])
- React.useEffect(() => { writeLocalString("lanqin:auto-reply-text", autoReplyText) }, [autoReplyText])
function submitLabel(event: React.FormEvent) {
event.preventDefault()
@@ -801,15 +772,6 @@ function MailPreferencesSection({
setLabelColor("#3b82f6")
}
- function submitWhitelist(event: React.FormEvent) {
- event.preventDefault()
- const form = new FormData(event.currentTarget)
- const value = String(form.get("whitelist") || "").trim()
- if (!value || whitelist.includes(value)) return
- setWhitelist((items) => [value, ...items])
- event.currentTarget.reset()
- }
-
function submitSignature(event: React.FormEvent) {
event.preventDefault()
const form = new FormData(event.currentTarget)
@@ -843,22 +805,6 @@ function MailPreferencesSection({
-
-
-
- {whitelist.map((item) => (
-
- {item}
-
-
- ))}
- {whitelist.length === 0 &&
暂无白名单
}
-
-
-
共 {signatures.length} 个签名}>
-
-
- setImageKey(event.target.value)} className="h-10 flex-1" placeholder="输入 NodeImage API Key" />
-
-
-
-
-
-
-
-
启用自动回复
-
用于休假、临时离线等场景。
-
-
setAutoReplyEnabled((value) => !value)} />
-
-
-