fix: harden runtime and remove placeholder features

This commit is contained in:
zxyszx
2026-08-03 19:51:27 +08:00
parent 18f8d870e8
commit 9a489992ed
40 changed files with 447 additions and 1253 deletions
+8 -8
View File
@@ -3,9 +3,14 @@ module lanqin-email-api
go 1.25.0 go 1.25.0
require ( 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 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 golang.org/x/text v0.38.0
modernc.org/sqlite v1.31.1 modernc.org/sqlite v1.31.1
) )
@@ -13,19 +18,14 @@ require (
require ( require (
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // 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-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/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.26.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.23.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
+8 -8
View File
@@ -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-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 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= 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.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 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 h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 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= 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= 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-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.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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= 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-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.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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 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= 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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+34 -12
View File
@@ -24,6 +24,7 @@ import (
type App struct { type App struct {
cfg Config cfg Config
cfgMu sync.RWMutex
db *sql.DB db *sql.DB
log *slog.Logger log *slog.Logger
now func() time.Time now func() time.Time
@@ -34,6 +35,24 @@ type App struct {
externalIMAP externalIMAPClientFactory 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) { func New(cfg Config, logger *slog.Logger) (*App, error) {
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
@@ -76,7 +95,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
workerCtx, cancel := context.WithCancel(context.Background()) workerCtx, cancel := context.WithCancel(context.Background())
a.workerCancel = cancel a.workerCancel = cancel
a.startWorker(func() { a.scheduledSendWorker(workerCtx) }) 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.maildirWorker(workerCtx) })
} }
a.startWorker(func() { a.sendQueueWorker(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 // 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. // 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 { func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
adminEmail := normalizeEmail(a.cfg.AdminEmail) adminEmail := normalizeEmail(a.config().AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") { if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil return nil
} }
@@ -1387,6 +1406,7 @@ func messageIndexes() []string {
} }
func (a *App) seed(ctx context.Context) error { func (a *App) seed(ctx context.Context) error {
cfg := a.config()
var count int var count int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil { if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return err return err
@@ -1395,7 +1415,7 @@ func (a *App) seed(ctx context.Context) error {
return a.ensureConfiguredAdminSuperAdmin(ctx) return a.ensureConfiguredAdminSuperAdmin(ctx)
} }
adminPassword := a.cfg.AdminPassword adminPassword := cfg.AdminPassword
if adminPassword == "" { if adminPassword == "" {
buf := make([]byte, 16) buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil { 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) now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr") userID := newID("usr")
if strings.TrimSpace(a.cfg.AdminUsername) != "" { if strings.TrimSpace(cfg.AdminUsername) != "" {
adminUsername, err := cleanUsername(a.cfg.AdminUsername) adminUsername, err := cleanUsername(cfg.AdminUsername)
if err != nil { if err != nil {
return fmt.Errorf("invalid admin username: %w", err) 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) a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
return nil return nil
} }
adminEmail := normalizeEmail(a.cfg.AdminEmail) adminEmail := normalizeEmail(cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") { if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return errors.New("invalid admin email") 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 { 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=?`, _, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`,
a.now().UTC().Format(time.RFC3339Nano), adminUsername) a.now().UTC().Format(time.RFC3339Nano), adminUsername)
return err return err
} }
adminEmail := normalizeEmail(a.cfg.AdminEmail) adminEmail := normalizeEmail(cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") { if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil 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 { func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
cfg := a.config()
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox") folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
if err != nil { if err != nil {
return err return err
@@ -1599,10 +1621,10 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>" bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil { if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
rendered := renderMailTemplate(tpl, templateRenderData{ rendered := renderMailTemplate(tpl, templateRenderData{
To: a.cfg.AdminEmail, To: cfg.AdminEmail,
From: "system@lanqin.local", From: "system@lanqin.local",
PublicHostname: a.cfg.PublicHostname, PublicHostname: cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL, PublicBaseURL: cfg.PublicBaseURL,
Time: now, Time: now,
}) })
subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML 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, Subject: subject,
From: "system@lanqin.local", From: "system@lanqin.local",
FromName: "NewSzxcn 邮箱", FromName: "NewSzxcn 邮箱",
To: []string{a.cfg.AdminEmail}, To: []string{cfg.AdminEmail},
SentAt: now, SentAt: now,
ReceivedAt: now, ReceivedAt: now,
Snippet: snippetFrom(bodyText, bodyHTML), Snippet: snippetFrom(bodyText, bodyHTML),
+77 -77
View File
@@ -650,7 +650,7 @@ func TestExternalIMAPDisabledByDefaultAndAdminSettings(t *testing.T) {
if settings.ExternalIMAPGmailClientID != "gmail-client" || settings.ExternalIMAPOutlookClientID != "outlook-client" { if settings.ExternalIMAPGmailClientID != "gmail-client" || settings.ExternalIMAPOutlookClientID != "outlook-client" {
t.Fatalf("oauth client ids not saved: %+v", settings) 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") t.Fatalf("secret settings not persisted in config")
} }
if code := admin.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || !public.ExternalIMAPEnabled { 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) { func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.ExternalIMAPEnabled = true a.updateConfig(func(cfg *Config) { cfg.ExternalIMAPEnabled = true })
a.cfg.ExternalIMAPSecretKey = "test-secret" a.updateConfig(func(cfg *Config) { cfg.ExternalIMAPSecretKey = "test-secret" })
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
admin := &testClient{t: t, server: ts} admin := &testClient{t: t, server: ts}
@@ -958,7 +958,7 @@ func TestMailRulesConditionGroupsAndActions(t *testing.T) {
func TestMailRulesForwardingAction(t *testing.T) { func TestMailRulesForwardingAction(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
stopTestWorkers(a) 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()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
admin := &testClient{t: t, server: ts} 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) 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 { var registered struct {
User User `json:"user"` User User `json:"user"`
} }
@@ -1968,8 +1968,8 @@ func TestHTMLPolicyPreservesEmailLayoutStyles(t *testing.T) {
func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) { func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1" a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.cfg.SMTPPort = "1" a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
admin := &testClient{t: t, server: ts} admin := &testClient{t: t, server: ts}
@@ -2010,8 +2010,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
stopTestWorkers(a) stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 8) host, port, received := startCapturingSMTP(t, 8)
a.cfg.SMTPHost = host a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.cfg.SMTPPort = port a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
admin := &testClient{t: t, server: ts} admin := &testClient{t: t, server: ts}
@@ -2187,8 +2187,8 @@ func TestMailSendRejectsUnauthorizedFrom(t *testing.T) {
func TestMailSendRollsBackSentCopyWhenQueueInsertFails(t *testing.T) { func TestMailSendRollsBackSentCopyWhenQueueInsertFails(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.SMTPHost = "postfix" a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "postfix" })
a.cfg.SMTPPort = "25" a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
user, mb := defaultAdminUserAndMailbox(t, a) user, mb := defaultAdminUserAndMailbox(t, a)
if _, err := a.db.ExecContext(context.Background(), `DROP TABLE send_queue`); err != nil { if _, err := a.db.ExecContext(context.Background(), `DROP TABLE send_queue`); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -2387,8 +2387,8 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) { func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
stopTestWorkers(a) stopTestWorkers(a)
a.cfg.SMTPHost = "127.0.0.1" a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.cfg.SMTPPort = "25" a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
admin := &testClient{t: t, server: ts} admin := &testClient{t: t, server: ts}
@@ -2496,9 +2496,9 @@ func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) {
func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) { func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
stopTestWorkers(a) stopTestWorkers(a)
a.cfg.SMTPHost = "127.0.0.1" a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.cfg.SMTPPort = "25" a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
a.cfg.DeliveryWebhookSecret = "delivery-test-secret" a.updateConfig(func(cfg *Config) { cfg.DeliveryWebhookSecret = "delivery-test-secret" })
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() 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)}}} }{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) body, _ := json.Marshal(eventPayload)
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10) 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([]byte(timestamp + "."))
_, _ = mac.Write(body) _, _ = mac.Write(body)
webhookHeaders := map[string]string{"X-LanQin-Timestamp": timestamp, "X-LanQin-Signature": "sha256=" + hex.EncodeToString(mac.Sum(nil))} 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) t.Fatalf("invalid delivery webhook signature code=%d", code)
} }
oldTimestamp := strconv.FormatInt(a.now().UTC().Add(-10*time.Minute).Unix(), 10) 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([]byte(oldTimestamp + "."))
_, _ = oldMAC.Write(body) _, _ = oldMAC.Write(body)
oldHeaders := map[string]string{"X-LanQin-Timestamp": oldTimestamp, "X-LanQin-Signature": "sha256=" + hex.EncodeToString(oldMAC.Sum(nil))} 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) w.WriteHeader(http.StatusNoContent)
})) }))
defer receiver.Close() defer receiver.Close()
a.cfg.StatusWebhookURL = receiver.URL a.updateConfig(func(cfg *Config) { cfg.StatusWebhookURL = receiver.URL })
a.cfg.StatusWebhookSecret = "outbound-test-secret" a.updateConfig(func(cfg *Config) { cfg.StatusWebhookSecret = "outbound-test-secret" })
a.cfg.StatusWebhookAllowPrivateHosts = true a.updateConfig(func(cfg *Config) { cfg.StatusWebhookAllowPrivateHosts = true })
user, mb := defaultAdminUserAndMailbox(t, a) 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"}) 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) {})) privateTLS := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer privateTLS.Close() defer privateTLS.Close()
a.cfg.StatusWebhookURL = privateTLS.URL a.updateConfig(func(cfg *Config) { cfg.StatusWebhookURL = privateTLS.URL })
a.cfg.StatusWebhookAllowPrivateHosts = false a.updateConfig(func(cfg *Config) { cfg.StatusWebhookAllowPrivateHosts = false })
if _, err := a.validatedStatusWebhookURL(context.Background()); err == nil || !strings.Contains(err.Error(), "private or local") { 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) t.Fatalf("private webhook target should be rejected, err=%v", err)
} }
@@ -2818,8 +2818,8 @@ func TestSendQueueRecoversStaleSendingItems(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
stopTestWorkers(a) stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 1) host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.cfg.SMTPPort = port a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb := defaultAdminUserAndMailbox(t, a) user, mb := defaultAdminUserAndMailbox(t, a)
now := a.now().UTC() now := a.now().UTC()
mimeBytes := []byte("From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: stale\r\n\r\nbody") 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) a := newTestApp(t)
stopTestWorkers(a) stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 1) host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.cfg.SMTPPort = port a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb := defaultAdminUserAndMailbox(t, a) user, mb := defaultAdminUserAndMailbox(t, a)
now := a.now().UTC() now := a.now().UTC()
mimeBytes := []byte("From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: marker\r\n\r\nbody") 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) { func TestSendQueueAPIPermissionIsolation(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1" a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.cfg.SMTPPort = "25" a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
admin := &testClient{t: t, server: ts} admin := &testClient{t: t, server: ts}
@@ -2997,8 +2997,8 @@ func TestSendQueueAPIPermissionIsolation(t *testing.T) {
func TestSendQueueAPIFiltersStableCursorAndMessageDetailLink(t *testing.T) { func TestSendQueueAPIFiltersStableCursorAndMessageDetailLink(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1" a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.cfg.SMTPPort = "25" a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
client := &testClient{t: t, server: ts} client := &testClient{t: t, server: ts}
@@ -3129,8 +3129,8 @@ func TestSendQueueAPIFiltersStableCursorAndMessageDetailLink(t *testing.T) {
func TestSendQueueAPIRetryAndCancel(t *testing.T) { func TestSendQueueAPIRetryAndCancel(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1) host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.cfg.SMTPPort = port a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
client := &testClient{t: t, server: ts} client := &testClient{t: t, server: ts}
@@ -3384,8 +3384,8 @@ func TestSubmissionAuthRequiresMailboxPasswordAndSendPermission(t *testing.T) {
func TestSubmissionSendsRelayAndStoresSentCopy(t *testing.T) { func TestSubmissionSendsRelayAndStoresSentCopy(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2) host, port, received := startCapturingSMTP(t, 2)
a.cfg.SMTPHost = host a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.cfg.SMTPPort = port a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
raw := strings.Join([]string{ raw := strings.Join([]string{
"From: Admin <admin@lanqin.local>", "From: Admin <admin@lanqin.local>",
"To: person@example.com", "To: person@example.com",
@@ -3481,8 +3481,8 @@ func TestSerializeMessageUsesStableHeaderOrder(t *testing.T) {
func TestSubmissionRelayFailureKeepsSentCopyAndRetries(t *testing.T) { func TestSubmissionRelayFailureKeepsSentCopyAndRetries(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1" a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.cfg.SMTPPort = "1" a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!") user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -3513,8 +3513,8 @@ func TestSubmissionRelayFailureKeepsSentCopyAndRetries(t *testing.T) {
func TestSubmissionSentCopyDedupesByMessageID(t *testing.T) { func TestSubmissionSentCopyDedupesByMessageID(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
host, port, _ := startCapturingSMTP(t, 4) host, port, _ := startCapturingSMTP(t, 4)
a.cfg.SMTPHost = host a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.cfg.SMTPPort = port a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!") user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -3587,8 +3587,8 @@ func TestInsertSentMessageOnceFailsWhenDedupeKeyHasNoMessage(t *testing.T) {
func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) { func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1" a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.cfg.SMTPPort = "1" a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!") user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -3602,8 +3602,8 @@ func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
} }
host, port, received := startCapturingSMTP(t, 1) host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.cfg.SMTPPort = port 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 { if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -3628,8 +3628,8 @@ func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) { func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2) host, port, received := startCapturingSMTP(t, 2)
a.cfg.SMTPHost = host a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.cfg.SMTPPort = port a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!") user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -3670,8 +3670,8 @@ func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) {
func TestSubmissionRequeuesCanceledDuplicateMessageID(t *testing.T) { func TestSubmissionRequeuesCanceledDuplicateMessageID(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1) host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.cfg.SMTPPort = port a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!") user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -3821,9 +3821,9 @@ func TestSendQueueMessageIDMigrationDropsDuplicatesBeforeUniqueIndex(t *testing.
func TestSubmissionTLSConfigRequiresCertificateFiles(t *testing.T) { func TestSubmissionTLSConfigRequiresCertificateFiles(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.SubmissionAddr = ":587" a.updateConfig(func(cfg *Config) { cfg.SubmissionAddr = ":587" })
a.cfg.SubmissionTLSAddr = ":465" a.updateConfig(func(cfg *Config) { cfg.SubmissionTLSAddr = ":465" })
if _, err := LoadServerTLSConfig(a.cfg); err == nil { if _, err := LoadServerTLSConfig(a.config()); err == nil {
t.Fatal("submission TLS config should require certificate files") t.Fatal("submission TLS config should require certificate files")
} }
} }
@@ -3831,9 +3831,9 @@ func TestSubmissionTLSConfigRequiresCertificateFiles(t *testing.T) {
func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) { func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
certPath, keyPath := writeTestCertificateFiles(t, "first.example.test") certPath, keyPath := writeTestCertificateFiles(t, "first.example.test")
a.cfg.TLSCertFile = certPath a.updateConfig(func(cfg *Config) { cfg.TLSCertFile = certPath })
a.cfg.TLSKeyFile = keyPath a.updateConfig(func(cfg *Config) { cfg.TLSKeyFile = keyPath })
tlsConfig, err := LoadServerTLSConfig(a.cfg) tlsConfig, err := LoadServerTLSConfig(a.config())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -3876,12 +3876,12 @@ func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) { func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2) host, port, received := startCapturingSMTP(t, 2)
a.cfg.SMTPHost = host a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.cfg.SMTPPort = port a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
certPath, keyPath := writeTestCertificateFiles(t, "mail.example.test") certPath, keyPath := writeTestCertificateFiles(t, "mail.example.test")
a.cfg.TLSCertFile = certPath a.updateConfig(func(cfg *Config) { cfg.TLSCertFile = certPath })
a.cfg.TLSKeyFile = keyPath a.updateConfig(func(cfg *Config) { cfg.TLSKeyFile = keyPath })
tlsConfig, err := LoadServerTLSConfig(a.cfg) tlsConfig, err := LoadServerTLSConfig(a.config())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -3952,8 +3952,8 @@ func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
func TestAdminSMTPTestEndpoint(t *testing.T) { func TestAdminSMTPTestEndpoint(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
host, port, received := startFakeSMTP(t) host, port, received := startFakeSMTP(t)
a.cfg.SMTPHost = host a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.cfg.SMTPPort = port a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
admin := &testClient{t: t, server: ts} admin := &testClient{t: t, server: ts}
@@ -4102,7 +4102,7 @@ func TestUserMailSignaturesDefaultResolution(t *testing.T) {
func TestUserTwoFactorSetupAndLogin(t *testing.T) { func TestUserTwoFactorSetupAndLogin(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.TwoFactorEnabled = true a.updateConfig(func(cfg *Config) { cfg.TwoFactorEnabled = true })
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
client := &testClient{t: t, server: ts} client := &testClient{t: t, server: ts}
@@ -4569,7 +4569,7 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
root := t.TempDir() root := t.TempDir()
a.cfg.MaildirRoot = root a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
var domainID string var domainID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil { if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -4650,7 +4650,7 @@ func TestMaildirImportStoresAuthenticationResults(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
root := t.TempDir() root := t.TempDir()
a.cfg.MaildirRoot = root a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
@@ -4753,8 +4753,8 @@ func TestMaildirSyncHealthAfterTrackedSync(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
root := t.TempDir() root := t.TempDir()
a.cfg.MaildirRoot = root a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
a.cfg.MaildirScanSeconds = 45 a.updateConfig(func(cfg *Config) { cfg.MaildirScanSeconds = 45 })
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local") adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -4806,7 +4806,7 @@ func TestMaildirSyncHealthAfterTrackedSync(t *testing.T) {
if counts.Imported != 1 || counts.FilesScanned != 1 { if counts.Imported != 1 || counts.FilesScanned != 1 {
t.Fatalf("counts=%+v, want imported=1 filesScanned=1", counts) 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 { if !health.Configured || !health.Enabled {
t.Fatalf("configured health=%+v, want enabled", health) t.Fatalf("configured health=%+v, want enabled", health)
} }
@@ -4828,7 +4828,7 @@ func TestMaildirSyncImportsSentFolder(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
root := t.TempDir() root := t.TempDir()
a.cfg.MaildirRoot = root a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local") adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -4901,7 +4901,7 @@ func TestMaildirSyncImportsSentFolder(t *testing.T) {
func TestWebmailSentWritesMaildirSent(t *testing.T) { func TestWebmailSentWritesMaildirSent(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir() a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a) user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID) clearMailboxMessagesForTest(t, a, mb.ID)
@@ -4941,7 +4941,7 @@ func TestWebmailSentWritesMaildirSent(t *testing.T) {
func TestMaildirSyncBackfillsSQLiteOnlySent(t *testing.T) { func TestMaildirSyncBackfillsSQLiteOnlySent(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir() a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a) user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID) clearMailboxMessagesForTest(t, a, mb.ID)
@@ -4984,7 +4984,7 @@ func TestMaildirSyncBackfillsSQLiteOnlySent(t *testing.T) {
func TestDraftWritesAndUpdatesMaildirDrafts(t *testing.T) { func TestDraftWritesAndUpdatesMaildirDrafts(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.MaildirRoot = t.TempDir() a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router()) srv := httptest.NewServer(a.Router())
defer srv.Close() defer srv.Close()
client := &testClient{t: t, server: srv} client := &testClient{t: t, server: srv}
@@ -5037,7 +5037,7 @@ func TestDraftWritesAndUpdatesMaildirDrafts(t *testing.T) {
func TestMoveAndDeleteMessageUpdateMaildir(t *testing.T) { func TestMoveAndDeleteMessageUpdateMaildir(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir() a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router()) srv := httptest.NewServer(a.Router())
defer srv.Close() defer srv.Close()
client := &testClient{t: t, server: srv} client := &testClient{t: t, server: srv}
@@ -5090,7 +5090,7 @@ func TestMoveAndDeleteMessageUpdateMaildir(t *testing.T) {
func TestMessageFlagsUpdateMaildir(t *testing.T) { func TestMessageFlagsUpdateMaildir(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir() a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router()) srv := httptest.NewServer(a.Router())
defer srv.Close() defer srv.Close()
client := &testClient{t: t, server: srv} client := &testClient{t: t, server: srv}
@@ -5136,7 +5136,7 @@ func TestMessageFlagsUpdateMaildir(t *testing.T) {
func TestIMAPUIDAndModSeqProgression(t *testing.T) { func TestIMAPUIDAndModSeqProgression(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir() a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router()) srv := httptest.NewServer(a.Router())
defer srv.Close() defer srv.Close()
client := &testClient{t: t, server: srv} client := &testClient{t: t, server: srv}
@@ -5229,7 +5229,7 @@ func TestIMAPUIDAndModSeqProgression(t *testing.T) {
func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) { func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir() a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a) user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID) clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5275,7 +5275,7 @@ func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) {
func TestMaildirSyncKeepsDistinctCopiesWithSameMessageID(t *testing.T) { func TestMaildirSyncKeepsDistinctCopiesWithSameMessageID(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir() a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a) user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID) clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5304,7 +5304,7 @@ func TestMaildirSyncKeepsDistinctCopiesWithSameMessageID(t *testing.T) {
func TestMaildirSyncUpdatesFlagsFromIMAP(t *testing.T) { func TestMaildirSyncUpdatesFlagsFromIMAP(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir() a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a) user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID) clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5345,7 +5345,7 @@ func TestMaildirSyncUpdatesFlagsFromIMAP(t *testing.T) {
func TestMaildirSyncDeletesMissingMessage(t *testing.T) { func TestMaildirSyncDeletesMissingMessage(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir() a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a) user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID) clearMailboxMessagesForTest(t, a, mb.ID)
+5 -5
View File
@@ -70,7 +70,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "账号或密码错误") respondError(w, http.StatusUnauthorized, "账号或密码错误")
return return
} }
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled { if a.config().TwoFactorEnabled && user.TwoFactorEnabled {
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID) challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "验证码生成失败,请稍后重试") 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) { func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
if !a.cfg.OpenRegistration { if !a.config().OpenRegistration {
respondError(w, http.StatusForbidden, "当前未开放注册") respondError(w, http.StatusForbidden, "当前未开放注册")
return return
} }
@@ -176,7 +176,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
if mailboxDomainID != "" && mailboxLocalPart != "" { if mailboxDomainID != "" && mailboxLocalPart != "" {
// Check reserved prefixes // Check reserved prefixes
reserved := map[string]bool{} reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) { for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true reserved[item] = true
} }
if reserved[mailboxLocalPart] { 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) { 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)) _, _ = 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}) respondJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
+2 -2
View File
@@ -34,7 +34,7 @@ func (a *App) handleDNSCheck(w http.ResponseWriter, r *http.Request) {
func (a *App) dnsRecordsFor(d *Domain) []DNSRecord { func (a *App) dnsRecordsFor(d *Domain) []DNSRecord {
name := strings.TrimSuffix(d.Name, ".") name := strings.TrimSuffix(d.Name, ".")
host := strings.TrimSuffix(a.cfg.PublicHostname, ".") + "." host := strings.TrimSuffix(a.config().PublicHostname, ".") + "."
return []DNSRecord{ return []DNSRecord{
{Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300}, {Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300},
{Type: "TXT", Name: name, Value: "v=spf1 mx -all", 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 { for _, item := range mx {
entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, ".")) entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, "."))
found = append(found, entry) 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 ok = true
} }
} }
+13 -22
View File
@@ -131,7 +131,7 @@ type externalIMAPOAuthState struct {
} }
func (a *App) externalIMAPWorker(ctx context.Context) { 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 { if interval <= 0 {
interval = 5 * time.Minute interval = 5 * time.Minute
} }
@@ -148,7 +148,7 @@ func (a *App) externalIMAPWorker(ctx context.Context) {
} }
func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) { func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
if !a.cfg.ExternalIMAPEnabled { if !a.config().ExternalIMAPEnabled {
return 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) 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 { func (a *App) requireExternalIMAPEnabled(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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") respondError(w, http.StatusForbidden, "external imap is disabled")
return return
} }
@@ -540,7 +540,7 @@ func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Req
respondError(w, http.StatusInternalServerError, "failed to save oauth account") respondError(w, http.StatusInternalServerError, "failed to save oauth account")
return 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) { 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 { func (a *App) validateExternalIMAPHost(ctx context.Context, host string) error {
if a.cfg.ExternalIMAPAllowPrivateHosts { if a.config().ExternalIMAPAllowPrivateHosts {
return nil return nil
} }
if strings.EqualFold(host, "localhost") { if strings.EqualFold(host, "localhost") {
@@ -868,7 +868,7 @@ func (a *App) decryptExternalIMAPPassword(ciphertext string) (string, error) {
} }
func (a *App) externalIMAPKey() ([]byte, error) { func (a *App) externalIMAPKey() ([]byte, error) {
secret := strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) secret := strings.TrimSpace(a.config().ExternalIMAPSecretKey)
if secret == "" { if secret == "" {
return nil, errors.New("LANQIN_EXTERNAL_IMAP_SECRET_KEY is required") 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) { 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 { switch provider {
case externalIMAPOAuthGmail: 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 nil, externalIMAPOAuthProvider{}, errors.New("gmail oauth is not configured")
} }
return &oauth2.Config{ return &oauth2.Config{
ClientID: a.cfg.ExternalIMAPGmailClientID, ClientID: a.config().ExternalIMAPGmailClientID,
ClientSecret: a.cfg.ExternalIMAPGmailClientSecret, ClientSecret: a.config().ExternalIMAPGmailClientSecret,
RedirectURL: callback, RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"}, Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"},
Endpoint: oauth2.Endpoint{ 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 }, externalIMAPOAuthProvider{Name: "Gmail", Host: "imap.gmail.com", Port: 993}, nil
case externalIMAPOAuthOutlook: 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 nil, externalIMAPOAuthProvider{}, errors.New("outlook oauth is not configured")
} }
return &oauth2.Config{ return &oauth2.Config{
ClientID: a.cfg.ExternalIMAPOutlookClientID, ClientID: a.config().ExternalIMAPOutlookClientID,
ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret, ClientSecret: a.config().ExternalIMAPOutlookClientSecret,
RedirectURL: callback, RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"}, Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
Endpoint: oauth2.Endpoint{ Endpoint: oauth2.Endpoint{
@@ -1376,15 +1376,6 @@ func safeExternalEMLFilename(subject string) string {
return name + ".eml" 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 { func externalIMAPAttachmentPartsFromBodyStructure(body imap.BodyStructure) []externalIMAPAttachmentPart {
now := time.Now().UTC() now := time.Now().UTC()
items := []externalIMAPAttachmentPart{} items := []externalIMAPAttachmentPart{}
+2 -2
View File
@@ -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) a.log.Warn("skip forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return return
} }
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname) forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID) _ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(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) a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return nil return nil
} }
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname) forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID) _ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" { if strings.TrimSpace(rfcMessageID) == "" {
+3 -3
View File
@@ -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) { 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 未配置,无法发送验证邮件") return "", errors.New("SMTP 未配置,无法发送验证邮件")
} }
mb, err := a.primaryMailboxForUser(ctx, userID) 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 { 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 == "" { 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) return base + "/api/verify-email?token=" + url.QueryEscape(token)
} }
-19
View File
@@ -232,25 +232,6 @@ func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderI
return next, nil 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) { func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
if folderID == "" { if folderID == "" {
var dbFolderID sql.NullString var dbFolderID sql.NullString
+4 -4
View File
@@ -971,7 +971,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
for _, rcpt := range localRecipients { for _, rcpt := range localRecipients {
rcptMailbox, err := a.mailboxByAddress(ctx, rcpt) rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
if err != nil { if err != nil {
if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) { if !a.config().CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
continue continue
} }
copyMsg := base copyMsg := base
@@ -986,7 +986,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
continue continue
} }
if rcptMailbox.Status != "active" { if rcptMailbox.Status != "active" {
if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) { if a.config().CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
copyMsg := base copyMsg := base
copyMsg.MailboxID = "" copyMsg.MailboxID = ""
copyMsg.FolderID = "" copyMsg.FolderID = ""
@@ -2345,7 +2345,7 @@ func (a *App) storeAttachmentWithDB(ctx context.Context, db dbExecutor, messageI
if err != nil { if err != nil {
return err 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 { if err := os.MkdirAll(dir, 0o755); err != nil {
return err return err
} }
@@ -2550,7 +2550,7 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
_ = os.Remove(p) _ = 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) { func (a *App) deleteMessage(ctx context.Context, messageID string) {
@@ -216,7 +216,7 @@ func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
imported, skipped := 0, 0 imported, skipped := 0, 0
problems := []string{} problems := []string{}
maxMessageBytes := int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024 maxMessageBytes := int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
if maxMessageBytes <= 0 { if maxMessageBytes <= 0 {
maxMessageBytes = 35 * 1024 * 1024 maxMessageBytes = 35 * 1024 * 1024
} }
+4 -4
View File
@@ -32,7 +32,7 @@ type translateMailMessageResponse struct {
} }
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) { 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") respondError(w, http.StatusForbidden, "mail translation is disabled")
return return
} }
@@ -59,7 +59,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusBadRequest, "message has no translatable text") respondError(w, http.StatusBadRequest, "message has no translatable text")
return return
} }
maxChars := a.cfg.MailTranslateMaxChars maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 { if maxChars <= 0 {
maxChars = 8000 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) { 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") respondError(w, http.StatusForbidden, "mail translation is disabled")
return return
} }
@@ -126,7 +126,7 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
respondError(w, http.StatusBadRequest, "message has no translatable text") respondError(w, http.StatusBadRequest, "message has no translatable text")
return return
} }
maxChars := a.cfg.MailTranslateMaxChars maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 { if maxChars <= 0 {
maxChars = 8000 maxChars = 8000
} }
+1 -1
View File
@@ -193,5 +193,5 @@ func cloneTimePtr(in *time.Time) *time.Time {
} }
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) { 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()))
} }
+6 -21
View File
@@ -45,13 +45,13 @@ type parsedMail struct {
} }
func (a *App) maildirWorker(ctx context.Context) { 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 { if interval <= 0 {
interval = 30 * time.Second interval = 30 * time.Second
} }
nextRunAt := a.now().UTC() nextRunAt := a.now().UTC()
a.maildirHealth.markWorkerStarted(&nextRunAt) 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 { if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
a.log.Warn("initial maildir sync failed", "error", err) a.log.Warn("initial maildir sync failed", "error", err)
} else if n := counts.total(); n > 0 { } 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) { func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot) root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" { if root == "" {
return maildirSyncCounts{}, nil return maildirSyncCounts{}, nil
} }
@@ -190,7 +190,7 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err 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`) domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -216,13 +216,8 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
return out, nil 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) { 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{} counts := maildirSyncCounts{}
for _, sub := range []string{"new", "cur"} { for _, sub := range []string{"new", "cur"} {
select { select {
@@ -394,16 +389,6 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
return count > 0, nil 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) { func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
var samePathID, oldFolderID string 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) { func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" { if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil return 0, nil
} }
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano) cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
+9 -9
View File
@@ -14,7 +14,7 @@ import (
) )
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error { 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 return nil
} }
raw, err := BuildMIME(MIMEMessage{ 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 { func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" { if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil return nil
} }
msg, err := a.storedMessageByID(ctx, messageID) 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 { 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 return nil
} }
state, err := a.maildirMessageState(ctx, messageID) state, err := a.maildirMessageState(ctx, messageID)
@@ -114,7 +114,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
if err != nil { if err != nil {
return err 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) folderBase := maildirFolderPath(base, folderName)
subdir := "cur" subdir := "cur"
if strings.EqualFold(folderName, "Inbox") && !state.IsRead { 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 { 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) state, stateErr := a.maildirMessageState(ctx, messageID)
if stateErr != nil { if stateErr != nil {
return stateErr return stateErr
@@ -215,7 +215,7 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
if err != nil { if err != nil {
return err 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) folderBase := maildirFolderPath(base, folderName)
if err := ensureMaildirFolderDirs(base, folderBase); err != nil { if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
return err 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 { 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 return nil
} }
state, err := a.maildirMessageState(ctx, messageID) 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) { func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" { if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil 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`) 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) { 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) == "" { if root == "" || strings.TrimSpace(path) == "" {
return false, nil return false, nil
} }
+1 -1
View File
@@ -136,7 +136,7 @@ func writeBase64(w io.Writer, data []byte) {
} }
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error { 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 { func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
+1 -1
View File
@@ -31,7 +31,7 @@ type deliveryWebhookEvent struct {
} }
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(a.cfg.DeliveryWebhookSecret) secret := strings.TrimSpace(a.config().DeliveryWebhookSecret)
if secret == "" { if secret == "" {
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured") respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
return return
@@ -923,15 +923,3 @@ func parseOpenAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
} }
return limit 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
}
+2 -25
View File
@@ -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 { func isAssignablePermissionGroupID(groupID string) bool {
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
} }
@@ -517,14 +502,6 @@ func permissionGroupOrder() map[string]int {
return out 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 { func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
for _, item := range defaultPermissionGroups() { for _, item := range defaultPermissionGroups() {
@@ -1057,10 +1034,10 @@ func (a *App) isDefaultAdminUser(u *User) bool {
if u == nil { if u == nil {
return false 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) 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) return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
} }
+6 -6
View File
@@ -27,14 +27,14 @@ func (a *App) handleMailboxApplyOptions(w http.ResponseWriter, r *http.Request)
return return
} }
respondJSON(w, http.StatusOK, MailboxApplyOptions{ respondJSON(w, http.StatusOK, MailboxApplyOptions{
Enabled: a.cfg.UserMailboxApplyEnabled, Enabled: a.config().UserMailboxApplyEnabled,
Domains: domains, Domains: domains,
ReservedPrefixes: parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), ReservedPrefixes: parseReservedPrefixes(a.config().ReservedMailboxPrefixes),
}) })
} }
func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) { func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
if !a.cfg.UserMailboxApplyEnabled { if !a.config().UserMailboxApplyEnabled {
respondError(w, http.StatusForbidden, "当前未开放邮箱申请") respondError(w, http.StatusForbidden, "当前未开放邮箱申请")
return return
} }
@@ -73,7 +73,7 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
return return
} }
reserved := map[string]bool{} reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) { for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true reserved[item] = true
} }
if reserved[localPart] { 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) { func (a *App) mailboxApplyDomains(ctx context.Context) ([]Domain, error) {
if !a.cfg.UserMailboxApplyEnabled { if !a.config().UserMailboxApplyEnabled {
return []Domain{}, nil return []Domain{}, nil
} }
ids := cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")) ids := cleanIDList(strings.Split(a.config().UserMailboxDomainIDs, ","))
if len(ids) == 0 { if len(ids) == 0 {
return []Domain{}, nil return []Domain{}, nil
} }
+2 -2
View File
@@ -215,7 +215,7 @@ func (a *App) registerOpenAPIRoutes(r chi.Router) {
func (a *App) corsMiddleware(next http.Handler) http.Handler { func (a *App) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin") 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("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin") w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true") 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) { 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 == "" { if err != nil || cookie.Value == "" {
return nil, errors.New("no session") return nil, errors.New("no session")
} }
+3 -3
View File
@@ -67,7 +67,7 @@ type sendQueueItem struct {
} }
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) { 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 return "", nil
} }
now := in.Now.UTC() now := in.Now.UTC()
@@ -149,7 +149,7 @@ func (a *App) sendQueueWorker(ctx context.Context) {
} }
func (a *App) processDueSendQueue(ctx context.Context) error { func (a *App) processDueSendQueue(ctx context.Context) error {
if strings.TrimSpace(a.cfg.SMTPHost) == "" { if strings.TrimSpace(a.config().SMTPHost) == "" {
return nil return nil
} }
if err := a.recoverStaleSendQueueItems(ctx); err != nil { if err := a.recoverStaleSendQueueItems(ctx); err != nil {
@@ -396,7 +396,7 @@ func (a *App) sendQueueDeliveredMarkerPath(id string) string {
if safeID == "" || safeID == "." { if safeID == "" || safeID == "." {
safeID = "unknown" 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 { func (a *App) writeSendQueueDeliveredMarker(id string) error {
+3 -3
View File
@@ -8,20 +8,20 @@ import (
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error { func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
token := randomToken() token := randomToken()
sessionID := newID("ses") 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(?,?,?,?,?)`, 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 { sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
return err return err
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: a.cfg.CookieName, Name: a.config().CookieName,
Value: token, Value: token,
Path: "/", Path: "/",
Expires: expires, Expires: expires,
MaxAge: int(time.Until(expires).Seconds()), MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Secure: !a.cfg.AllowInsecureHTTP, Secure: !a.config().AllowInsecureHTTP,
}) })
return nil return nil
} }
+75 -68
View File
@@ -100,15 +100,16 @@ func (a *App) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) handlePublicSettings(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) != "" cfg := a.config()
refreshSeconds := a.cfg.MailRefreshSeconds enabled := cfg.TurnstileEnabled && strings.TrimSpace(cfg.TurnstileSiteKey) != "" && strings.TrimSpace(cfg.TurnstileSecretKey) != ""
refreshSeconds := cfg.MailRefreshSeconds
if refreshSeconds <= 0 { if refreshSeconds <= 0 {
refreshSeconds = 30 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 // 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`) rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`)
if err == nil { if err == nil {
defer rows.Close() defer rows.Close()
@@ -131,7 +132,7 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
badRequest(w, err) badRequest(w, err)
return return
} }
next := a.cfg next := a.config()
next.PublicHostname = normalizeHostname(req.PublicHostname) next.PublicHostname = normalizeHostname(req.PublicHostname)
if next.PublicHostname == "" { if next.PublicHostname == "" {
badRequest(w, errors.New("publicHostname is required")) 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") respondError(w, http.StatusInternalServerError, "failed to save settings")
return return
} }
a.cfg = next a.setConfig(next)
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot()) respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
} }
@@ -218,7 +219,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
badRequest(w, err) badRequest(w, err)
return return
} }
cfg := a.cfg cfg := a.config()
if strings.TrimSpace(cfg.SMTPHost) == "" { if strings.TrimSpace(cfg.SMTPHost) == "" {
badRequest(w, errors.New("SMTP 主机未设置")) badRequest(w, errors.New("SMTP 主机未设置"))
return return
@@ -285,41 +286,43 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) systemSettingsSnapshot() SystemSettings { func (a *App) systemSettingsSnapshot() SystemSettings {
cfg := a.config()
return SystemSettings{ return SystemSettings{
PublicHostname: a.cfg.PublicHostname, PublicHostname: cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL, PublicBaseURL: cfg.PublicBaseURL,
SMTPHost: a.cfg.SMTPHost, SMTPHost: cfg.SMTPHost,
SMTPPort: a.cfg.SMTPPort, SMTPPort: cfg.SMTPPort,
SMTPUsername: a.cfg.SMTPUsername, SMTPUsername: cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "", SMTPPasswordSet: strings.TrimSpace(cfg.SMTPPassword) != "",
SMTPRequireTLS: a.cfg.SMTPRequireTLS, SMTPRequireTLS: cfg.SMTPRequireTLS,
MaildirRoot: a.cfg.MaildirRoot, MaildirRoot: cfg.MaildirRoot,
MaildirScanSeconds: a.cfg.MaildirScanSeconds, MaildirScanSeconds: cfg.MaildirScanSeconds,
SessionTTLHours: a.cfg.SessionTTLHours, SessionTTLHours: cfg.SessionTTLHours,
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP, AllowInsecureHTTP: cfg.AllowInsecureHTTP,
OpenRegistration: a.cfg.OpenRegistration, OpenRegistration: cfg.OpenRegistration,
TwoFactorEnabled: a.cfg.TwoFactorEnabled, TwoFactorEnabled: cfg.TwoFactorEnabled,
TurnstileEnabled: a.cfg.TurnstileEnabled, TurnstileEnabled: cfg.TurnstileEnabled,
TurnstileSiteKey: a.cfg.TurnstileSiteKey, TurnstileSiteKey: cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "", TurnstileSecretSet: strings.TrimSpace(cfg.TurnstileSecretKey) != "",
CatchAllEnabled: a.cfg.CatchAllEnabled, CatchAllEnabled: cfg.CatchAllEnabled,
MailAutoRefresh: a.cfg.MailAutoRefresh, MailAutoRefresh: cfg.MailAutoRefresh,
MailRefreshSeconds: a.cfg.MailRefreshSeconds, MailRefreshSeconds: cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled, UserMailboxApplyEnabled: cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")), UserMailboxDomainIDs: cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"), ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), "\n"),
ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled, ExternalIMAPEnabled: cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) != "", ExternalIMAPSecretSet: strings.TrimSpace(cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: a.cfg.ExternalIMAPSyncSeconds, ExternalIMAPSyncSeconds: cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: a.cfg.ExternalIMAPAllowPrivateHosts, ExternalIMAPAllowPrivateHosts: cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: a.cfg.ExternalIMAPGmailClientID, ExternalIMAPGmailClientID: cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPGmailClientSecret) != "", ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: a.cfg.ExternalIMAPOutlookClientID, ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPOutlookClientSecret) != "", ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
} }
} }
func (a *App) loadPersistedSystemSettings(ctx context.Context) error { func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
cfg := a.config()
rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`) rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`)
if err != nil { if err != nil {
return err return err
@@ -332,76 +335,80 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
} }
switch key { switch key {
case "publicHostname": case "publicHostname":
a.cfg.PublicHostname = value cfg.PublicHostname = value
case "publicBaseUrl": case "publicBaseUrl":
a.cfg.PublicBaseURL = value cfg.PublicBaseURL = value
case "smtpHost": case "smtpHost":
a.cfg.SMTPHost = value cfg.SMTPHost = value
case "smtpPort": case "smtpPort":
a.cfg.SMTPPort = value cfg.SMTPPort = value
case "smtpUsername": case "smtpUsername":
a.cfg.SMTPUsername = value cfg.SMTPUsername = value
case "smtpPassword": case "smtpPassword":
a.cfg.SMTPPassword = value cfg.SMTPPassword = value
case "smtpRequireTls": case "smtpRequireTls":
a.cfg.SMTPRequireTLS = value == "true" cfg.SMTPRequireTLS = value == "true"
case "maildirRoot": case "maildirRoot":
a.cfg.MaildirRoot = value cfg.MaildirRoot = value
case "maildirScanSeconds": case "maildirScanSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 { if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MaildirScanSeconds = n cfg.MaildirScanSeconds = n
} }
case "sessionTtlHours": case "sessionTtlHours":
if n, err := strconv.Atoi(value); err == nil && n > 0 { if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.SessionTTLHours = n cfg.SessionTTLHours = n
} }
case "allowInsecureHttp": case "allowInsecureHttp":
a.cfg.AllowInsecureHTTP = value == "true" cfg.AllowInsecureHTTP = value == "true"
case "openRegistration": case "openRegistration":
a.cfg.OpenRegistration = value == "true" cfg.OpenRegistration = value == "true"
case "twoFactorEnabled": case "twoFactorEnabled":
a.cfg.TwoFactorEnabled = value == "true" cfg.TwoFactorEnabled = value == "true"
case "turnstileEnabled": case "turnstileEnabled":
a.cfg.TurnstileEnabled = value == "true" cfg.TurnstileEnabled = value == "true"
case "turnstileSiteKey": case "turnstileSiteKey":
a.cfg.TurnstileSiteKey = value cfg.TurnstileSiteKey = value
case "turnstileSecretKey": case "turnstileSecretKey":
a.cfg.TurnstileSecretKey = value cfg.TurnstileSecretKey = value
case "catchAllEnabled": case "catchAllEnabled":
a.cfg.CatchAllEnabled = value == "true" cfg.CatchAllEnabled = value == "true"
case "mailAutoRefresh": case "mailAutoRefresh":
a.cfg.MailAutoRefresh = value == "true" cfg.MailAutoRefresh = value == "true"
case "mailRefreshSeconds": case "mailRefreshSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 { if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MailRefreshSeconds = n cfg.MailRefreshSeconds = n
} }
case "userMailboxApplyEnabled": case "userMailboxApplyEnabled":
a.cfg.UserMailboxApplyEnabled = value == "true" cfg.UserMailboxApplyEnabled = value == "true"
case "userMailboxDomainIds": case "userMailboxDomainIds":
a.cfg.UserMailboxDomainIDs = value cfg.UserMailboxDomainIDs = value
case "reservedMailboxPrefixes": case "reservedMailboxPrefixes":
a.cfg.ReservedMailboxPrefixes = value cfg.ReservedMailboxPrefixes = value
case "externalImapEnabled": case "externalImapEnabled":
a.cfg.ExternalIMAPEnabled = value == "true" cfg.ExternalIMAPEnabled = value == "true"
case "externalImapSecretKey": case "externalImapSecretKey":
a.cfg.ExternalIMAPSecretKey = value cfg.ExternalIMAPSecretKey = value
case "externalImapSyncSeconds": case "externalImapSyncSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 { if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.ExternalIMAPSyncSeconds = n cfg.ExternalIMAPSyncSeconds = n
} }
case "externalImapAllowPrivateHosts": case "externalImapAllowPrivateHosts":
a.cfg.ExternalIMAPAllowPrivateHosts = value == "true" cfg.ExternalIMAPAllowPrivateHosts = value == "true"
case "externalImapGmailClientId": case "externalImapGmailClientId":
a.cfg.ExternalIMAPGmailClientID = value cfg.ExternalIMAPGmailClientID = value
case "externalImapGmailClientSecret": case "externalImapGmailClientSecret":
a.cfg.ExternalIMAPGmailClientSecret = value cfg.ExternalIMAPGmailClientSecret = value
case "externalImapOutlookClientId": case "externalImapOutlookClientId":
a.cfg.ExternalIMAPOutlookClientID = value cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret": 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 { func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
+9 -9
View File
@@ -27,7 +27,7 @@ type statusWebhookEnvelope struct {
} }
func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey, eventType, mailboxID string, data any) error { 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 return nil
} }
now := a.now().UTC() 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) { func (a *App) statusWebhookWorker(ctx context.Context) {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" { if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return return
} }
a.log.Info("status webhook worker started") 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 { func (a *App) processDueStatusWebhooks(ctx context.Context) error {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" { if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return nil return nil
} }
_, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox _, _ = 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 return err
} }
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10) 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([]byte(timestamp + "."))
_, _ = mac.Write(payload) _, _ = mac.Write(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(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) { 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") 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 != "" { if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
return nil, errors.New("invalid status webhook URL") 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") 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 { if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
return nil, err return nil, err
} }
@@ -157,7 +157,7 @@ func (a *App) statusWebhookDialContext(ctx context.Context, network, address str
if err != nil { if err != nil {
return nil, err return nil, err
} }
if a.cfg.StatusWebhookAllowPrivateHosts { if a.config().StatusWebhookAllowPrivateHosts {
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address) return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
} }
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
+4 -4
View File
@@ -49,8 +49,8 @@ func (s *SubmissionServers) Shutdown(ctx context.Context) error {
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers { func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
return &SubmissionServers{ return &SubmissionServers{
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig), Plain: a.newSubmissionServer(a.config().SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, 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 := smtpserver.NewServer(submissionBackend{app: a})
s.Addr = addr s.Addr = addr
s.Domain = a.cfg.PublicHostname s.Domain = a.config().PublicHostname
s.TLSConfig = tlsConfig s.TLSConfig = tlsConfig
s.AllowInsecureAuth = false s.AllowInsecureAuth = false
s.MaxRecipients = defaultSubmissionMaxRecipients s.MaxRecipients = defaultSubmissionMaxRecipients
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024 s.MaxMessageBytes = int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
s.ReadTimeout = smtpSessionTimeout s.ReadTimeout = smtpSessionTimeout
s.WriteTimeout = smtpSessionTimeout s.WriteTimeout = smtpSessionTimeout
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0) s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
@@ -97,7 +97,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) { func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
current := strings.TrimSpace(a.cfg.AppVersion) current := strings.TrimSpace(a.config().AppVersion)
if current == "" { if current == "" {
current = BuildVersion current = BuildVersion
} }
@@ -124,7 +124,7 @@ func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
} }
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, 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) parsed, err := url.Parse(endpoint)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return githubRelease{}, errors.New("invalid release API URL") return githubRelease{}, errors.New("invalid release API URL")
@@ -134,7 +134,7 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
return githubRelease{}, err return githubRelease{}, err
} }
req.Header.Set("Accept", "application/vnd.github+json") 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{ client := &http.Client{
Timeout: 8 * time.Second, Timeout: 8 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { 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 { 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 { 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") { if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("invalid update service URL") return errors.New("invalid update service URL")
} }
@@ -173,7 +173,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
if err != nil { if err != nil {
return err 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{ client := &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { 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) { 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 { if err := os.MkdirAll(backupDir, 0o700); err != nil {
return "", err return "", err
} }
+2 -2
View File
@@ -17,11 +17,11 @@ type turnstileVerifyResponse struct {
} }
func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error { func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error {
if !a.cfg.TurnstileEnabled { if !a.config().TurnstileEnabled {
return nil return nil
} }
token = strings.TrimSpace(token) token = strings.TrimSpace(token)
secret := strings.TrimSpace(a.cfg.TurnstileSecretKey) secret := strings.TrimSpace(a.config().TurnstileSecretKey)
if secret == "" || token == "" { if secret == "" || token == "" {
return errors.New("turnstile verification required") return errors.New("turnstile verification required")
} }
+2 -2
View File
@@ -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) { func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
if !a.cfg.TwoFactorEnabled { if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭") respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return 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) { func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
if !a.cfg.TwoFactorEnabled { if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭") respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return return
} }
+3 -8
View File
@@ -26,27 +26,22 @@
"@radix-ui/react-tooltip": "^1.2.9", "@radix-ui/react-tooltip": "^1.2.9",
"@tanstack/react-query": "5.59.16", "@tanstack/react-query": "5.59.16",
"@tiptap/core": "^3.27.0", "@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-image": "^3.27.0",
"@tiptap/extension-link": "^3.27.0", "@tiptap/extension-link": "^3.27.0",
"@tiptap/extension-placeholder": "^3.27.0", "@tiptap/extension-placeholder": "^3.27.0",
"@tiptap/extension-text-align": "^3.27.0", "@tiptap/extension-text-align": "^3.27.0",
"@tiptap/extension-text-style": "^3.27.0", "@tiptap/extension-text-style": "^3.27.0",
"@tiptap/extension-underline": "^3.27.0",
"@tiptap/pm": "^3.27.0", "@tiptap/pm": "^3.27.0",
"@tiptap/react": "^3.27.0", "@tiptap/react": "^3.27.0",
"@tiptap/starter-kit": "^3.27.0", "@tiptap/starter-kit": "^3.27.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "2.1.1", "clsx": "2.1.1",
"dompurify": "3.4.10", "dompurify": "3.4.12",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-resizable-panels": "^2.1.7", "react-router-dom": "7.18.2",
"react-router-dom": "6.30.4",
"tailwind-merge": "2.5.4" "tailwind-merge": "2.5.4"
}, },
"devDependencies": { "devDependencies": {
@@ -55,7 +50,7 @@
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"postcss": "8.5.15", "postcss": "8.5.25",
"tailwindcss": "3.4.15", "tailwindcss": "3.4.15",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "5.6.3", "typescript": "5.6.3",
@@ -1,4 +1,3 @@
import * as React from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-43
View File
@@ -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<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+1 -1
View File
@@ -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" export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000 const REQUEST_TIMEOUT_MS = 15_000
+9 -6
View File
@@ -6,14 +6,15 @@ import { Toaster } from "@/components/ui/toaster"
import { LanguageDomSync } from "@/lib/language" import { LanguageDomSync } from "@/lib/language"
import { ProtectedLayout } from "@/components/protected-layout" import { ProtectedLayout } from "@/components/protected-layout"
import { AdminOnly } from "@/components/admin-only" 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" 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 queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> }, { path: "/login", element: <LoginPage /> },
@@ -31,7 +32,9 @@ const router = createBrowserRouter([
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<React.Suspense fallback={<div className="grid h-svh place-items-center text-sm text-muted-foreground">...</div>}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</React.Suspense>
<Toaster /> <Toaster />
<LanguageDomSync /> <LanguageDomSync />
</QueryClientProvider> </QueryClientProvider>
+3 -8
View File
@@ -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 defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen })
const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || []) const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultPermissionLimits) const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultLimits)
React.useEffect(() => { React.useEffect(() => {
if (dialogOpen) { if (dialogOpen) {
setPermissions(group?.permissions || []) setPermissions(group?.permissions || [])
setLimits(group?.limits || defaultPermissionLimits) setLimits(group?.limits || defaultLimits)
} }
}, [dialogOpen, group]) }, [defaultLimits, dialogOpen, group])
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (form: FormData) => { mutationFn: (form: FormData) => {
const payload = { const payload = {
@@ -1979,11 +1979,6 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card> return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
} }
const dnsDescriptions: Record<string, string> = {
MX: "指定收件服务器。把邮件投递到该地址指向的服务器。",
TXT: "", // 具体含义根据内容区分
}
function dnsDescription(record: DNSRecord): string { function dnsDescription(record: DNSRecord): string {
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。" if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。" if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
+3 -21
View File
@@ -11,8 +11,8 @@ import TextAlign from "@tiptap/extension-text-align"
import Placeholder from "@tiptap/extension-placeholder" import Placeholder from "@tiptap/extension-placeholder"
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style" import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
import { useNavigate } from "react-router-dom" 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 { 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, ExternalImapFolder, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api" 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 { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme" import { applyTheme, getInitialTheme } from "@/lib/theme"
import { useDisplayMode } from "@/lib/display-mode" import { useDisplayMode } from "@/lib/display-mode"
@@ -140,7 +140,7 @@ export function MailPage() {
const [autoRefreshing, setAutoRefreshing] = React.useState(false) const [autoRefreshing, setAutoRefreshing] = React.useState(false)
const [exportingMail, setExportingMail] = React.useState(false) const [exportingMail, setExportingMail] = React.useState(false)
const [importingMail, setImportingMail] = React.useState(false) const [importingMail, setImportingMail] = React.useState(false)
const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState<Date | null>(null) const [, setLastAutoRefreshAt] = React.useState<Date | null>(null)
const [bulkPending, setBulkPending] = React.useState(false) const [bulkPending, setBulkPending] = React.useState(false)
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null) const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("") const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
@@ -4728,24 +4728,6 @@ function toDateTimeLocalValue(date: Date) {
function normalizeSchedule(schedule: ScheduleDraft): ScheduleDraft { function normalizeSchedule(schedule: ScheduleDraft): ScheduleDraft {
return { ...schedule, title: schedule.title.trim(), location: schedule.location.trim(), description: schedule.description.trim() } 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(`
<div style="border:1px solid #d4d4d8;border-radius:8px;padding:14px 16px;margin:16px 0;background:#fafafa;">
<div style="font-weight:600;font-size:16px;margin-bottom:10px;">${escapeHtml(schedule.title)}</div>
${rows.map(([label, value]) => `<div style="margin:6px 0;"><span style="color:#71717a;">${label}</span>${escapeHtml(value)}</div>`).join("")}
</div>
`)
}
function scheduleToFile(schedule: ScheduleDraft) { function scheduleToFile(schedule: ScheduleDraft) {
const ics = scheduleToIcs(schedule) const ics = scheduleToIcs(schedule)
const filename = `${safeFilename(schedule.title || "schedule")}.ics` const filename = `${safeFilename(schedule.title || "schedule")}.ics`
File diff suppressed because it is too large Load Diff
+2
View File
@@ -8,6 +8,8 @@
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true, "strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
+63 -105
View File
@@ -50,15 +50,6 @@ importers:
'@tiptap/core': '@tiptap/core':
specifier: ^3.27.0 specifier: ^3.27.0
version: 3.27.0(@tiptap/pm@3.27.0) version: 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-color':
specifier: ^3.27.0
version: 3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))
'@tiptap/extension-font-family':
specifier: ^3.27.0
version: 3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))
'@tiptap/extension-highlight':
specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-image': '@tiptap/extension-image':
specifier: ^3.27.0 specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)) version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
@@ -74,9 +65,6 @@ importers:
'@tiptap/extension-text-style': '@tiptap/extension-text-style':
specifier: ^3.27.0 specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)) version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-underline':
specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/pm': '@tiptap/pm':
specifier: ^3.27.0 specifier: ^3.27.0
version: 3.27.0 version: 3.27.0
@@ -93,8 +81,8 @@ importers:
specifier: 2.1.1 specifier: 2.1.1
version: 2.1.1 version: 2.1.1
dompurify: dompurify:
specifier: 3.4.10 specifier: 3.4.12
version: 3.4.10 version: 3.4.12
lucide-react: lucide-react:
specifier: ^0.468.0 specifier: ^0.468.0
version: 0.468.0(react@18.3.1) version: 0.468.0(react@18.3.1)
@@ -107,12 +95,9 @@ importers:
react-dom: react-dom:
specifier: 18.3.1 specifier: 18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-resizable-panels:
specifier: ^2.1.7
version: 2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom: react-router-dom:
specifier: 6.30.4 specifier: 7.18.2
version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge: tailwind-merge:
specifier: 2.5.4 specifier: 2.5.4
version: 2.5.4 version: 2.5.4
@@ -131,10 +116,10 @@ importers:
version: 6.0.2(vite@8.0.16(@types/node@24.13.2)(jiti@1.21.7)(yaml@2.9.0)) version: 6.0.2(vite@8.0.16(@types/node@24.13.2)(jiti@1.21.7)(yaml@2.9.0))
autoprefixer: autoprefixer:
specifier: 10.4.20 specifier: 10.4.20
version: 10.4.20(postcss@8.5.15) version: 10.4.20(postcss@8.5.25)
postcss: postcss:
specifier: 8.5.15 specifier: 8.5.25
version: 8.5.15 version: 8.5.25
tailwindcss: tailwindcss:
specifier: 3.4.15 specifier: 3.4.15
version: 3.4.15 version: 3.4.15
@@ -642,10 +627,6 @@ packages:
'@radix-ui/rect@1.1.2': '@radix-ui/rect@1.1.2':
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
'@remix-run/router@1.23.3':
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
engines: {node: '>=14.0.0'}
'@rolldown/binding-android-arm64@1.0.3': '@rolldown/binding-android-arm64@1.0.3':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -789,11 +770,6 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': 3.27.0 '@tiptap/core': 3.27.0
'@tiptap/extension-color@3.27.0':
resolution: {integrity: sha512-K+2aI4k0yEekZ4Sq+puNWE4/z26E1wfAmRqci6k6T8tAQz5PsDPzgNUSHTB4FSTzH0hLYrFzj/yaggjF9VeHGg==}
peerDependencies:
'@tiptap/extension-text-style': 3.27.0
'@tiptap/extension-document@3.27.0': '@tiptap/extension-document@3.27.0':
resolution: {integrity: sha512-xE+rUAPAA+65Usxbn5OoPVh0I0FSPz5dYprj+uo1mogPgqpcPLGVNMMoRLZ4WdiZ3I451d5+U7CUynIjD/iikw==} resolution: {integrity: sha512-xE+rUAPAA+65Usxbn5OoPVh0I0FSPz5dYprj+uo1mogPgqpcPLGVNMMoRLZ4WdiZ3I451d5+U7CUynIjD/iikw==}
peerDependencies: peerDependencies:
@@ -811,11 +787,6 @@ packages:
'@tiptap/core': 3.27.0 '@tiptap/core': 3.27.0
'@tiptap/pm': 3.27.0 '@tiptap/pm': 3.27.0
'@tiptap/extension-font-family@3.27.0':
resolution: {integrity: sha512-cK+5R6KoOoxXmwbA03RYZS7G0//DovLNV/6mhgYPIuO/tNXXcwMGOxUKmMC2aUr95aL00EqGtsX4JotRyBVvXg==}
peerDependencies:
'@tiptap/extension-text-style': 3.27.0
'@tiptap/extension-gapcursor@3.27.0': '@tiptap/extension-gapcursor@3.27.0':
resolution: {integrity: sha512-tHIUQmtebBytVpd2f5oCUMAivdN5Yj8zRDpkA5uT3x38s9OdXLjLLYZHDD7b0ANQnN0r1vkxPXi9YF6+2XWZvA==} resolution: {integrity: sha512-tHIUQmtebBytVpd2f5oCUMAivdN5Yj8zRDpkA5uT3x38s9OdXLjLLYZHDD7b0ANQnN0r1vkxPXi9YF6+2XWZvA==}
peerDependencies: peerDependencies:
@@ -831,11 +802,6 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': 3.27.0 '@tiptap/core': 3.27.0
'@tiptap/extension-highlight@3.27.0':
resolution: {integrity: sha512-p7Epzx8KbxxqIUG68dFk2lpmPp0Bvth7K11oL/pAlaXOR2Z4X9kef5+FJ/fEDehUtWPzfzVn/eO2a5bYN7k2AA==}
peerDependencies:
'@tiptap/core': 3.27.0
'@tiptap/extension-horizontal-rule@3.27.0': '@tiptap/extension-horizontal-rule@3.27.0':
resolution: {integrity: sha512-04Xga9CqIqzKb1cqDk9AV9pTbtleqF+o8X3bb3n7HDgplYHxLjHId6RCzhfSZU6U8VZZF/RJQ1jbTebeYqGSBw==} resolution: {integrity: sha512-04Xga9CqIqzKb1cqDk9AV9pTbtleqF+o8X3bb3n7HDgplYHxLjHId6RCzhfSZU6U8VZZF/RJQ1jbTebeYqGSBw==}
peerDependencies: peerDependencies:
@@ -1031,6 +997,10 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cssesc@3.0.0: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1052,8 +1022,8 @@ packages:
dlv@1.1.3: dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dompurify@3.4.10: dompurify@3.4.12:
resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==} resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
electron-to-chromium@1.5.375: electron-to-chromium@1.5.375:
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==} resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
@@ -1252,8 +1222,8 @@ packages:
mz@2.7.0: mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nanoid@3.3.12: nanoid@3.3.16:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
@@ -1339,8 +1309,8 @@ packages:
postcss-value-parser@4.2.0: postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.15: postcss@8.5.25:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
prosemirror-changeset@2.4.1: prosemirror-changeset@2.4.1:
@@ -1415,24 +1385,22 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-resizable-panels@2.1.9: react-router-dom@7.18.2:
resolution: {integrity: sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==} resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==}
engines: {node: '>=20.0.0'}
peerDependencies: peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc react: '>=18'
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: '>=18'
react-router-dom@6.30.4: react-router@7.18.2:
resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==} resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==}
engines: {node: '>=14.0.0'} engines: {node: '>=20.0.0'}
peerDependencies: peerDependencies:
react: '>=16.8' react: '>=18'
react-dom: '>=16.8' react-dom: '>=18'
peerDependenciesMeta:
react-router@6.30.4: react-dom:
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==} optional: true
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-style-singleton@2.2.3: react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
@@ -1478,6 +1446,9 @@ packages:
scheduler@0.23.2: scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2123,8 +2094,6 @@ snapshots:
'@radix-ui/rect@1.1.2': {} '@radix-ui/rect@1.1.2': {}
'@remix-run/router@1.23.3': {}
'@rolldown/binding-android-arm64@1.0.3': '@rolldown/binding-android-arm64@1.0.3':
optional: true optional: true
@@ -2215,10 +2184,6 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0) '@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-color@3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))':
dependencies:
'@tiptap/extension-text-style': 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-document@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))': '@tiptap/extension-document@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))':
dependencies: dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0) '@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
@@ -2234,10 +2199,6 @@ snapshots:
'@tiptap/pm': 3.27.0 '@tiptap/pm': 3.27.0
optional: true optional: true
'@tiptap/extension-font-family@3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))':
dependencies:
'@tiptap/extension-text-style': 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-gapcursor@3.27.0(@tiptap/extensions@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0))': '@tiptap/extension-gapcursor@3.27.0(@tiptap/extensions@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0))':
dependencies: dependencies:
'@tiptap/extensions': 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0) '@tiptap/extensions': 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0)
@@ -2250,10 +2211,6 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0) '@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-highlight@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))':
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-horizontal-rule@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0)': '@tiptap/extension-horizontal-rule@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0)':
dependencies: dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0) '@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
@@ -2426,14 +2383,14 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
autoprefixer@10.4.20(postcss@8.5.15): autoprefixer@10.4.20(postcss@8.5.25):
dependencies: dependencies:
browserslist: 4.28.2 browserslist: 4.28.2
caniuse-lite: 1.0.30001799 caniuse-lite: 1.0.30001799
fraction.js: 4.3.7 fraction.js: 4.3.7
normalize-range: 0.1.2 normalize-range: 0.1.2
picocolors: 1.1.1 picocolors: 1.1.1
postcss: 8.5.15 postcss: 8.5.25
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
baseline-browser-mapping@2.10.37: {} baseline-browser-mapping@2.10.37: {}
@@ -2476,6 +2433,8 @@ snapshots:
commander@4.1.1: {} commander@4.1.1: {}
cookie@1.1.1: {}
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.2.3: {} csstype@3.2.3: {}
@@ -2488,7 +2447,7 @@ snapshots:
dlv@1.1.3: {} dlv@1.1.3: {}
dompurify@3.4.10: dompurify@3.4.12:
optionalDependencies: optionalDependencies:
'@types/trusted-types': 2.0.7 '@types/trusted-types': 2.0.7
@@ -2639,7 +2598,7 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
thenify-all: 1.6.0 thenify-all: 1.6.0
nanoid@3.3.12: {} nanoid@3.3.16: {}
node-releases@2.0.47: {} node-releases@2.0.47: {}
@@ -2665,28 +2624,28 @@ snapshots:
pirates@4.0.7: {} pirates@4.0.7: {}
postcss-import@15.1.0(postcss@8.5.15): postcss-import@15.1.0(postcss@8.5.25):
dependencies: dependencies:
postcss: 8.5.15 postcss: 8.5.25
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
read-cache: 1.0.0 read-cache: 1.0.0
resolve: 1.22.12 resolve: 1.22.12
postcss-js@4.1.0(postcss@8.5.15): postcss-js@4.1.0(postcss@8.5.25):
dependencies: dependencies:
camelcase-css: 2.0.1 camelcase-css: 2.0.1
postcss: 8.5.15 postcss: 8.5.25
postcss-load-config@4.0.2(postcss@8.5.15): postcss-load-config@4.0.2(postcss@8.5.25):
dependencies: dependencies:
lilconfig: 3.1.3 lilconfig: 3.1.3
yaml: 2.9.0 yaml: 2.9.0
optionalDependencies: optionalDependencies:
postcss: 8.5.15 postcss: 8.5.25
postcss-nested@6.2.0(postcss@8.5.15): postcss-nested@6.2.0(postcss@8.5.25):
dependencies: dependencies:
postcss: 8.5.15 postcss: 8.5.25
postcss-selector-parser: 6.1.4 postcss-selector-parser: 6.1.4
postcss-selector-parser@6.1.4: postcss-selector-parser@6.1.4:
@@ -2696,9 +2655,9 @@ snapshots:
postcss-value-parser@4.2.0: {} postcss-value-parser@4.2.0: {}
postcss@8.5.15: postcss@8.5.25:
dependencies: dependencies:
nanoid: 3.3.12 nanoid: 3.3.16
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
@@ -2807,22 +2766,19 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.3.12 '@types/react': 18.3.12
react-resizable-panels@2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): react-router-dom@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
react-router: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): react-router@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
'@remix-run/router': 1.23.3 cookie: 1.1.1
react: 18.3.1 react: 18.3.1
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
react-router: 6.30.4(react@18.3.1)
react-router@6.30.4(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.3
react: 18.3.1
react-style-singleton@2.2.3(@types/react@18.3.12)(react@18.3.1): react-style-singleton@2.2.3(@types/react@18.3.12)(react@18.3.1):
dependencies: dependencies:
@@ -2884,6 +2840,8 @@ snapshots:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
set-cookie-parser@2.7.2: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
sucrase@3.35.1: sucrase@3.35.1:
@@ -2920,11 +2878,11 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
object-hash: 3.0.0 object-hash: 3.0.0
picocolors: 1.1.1 picocolors: 1.1.1
postcss: 8.5.15 postcss: 8.5.25
postcss-import: 15.1.0(postcss@8.5.15) postcss-import: 15.1.0(postcss@8.5.25)
postcss-js: 4.1.0(postcss@8.5.15) postcss-js: 4.1.0(postcss@8.5.25)
postcss-load-config: 4.0.2(postcss@8.5.15) postcss-load-config: 4.0.2(postcss@8.5.25)
postcss-nested: 6.2.0(postcss@8.5.15) postcss-nested: 6.2.0(postcss@8.5.25)
postcss-selector-parser: 6.1.4 postcss-selector-parser: 6.1.4
resolve: 1.22.12 resolve: 1.22.12
sucrase: 3.35.1 sucrase: 3.35.1
@@ -2987,7 +2945,7 @@ snapshots:
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.4 picomatch: 4.0.4
postcss: 8.5.15 postcss: 8.5.25
rolldown: 1.0.3 rolldown: 1.0.3
tinyglobby: 0.2.17 tinyglobby: 0.2.17
optionalDependencies: optionalDependencies: