fix: harden runtime and remove placeholder features
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user