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} 个签名}>
@@ -901,24 +847,6 @@ function MailPreferencesSection({
- -
- setImageKey(event.target.value)} className="h-10 flex-1" placeholder="输入 NodeImage API Key" /> - -
-
- - -
-
-
启用自动回复
-
用于休假、临时离线等场景。
-
- setAutoReplyEnabled((value) => !value)} /> -
-