From 707f11687d24a86ee7090f783a133fe0cc7389f5 Mon Sep 17 00:00:00 2001 From: LanQin Date: Thu, 25 Jun 2026 22:44:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(external-imap):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=A4=96=E9=83=A8=20IMAP=20=E5=85=A8=E5=B1=80=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E4=B8=8E=E5=90=8E=E5=8F=B0=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持在系统设置中启用/禁用外部 IMAP,并持久化相关密钥与 OAuth 配置。 - 在公共设置和前端页面中联动开关状态,未启用时隐藏外部 IMAP 相关功能并拦截后端接口。 - 补充外部 IMAP 默认关闭、配置说明与回调地址文档。 --- README.md | 7 +- apps/api/internal/app/app_test.go | 127 ++++++---- apps/api/internal/app/config.go | 2 + apps/api/internal/app/external_imap.go | 13 + apps/api/internal/app/router_auth.go | 32 +-- apps/api/internal/app/settings_handlers.go | 264 +++++++++++++-------- apps/web/src/lib/api-types.ts | 12 +- apps/web/src/pages/admin.tsx | 66 +++++- apps/web/src/pages/mail.tsx | 22 +- apps/web/src/pages/profile.tsx | 14 +- deploy/.env.example | 13 +- deploy/README.md | 2 +- 12 files changed, 394 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index 4b708b0..2fe352c 100644 --- a/README.md +++ b/README.md @@ -154,9 +154,10 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build | `LANQIN_MAILDIR_ROOT` | Maildir 根目录 | `/var/mail/vhosts` | | `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` | | `LANQIN_USER_MAILBOX_APPLY_ENABLED` | 是否允许用户自助申请邮箱 | `false` | -| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置 | 随机长字符串 | -| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔 | `300` | -| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机 | `false` | +| `LANQIN_EXTERNAL_IMAP_ENABLED` | 是否启用外部 IMAP 接入;也可在后台“系统设置 > 外部 IMAP”配置 | `false` | +| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置;也可在后台配置 | 随机长字符串 | +| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔;也可在后台配置 | `300` | +| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机;也可在后台配置 | `false` | | `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/gmail/callback` | 空 | | `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 | diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 38a3997..d6b684f 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -308,28 +308,36 @@ func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, per func systemSettingsPayload(settings SystemSettings) map[string]any { return map[string]any{ - "publicHostname": settings.PublicHostname, - "publicBaseUrl": settings.PublicBaseURL, - "smtpHost": settings.SMTPHost, - "smtpPort": settings.SMTPPort, - "smtpUsername": settings.SMTPUsername, - "smtpPassword": "", - "smtpRequireTls": settings.SMTPRequireTLS, - "maildirRoot": settings.MaildirRoot, - "maildirScanSeconds": settings.MaildirScanSeconds, - "sessionTtlHours": settings.SessionTTLHours, - "allowInsecureHttp": settings.AllowInsecureHTTP, - "openRegistration": settings.OpenRegistration, - "twoFactorEnabled": settings.TwoFactorEnabled, - "turnstileEnabled": settings.TurnstileEnabled, - "turnstileSiteKey": settings.TurnstileSiteKey, - "turnstileSecretKey": "", - "catchAllEnabled": settings.CatchAllEnabled, - "mailAutoRefresh": settings.MailAutoRefresh, - "mailRefreshSeconds": settings.MailRefreshSeconds, - "userMailboxApplyEnabled": settings.UserMailboxApplyEnabled, - "userMailboxDomainIds": settings.UserMailboxDomainIDs, - "reservedMailboxPrefixes": settings.ReservedMailboxPrefixes, + "publicHostname": settings.PublicHostname, + "publicBaseUrl": settings.PublicBaseURL, + "smtpHost": settings.SMTPHost, + "smtpPort": settings.SMTPPort, + "smtpUsername": settings.SMTPUsername, + "smtpPassword": "", + "smtpRequireTls": settings.SMTPRequireTLS, + "maildirRoot": settings.MaildirRoot, + "maildirScanSeconds": settings.MaildirScanSeconds, + "sessionTtlHours": settings.SessionTTLHours, + "allowInsecureHttp": settings.AllowInsecureHTTP, + "openRegistration": settings.OpenRegistration, + "twoFactorEnabled": settings.TwoFactorEnabled, + "turnstileEnabled": settings.TurnstileEnabled, + "turnstileSiteKey": settings.TurnstileSiteKey, + "turnstileSecretKey": "", + "catchAllEnabled": settings.CatchAllEnabled, + "mailAutoRefresh": settings.MailAutoRefresh, + "mailRefreshSeconds": settings.MailRefreshSeconds, + "userMailboxApplyEnabled": settings.UserMailboxApplyEnabled, + "userMailboxDomainIds": settings.UserMailboxDomainIDs, + "reservedMailboxPrefixes": settings.ReservedMailboxPrefixes, + "externalImapEnabled": settings.ExternalIMAPEnabled, + "externalImapSecretKey": "", + "externalImapSyncSeconds": settings.ExternalIMAPSyncSeconds, + "externalImapAllowPrivateHosts": settings.ExternalIMAPAllowPrivateHosts, + "externalImapGmailClientId": settings.ExternalIMAPGmailClientID, + "externalImapGmailClientSecret": "", + "externalImapOutlookClientId": settings.ExternalIMAPOutlookClientID, + "externalImapOutlookClientSecret": "", } } @@ -450,6 +458,7 @@ func TestExternalIMAPAccountEncryptsPasswordAndDoesNotReturnSecret(t *testing.T) PublicHostname: "mail.example.test", PublicBaseURL: "http://localhost:5173", AllowInsecureHTTP: true, + ExternalIMAPEnabled: true, ExternalIMAPSecretKey: "test-secret", ExternalIMAPAllowPrivateHosts: true, }) @@ -494,8 +503,57 @@ func TestExternalIMAPAccountEncryptsPasswordAndDoesNotReturnSecret(t *testing.T) } } +func TestExternalIMAPDisabledByDefaultAndAdminSettings(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK { + t.Fatalf("login code=%d", code) + } + _, mb := defaultAdminUserAndMailbox(t, a) + payload := map[string]any{"mailboxId": mb.ID, "name": "Disabled", "host": "imap.example.com", "port": 993, "tlsMode": "tls", "username": "user@example.com", "password": "secret", "storageMode": "remote"} + var body map[string]any + if code := admin.do("POST", "/api/me/external-imap-accounts", payload, &body); code != http.StatusForbidden { + t.Fatalf("external imap should be disabled by default code=%d body=%v", code, body) + } + var public PublicSettings + if code := admin.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || public.ExternalIMAPEnabled { + t.Fatalf("public settings should expose disabled external imap code=%d settings=%+v", code, public) + } + var settings SystemSettings + if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK { + t.Fatalf("get settings code=%d", code) + } + update := systemSettingsPayload(settings) + update["externalImapEnabled"] = true + if code := admin.do("POST", "/api/admin/settings", update, &body); code != http.StatusBadRequest { + t.Fatalf("enable without secret should fail code=%d body=%v", code, body) + } + update["externalImapSecretKey"] = "test-secret" + update["externalImapSyncSeconds"] = 120 + update["externalImapAllowPrivateHosts"] = true + update["externalImapGmailClientId"] = "gmail-client" + update["externalImapGmailClientSecret"] = "gmail-secret" + update["externalImapOutlookClientId"] = "outlook-client" + update["externalImapOutlookClientSecret"] = "outlook-secret" + if code := admin.do("POST", "/api/admin/settings", update, &settings); code != http.StatusOK || !settings.ExternalIMAPEnabled || !settings.ExternalIMAPSecretSet || settings.ExternalIMAPSyncSeconds != 120 || !settings.ExternalIMAPAllowPrivateHosts || !settings.ExternalIMAPGmailClientSecretSet || !settings.ExternalIMAPOutlookClientSecretSet { + t.Fatalf("enable external imap code=%d settings=%+v", code, settings) + } + 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" { + t.Fatalf("secret settings not persisted in config") + } + if code := admin.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || !public.ExternalIMAPEnabled { + t.Fatalf("public settings should expose enabled external imap code=%d settings=%+v", code, public) + } +} + func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) { a := newTestApp(t) + a.cfg.ExternalIMAPEnabled = true a.cfg.ExternalIMAPSecretKey = "test-secret" ts := httptest.NewServer(a.Router()) defer ts.Close() @@ -524,6 +582,7 @@ func TestExternalIMAPOAuthStateDoesNotDefaultToLocalMailbox(t *testing.T) { PublicHostname: "mail.example.test", PublicBaseURL: "http://localhost:5173", AllowInsecureHTTP: true, + ExternalIMAPEnabled: true, ExternalIMAPSecretKey: "test-secret", ExternalIMAPOutlookClientID: "client-id", ExternalIMAPOutlookClientSecret: "client-secret", @@ -635,6 +694,7 @@ func TestExternalIMAPAccountOwnershipIsolation(t *testing.T) { PublicHostname: "mail.example.test", PublicBaseURL: "http://localhost:5173", AllowInsecureHTTP: true, + ExternalIMAPEnabled: true, ExternalIMAPSecretKey: "test-secret", ExternalIMAPAllowPrivateHosts: true, }) @@ -1457,27 +1517,8 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) { if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK { t.Fatalf("get settings code=%d", code) } - update := map[string]any{ - "publicHostname": settings.PublicHostname, - "publicBaseUrl": settings.PublicBaseURL, - "smtpHost": settings.SMTPHost, - "smtpPort": settings.SMTPPort, - "smtpUsername": settings.SMTPUsername, - "smtpPassword": "", - "smtpRequireTls": settings.SMTPRequireTLS, - "maildirRoot": settings.MaildirRoot, - "maildirScanSeconds": settings.MaildirScanSeconds, - "sessionTtlHours": settings.SessionTTLHours, - "allowInsecureHttp": settings.AllowInsecureHTTP, - "openRegistration": settings.OpenRegistration, - "twoFactorEnabled": settings.TwoFactorEnabled, - "turnstileEnabled": settings.TurnstileEnabled, - "turnstileSiteKey": settings.TurnstileSiteKey, - "turnstileSecretKey": "", - "catchAllEnabled": true, - "mailAutoRefresh": settings.MailAutoRefresh, - "mailRefreshSeconds": settings.MailRefreshSeconds, - } + update := systemSettingsPayload(settings) + update["catchAllEnabled"] = true if code := admin.do("POST", "/api/admin/settings", update, &settings); code != http.StatusOK || !settings.CatchAllEnabled { t.Fatalf("enable catch-all code=%d settings=%+v", code, settings) } diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index a1f38e6..04b6c0a 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -41,6 +41,7 @@ type Config struct { UserMailboxApplyEnabled bool UserMailboxDomainIDs string ReservedMailboxPrefixes string + ExternalIMAPEnabled bool ExternalIMAPSecretKey string ExternalIMAPSyncSeconds int ExternalIMAPAllowPrivateHosts bool @@ -86,6 +87,7 @@ func LoadConfig() Config { UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false), UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""), ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"), + ExternalIMAPEnabled: getenvBool("LANQIN_EXTERNAL_IMAP_ENABLED", false), ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""), ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300), ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false), diff --git a/apps/api/internal/app/external_imap.go b/apps/api/internal/app/external_imap.go index 112505d..11a9cac 100644 --- a/apps/api/internal/app/external_imap.go +++ b/apps/api/internal/app/external_imap.go @@ -148,6 +148,9 @@ func (a *App) externalIMAPWorker(ctx context.Context) { } func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) { + if !a.cfg.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) if err != nil { a.log.Warn("failed to list external imap accounts", "error", err) @@ -165,6 +168,16 @@ 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 { + respondError(w, http.StatusForbidden, "external imap is disabled") + return + } + next.ServeHTTP(w, r) + }) +} + func (a *App) handleListExternalIMAPAccounts(w http.ResponseWriter, r *http.Request) { user := currentUser(r) mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 118b8ec..1b78d65 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -59,16 +59,16 @@ func (a *App) Router() http.Handler { r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender) r.With(a.requireAuth, a.requirePermission(PermissionMailStats)).Get("/me/stats", a.handleMailStats) r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup) - r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Get("/me/external-imap-accounts", a.handleListExternalIMAPAccounts) - r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts", a.handleCreateExternalIMAPAccount) - r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}", a.handleUpdateExternalIMAPAccount) - r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Delete("/me/external-imap-accounts/{id}", a.handleDeleteExternalIMAPAccount) - r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/test", a.handleTestExternalIMAPAccount) - r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Get("/me/external-imap-accounts/{id}/runs", a.handleExternalIMAPSyncRuns) - r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/sync", a.handleSyncExternalIMAPAccount) - r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/sync-folder", a.handleSyncExternalIMAPFolder) - r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-oauth/{provider}/start", a.handleStartExternalIMAPOAuth) - r.Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Get("/me/external-imap-accounts", a.handleListExternalIMAPAccounts) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts", a.handleCreateExternalIMAPAccount) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}", a.handleUpdateExternalIMAPAccount) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Delete("/me/external-imap-accounts/{id}", a.handleDeleteExternalIMAPAccount) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/test", a.handleTestExternalIMAPAccount) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Get("/me/external-imap-accounts/{id}/runs", a.handleExternalIMAPSyncRuns) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/sync", a.handleSyncExternalIMAPAccount) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/sync-folder", a.handleSyncExternalIMAPFolder) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-oauth/{provider}/start", a.handleStartExternalIMAPOAuth) + r.With(a.requireExternalIMAPEnabled).Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback) r.With(a.requireAuth).Get("/events", a.handleEvents) r.Group(func(r chi.Router) { @@ -84,12 +84,12 @@ func (a *App) Router() http.Handler { r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage) - r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts", a.handleMailExternalAccounts) - r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/folders", a.handleExternalIMAPFolders) - r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/messages", a.handleExternalIMAPMessages) - r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/messages/{remoteId}", a.handleExternalIMAPMessage) - r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/external-accounts/{id}/attachments/{remoteId}/{partId}", a.handleExternalIMAPAttachment) - r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/external-accounts/{id}/messages/{remoteId}/mark-read", a.handleExternalIMAPMarkRead) + r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts", a.handleMailExternalAccounts) + r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/folders", a.handleExternalIMAPFolders) + r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages", a.handleExternalIMAPMessages) + r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages/{remoteId}", a.handleExternalIMAPMessage) + r.With(a.requirePermission(PermissionMailAttachments), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/attachments/{remoteId}/{partId}", a.handleExternalIMAPAttachment) + r.With(a.requirePermission(PermissionMailOrganize), a.requireExternalIMAPEnabled).Post("/mail/external-accounts/{id}/messages/{remoteId}/mark-read", a.handleExternalIMAPMarkRead) r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit) diff --git a/apps/api/internal/app/settings_handlers.go b/apps/api/internal/app/settings_handlers.go index 15eac8a..6b50222 100644 --- a/apps/api/internal/app/settings_handlers.go +++ b/apps/api/internal/app/settings_handlers.go @@ -10,63 +10,80 @@ import ( ) type SystemSettings struct { - PublicHostname string `json:"publicHostname"` - PublicBaseURL string `json:"publicBaseUrl"` - SMTPHost string `json:"smtpHost"` - SMTPPort string `json:"smtpPort"` - SMTPUsername string `json:"smtpUsername"` - SMTPPasswordSet bool `json:"smtpPasswordSet"` - SMTPRequireTLS bool `json:"smtpRequireTls"` - MaildirRoot string `json:"maildirRoot"` - MaildirScanSeconds int `json:"maildirScanSeconds"` - SessionTTLHours int `json:"sessionTtlHours"` - AllowInsecureHTTP bool `json:"allowInsecureHttp"` - OpenRegistration bool `json:"openRegistration"` - TwoFactorEnabled bool `json:"twoFactorEnabled"` - TurnstileEnabled bool `json:"turnstileEnabled"` - TurnstileSiteKey string `json:"turnstileSiteKey"` - TurnstileSecretSet bool `json:"turnstileSecretSet"` - CatchAllEnabled bool `json:"catchAllEnabled"` - MailAutoRefresh bool `json:"mailAutoRefresh"` - MailRefreshSeconds int `json:"mailRefreshSeconds"` - UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"` - UserMailboxDomainIDs []string `json:"userMailboxDomainIds"` - ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"` + PublicHostname string `json:"publicHostname"` + PublicBaseURL string `json:"publicBaseUrl"` + SMTPHost string `json:"smtpHost"` + SMTPPort string `json:"smtpPort"` + SMTPUsername string `json:"smtpUsername"` + SMTPPasswordSet bool `json:"smtpPasswordSet"` + SMTPRequireTLS bool `json:"smtpRequireTls"` + MaildirRoot string `json:"maildirRoot"` + MaildirScanSeconds int `json:"maildirScanSeconds"` + SessionTTLHours int `json:"sessionTtlHours"` + AllowInsecureHTTP bool `json:"allowInsecureHttp"` + OpenRegistration bool `json:"openRegistration"` + TwoFactorEnabled bool `json:"twoFactorEnabled"` + TurnstileEnabled bool `json:"turnstileEnabled"` + TurnstileSiteKey string `json:"turnstileSiteKey"` + TurnstileSecretSet bool `json:"turnstileSecretSet"` + CatchAllEnabled bool `json:"catchAllEnabled"` + MailAutoRefresh bool `json:"mailAutoRefresh"` + MailRefreshSeconds int `json:"mailRefreshSeconds"` + UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"` + UserMailboxDomainIDs []string `json:"userMailboxDomainIds"` + ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"` + ExternalIMAPEnabled bool `json:"externalImapEnabled"` + ExternalIMAPSecretSet bool `json:"externalImapSecretSet"` + ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"` + ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"` + ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"` + ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"` + ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"` + ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"` } type systemSettingsUpdate struct { - PublicHostname string `json:"publicHostname"` - PublicBaseURL string `json:"publicBaseUrl"` - SMTPHost string `json:"smtpHost"` - SMTPPort string `json:"smtpPort"` - SMTPUsername string `json:"smtpUsername"` - SMTPPassword string `json:"smtpPassword"` - SMTPRequireTLS bool `json:"smtpRequireTls"` - MaildirRoot string `json:"maildirRoot"` - MaildirScanSeconds int `json:"maildirScanSeconds"` - SessionTTLHours int `json:"sessionTtlHours"` - AllowInsecureHTTP bool `json:"allowInsecureHttp"` - OpenRegistration bool `json:"openRegistration"` - TwoFactorEnabled bool `json:"twoFactorEnabled"` - TurnstileEnabled bool `json:"turnstileEnabled"` - TurnstileSiteKey string `json:"turnstileSiteKey"` - TurnstileSecretKey string `json:"turnstileSecretKey"` - CatchAllEnabled bool `json:"catchAllEnabled"` - MailAutoRefresh bool `json:"mailAutoRefresh"` - MailRefreshSeconds int `json:"mailRefreshSeconds"` - UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"` - UserMailboxDomainIDs []string `json:"userMailboxDomainIds"` - ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"` + PublicHostname string `json:"publicHostname"` + PublicBaseURL string `json:"publicBaseUrl"` + SMTPHost string `json:"smtpHost"` + SMTPPort string `json:"smtpPort"` + SMTPUsername string `json:"smtpUsername"` + SMTPPassword string `json:"smtpPassword"` + SMTPRequireTLS bool `json:"smtpRequireTls"` + MaildirRoot string `json:"maildirRoot"` + MaildirScanSeconds int `json:"maildirScanSeconds"` + SessionTTLHours int `json:"sessionTtlHours"` + AllowInsecureHTTP bool `json:"allowInsecureHttp"` + OpenRegistration bool `json:"openRegistration"` + TwoFactorEnabled bool `json:"twoFactorEnabled"` + TurnstileEnabled bool `json:"turnstileEnabled"` + TurnstileSiteKey string `json:"turnstileSiteKey"` + TurnstileSecretKey string `json:"turnstileSecretKey"` + CatchAllEnabled bool `json:"catchAllEnabled"` + MailAutoRefresh bool `json:"mailAutoRefresh"` + MailRefreshSeconds int `json:"mailRefreshSeconds"` + UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"` + UserMailboxDomainIDs []string `json:"userMailboxDomainIds"` + ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"` + ExternalIMAPEnabled bool `json:"externalImapEnabled"` + ExternalIMAPSecretKey string `json:"externalImapSecretKey"` + ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"` + ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"` + ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"` + ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"` + ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"` + ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"` } type PublicSettings struct { - OpenRegistration bool `json:"openRegistration"` - TurnstileEnabled bool `json:"turnstileEnabled"` - TurnstileSiteKey string `json:"turnstileSiteKey"` - PublicHostname string `json:"publicHostname"` - MailAutoRefresh bool `json:"mailAutoRefresh"` - MailRefreshMs int `json:"mailRefreshMs"` - MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"` + OpenRegistration bool `json:"openRegistration"` + TurnstileEnabled bool `json:"turnstileEnabled"` + TurnstileSiteKey string `json:"turnstileSiteKey"` + PublicHostname string `json:"publicHostname"` + MailAutoRefresh bool `json:"mailAutoRefresh"` + MailRefreshMs int `json:"mailRefreshMs"` + ExternalIMAPEnabled bool `json:"externalImapEnabled"` + MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"` } type PublicDomain struct { @@ -88,7 +105,7 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) { 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} + 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} // Include available domains for mailbox creation during registration if a.cfg.OpenRegistration { @@ -165,6 +182,27 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) next.UserMailboxApplyEnabled = req.UserMailboxApplyEnabled next.UserMailboxDomainIDs = strings.Join(cleanIDList(req.UserMailboxDomainIDs), ",") next.ReservedMailboxPrefixes = strings.Join(parseReservedPrefixes(req.ReservedMailboxPrefixes), ",") + next.ExternalIMAPEnabled = req.ExternalIMAPEnabled + if strings.TrimSpace(req.ExternalIMAPSecretKey) != "" { + next.ExternalIMAPSecretKey = strings.TrimSpace(req.ExternalIMAPSecretKey) + } + if req.ExternalIMAPSyncSeconds <= 0 { + req.ExternalIMAPSyncSeconds = 300 + } + next.ExternalIMAPSyncSeconds = req.ExternalIMAPSyncSeconds + next.ExternalIMAPAllowPrivateHosts = req.ExternalIMAPAllowPrivateHosts + next.ExternalIMAPGmailClientID = strings.TrimSpace(req.ExternalIMAPGmailClientID) + if strings.TrimSpace(req.ExternalIMAPGmailClientSecret) != "" { + next.ExternalIMAPGmailClientSecret = strings.TrimSpace(req.ExternalIMAPGmailClientSecret) + } + next.ExternalIMAPOutlookClientID = strings.TrimSpace(req.ExternalIMAPOutlookClientID) + if strings.TrimSpace(req.ExternalIMAPOutlookClientSecret) != "" { + next.ExternalIMAPOutlookClientSecret = strings.TrimSpace(req.ExternalIMAPOutlookClientSecret) + } + if next.ExternalIMAPEnabled && strings.TrimSpace(next.ExternalIMAPSecretKey) == "" { + badRequest(w, errors.New("外部 IMAP 加密密钥未设置")) + return + } if err := a.saveSystemSettings(r.Context(), next); err != nil { respondError(w, http.StatusInternalServerError, "failed to save settings") @@ -248,28 +286,36 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) { func (a *App) systemSettingsSnapshot() SystemSettings { 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"), + 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) != "", } } @@ -335,6 +381,24 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error { a.cfg.UserMailboxDomainIDs = value case "reservedMailboxPrefixes": a.cfg.ReservedMailboxPrefixes = value + case "externalImapEnabled": + a.cfg.ExternalIMAPEnabled = value == "true" + case "externalImapSecretKey": + a.cfg.ExternalIMAPSecretKey = value + case "externalImapSyncSeconds": + if n, err := strconv.Atoi(value); err == nil && n > 0 { + a.cfg.ExternalIMAPSyncSeconds = n + } + case "externalImapAllowPrivateHosts": + a.cfg.ExternalIMAPAllowPrivateHosts = value == "true" + case "externalImapGmailClientId": + a.cfg.ExternalIMAPGmailClientID = value + case "externalImapGmailClientSecret": + a.cfg.ExternalIMAPGmailClientSecret = value + case "externalImapOutlookClientId": + a.cfg.ExternalIMAPOutlookClientID = value + case "externalImapOutlookClientSecret": + a.cfg.ExternalIMAPOutlookClientSecret = value } } return rows.Err() @@ -342,28 +406,36 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error { func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error { values := map[string]string{ - "publicHostname": cfg.PublicHostname, - "publicBaseUrl": cfg.PublicBaseURL, - "smtpHost": cfg.SMTPHost, - "smtpPort": cfg.SMTPPort, - "smtpUsername": cfg.SMTPUsername, - "smtpPassword": cfg.SMTPPassword, - "smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS), - "maildirRoot": cfg.MaildirRoot, - "maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds), - "sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours), - "allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP), - "openRegistration": strconv.FormatBool(cfg.OpenRegistration), - "twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled), - "turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled), - "turnstileSiteKey": cfg.TurnstileSiteKey, - "turnstileSecretKey": cfg.TurnstileSecretKey, - "catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled), - "mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh), - "mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds), - "userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled), - "userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","), - "reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","), + "publicHostname": cfg.PublicHostname, + "publicBaseUrl": cfg.PublicBaseURL, + "smtpHost": cfg.SMTPHost, + "smtpPort": cfg.SMTPPort, + "smtpUsername": cfg.SMTPUsername, + "smtpPassword": cfg.SMTPPassword, + "smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS), + "maildirRoot": cfg.MaildirRoot, + "maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds), + "sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours), + "allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP), + "openRegistration": strconv.FormatBool(cfg.OpenRegistration), + "twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled), + "turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled), + "turnstileSiteKey": cfg.TurnstileSiteKey, + "turnstileSecretKey": cfg.TurnstileSecretKey, + "catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled), + "mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh), + "mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds), + "userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled), + "userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","), + "reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","), + "externalImapEnabled": strconv.FormatBool(cfg.ExternalIMAPEnabled), + "externalImapSecretKey": cfg.ExternalIMAPSecretKey, + "externalImapSyncSeconds": strconv.Itoa(cfg.ExternalIMAPSyncSeconds), + "externalImapAllowPrivateHosts": strconv.FormatBool(cfg.ExternalIMAPAllowPrivateHosts), + "externalImapGmailClientId": cfg.ExternalIMAPGmailClientID, + "externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret, + "externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID, + "externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret, } now := a.now().UTC().Format(time.RFC3339Nano) tx, err := a.db.BeginTx(ctx, nil) diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 34aee97..c878d7b 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -172,10 +172,18 @@ export type SystemSettings = { userMailboxApplyEnabled: boolean userMailboxDomainIds: string[] reservedMailboxPrefixes: string + externalImapEnabled: boolean + externalImapSecretSet: boolean + externalImapSyncSeconds: number + externalImapAllowPrivateHosts: boolean + externalImapGmailClientId: string + externalImapGmailClientSecretSet: boolean + externalImapOutlookClientId: string + externalImapOutlookClientSecretSet: boolean } -export type SystemSettingsPayload = Omit & { smtpPassword: string; turnstileSecretKey: string } +export type SystemSettingsPayload = Omit & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string } export type PublicDomain = { id: string; name: string } -export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; mailboxDomains?: PublicDomain[] } +export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] } export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string } export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string } export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string } diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index c6fc09d..f37664c 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -980,7 +980,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting const canUpdateTemplates = hasPermission(user, "admin.templates.update") const canResetTemplates = hasPermission(user, "admin.templates.reset") const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates }) - const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base") + const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about">("base") const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" }) const [smtpRequireTls, setSmtpRequireTls] = React.useState(false) const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true) @@ -991,6 +991,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true) const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(false) const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState([]) + const [externalImapEnabled, setExternalImapEnabled] = React.useState(false) + const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(false) React.useEffect(() => { if (!settings) return setSmtpRequireTls(settings.smtpRequireTls) @@ -1002,6 +1004,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting setMailAutoRefresh(settings.mailAutoRefresh) setUserMailboxApplyEnabled(settings.userMailboxApplyEnabled) setUserMailboxDomainIds(settings.userMailboxDomainIds || []) + setExternalImapEnabled(settings.externalImapEnabled) + setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts) }, [settings]) const save = useMutation({ mutationFn: (form: FormData) => api.updateSystemSettings({ @@ -1027,6 +1031,14 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting userMailboxApplyEnabled, userMailboxDomainIds, reservedMailboxPrefixes: fieldValue(form, "reservedMailboxPrefixes", settings?.reservedMailboxPrefixes || ""), + externalImapEnabled, + externalImapSecretKey: fieldValue(form, "externalImapSecretKey", ""), + externalImapSyncSeconds: fieldNumber(form, "externalImapSyncSeconds", settings?.externalImapSyncSeconds || 300), + externalImapAllowPrivateHosts, + externalImapGmailClientId: fieldValue(form, "externalImapGmailClientId", settings?.externalImapGmailClientId || ""), + externalImapGmailClientSecret: fieldValue(form, "externalImapGmailClientSecret", ""), + externalImapOutlookClientId: fieldValue(form, "externalImapOutlookClientId", settings?.externalImapOutlookClientId || ""), + externalImapOutlookClientSecret: fieldValue(form, "externalImapOutlookClientSecret", ""), }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "settings"] }) @@ -1060,6 +1072,14 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting settings.userMailboxApplyEnabled, (settings.userMailboxDomainIds || []).join(","), settings.reservedMailboxPrefixes, + settings.externalImapEnabled, + settings.externalImapSecretSet, + settings.externalImapSyncSeconds, + settings.externalImapAllowPrivateHosts, + settings.externalImapGmailClientId, + settings.externalImapGmailClientSecretSet, + settings.externalImapOutlookClientId, + settings.externalImapOutlookClientSecretSet, ].join("|") : "loading" const tabs: { key: typeof settingsTab; label: string }[] = [ ...(canSettingsView ? [ @@ -1067,6 +1087,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting { key: "smtp" as const, label: "SMTP" }, { key: "storage" as const, label: "存储" }, { key: "mail" as const, label: "邮件" }, + { key: "externalImap" as const, label: "外部 IMAP" }, ] : []), ...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []), ...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []), @@ -1175,6 +1196,49 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting } + {settingsTab === "externalImap" && + + 外部 IMAP 接入 + + +
+ 默认关闭。关闭后用户端会隐藏外部 IMAP 接入,相关后端接口也会返回禁用。 +
+ + {externalImapEnabled && ( +
+
+ + +
+ + +
+
+
Gmail OAuth2
+
回调地址:{(settings?.publicBaseUrl || "${LANQIN_PUBLIC_BASE_URL}").replace(/\/$/, "")}/api/external-imap-oauth/gmail/callback
+
+
+ + +
+
+ +
+
+
Microsoft 365 / Outlook OAuth2
+
回调地址:{(settings?.publicBaseUrl || "${LANQIN_PUBLIC_BASE_URL}").replace(/\/$/, "")}/api/external-imap-oauth/outlook/callback
+
+
+ + +
+
+
+ )} +
+
} + {settingsTab === "templates" && canViewTemplates && } {settingsTab === "security" && diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 6aff9c6..43e8358 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -138,12 +138,13 @@ export function MailPage() { const canManageLabels = hasPermission(user, "mail.labels.manage") const canDownloadAttachments = hasPermission(user, "mail.attachments.download") const canManageSignatures = hasPermission(user, "mail.signatures.manage") + const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) + const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail }) - const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail }) - const selectedExternalAccount = React.useMemo(() => externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId), [externalMailAccounts.data?.items, selectedExternalAccountId]) - const externalFolders = useQuery({ queryKey: ["mail-external-folders", selectedExternalAccountId], queryFn: () => api.externalFolders(selectedExternalAccountId), enabled: !!selectedExternalAccountId && canReadMail }) - const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) + const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail && externalImapEnabled }) + const selectedExternalAccount = React.useMemo(() => externalImapEnabled ? externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId) : undefined, [externalImapEnabled, externalMailAccounts.data?.items, selectedExternalAccountId]) + const externalFolders = useQuery({ queryKey: ["mail-external-folders", selectedExternalAccountId], queryFn: () => api.externalFolders(selectedExternalAccountId), enabled: !!selectedExternalAccountId && canReadMail && externalImapEnabled }) const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId]) const activeMailboxId = selectedMailbox?.id || "" const hasMailboxes = (mailboxList.data?.items.length || 0) > 0 @@ -160,6 +161,11 @@ export function MailPage() { }) const sendQueueAudit = useQuery({ queryKey: ["send-queue-audit", sendQueueAuditId], queryFn: () => api.sendQueueAudit(sendQueueAuditId), enabled: !!sendQueueAuditId && canViewSendQueue }) const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false + React.useEffect(() => { + if (externalImapEnabled) return + setSelectedExternalAccountId("") + if (mailView === "external") setMailView("folder") + }, [externalImapEnabled, mailView]) const inboxProbe = useQuery({ queryKey: ["mail-notifications", activeMailboxId], queryFn: () => api.messages("Inbox", "", "", activeMailboxId), @@ -184,9 +190,9 @@ export function MailPage() { queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : "", query), initialPageParam: "", getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, - enabled: !!selectedExternalAccountId && canReadMail && mailView === "external", + enabled: !!selectedExternalAccountId && canReadMail && mailView === "external" && externalImapEnabled, }) - const detail = useQuery({ queryKey: ["message", selectedId, mailView, selectedExternalAccountId], queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail && (mailView !== "external" || !!selectedExternalAccountId) }) + const detail = useQuery({ queryKey: ["message", selectedId, mailView, selectedExternalAccountId], queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail && (mailView !== "external" || (!!selectedExternalAccountId && externalImapEnabled)) }) function updateCachedMessage(id: string, patch: Partial) { qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current) qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData | undefined) => { @@ -573,8 +579,8 @@ export function MailPage() { const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length const visibleSendQueueItems = sendQueueItems const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue) - const externalAccountItems = externalMailAccounts.data?.items || [] - const externalFolderItems = externalFolders.data?.items || [] + const externalAccountItems = externalImapEnabled ? externalMailAccounts.data?.items || [] : [] + const externalFolderItems = externalImapEnabled ? externalFolders.data?.items || [] : [] const labelItems = labels.data?.items || [] const selectedLabel = labelItems.find((item) => item.id === selectedLabelId) const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 544e2b1..becd8cb 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -102,15 +102,16 @@ export function ProfilePage() { const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked }) const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId]) const activeMailboxId = selectedMailbox?.id || "" - const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail }) + const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false + const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail && externalImapEnabled }) React.useEffect(() => { if (!externalRunAccountId) return if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return setExternalRunAccountId("") }, [externalImapAccounts.data?.items, externalRunAccountId]) const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId) - const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail }) - const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail }) + const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled }) + const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled }) const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) }) const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats }) @@ -342,6 +343,7 @@ export function ProfilePage() { applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} + externalImapEnabled={externalImapEnabled} externalAccounts={externalImapAccounts.data?.items || []} externalPending={createExternalImap.isPending || updateExternalImap.isPending || deleteExternalImap.isPending || testExternalImap.isPending || syncExternalImap.isPending || syncExternalImapFolder.isPending || startExternalOAuth.isPending} selectedExternalRunAccountId={externalRunAccountId} @@ -559,6 +561,7 @@ function MailboxManagement({ applyOptions, applyPending, selectedMailboxId, + externalImapEnabled, externalAccounts, externalPending, selectedExternalRunAccountId, @@ -581,6 +584,7 @@ function MailboxManagement({ applyOptions?: MailboxApplyOptions applyPending: boolean selectedMailboxId: string + externalImapEnabled: boolean externalAccounts: ExternalImapAccount[] externalPending: boolean selectedExternalRunAccountId: string @@ -610,7 +614,7 @@ function MailboxManagement({ {mailboxes.map((m) =>
{m.address}
{selectedMailboxId === m.id && 当前}
)} {mailboxes.length === 0 && } - + {externalImapEnabled &&
@@ -655,7 +659,7 @@ function MailboxManagement({ ) })} - + }
) } diff --git a/deploy/.env.example b/deploy/.env.example index 032806d..28d8ac3 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -132,20 +132,23 @@ LANQIN_MAIL_AUTO_REFRESH=true # 自动刷新间隔,单位:秒。 LANQIN_MAIL_REFRESH_SECONDS=30 -# 外部 IMAP 密码加密密钥。启用外部 IMAP 接入前必须设置为足够长的随机字符串。 +# 是否启用外部 IMAP 接入。也可在后台“系统设置 > 外部 IMAP”里开启,默认关闭。 +LANQIN_EXTERNAL_IMAP_ENABLED=false + +# 外部 IMAP 密码加密密钥。启用外部 IMAP 接入前必须设置为足够长的随机字符串;也可在后台“系统设置 > 外部 IMAP”里配置。 LANQIN_EXTERNAL_IMAP_SECRET_KEY= -# 外部 IMAP 本地存储模式的后台同步间隔,单位:秒。 +# 外部 IMAP 本地存储模式的后台同步间隔,单位:秒;也可在后台配置。 LANQIN_EXTERNAL_IMAP_SYNC_SECONDS=300 -# 是否允许用户配置 localhost / 内网 / link-local IMAP 主机。默认 false,避免 SSRF 风险。 +# 是否允许用户配置 localhost / 内网 / link-local IMAP 主机。默认 false,避免 SSRF 风险;也可在后台配置。 LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS=false -# Gmail 外部 IMAP OAuth2。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/gmail/callback +# Gmail 外部 IMAP OAuth2。也可在后台配置。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/gmail/callback LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID= LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET= -# Microsoft 365 / Outlook 外部 IMAP OAuth2。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/outlook/callback +# Microsoft 365 / Outlook 外部 IMAP OAuth2。也可在后台配置。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/outlook/callback LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID= LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET= diff --git a/deploy/README.md b/deploy/README.md index 2a4772b..34c3abf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -129,7 +129,7 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up - Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。 - Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。 - 第三方客户端可通过 LanQin API 提供的 SMTP `465/587` 发信;Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重。 -- 用户可在个人邮箱管理中接入外部 IMAP 账号;本地存储模式会同步到 LanQin,远端直连模式每次从远端读取。启用前必须配置 `LANQIN_EXTERNAL_IMAP_SECRET_KEY`,默认不允许连接 localhost / 内网 / link-local IMAP 主机。Gmail / Microsoft 365 / Outlook OAuth2 需要在对应控制台配置回调地址:`/api/external-imap-oauth/gmail/callback` 或 `/api/external-imap-oauth/outlook/callback`。 +- 用户可在个人邮箱管理中接入外部 IMAP 账号;默认关闭,可在后台“系统设置 > 外部 IMAP”开启并配置密钥/OAuth。本地存储模式会同步到 LanQin,远端直连模式每次从远端读取。启用前必须配置外部 IMAP 密码加密密钥,默认不允许连接 localhost / 内网 / link-local IMAP 主机。Gmail / Microsoft 365 / Outlook OAuth2 需要在对应控制台配置回调地址:`/api/external-imap-oauth/gmail/callback` 或 `/api/external-imap-oauth/outlook/callback`。 - send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。 ## 邮件客户端 TLS 证书