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
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/go-chi/chi/v5 v5.3.0
github.com/microcosm-cc/bluemonday v1.0.27
golang.org/x/crypto v0.26.0
golang.org/x/crypto v0.51.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.36.0
golang.org/x/text v0.38.0
modernc.org/sqlite v1.31.1
)
@@ -13,19 +18,14 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emersion/go-smtp v0.24.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/sys v0.45.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
+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-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -33,8 +33,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
@@ -43,8 +43,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -59,8 +59,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+34 -12
View File
@@ -24,6 +24,7 @@ import (
type App struct {
cfg Config
cfgMu sync.RWMutex
db *sql.DB
log *slog.Logger
now func() time.Time
@@ -34,6 +35,24 @@ type App struct {
externalIMAP externalIMAPClientFactory
}
func (a *App) config() Config {
a.cfgMu.RLock()
defer a.cfgMu.RUnlock()
return a.cfg
}
func (a *App) setConfig(cfg Config) {
a.cfgMu.Lock()
a.cfg = cfg
a.cfgMu.Unlock()
}
func (a *App) updateConfig(update func(*Config)) {
a.cfgMu.Lock()
defer a.cfgMu.Unlock()
update(&a.cfg)
}
func New(cfg Config, logger *slog.Logger) (*App, error) {
if logger == nil {
logger = slog.Default()
@@ -76,7 +95,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
workerCtx, cancel := context.WithCancel(context.Background())
a.workerCancel = cancel
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
if strings.TrimSpace(a.config().MaildirRoot) != "" {
a.startWorker(func() { a.maildirWorker(workerCtx) })
}
a.startWorker(func() { a.sendQueueWorker(workerCtx) })
@@ -925,7 +944,7 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
// Current seed() creates mailboxes with display_name = admin email, so this migration
// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema.
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
adminEmail := normalizeEmail(a.cfg.AdminEmail)
adminEmail := normalizeEmail(a.config().AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
}
@@ -1387,6 +1406,7 @@ func messageIndexes() []string {
}
func (a *App) seed(ctx context.Context) error {
cfg := a.config()
var count int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return err
@@ -1395,7 +1415,7 @@ func (a *App) seed(ctx context.Context) error {
return a.ensureConfiguredAdminSuperAdmin(ctx)
}
adminPassword := a.cfg.AdminPassword
adminPassword := cfg.AdminPassword
if adminPassword == "" {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
@@ -1410,8 +1430,8 @@ func (a *App) seed(ctx context.Context) error {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
if strings.TrimSpace(a.cfg.AdminUsername) != "" {
adminUsername, err := cleanUsername(a.cfg.AdminUsername)
if strings.TrimSpace(cfg.AdminUsername) != "" {
adminUsername, err := cleanUsername(cfg.AdminUsername)
if err != nil {
return fmt.Errorf("invalid admin username: %w", err)
}
@@ -1422,7 +1442,7 @@ func (a *App) seed(ctx context.Context) error {
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
return nil
}
adminEmail := normalizeEmail(a.cfg.AdminEmail)
adminEmail := normalizeEmail(cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return errors.New("invalid admin email")
}
@@ -1463,12 +1483,13 @@ func (a *App) seed(ctx context.Context) error {
}
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
cfg := a.config()
if adminUsername := normalizeLoginName(cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`,
a.now().UTC().Format(time.RFC3339Nano), adminUsername)
return err
}
adminEmail := normalizeEmail(a.cfg.AdminEmail)
adminEmail := normalizeEmail(cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
}
@@ -1589,6 +1610,7 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
}
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
cfg := a.config()
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
if err != nil {
return err
@@ -1599,10 +1621,10 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
rendered := renderMailTemplate(tpl, templateRenderData{
To: a.cfg.AdminEmail,
To: cfg.AdminEmail,
From: "system@lanqin.local",
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
PublicHostname: cfg.PublicHostname,
PublicBaseURL: cfg.PublicBaseURL,
Time: now,
})
subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML
@@ -1615,7 +1637,7 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
Subject: subject,
From: "system@lanqin.local",
FromName: "NewSzxcn 邮箱",
To: []string{a.cfg.AdminEmail},
To: []string{cfg.AdminEmail},
SentAt: now,
ReceivedAt: now,
Snippet: snippetFrom(bodyText, bodyHTML),
+77 -77
View File
@@ -650,7 +650,7 @@ func TestExternalIMAPDisabledByDefaultAndAdminSettings(t *testing.T) {
if settings.ExternalIMAPGmailClientID != "gmail-client" || settings.ExternalIMAPOutlookClientID != "outlook-client" {
t.Fatalf("oauth client ids not saved: %+v", settings)
}
if a.cfg.ExternalIMAPSecretKey != "test-secret" || a.cfg.ExternalIMAPGmailClientSecret != "gmail-secret" || a.cfg.ExternalIMAPOutlookClientSecret != "outlook-secret" {
if a.config().ExternalIMAPSecretKey != "test-secret" || a.config().ExternalIMAPGmailClientSecret != "gmail-secret" || a.config().ExternalIMAPOutlookClientSecret != "outlook-secret" {
t.Fatalf("secret settings not persisted in config")
}
if code := admin.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || !public.ExternalIMAPEnabled {
@@ -660,8 +660,8 @@ func TestExternalIMAPDisabledByDefaultAndAdminSettings(t *testing.T) {
func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) {
a := newTestApp(t)
a.cfg.ExternalIMAPEnabled = true
a.cfg.ExternalIMAPSecretKey = "test-secret"
a.updateConfig(func(cfg *Config) { cfg.ExternalIMAPEnabled = true })
a.updateConfig(func(cfg *Config) { cfg.ExternalIMAPSecretKey = "test-secret" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -958,7 +958,7 @@ func TestMailRulesConditionGroupsAndActions(t *testing.T) {
func TestMailRulesForwardingAction(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
a.cfg.SMTPHost = "127.0.0.1"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -1344,7 +1344,7 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
t.Fatalf("closed registration code=%d body=%v", code, out)
}
a.cfg.OpenRegistration = true
a.updateConfig(func(cfg *Config) { cfg.OpenRegistration = true })
var registered struct {
User User `json:"user"`
}
@@ -1968,8 +1968,8 @@ func TestHTMLPolicyPreservesEmailLayoutStyles(t *testing.T) {
func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "1"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2010,8 +2010,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 8)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2187,8 +2187,8 @@ func TestMailSendRejectsUnauthorizedFrom(t *testing.T) {
func TestMailSendRollsBackSentCopyWhenQueueInsertFails(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "postfix"
a.cfg.SMTPPort = "25"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "postfix" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
user, mb := defaultAdminUserAndMailbox(t, a)
if _, err := a.db.ExecContext(context.Background(), `DROP TABLE send_queue`); err != nil {
t.Fatal(err)
@@ -2387,8 +2387,8 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "25"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2496,9 +2496,9 @@ func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) {
func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "25"
a.cfg.DeliveryWebhookSecret = "delivery-test-secret"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
a.updateConfig(func(cfg *Config) { cfg.DeliveryWebhookSecret = "delivery-test-secret" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
@@ -2580,7 +2580,7 @@ func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) {
}{Events: []deliveryWebhookEvent{{ID: "provider-event-1", Provider: "test-provider", MessageID: first.MessageID, Recipient: recipient.Address, Status: "bounced", Reason: "550 mailbox unavailable", OccurredAt: a.now().UTC().Format(time.RFC3339Nano)}}}
body, _ := json.Marshal(eventPayload)
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
mac := hmac.New(sha256.New, []byte(a.cfg.DeliveryWebhookSecret))
mac := hmac.New(sha256.New, []byte(a.config().DeliveryWebhookSecret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(body)
webhookHeaders := map[string]string{"X-LanQin-Timestamp": timestamp, "X-LanQin-Signature": "sha256=" + hex.EncodeToString(mac.Sum(nil))}
@@ -2589,7 +2589,7 @@ func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) {
t.Fatalf("invalid delivery webhook signature code=%d", code)
}
oldTimestamp := strconv.FormatInt(a.now().UTC().Add(-10*time.Minute).Unix(), 10)
oldMAC := hmac.New(sha256.New, []byte(a.cfg.DeliveryWebhookSecret))
oldMAC := hmac.New(sha256.New, []byte(a.config().DeliveryWebhookSecret))
_, _ = oldMAC.Write([]byte(oldTimestamp + "."))
_, _ = oldMAC.Write(body)
oldHeaders := map[string]string{"X-LanQin-Timestamp": oldTimestamp, "X-LanQin-Signature": "sha256=" + hex.EncodeToString(oldMAC.Sum(nil))}
@@ -2769,9 +2769,9 @@ func TestStatusWebhookOutboxDeliveryRetryAndSSRFProtection(t *testing.T) {
w.WriteHeader(http.StatusNoContent)
}))
defer receiver.Close()
a.cfg.StatusWebhookURL = receiver.URL
a.cfg.StatusWebhookSecret = "outbound-test-secret"
a.cfg.StatusWebhookAllowPrivateHosts = true
a.updateConfig(func(cfg *Config) { cfg.StatusWebhookURL = receiver.URL })
a.updateConfig(func(cfg *Config) { cfg.StatusWebhookSecret = "outbound-test-secret" })
a.updateConfig(func(cfg *Config) { cfg.StatusWebhookAllowPrivateHosts = true })
user, mb := defaultAdminUserAndMailbox(t, a)
a.recordSendAudit(context.Background(), sendAuditFailed, sendQueueStatusFailed, sendAuditInput{QueueID: "snd_test", UserID: user.ID, MailboxID: mb.ID, SentMessageID: "mail_test", Source: sendSourceOpenAPI, MailFrom: mb.Address, Recipients: []string{"recipient@example.test"}, Error: "test failure"})
@@ -2807,8 +2807,8 @@ func TestStatusWebhookOutboxDeliveryRetryAndSSRFProtection(t *testing.T) {
privateTLS := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer privateTLS.Close()
a.cfg.StatusWebhookURL = privateTLS.URL
a.cfg.StatusWebhookAllowPrivateHosts = false
a.updateConfig(func(cfg *Config) { cfg.StatusWebhookURL = privateTLS.URL })
a.updateConfig(func(cfg *Config) { cfg.StatusWebhookAllowPrivateHosts = false })
if _, err := a.validatedStatusWebhookURL(context.Background()); err == nil || !strings.Contains(err.Error(), "private or local") {
t.Fatalf("private webhook target should be rejected, err=%v", err)
}
@@ -2818,8 +2818,8 @@ func TestSendQueueRecoversStaleSendingItems(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb := defaultAdminUserAndMailbox(t, a)
now := a.now().UTC()
mimeBytes := []byte("From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: stale\r\n\r\nbody")
@@ -2868,8 +2868,8 @@ func TestSendQueueStaleDeliveredMarkerDoesNotRedeliver(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb := defaultAdminUserAndMailbox(t, a)
now := a.now().UTC()
mimeBytes := []byte("From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: marker\r\n\r\nbody")
@@ -2922,8 +2922,8 @@ func TestSendQueueStaleDeliveredMarkerDoesNotRedeliver(t *testing.T) {
func TestSendQueueAPIPermissionIsolation(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "25"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2997,8 +2997,8 @@ func TestSendQueueAPIPermissionIsolation(t *testing.T) {
func TestSendQueueAPIFiltersStableCursorAndMessageDetailLink(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "25"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
@@ -3129,8 +3129,8 @@ func TestSendQueueAPIFiltersStableCursorAndMessageDetailLink(t *testing.T) {
func TestSendQueueAPIRetryAndCancel(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
@@ -3384,8 +3384,8 @@ func TestSubmissionAuthRequiresMailboxPasswordAndSendPermission(t *testing.T) {
func TestSubmissionSendsRelayAndStoresSentCopy(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
raw := strings.Join([]string{
"From: Admin <admin@lanqin.local>",
"To: person@example.com",
@@ -3481,8 +3481,8 @@ func TestSerializeMessageUsesStableHeaderOrder(t *testing.T) {
func TestSubmissionRelayFailureKeepsSentCopyAndRetries(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "1"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3513,8 +3513,8 @@ func TestSubmissionRelayFailureKeepsSentCopyAndRetries(t *testing.T) {
func TestSubmissionSentCopyDedupesByMessageID(t *testing.T) {
a := newTestApp(t)
host, port, _ := startCapturingSMTP(t, 4)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3587,8 +3587,8 @@ func TestInsertSentMessageOnceFailsWhenDedupeKeyHasNoMessage(t *testing.T) {
func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "1"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3602,8 +3602,8 @@ func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
}
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
t.Fatal(err)
}
@@ -3628,8 +3628,8 @@ func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3670,8 +3670,8 @@ func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) {
func TestSubmissionRequeuesCanceledDuplicateMessageID(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3821,9 +3821,9 @@ func TestSendQueueMessageIDMigrationDropsDuplicatesBeforeUniqueIndex(t *testing.
func TestSubmissionTLSConfigRequiresCertificateFiles(t *testing.T) {
a := newTestApp(t)
a.cfg.SubmissionAddr = ":587"
a.cfg.SubmissionTLSAddr = ":465"
if _, err := LoadServerTLSConfig(a.cfg); err == nil {
a.updateConfig(func(cfg *Config) { cfg.SubmissionAddr = ":587" })
a.updateConfig(func(cfg *Config) { cfg.SubmissionTLSAddr = ":465" })
if _, err := LoadServerTLSConfig(a.config()); err == nil {
t.Fatal("submission TLS config should require certificate files")
}
}
@@ -3831,9 +3831,9 @@ func TestSubmissionTLSConfigRequiresCertificateFiles(t *testing.T) {
func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
a := newTestApp(t)
certPath, keyPath := writeTestCertificateFiles(t, "first.example.test")
a.cfg.TLSCertFile = certPath
a.cfg.TLSKeyFile = keyPath
tlsConfig, err := LoadServerTLSConfig(a.cfg)
a.updateConfig(func(cfg *Config) { cfg.TLSCertFile = certPath })
a.updateConfig(func(cfg *Config) { cfg.TLSKeyFile = keyPath })
tlsConfig, err := LoadServerTLSConfig(a.config())
if err != nil {
t.Fatal(err)
}
@@ -3876,12 +3876,12 @@ func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
certPath, keyPath := writeTestCertificateFiles(t, "mail.example.test")
a.cfg.TLSCertFile = certPath
a.cfg.TLSKeyFile = keyPath
tlsConfig, err := LoadServerTLSConfig(a.cfg)
a.updateConfig(func(cfg *Config) { cfg.TLSCertFile = certPath })
a.updateConfig(func(cfg *Config) { cfg.TLSKeyFile = keyPath })
tlsConfig, err := LoadServerTLSConfig(a.config())
if err != nil {
t.Fatal(err)
}
@@ -3952,8 +3952,8 @@ func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
func TestAdminSMTPTestEndpoint(t *testing.T) {
a := newTestApp(t)
host, port, received := startFakeSMTP(t)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -4102,7 +4102,7 @@ func TestUserMailSignaturesDefaultResolution(t *testing.T) {
func TestUserTwoFactorSetupAndLogin(t *testing.T) {
a := newTestApp(t)
a.cfg.TwoFactorEnabled = true
a.updateConfig(func(cfg *Config) { cfg.TwoFactorEnabled = true })
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
@@ -4569,7 +4569,7 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
var domainID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil {
t.Fatal(err)
@@ -4650,7 +4650,7 @@ func TestMaildirImportStoresAuthenticationResults(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
ts := httptest.NewServer(a.Router())
defer ts.Close()
@@ -4753,8 +4753,8 @@ func TestMaildirSyncHealthAfterTrackedSync(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
a.cfg.MaildirScanSeconds = 45
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
a.updateConfig(func(cfg *Config) { cfg.MaildirScanSeconds = 45 })
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
@@ -4806,7 +4806,7 @@ func TestMaildirSyncHealthAfterTrackedSync(t *testing.T) {
if counts.Imported != 1 || counts.FilesScanned != 1 {
t.Fatalf("counts=%+v, want imported=1 filesScanned=1", counts)
}
health := a.maildirHealth.snapshot(a.cfg)
health := a.maildirHealth.snapshot(a.config())
if !health.Configured || !health.Enabled {
t.Fatalf("configured health=%+v, want enabled", health)
}
@@ -4828,7 +4828,7 @@ func TestMaildirSyncImportsSentFolder(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
@@ -4901,7 +4901,7 @@ func TestMaildirSyncImportsSentFolder(t *testing.T) {
func TestWebmailSentWritesMaildirSent(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -4941,7 +4941,7 @@ func TestWebmailSentWritesMaildirSent(t *testing.T) {
func TestMaildirSyncBackfillsSQLiteOnlySent(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -4984,7 +4984,7 @@ func TestMaildirSyncBackfillsSQLiteOnlySent(t *testing.T) {
func TestDraftWritesAndUpdatesMaildirDrafts(t *testing.T) {
a := newTestApp(t)
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5037,7 +5037,7 @@ func TestDraftWritesAndUpdatesMaildirDrafts(t *testing.T) {
func TestMoveAndDeleteMessageUpdateMaildir(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5090,7 +5090,7 @@ func TestMoveAndDeleteMessageUpdateMaildir(t *testing.T) {
func TestMessageFlagsUpdateMaildir(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5136,7 +5136,7 @@ func TestMessageFlagsUpdateMaildir(t *testing.T) {
func TestIMAPUIDAndModSeqProgression(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5229,7 +5229,7 @@ func TestIMAPUIDAndModSeqProgression(t *testing.T) {
func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5275,7 +5275,7 @@ func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) {
func TestMaildirSyncKeepsDistinctCopiesWithSameMessageID(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5304,7 +5304,7 @@ func TestMaildirSyncKeepsDistinctCopiesWithSameMessageID(t *testing.T) {
func TestMaildirSyncUpdatesFlagsFromIMAP(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5345,7 +5345,7 @@ func TestMaildirSyncUpdatesFlagsFromIMAP(t *testing.T) {
func TestMaildirSyncDeletesMissingMessage(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
+5 -5
View File
@@ -70,7 +70,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
if a.config().TwoFactorEnabled && user.TwoFactorEnabled {
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "验证码生成失败,请稍后重试")
@@ -87,7 +87,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
if !a.cfg.OpenRegistration {
if !a.config().OpenRegistration {
respondError(w, http.StatusForbidden, "当前未开放注册")
return
}
@@ -176,7 +176,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
if mailboxDomainID != "" && mailboxLocalPart != "" {
// Check reserved prefixes
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[mailboxLocalPart] {
@@ -192,10 +192,10 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
if cookie, err := r.Cookie(a.config().CookieName); err == nil {
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
}
http.SetCookie(w, &http.Cookie{Name: a.cfg.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
http.SetCookie(w, &http.Cookie{Name: a.config().CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+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 {
name := strings.TrimSuffix(d.Name, ".")
host := strings.TrimSuffix(a.cfg.PublicHostname, ".") + "."
host := strings.TrimSuffix(a.config().PublicHostname, ".") + "."
return []DNSRecord{
{Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300},
{Type: "TXT", Name: name, Value: "v=spf1 mx -all", TTL: 300},
@@ -58,7 +58,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
for _, item := range mx {
entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, "."))
found = append(found, entry)
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.cfg.PublicHostname, ".")) {
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.config().PublicHostname, ".")) {
ok = true
}
}
+13 -22
View File
@@ -131,7 +131,7 @@ type externalIMAPOAuthState struct {
}
func (a *App) externalIMAPWorker(ctx context.Context) {
interval := time.Duration(a.cfg.ExternalIMAPSyncSeconds) * time.Second
interval := time.Duration(a.config().ExternalIMAPSyncSeconds) * time.Second
if interval <= 0 {
interval = 5 * time.Minute
}
@@ -148,7 +148,7 @@ func (a *App) externalIMAPWorker(ctx context.Context) {
}
func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
if !a.cfg.ExternalIMAPEnabled {
if !a.config().ExternalIMAPEnabled {
return
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM external_imap_accounts WHERE enabled=1 AND storage_mode=? ORDER BY COALESCE(last_sync_at, created_at) ASC LIMIT 10`, externalIMAPStorageLocal)
@@ -170,7 +170,7 @@ func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
func (a *App) requireExternalIMAPEnabled(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !a.cfg.ExternalIMAPEnabled {
if !a.config().ExternalIMAPEnabled {
respondError(w, http.StatusForbidden, "external imap is disabled")
return
}
@@ -540,7 +540,7 @@ func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Req
respondError(w, http.StatusInternalServerError, "failed to save oauth account")
return
}
http.Redirect(w, r, strings.TrimRight(a.cfg.PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
http.Redirect(w, r, strings.TrimRight(a.config().PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
}
func (a *App) handleMailExternalAccounts(w http.ResponseWriter, r *http.Request) {
@@ -789,7 +789,7 @@ func (a *App) normalizeExternalIMAPPayload(ctx context.Context, req externalIMAP
}
func (a *App) validateExternalIMAPHost(ctx context.Context, host string) error {
if a.cfg.ExternalIMAPAllowPrivateHosts {
if a.config().ExternalIMAPAllowPrivateHosts {
return nil
}
if strings.EqualFold(host, "localhost") {
@@ -868,7 +868,7 @@ func (a *App) decryptExternalIMAPPassword(ciphertext string) (string, error) {
}
func (a *App) externalIMAPKey() ([]byte, error) {
secret := strings.TrimSpace(a.cfg.ExternalIMAPSecretKey)
secret := strings.TrimSpace(a.config().ExternalIMAPSecretKey)
if secret == "" {
return nil, errors.New("LANQIN_EXTERNAL_IMAP_SECRET_KEY is required")
}
@@ -883,15 +883,15 @@ type externalIMAPOAuthProvider struct {
}
func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, externalIMAPOAuthProvider, error) {
callback := strings.TrimRight(a.cfg.PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
callback := strings.TrimRight(a.config().PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
switch provider {
case externalIMAPOAuthGmail:
if a.cfg.ExternalIMAPGmailClientID == "" || a.cfg.ExternalIMAPGmailClientSecret == "" {
if a.config().ExternalIMAPGmailClientID == "" || a.config().ExternalIMAPGmailClientSecret == "" {
return nil, externalIMAPOAuthProvider{}, errors.New("gmail oauth is not configured")
}
return &oauth2.Config{
ClientID: a.cfg.ExternalIMAPGmailClientID,
ClientSecret: a.cfg.ExternalIMAPGmailClientSecret,
ClientID: a.config().ExternalIMAPGmailClientID,
ClientSecret: a.config().ExternalIMAPGmailClientSecret,
RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"},
Endpoint: oauth2.Endpoint{
@@ -900,12 +900,12 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external
},
}, externalIMAPOAuthProvider{Name: "Gmail", Host: "imap.gmail.com", Port: 993}, nil
case externalIMAPOAuthOutlook:
if a.cfg.ExternalIMAPOutlookClientID == "" || a.cfg.ExternalIMAPOutlookClientSecret == "" {
if a.config().ExternalIMAPOutlookClientID == "" || a.config().ExternalIMAPOutlookClientSecret == "" {
return nil, externalIMAPOAuthProvider{}, errors.New("outlook oauth is not configured")
}
return &oauth2.Config{
ClientID: a.cfg.ExternalIMAPOutlookClientID,
ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret,
ClientID: a.config().ExternalIMAPOutlookClientID,
ClientSecret: a.config().ExternalIMAPOutlookClientSecret,
RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
Endpoint: oauth2.Endpoint{
@@ -1376,15 +1376,6 @@ func safeExternalEMLFilename(subject string) string {
return name + ".eml"
}
func externalIMAPAttachmentsFromBodyStructure(body imap.BodyStructure) []Attachment {
parts := externalIMAPAttachmentPartsFromBodyStructure(body)
items := make([]Attachment, 0, len(parts))
for _, part := range parts {
items = append(items, part.Attachment)
}
return items
}
func externalIMAPAttachmentPartsFromBodyStructure(body imap.BodyStructure) []externalIMAPAttachmentPart {
now := time.Now().UTC()
items := []externalIMAPAttachmentPart{}
+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)
return
}
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" {
@@ -101,7 +101,7 @@ func (a *App) processRuleForwarding(ctx context.Context, messageID, mailboxID st
a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return nil
}
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" {
+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) {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return "", errors.New("SMTP 未配置,无法发送验证邮件")
}
mb, err := a.primaryMailboxForUser(ctx, userID)
@@ -428,9 +428,9 @@ func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targe
}
func (a *App) forwardingVerificationURL(token string) string {
base := strings.TrimRight(strings.TrimSpace(a.cfg.PublicBaseURL), "/")
base := strings.TrimRight(strings.TrimSpace(a.config().PublicBaseURL), "/")
if base == "" {
base = "https://" + strings.Trim(strings.TrimSpace(a.cfg.PublicHostname), "/")
base = "https://" + strings.Trim(strings.TrimSpace(a.config().PublicHostname), "/")
}
return base + "/api/verify-email?token=" + url.QueryEscape(token)
}
-19
View File
@@ -232,25 +232,6 @@ func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderI
return next, nil
}
func (a *App) touchMessageIMAPModSeq(ctx context.Context, messageID string) error {
var folderID sql.NullString
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID); err != nil {
return err
}
if !folderID.Valid || folderID.String == "" {
return nil
}
modSeq, err := a.bumpFolderModSeq(ctx, folderID.String)
if err != nil {
return err
}
if modSeq == 0 {
return nil
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
return err
}
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
if folderID == "" {
var dbFolderID sql.NullString
+4 -4
View File
@@ -971,7 +971,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
for _, rcpt := range localRecipients {
rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
if err != nil {
if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
if !a.config().CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
continue
}
copyMsg := base
@@ -986,7 +986,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
continue
}
if rcptMailbox.Status != "active" {
if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
if a.config().CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
copyMsg := base
copyMsg.MailboxID = ""
copyMsg.FolderID = ""
@@ -2345,7 +2345,7 @@ func (a *App) storeAttachmentWithDB(ctx context.Context, db dbExecutor, messageI
if err != nil {
return err
}
dir := filepath.Join(a.cfg.DataDir, "attachments", messageID)
dir := filepath.Join(a.config().DataDir, "attachments", messageID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@@ -2550,7 +2550,7 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
_ = os.Remove(p)
}
}
_ = os.RemoveAll(filepath.Join(a.cfg.DataDir, "attachments", messageID))
_ = os.RemoveAll(filepath.Join(a.config().DataDir, "attachments", messageID))
}
func (a *App) deleteMessage(ctx context.Context, messageID string) {
@@ -216,7 +216,7 @@ func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
imported, skipped := 0, 0
problems := []string{}
maxMessageBytes := int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
maxMessageBytes := int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
if maxMessageBytes <= 0 {
maxMessageBytes = 35 * 1024 * 1024
}
+4 -4
View File
@@ -32,7 +32,7 @@ type translateMailMessageResponse struct {
}
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) {
if !a.cfg.MailTranslateEnabled {
if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
@@ -59,7 +59,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.cfg.MailTranslateMaxChars
maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
@@ -78,7 +78,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
}
func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) {
if !a.cfg.MailTranslateEnabled {
if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
@@ -126,7 +126,7 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.cfg.MailTranslateMaxChars
maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
+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) {
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) {
interval := time.Duration(a.cfg.MaildirScanSeconds) * time.Second
interval := time.Duration(a.config().MaildirScanSeconds) * time.Second
if interval <= 0 {
interval = 30 * time.Second
}
nextRunAt := a.now().UTC()
a.maildirHealth.markWorkerStarted(&nextRunAt)
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
a.log.Info("maildir sync worker started", "root", a.config().MaildirRoot, "interval", interval.String())
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
a.log.Warn("initial maildir sync failed", "error", err)
} else if n := counts.total(); n > 0 {
@@ -98,7 +98,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
}
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" {
return maildirSyncCounts{}, nil
}
@@ -190,7 +190,7 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
if err := rows.Err(); err != nil {
return nil, err
}
if a.cfg.CatchAllEnabled {
if a.config().CatchAllEnabled {
domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
if err != nil {
return nil, err
@@ -216,13 +216,8 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
return out, nil
}
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
return counts.Imported, err
}
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
counts := maildirSyncCounts{}
for _, sub := range []string{"new", "cur"} {
select {
@@ -394,16 +389,6 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
return count > 0, nil
}
func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID string) {
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
return
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
}
}
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
now := a.now().UTC().Format(time.RFC3339Nano)
var samePathID, oldFolderID string
@@ -514,7 +499,7 @@ func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailbo
}
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
+9 -9
View File
@@ -14,7 +14,7 @@ import (
)
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
return nil
}
raw, err := BuildMIME(MIMEMessage{
@@ -37,7 +37,7 @@ func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string,
}
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
msg, err := a.storedMessageByID(ctx, messageID)
@@ -76,7 +76,7 @@ func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, ra
}
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
@@ -114,7 +114,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
subdir := "cur"
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
@@ -166,7 +166,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
}
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
state, stateErr := a.maildirMessageState(ctx, messageID)
if stateErr != nil {
return stateErr
@@ -215,7 +215,7 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
return err
@@ -284,7 +284,7 @@ func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
}
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
@@ -354,7 +354,7 @@ func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
}
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
@@ -472,7 +472,7 @@ func (a *App) folderNameByID(ctx context.Context, folderID string) (string, erro
}
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" || strings.TrimSpace(path) == "" {
return false, nil
}
+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 {
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 {
+1 -1
View File
@@ -31,7 +31,7 @@ type deliveryWebhookEvent struct {
}
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(a.cfg.DeliveryWebhookSecret)
secret := strings.TrimSpace(a.config().DeliveryWebhookSecret)
if secret == "" {
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
return
@@ -923,15 +923,3 @@ func parseOpenAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
}
return limit
}
func parseOpenAPIOffset(r *http.Request) int {
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
if cursor == "" {
return 0
}
offset, err := strconv.Atoi(cursor)
if err != nil || offset < 0 {
return 0
}
return offset
}
+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 {
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
}
@@ -517,14 +502,6 @@ func permissionGroupOrder() map[string]int {
return out
}
func permissionGroupNames() map[string]string {
out := map[string]string{}
for _, group := range defaultPermissionGroups() {
out[group.ID] = group.Name
}
return out
}
func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
for _, item := range defaultPermissionGroups() {
@@ -1057,10 +1034,10 @@ func (a *App) isDefaultAdminUser(u *User) bool {
if u == nil {
return false
}
if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
if adminUsername := normalizeLoginName(a.config().AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
}
adminEmail := normalizeEmail(a.cfg.AdminEmail)
adminEmail := normalizeEmail(a.config().AdminEmail)
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
}
+6 -6
View File
@@ -27,14 +27,14 @@ func (a *App) handleMailboxApplyOptions(w http.ResponseWriter, r *http.Request)
return
}
respondJSON(w, http.StatusOK, MailboxApplyOptions{
Enabled: a.cfg.UserMailboxApplyEnabled,
Enabled: a.config().UserMailboxApplyEnabled,
Domains: domains,
ReservedPrefixes: parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes),
ReservedPrefixes: parseReservedPrefixes(a.config().ReservedMailboxPrefixes),
})
}
func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
if !a.cfg.UserMailboxApplyEnabled {
if !a.config().UserMailboxApplyEnabled {
respondError(w, http.StatusForbidden, "当前未开放邮箱申请")
return
}
@@ -73,7 +73,7 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
return
}
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[localPart] {
@@ -129,10 +129,10 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
}
func (a *App) mailboxApplyDomains(ctx context.Context) ([]Domain, error) {
if !a.cfg.UserMailboxApplyEnabled {
if !a.config().UserMailboxApplyEnabled {
return []Domain{}, nil
}
ids := cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ","))
ids := cleanIDList(strings.Split(a.config().UserMailboxDomainIDs, ","))
if len(ids) == 0 {
return []Domain{}, nil
}
+2 -2
View File
@@ -215,7 +215,7 @@ func (a *App) registerOpenAPIRoutes(r chi.Router) {
func (a *App) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.cfg.PublicBaseURL) {
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.config().PublicBaseURL) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
@@ -273,7 +273,7 @@ func currentUser(r *http.Request) *User {
}
func (a *App) authenticateRequest(r *http.Request) (*User, error) {
cookie, err := r.Cookie(a.cfg.CookieName)
cookie, err := r.Cookie(a.config().CookieName)
if err != nil || cookie.Value == "" {
return nil, errors.New("no session")
}
+3 -3
View File
@@ -67,7 +67,7 @@ type sendQueueItem struct {
}
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return "", nil
}
now := in.Now.UTC()
@@ -149,7 +149,7 @@ func (a *App) sendQueueWorker(ctx context.Context) {
}
func (a *App) processDueSendQueue(ctx context.Context) error {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return nil
}
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
@@ -396,7 +396,7 @@ func (a *App) sendQueueDeliveredMarkerPath(id string) string {
if safeID == "" || safeID == "." {
safeID = "unknown"
}
return filepath.Join(a.cfg.DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
return filepath.Join(a.config().DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
}
func (a *App) writeSendQueueDeliveredMarker(id string) error {
+3 -3
View File
@@ -8,20 +8,20 @@ import (
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
token := randomToken()
sessionID := newID("ses")
expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
expires := a.now().UTC().Add(time.Duration(a.config().SessionTTLHours) * time.Hour)
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: a.cfg.CookieName,
Name: a.config().CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: !a.cfg.AllowInsecureHTTP,
Secure: !a.config().AllowInsecureHTTP,
})
return nil
}
+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) {
enabled := a.cfg.TurnstileEnabled && strings.TrimSpace(a.cfg.TurnstileSiteKey) != "" && strings.TrimSpace(a.cfg.TurnstileSecretKey) != ""
refreshSeconds := a.cfg.MailRefreshSeconds
cfg := a.config()
enabled := cfg.TurnstileEnabled && strings.TrimSpace(cfg.TurnstileSiteKey) != "" && strings.TrimSpace(cfg.TurnstileSecretKey) != ""
refreshSeconds := cfg.MailRefreshSeconds
if refreshSeconds <= 0 {
refreshSeconds = 30
}
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled}
settings := PublicSettings{OpenRegistration: cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: cfg.TurnstileSiteKey, PublicHostname: cfg.PublicHostname, MailAutoRefresh: cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: cfg.ExternalIMAPEnabled}
// Include available domains for mailbox creation during registration
if a.cfg.OpenRegistration {
if cfg.OpenRegistration {
rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`)
if err == nil {
defer rows.Close()
@@ -131,7 +132,7 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
badRequest(w, err)
return
}
next := a.cfg
next := a.config()
next.PublicHostname = normalizeHostname(req.PublicHostname)
if next.PublicHostname == "" {
badRequest(w, errors.New("publicHostname is required"))
@@ -208,7 +209,7 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusInternalServerError, "failed to save settings")
return
}
a.cfg = next
a.setConfig(next)
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
}
@@ -218,7 +219,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
cfg := a.cfg
cfg := a.config()
if strings.TrimSpace(cfg.SMTPHost) == "" {
badRequest(w, errors.New("SMTP 主机未设置"))
return
@@ -285,41 +286,43 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
}
func (a *App) systemSettingsSnapshot() SystemSettings {
cfg := a.config()
return SystemSettings{
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
SMTPHost: a.cfg.SMTPHost,
SMTPPort: a.cfg.SMTPPort,
SMTPUsername: a.cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
MaildirRoot: a.cfg.MaildirRoot,
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
SessionTTLHours: a.cfg.SessionTTLHours,
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
OpenRegistration: a.cfg.OpenRegistration,
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
TurnstileEnabled: a.cfg.TurnstileEnabled,
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
CatchAllEnabled: a.cfg.CatchAllEnabled,
MailAutoRefresh: a.cfg.MailAutoRefresh,
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: a.cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: a.cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: a.cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: a.cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPOutlookClientSecret) != "",
PublicHostname: cfg.PublicHostname,
PublicBaseURL: cfg.PublicBaseURL,
SMTPHost: cfg.SMTPHost,
SMTPPort: cfg.SMTPPort,
SMTPUsername: cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(cfg.SMTPPassword) != "",
SMTPRequireTLS: cfg.SMTPRequireTLS,
MaildirRoot: cfg.MaildirRoot,
MaildirScanSeconds: cfg.MaildirScanSeconds,
SessionTTLHours: cfg.SessionTTLHours,
AllowInsecureHTTP: cfg.AllowInsecureHTTP,
OpenRegistration: cfg.OpenRegistration,
TwoFactorEnabled: cfg.TwoFactorEnabled,
TurnstileEnabled: cfg.TurnstileEnabled,
TurnstileSiteKey: cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(cfg.TurnstileSecretKey) != "",
CatchAllEnabled: cfg.CatchAllEnabled,
MailAutoRefresh: cfg.MailAutoRefresh,
MailRefreshSeconds: cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), "\n"),
ExternalIMAPEnabled: cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
}
}
func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
cfg := a.config()
rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`)
if err != nil {
return err
@@ -332,76 +335,80 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
}
switch key {
case "publicHostname":
a.cfg.PublicHostname = value
cfg.PublicHostname = value
case "publicBaseUrl":
a.cfg.PublicBaseURL = value
cfg.PublicBaseURL = value
case "smtpHost":
a.cfg.SMTPHost = value
cfg.SMTPHost = value
case "smtpPort":
a.cfg.SMTPPort = value
cfg.SMTPPort = value
case "smtpUsername":
a.cfg.SMTPUsername = value
cfg.SMTPUsername = value
case "smtpPassword":
a.cfg.SMTPPassword = value
cfg.SMTPPassword = value
case "smtpRequireTls":
a.cfg.SMTPRequireTLS = value == "true"
cfg.SMTPRequireTLS = value == "true"
case "maildirRoot":
a.cfg.MaildirRoot = value
cfg.MaildirRoot = value
case "maildirScanSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MaildirScanSeconds = n
cfg.MaildirScanSeconds = n
}
case "sessionTtlHours":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.SessionTTLHours = n
cfg.SessionTTLHours = n
}
case "allowInsecureHttp":
a.cfg.AllowInsecureHTTP = value == "true"
cfg.AllowInsecureHTTP = value == "true"
case "openRegistration":
a.cfg.OpenRegistration = value == "true"
cfg.OpenRegistration = value == "true"
case "twoFactorEnabled":
a.cfg.TwoFactorEnabled = value == "true"
cfg.TwoFactorEnabled = value == "true"
case "turnstileEnabled":
a.cfg.TurnstileEnabled = value == "true"
cfg.TurnstileEnabled = value == "true"
case "turnstileSiteKey":
a.cfg.TurnstileSiteKey = value
cfg.TurnstileSiteKey = value
case "turnstileSecretKey":
a.cfg.TurnstileSecretKey = value
cfg.TurnstileSecretKey = value
case "catchAllEnabled":
a.cfg.CatchAllEnabled = value == "true"
cfg.CatchAllEnabled = value == "true"
case "mailAutoRefresh":
a.cfg.MailAutoRefresh = value == "true"
cfg.MailAutoRefresh = value == "true"
case "mailRefreshSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MailRefreshSeconds = n
cfg.MailRefreshSeconds = n
}
case "userMailboxApplyEnabled":
a.cfg.UserMailboxApplyEnabled = value == "true"
cfg.UserMailboxApplyEnabled = value == "true"
case "userMailboxDomainIds":
a.cfg.UserMailboxDomainIDs = value
cfg.UserMailboxDomainIDs = value
case "reservedMailboxPrefixes":
a.cfg.ReservedMailboxPrefixes = value
cfg.ReservedMailboxPrefixes = value
case "externalImapEnabled":
a.cfg.ExternalIMAPEnabled = value == "true"
cfg.ExternalIMAPEnabled = value == "true"
case "externalImapSecretKey":
a.cfg.ExternalIMAPSecretKey = value
cfg.ExternalIMAPSecretKey = value
case "externalImapSyncSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.ExternalIMAPSyncSeconds = n
cfg.ExternalIMAPSyncSeconds = n
}
case "externalImapAllowPrivateHosts":
a.cfg.ExternalIMAPAllowPrivateHosts = value == "true"
cfg.ExternalIMAPAllowPrivateHosts = value == "true"
case "externalImapGmailClientId":
a.cfg.ExternalIMAPGmailClientID = value
cfg.ExternalIMAPGmailClientID = value
case "externalImapGmailClientSecret":
a.cfg.ExternalIMAPGmailClientSecret = value
cfg.ExternalIMAPGmailClientSecret = value
case "externalImapOutlookClientId":
a.cfg.ExternalIMAPOutlookClientID = value
cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret":
a.cfg.ExternalIMAPOutlookClientSecret = value
cfg.ExternalIMAPOutlookClientSecret = value
}
}
return rows.Err()
if err := rows.Err(); err != nil {
return err
}
a.setConfig(cfg)
return nil
}
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
+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 {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return nil
}
now := a.now().UTC()
@@ -39,7 +39,7 @@ func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey,
}
func (a *App) statusWebhookWorker(ctx context.Context) {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return
}
a.log.Info("status webhook worker started")
@@ -59,7 +59,7 @@ func (a *App) statusWebhookWorker(ctx context.Context) {
}
func (a *App) processDueStatusWebhooks(ctx context.Context) error {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return nil
}
_, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox
@@ -104,7 +104,7 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
return err
}
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
mac := hmac.New(sha256.New, []byte(a.cfg.StatusWebhookSecret))
mac := hmac.New(sha256.New, []byte(a.config().StatusWebhookSecret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
@@ -134,17 +134,17 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
}
func (a *App) validatedStatusWebhookURL(ctx context.Context) (*url.URL, error) {
if strings.TrimSpace(a.cfg.StatusWebhookSecret) == "" {
if strings.TrimSpace(a.config().StatusWebhookSecret) == "" {
return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required")
}
target, err := url.Parse(strings.TrimSpace(a.cfg.StatusWebhookURL))
target, err := url.Parse(strings.TrimSpace(a.config().StatusWebhookURL))
if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
return nil, errors.New("invalid status webhook URL")
}
if target.Scheme != "https" && !(a.cfg.StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
if target.Scheme != "https" && !(a.config().StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
return nil, errors.New("status webhook URL must use HTTPS")
}
if !a.cfg.StatusWebhookAllowPrivateHosts {
if !a.config().StatusWebhookAllowPrivateHosts {
if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
return nil, err
}
@@ -157,7 +157,7 @@ func (a *App) statusWebhookDialContext(ctx context.Context, network, address str
if err != nil {
return nil, err
}
if a.cfg.StatusWebhookAllowPrivateHosts {
if a.config().StatusWebhookAllowPrivateHosts {
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
+4 -4
View File
@@ -49,8 +49,8 @@ func (s *SubmissionServers) Shutdown(ctx context.Context) error {
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
return &SubmissionServers{
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
Plain: a.newSubmissionServer(a.config().SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.config().SubmissionTLSAddr, tlsConfig),
}
}
@@ -61,11 +61,11 @@ func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserve
}
s := smtpserver.NewServer(submissionBackend{app: a})
s.Addr = addr
s.Domain = a.cfg.PublicHostname
s.Domain = a.config().PublicHostname
s.TLSConfig = tlsConfig
s.AllowInsecureAuth = false
s.MaxRecipients = defaultSubmissionMaxRecipients
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
s.MaxMessageBytes = int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
s.ReadTimeout = smtpSessionTimeout
s.WriteTimeout = smtpSessionTimeout
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
@@ -97,7 +97,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
}
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
current := strings.TrimSpace(a.cfg.AppVersion)
current := strings.TrimSpace(a.config().AppVersion)
if current == "" {
current = BuildVersion
}
@@ -124,7 +124,7 @@ func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
}
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
endpoint := strings.TrimSpace(a.cfg.ReleaseAPIURL)
endpoint := strings.TrimSpace(a.config().ReleaseAPIURL)
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return githubRelease{}, errors.New("invalid release API URL")
@@ -134,7 +134,7 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
return githubRelease{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.cfg.AppVersion, "v"))
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.config().AppVersion, "v"))
client := &http.Client{
Timeout: 8 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
@@ -161,11 +161,11 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
}
func (a *App) updateEnabled() bool {
return strings.TrimSpace(a.cfg.UpdateServiceURL) != "" && strings.TrimSpace(a.cfg.UpdateServiceToken) != ""
return strings.TrimSpace(a.config().UpdateServiceURL) != "" && strings.TrimSpace(a.config().UpdateServiceToken) != ""
}
func (a *App) triggerUpdateService(ctx context.Context) error {
parsed, err := url.Parse(strings.TrimSpace(a.cfg.UpdateServiceURL))
parsed, err := url.Parse(strings.TrimSpace(a.config().UpdateServiceURL))
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("invalid update service URL")
}
@@ -173,7 +173,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.cfg.UpdateServiceToken))
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
@@ -193,7 +193,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
}
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
backupDir := filepath.Join(a.cfg.DataDir, "backups")
backupDir := filepath.Join(a.config().DataDir, "backups")
if err := os.MkdirAll(backupDir, 0o700); err != nil {
return "", err
}
+2 -2
View File
@@ -17,11 +17,11 @@ type turnstileVerifyResponse struct {
}
func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error {
if !a.cfg.TurnstileEnabled {
if !a.config().TurnstileEnabled {
return nil
}
token = strings.TrimSpace(token)
secret := strings.TrimSpace(a.cfg.TurnstileSecretKey)
secret := strings.TrimSpace(a.config().TurnstileSecretKey)
if secret == "" || token == "" {
return errors.New("turnstile verification required")
}
+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) {
if !a.cfg.TwoFactorEnabled {
if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return
}
@@ -179,7 +179,7 @@ func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
if !a.cfg.TwoFactorEnabled {
if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return
}
+3 -8
View File
@@ -26,27 +26,22 @@
"@radix-ui/react-tooltip": "^1.2.9",
"@tanstack/react-query": "5.59.16",
"@tiptap/core": "^3.27.0",
"@tiptap/extension-color": "^3.27.0",
"@tiptap/extension-font-family": "^3.27.0",
"@tiptap/extension-highlight": "^3.27.0",
"@tiptap/extension-image": "^3.27.0",
"@tiptap/extension-link": "^3.27.0",
"@tiptap/extension-placeholder": "^3.27.0",
"@tiptap/extension-text-align": "^3.27.0",
"@tiptap/extension-text-style": "^3.27.0",
"@tiptap/extension-underline": "^3.27.0",
"@tiptap/pm": "^3.27.0",
"@tiptap/react": "^3.27.0",
"@tiptap/starter-kit": "^3.27.0",
"class-variance-authority": "^0.7.0",
"clsx": "2.1.1",
"dompurify": "3.4.10",
"dompurify": "3.4.12",
"lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "6.30.4",
"react-router-dom": "7.18.2",
"tailwind-merge": "2.5.4"
},
"devDependencies": {
@@ -55,7 +50,7 @@
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
"autoprefixer": "10.4.20",
"postcss": "8.5.15",
"postcss": "8.5.25",
"tailwindcss": "3.4.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.6.3",
@@ -1,4 +1,3 @@
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-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"
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 { ProtectedLayout } from "@/components/protected-layout"
import { AdminOnly } from "@/components/admin-only"
import { LoginPage } from "@/pages/login"
import { RegisterPage } from "@/pages/register"
import { MailPage } from "@/pages/mail"
import { AdminPage } from "@/pages/admin"
import { ProfilePage } from "@/pages/profile"
import { NotFoundPage } from "@/pages/not-found"
import "./index.css"
const LoginPage = React.lazy(() => import("@/pages/login").then((module) => ({ default: module.LoginPage })))
const RegisterPage = React.lazy(() => import("@/pages/register").then((module) => ({ default: module.RegisterPage })))
const MailPage = React.lazy(() => import("@/pages/mail").then((module) => ({ default: module.MailPage })))
const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ default: module.AdminPage })))
const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
@@ -31,7 +32,9 @@ const router = createBrowserRouter([
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<React.Suspense fallback={<div className="grid h-svh place-items-center text-sm text-muted-foreground">...</div>}>
<RouterProvider router={router} />
</React.Suspense>
<Toaster />
<LanguageDomSync />
</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 defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
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(() => {
if (dialogOpen) {
setPermissions(group?.permissions || [])
setLimits(group?.limits || defaultPermissionLimits)
setLimits(group?.limits || defaultLimits)
}
}, [dialogOpen, group])
}, [defaultLimits, dialogOpen, group])
const mutation = useMutation({
mutationFn: (form: FormData) => {
const payload = {
@@ -1979,11 +1979,6 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
}
const dnsDescriptions: Record<string, string> = {
MX: "指定收件服务器。把邮件投递到该地址指向的服务器。",
TXT: "", // 具体含义根据内容区分
}
function dnsDescription(record: DNSRecord): string {
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
+3 -21
View File
@@ -11,8 +11,8 @@ import TextAlign from "@tiptap/extension-text-align"
import Placeholder from "@tiptap/extension-placeholder"
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
import { useNavigate } from "react-router-dom"
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { useDisplayMode } from "@/lib/display-mode"
@@ -140,7 +140,7 @@ export function MailPage() {
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
const [exportingMail, setExportingMail] = React.useState(false)
const [importingMail, setImportingMail] = React.useState(false)
const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState<Date | null>(null)
const [, setLastAutoRefreshAt] = React.useState<Date | null>(null)
const [bulkPending, setBulkPending] = React.useState(false)
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
@@ -4728,24 +4728,6 @@ function toDateTimeLocalValue(date: Date) {
function normalizeSchedule(schedule: ScheduleDraft): ScheduleDraft {
return { ...schedule, title: schedule.title.trim(), location: schedule.location.trim(), description: schedule.description.trim() }
}
function scheduleToHtml(schedule: ScheduleDraft) {
const start = parseScheduleStart(schedule)
const end = schedule.allDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : new Date(start.getTime() + schedule.durationMinutes * 60 * 1000)
const rows = [
["时间", schedule.allDay ? formatDate(start.toISOString()) : `${formatDateTime(start.toISOString())} - ${formatTimeOnly(end)}`],
["持续", schedule.allDay ? "全天" : durationLabel(schedule.durationMinutes)],
["提醒", reminderLabel(schedule.reminderMinutes)],
["重复", repeatLabel(schedule.repeat)],
schedule.location ? ["位置", schedule.location] : undefined,
schedule.description ? ["描述", schedule.description] : undefined,
].filter(Boolean) as string[][]
return DOMPurify.sanitize(`
<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) {
const ics = scheduleToIcs(schedule)
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,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
+63 -105
View File
@@ -50,15 +50,6 @@ importers:
'@tiptap/core':
specifier: ^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':
specifier: ^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':
specifier: ^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':
specifier: ^3.27.0
version: 3.27.0
@@ -93,8 +81,8 @@ importers:
specifier: 2.1.1
version: 2.1.1
dompurify:
specifier: 3.4.10
version: 3.4.10
specifier: 3.4.12
version: 3.4.12
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@18.3.1)
@@ -107,12 +95,9 @@ importers:
react-dom:
specifier: 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:
specifier: 6.30.4
version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: 7.18.2
version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge:
specifier: 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))
autoprefixer:
specifier: 10.4.20
version: 10.4.20(postcss@8.5.15)
version: 10.4.20(postcss@8.5.25)
postcss:
specifier: 8.5.15
version: 8.5.15
specifier: 8.5.25
version: 8.5.25
tailwindcss:
specifier: 3.4.15
version: 3.4.15
@@ -642,10 +627,6 @@ packages:
'@radix-ui/rect@1.1.2':
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':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -789,11 +770,6 @@ packages:
peerDependencies:
'@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':
resolution: {integrity: sha512-xE+rUAPAA+65Usxbn5OoPVh0I0FSPz5dYprj+uo1mogPgqpcPLGVNMMoRLZ4WdiZ3I451d5+U7CUynIjD/iikw==}
peerDependencies:
@@ -811,11 +787,6 @@ packages:
'@tiptap/core': 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':
resolution: {integrity: sha512-tHIUQmtebBytVpd2f5oCUMAivdN5Yj8zRDpkA5uT3x38s9OdXLjLLYZHDD7b0ANQnN0r1vkxPXi9YF6+2XWZvA==}
peerDependencies:
@@ -831,11 +802,6 @@ packages:
peerDependencies:
'@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':
resolution: {integrity: sha512-04Xga9CqIqzKb1cqDk9AV9pTbtleqF+o8X3bb3n7HDgplYHxLjHId6RCzhfSZU6U8VZZF/RJQ1jbTebeYqGSBw==}
peerDependencies:
@@ -1031,6 +997,10 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -1052,8 +1022,8 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dompurify@3.4.10:
resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==}
dompurify@3.4.12:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
electron-to-chromium@1.5.375:
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
@@ -1252,8 +1222,8 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -1339,8 +1309,8 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
postcss@8.5.25:
resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==}
engines: {node: ^10 || ^12 || >=14}
prosemirror-changeset@2.4.1:
@@ -1415,24 +1385,22 @@ packages:
'@types/react':
optional: true
react-resizable-panels@2.1.9:
resolution: {integrity: sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==}
react-router-dom@7.18.2:
resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react: '>=18'
react-dom: '>=18'
react-router-dom@6.30.4:
resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==}
engines: {node: '>=14.0.0'}
react-router@7.18.2:
resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.30.4:
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
@@ -1478,6 +1446,9 @@ packages:
scheduler@0.23.2:
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:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2123,8 +2094,6 @@ snapshots:
'@radix-ui/rect@1.1.2': {}
'@remix-run/router@1.23.3': {}
'@rolldown/binding-android-arm64@1.0.3':
optional: true
@@ -2215,10 +2184,6 @@ snapshots:
dependencies:
'@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))':
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
@@ -2234,10 +2199,6 @@ snapshots:
'@tiptap/pm': 3.27.0
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))':
dependencies:
'@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:
'@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)':
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
@@ -2426,14 +2383,14 @@ snapshots:
dependencies:
tslib: 2.8.1
autoprefixer@10.4.20(postcss@8.5.15):
autoprefixer@10.4.20(postcss@8.5.25):
dependencies:
browserslist: 4.28.2
caniuse-lite: 1.0.30001799
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
postcss: 8.5.15
postcss: 8.5.25
postcss-value-parser: 4.2.0
baseline-browser-mapping@2.10.37: {}
@@ -2476,6 +2433,8 @@ snapshots:
commander@4.1.1: {}
cookie@1.1.1: {}
cssesc@3.0.0: {}
csstype@3.2.3: {}
@@ -2488,7 +2447,7 @@ snapshots:
dlv@1.1.3: {}
dompurify@3.4.10:
dompurify@3.4.12:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -2639,7 +2598,7 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
nanoid@3.3.12: {}
nanoid@3.3.16: {}
node-releases@2.0.47: {}
@@ -2665,28 +2624,28 @@ snapshots:
pirates@4.0.7: {}
postcss-import@15.1.0(postcss@8.5.15):
postcss-import@15.1.0(postcss@8.5.25):
dependencies:
postcss: 8.5.15
postcss: 8.5.25
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.12
postcss-js@4.1.0(postcss@8.5.15):
postcss-js@4.1.0(postcss@8.5.25):
dependencies:
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:
lilconfig: 3.1.3
yaml: 2.9.0
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:
postcss: 8.5.15
postcss: 8.5.25
postcss-selector-parser: 6.1.4
postcss-selector-parser@6.1.4:
@@ -2696,9 +2655,9 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.5.15:
postcss@8.5.25:
dependencies:
nanoid: 3.3.12
nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -2807,22 +2766,19 @@ snapshots:
optionalDependencies:
'@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:
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:
'@remix-run/router': 1.23.3
cookie: 1.1.1
react: 18.3.1
set-cookie-parser: 2.7.2
optionalDependencies:
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):
dependencies:
@@ -2884,6 +2840,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
set-cookie-parser@2.7.2: {}
source-map-js@1.2.1: {}
sucrase@3.35.1:
@@ -2920,11 +2878,11 @@ snapshots:
normalize-path: 3.0.0
object-hash: 3.0.0
picocolors: 1.1.1
postcss: 8.5.15
postcss-import: 15.1.0(postcss@8.5.15)
postcss-js: 4.1.0(postcss@8.5.15)
postcss-load-config: 4.0.2(postcss@8.5.15)
postcss-nested: 6.2.0(postcss@8.5.15)
postcss: 8.5.25
postcss-import: 15.1.0(postcss@8.5.25)
postcss-js: 4.1.0(postcss@8.5.25)
postcss-load-config: 4.0.2(postcss@8.5.25)
postcss-nested: 6.2.0(postcss@8.5.25)
postcss-selector-parser: 6.1.4
resolve: 1.22.12
sucrase: 3.35.1
@@ -2987,7 +2945,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
postcss: 8.5.25
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies: