feat(external-imap): 新增外部 IMAP 全局开关与后台配置

- 支持在系统设置中启用/禁用外部 IMAP,并持久化相关密钥与 OAuth 配置。
- 在公共设置和前端页面中联动开关状态,未启用时隐藏外部 IMAP 相关功能并拦截后端接口。
- 补充外部 IMAP 默认关闭、配置说明与回调地址文档。
This commit is contained in:
LanQin
2026-06-25 22:44:32 +08:00
parent cc75382869
commit 707f11687d
12 changed files with 394 additions and 180 deletions
+4 -3
View File
@@ -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_MAILDIR_ROOT` | Maildir 根目录 | `/var/mail/vhosts` |
| `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` | | `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` |
| `LANQIN_USER_MAILBOX_APPLY_ENABLED` | 是否允许用户自助申请邮箱 | `false` | | `LANQIN_USER_MAILBOX_APPLY_ENABLED` | 是否允许用户自助申请邮箱 | `false` |
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置 | 随机长字符串 | | `LANQIN_EXTERNAL_IMAP_ENABLED` | 是否启用外部 IMAP 接入;也可在后台“系统设置 > 外部 IMAP”配置 | `false` |
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔 | `300` | | `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置;也可在后台配置 | 随机长字符串 |
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机 | `false` | | `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_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` | 空 | | `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 |
+62 -21
View File
@@ -330,6 +330,14 @@ func systemSettingsPayload(settings SystemSettings) map[string]any {
"userMailboxApplyEnabled": settings.UserMailboxApplyEnabled, "userMailboxApplyEnabled": settings.UserMailboxApplyEnabled,
"userMailboxDomainIds": settings.UserMailboxDomainIDs, "userMailboxDomainIds": settings.UserMailboxDomainIDs,
"reservedMailboxPrefixes": settings.ReservedMailboxPrefixes, "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", PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173", PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true, AllowInsecureHTTP: true,
ExternalIMAPEnabled: true,
ExternalIMAPSecretKey: "test-secret", ExternalIMAPSecretKey: "test-secret",
ExternalIMAPAllowPrivateHosts: true, 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) { func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.ExternalIMAPEnabled = true
a.cfg.ExternalIMAPSecretKey = "test-secret" a.cfg.ExternalIMAPSecretKey = "test-secret"
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
@@ -524,6 +582,7 @@ func TestExternalIMAPOAuthStateDoesNotDefaultToLocalMailbox(t *testing.T) {
PublicHostname: "mail.example.test", PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173", PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true, AllowInsecureHTTP: true,
ExternalIMAPEnabled: true,
ExternalIMAPSecretKey: "test-secret", ExternalIMAPSecretKey: "test-secret",
ExternalIMAPOutlookClientID: "client-id", ExternalIMAPOutlookClientID: "client-id",
ExternalIMAPOutlookClientSecret: "client-secret", ExternalIMAPOutlookClientSecret: "client-secret",
@@ -635,6 +694,7 @@ func TestExternalIMAPAccountOwnershipIsolation(t *testing.T) {
PublicHostname: "mail.example.test", PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173", PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true, AllowInsecureHTTP: true,
ExternalIMAPEnabled: true,
ExternalIMAPSecretKey: "test-secret", ExternalIMAPSecretKey: "test-secret",
ExternalIMAPAllowPrivateHosts: true, ExternalIMAPAllowPrivateHosts: true,
}) })
@@ -1457,27 +1517,8 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) {
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK { if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code) t.Fatalf("get settings code=%d", code)
} }
update := map[string]any{ update := systemSettingsPayload(settings)
"publicHostname": settings.PublicHostname, update["catchAllEnabled"] = true
"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,
}
if code := admin.do("POST", "/api/admin/settings", update, &settings); code != http.StatusOK || !settings.CatchAllEnabled { 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) t.Fatalf("enable catch-all code=%d settings=%+v", code, settings)
} }
+2
View File
@@ -41,6 +41,7 @@ type Config struct {
UserMailboxApplyEnabled bool UserMailboxApplyEnabled bool
UserMailboxDomainIDs string UserMailboxDomainIDs string
ReservedMailboxPrefixes string ReservedMailboxPrefixes string
ExternalIMAPEnabled bool
ExternalIMAPSecretKey string ExternalIMAPSecretKey string
ExternalIMAPSyncSeconds int ExternalIMAPSyncSeconds int
ExternalIMAPAllowPrivateHosts bool ExternalIMAPAllowPrivateHosts bool
@@ -86,6 +87,7 @@ func LoadConfig() Config {
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false), UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""), 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"), 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", ""), ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""),
ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300), ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300),
ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false), ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false),
+13
View File
@@ -148,6 +148,9 @@ func (a *App) externalIMAPWorker(ctx context.Context) {
} }
func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) { func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
if !a.cfg.ExternalIMAPEnabled {
return
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM external_imap_accounts WHERE enabled=1 AND storage_mode=? ORDER BY COALESCE(last_sync_at, created_at) ASC LIMIT 10`, externalIMAPStorageLocal) rows, err := a.db.QueryContext(ctx, `SELECT id FROM external_imap_accounts WHERE enabled=1 AND storage_mode=? ORDER BY COALESCE(last_sync_at, created_at) ASC LIMIT 10`, externalIMAPStorageLocal)
if err != nil { if err != nil {
a.log.Warn("failed to list external imap accounts", "error", err) 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) { func (a *App) handleListExternalIMAPAccounts(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
+16 -16
View File
@@ -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(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(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup) 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), a.requireExternalIMAPEnabled).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), a.requireExternalIMAPEnabled).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), a.requireExternalIMAPEnabled).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), a.requireExternalIMAPEnabled).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), a.requireExternalIMAPEnabled).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), a.requireExternalIMAPEnabled).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), a.requireExternalIMAPEnabled).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), a.requireExternalIMAPEnabled).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.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-oauth/{provider}/start", a.handleStartExternalIMAPOAuth)
r.Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback) r.With(a.requireExternalIMAPEnabled).Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback)
r.With(a.requireAuth).Get("/events", a.handleEvents) r.With(a.requireAuth).Get("/events", a.handleEvents)
r.Group(func(r chi.Router) { 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/messages", a.handleMailMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages) 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/messages/{id}", a.handleMailMessage)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts", a.handleMailExternalAccounts) r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts", a.handleMailExternalAccounts)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/folders", a.handleExternalIMAPFolders) r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).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), a.requireExternalIMAPEnabled).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(PermissionMailRead), a.requireExternalIMAPEnabled).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(PermissionMailAttachments), a.requireExternalIMAPEnabled).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(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(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", a.handleSendQueue)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
+73 -1
View File
@@ -32,6 +32,14 @@ type SystemSettings struct {
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"` UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"` UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"` 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 { type systemSettingsUpdate struct {
@@ -57,6 +65,14 @@ type systemSettingsUpdate struct {
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"` UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"` UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"` 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 { type PublicSettings struct {
@@ -66,6 +82,7 @@ type PublicSettings struct {
PublicHostname string `json:"publicHostname"` PublicHostname string `json:"publicHostname"`
MailAutoRefresh bool `json:"mailAutoRefresh"` MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshMs int `json:"mailRefreshMs"` MailRefreshMs int `json:"mailRefreshMs"`
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"` MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
} }
@@ -88,7 +105,7 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
if refreshSeconds <= 0 { if refreshSeconds <= 0 {
refreshSeconds = 30 refreshSeconds = 30
} }
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000} 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 // Include available domains for mailbox creation during registration
if a.cfg.OpenRegistration { if a.cfg.OpenRegistration {
@@ -165,6 +182,27 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
next.UserMailboxApplyEnabled = req.UserMailboxApplyEnabled next.UserMailboxApplyEnabled = req.UserMailboxApplyEnabled
next.UserMailboxDomainIDs = strings.Join(cleanIDList(req.UserMailboxDomainIDs), ",") next.UserMailboxDomainIDs = strings.Join(cleanIDList(req.UserMailboxDomainIDs), ",")
next.ReservedMailboxPrefixes = strings.Join(parseReservedPrefixes(req.ReservedMailboxPrefixes), ",") 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 { if err := a.saveSystemSettings(r.Context(), next); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save settings") respondError(w, http.StatusInternalServerError, "failed to save settings")
@@ -270,6 +308,14 @@ func (a *App) systemSettingsSnapshot() SystemSettings {
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled, UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")), UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"), 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 a.cfg.UserMailboxDomainIDs = value
case "reservedMailboxPrefixes": case "reservedMailboxPrefixes":
a.cfg.ReservedMailboxPrefixes = value 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() return rows.Err()
@@ -364,6 +428,14 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
"userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled), "userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled),
"userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","), "userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","),
"reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","), "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) now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(ctx, nil) tx, err := a.db.BeginTx(ctx, nil)
+10 -2
View File
@@ -172,10 +172,18 @@ export type SystemSettings = {
userMailboxApplyEnabled: boolean userMailboxApplyEnabled: boolean
userMailboxDomainIds: string[] userMailboxDomainIds: string[]
reservedMailboxPrefixes: string reservedMailboxPrefixes: string
externalImapEnabled: boolean
externalImapSecretSet: boolean
externalImapSyncSeconds: number
externalImapAllowPrivateHosts: boolean
externalImapGmailClientId: string
externalImapGmailClientSecretSet: boolean
externalImapOutlookClientId: string
externalImapOutlookClientSecretSet: boolean
} }
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { smtpPassword: string; turnstileSecretKey: string } export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string }
export type PublicDomain = { id: string; name: 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 LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: 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 } export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string }
+65 -1
View File
@@ -980,7 +980,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
const canUpdateTemplates = hasPermission(user, "admin.templates.update") const canUpdateTemplates = hasPermission(user, "admin.templates.update")
const canResetTemplates = hasPermission(user, "admin.templates.reset") const canResetTemplates = hasPermission(user, "admin.templates.reset")
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates }) 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 maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false) const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true) const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
@@ -991,6 +991,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true) const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true)
const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(false) const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(false)
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([]) const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
const [externalImapEnabled, setExternalImapEnabled] = React.useState(false)
const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(false)
React.useEffect(() => { React.useEffect(() => {
if (!settings) return if (!settings) return
setSmtpRequireTls(settings.smtpRequireTls) setSmtpRequireTls(settings.smtpRequireTls)
@@ -1002,6 +1004,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
setMailAutoRefresh(settings.mailAutoRefresh) setMailAutoRefresh(settings.mailAutoRefresh)
setUserMailboxApplyEnabled(settings.userMailboxApplyEnabled) setUserMailboxApplyEnabled(settings.userMailboxApplyEnabled)
setUserMailboxDomainIds(settings.userMailboxDomainIds || []) setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
setExternalImapEnabled(settings.externalImapEnabled)
setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts)
}, [settings]) }, [settings])
const save = useMutation({ const save = useMutation({
mutationFn: (form: FormData) => api.updateSystemSettings({ mutationFn: (form: FormData) => api.updateSystemSettings({
@@ -1027,6 +1031,14 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
userMailboxApplyEnabled, userMailboxApplyEnabled,
userMailboxDomainIds, userMailboxDomainIds,
reservedMailboxPrefixes: fieldValue(form, "reservedMailboxPrefixes", settings?.reservedMailboxPrefixes || ""), 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: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "settings"] }) qc.invalidateQueries({ queryKey: ["admin", "settings"] })
@@ -1060,6 +1072,14 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
settings.userMailboxApplyEnabled, settings.userMailboxApplyEnabled,
(settings.userMailboxDomainIds || []).join(","), (settings.userMailboxDomainIds || []).join(","),
settings.reservedMailboxPrefixes, settings.reservedMailboxPrefixes,
settings.externalImapEnabled,
settings.externalImapSecretSet,
settings.externalImapSyncSeconds,
settings.externalImapAllowPrivateHosts,
settings.externalImapGmailClientId,
settings.externalImapGmailClientSecretSet,
settings.externalImapOutlookClientId,
settings.externalImapOutlookClientSecretSet,
].join("|") : "loading" ].join("|") : "loading"
const tabs: { key: typeof settingsTab; label: string }[] = [ const tabs: { key: typeof settingsTab; label: string }[] = [
...(canSettingsView ? [ ...(canSettingsView ? [
@@ -1067,6 +1087,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
{ key: "smtp" as const, label: "SMTP" }, { key: "smtp" as const, label: "SMTP" },
{ key: "storage" as const, label: "存储" }, { key: "storage" as const, label: "存储" },
{ key: "mail" as const, label: "邮件" }, { key: "mail" as const, label: "邮件" },
{ key: "externalImap" as const, label: "外部 IMAP" },
] : []), ] : []),
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []), ...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []), ...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []),
@@ -1175,6 +1196,49 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
</CardContent> </CardContent>
</Card>} </Card>}
{settingsTab === "externalImap" && <Card>
<CardHeader>
<CardTitle> IMAP </CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">
IMAP
</div>
<SwitchRow label="启用外部 IMAP" checked={externalImapEnabled} onCheckedChange={setExternalImapEnabled} />
{externalImapEnabled && (
<div className="space-y-5 border-t pt-5">
<div className="grid gap-4 md:grid-cols-2">
<Field name="externalImapSecretKey" label={settings?.externalImapSecretSet ? "密码加密密钥(留空不变)" : "密码加密密钥"} type="password" required={!settings?.externalImapSecretSet} />
<Field name="externalImapSyncSeconds" label="后台同步间隔秒数" type="number" min={30} defaultValue={String(settings?.externalImapSyncSeconds || 300)} />
</div>
<SwitchRow label="允许 localhost / 内网 / link-local IMAP 主机" checked={externalImapAllowPrivateHosts} onCheckedChange={setExternalImapAllowPrivateHosts} />
<Separator />
<div className="space-y-3">
<div>
<div className="font-medium">Gmail OAuth2</div>
<div className="text-xs text-muted-foreground">{(settings?.publicBaseUrl || "${LANQIN_PUBLIC_BASE_URL}").replace(/\/$/, "")}/api/external-imap-oauth/gmail/callback</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field name="externalImapGmailClientId" label="Gmail Client ID" defaultValue={settings?.externalImapGmailClientId || ""} required={false} />
<Field name="externalImapGmailClientSecret" label={settings?.externalImapGmailClientSecretSet ? "Gmail Client Secret(留空不变)" : "Gmail Client Secret"} type="password" required={false} />
</div>
</div>
<Separator />
<div className="space-y-3">
<div>
<div className="font-medium">Microsoft 365 / Outlook OAuth2</div>
<div className="text-xs text-muted-foreground">{(settings?.publicBaseUrl || "${LANQIN_PUBLIC_BASE_URL}").replace(/\/$/, "")}/api/external-imap-oauth/outlook/callback</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field name="externalImapOutlookClientId" label="Outlook Client ID" defaultValue={settings?.externalImapOutlookClientId || ""} required={false} />
<Field name="externalImapOutlookClientSecret" label={settings?.externalImapOutlookClientSecretSet ? "Outlook Client Secret(留空不变)" : "Outlook Client Secret"} type="password" required={false} />
</div>
</div>
</div>
)}
</CardContent>
</Card>}
{settingsTab === "templates" && canViewTemplates && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />} {settingsTab === "templates" && canViewTemplates && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
{settingsTab === "security" && <Card> {settingsTab === "security" && <Card>
+14 -8
View File
@@ -138,12 +138,13 @@ export function MailPage() {
const canManageLabels = hasPermission(user, "mail.labels.manage") const canManageLabels = hasPermission(user, "mail.labels.manage")
const canDownloadAttachments = hasPermission(user, "mail.attachments.download") const canDownloadAttachments = hasPermission(user, "mail.attachments.download")
const canManageSignatures = hasPermission(user, "mail.signatures.manage") 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 mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail }) const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail && externalImapEnabled })
const selectedExternalAccount = React.useMemo(() => externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId), [externalMailAccounts.data?.items, selectedExternalAccountId]) 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 }) const externalFolders = useQuery({ queryKey: ["mail-external-folders", selectedExternalAccountId], queryFn: () => api.externalFolders(selectedExternalAccountId), enabled: !!selectedExternalAccountId && canReadMail && externalImapEnabled })
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId]) const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
const activeMailboxId = selectedMailbox?.id || "" const activeMailboxId = selectedMailbox?.id || ""
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0 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 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 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({ const inboxProbe = useQuery({
queryKey: ["mail-notifications", activeMailboxId], queryKey: ["mail-notifications", activeMailboxId],
queryFn: () => api.messages("Inbox", "", "", activeMailboxId), queryFn: () => api.messages("Inbox", "", "", activeMailboxId),
@@ -184,9 +190,9 @@ export function MailPage() {
queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : "", query), queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : "", query),
initialPageParam: "", initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, 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<MailMessage>) { function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current) qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData<MailListResponse> | undefined) => { qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData<MailListResponse> | undefined) => {
@@ -573,8 +579,8 @@ export function MailPage() {
const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length
const visibleSendQueueItems = sendQueueItems const visibleSendQueueItems = sendQueueItems
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue) const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue)
const externalAccountItems = externalMailAccounts.data?.items || [] const externalAccountItems = externalImapEnabled ? externalMailAccounts.data?.items || [] : []
const externalFolderItems = externalFolders.data?.items || [] const externalFolderItems = externalImapEnabled ? externalFolders.data?.items || [] : []
const labelItems = labels.data?.items || [] const labelItems = labels.data?.items || []
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId) 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 const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
+9 -5
View File
@@ -102,15 +102,16 @@ export function ProfilePage() {
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked }) 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 selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
const activeMailboxId = selectedMailbox?.id || "" 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(() => { React.useEffect(() => {
if (!externalRunAccountId) return if (!externalRunAccountId) return
if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return
setExternalRunAccountId("") setExternalRunAccountId("")
}, [externalImapAccounts.data?.items, externalRunAccountId]) }, [externalImapAccounts.data?.items, externalRunAccountId])
const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === 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 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 }) 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 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 }) const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats })
@@ -342,6 +343,7 @@ export function ProfilePage() {
applyOptions={mailboxApplyOptions.data} applyOptions={mailboxApplyOptions.data}
applyPending={applyMailbox.isPending} applyPending={applyMailbox.isPending}
selectedMailboxId={mailboxId} selectedMailboxId={mailboxId}
externalImapEnabled={externalImapEnabled}
externalAccounts={externalImapAccounts.data?.items || []} externalAccounts={externalImapAccounts.data?.items || []}
externalPending={createExternalImap.isPending || updateExternalImap.isPending || deleteExternalImap.isPending || testExternalImap.isPending || syncExternalImap.isPending || syncExternalImapFolder.isPending || startExternalOAuth.isPending} externalPending={createExternalImap.isPending || updateExternalImap.isPending || deleteExternalImap.isPending || testExternalImap.isPending || syncExternalImap.isPending || syncExternalImapFolder.isPending || startExternalOAuth.isPending}
selectedExternalRunAccountId={externalRunAccountId} selectedExternalRunAccountId={externalRunAccountId}
@@ -559,6 +561,7 @@ function MailboxManagement({
applyOptions, applyOptions,
applyPending, applyPending,
selectedMailboxId, selectedMailboxId,
externalImapEnabled,
externalAccounts, externalAccounts,
externalPending, externalPending,
selectedExternalRunAccountId, selectedExternalRunAccountId,
@@ -581,6 +584,7 @@ function MailboxManagement({
applyOptions?: MailboxApplyOptions applyOptions?: MailboxApplyOptions
applyPending: boolean applyPending: boolean
selectedMailboxId: string selectedMailboxId: string
externalImapEnabled: boolean
externalAccounts: ExternalImapAccount[] externalAccounts: ExternalImapAccount[]
externalPending: boolean externalPending: boolean
selectedExternalRunAccountId: string selectedExternalRunAccountId: string
@@ -610,7 +614,7 @@ function MailboxManagement({
{mailboxes.map((m) => <Card key={m.id} className={cn(selectedMailboxId === m.id && "border-primary")}><CardHeader><div className="flex items-start justify-between gap-3"><div className="min-w-0"><CardTitle className="truncate text-base">{m.address}</CardTitle></div>{selectedMailboxId === m.id && <Badge></Badge>}</div></CardHeader><CardContent className="flex flex-wrap gap-2"><Button variant="outline" size="sm" onClick={() => onSelect(m.id)}></Button><Button variant="outline" size="sm" onClick={() => onCopy(m.address)}><Copy className="h-4 w-4" /></Button><Button size="sm" onClick={() => onOpen(m.id)}></Button></CardContent></Card>)} {mailboxes.map((m) => <Card key={m.id} className={cn(selectedMailboxId === m.id && "border-primary")}><CardHeader><div className="flex items-start justify-between gap-3"><div className="min-w-0"><CardTitle className="truncate text-base">{m.address}</CardTitle></div>{selectedMailboxId === m.id && <Badge></Badge>}</div></CardHeader><CardContent className="flex flex-wrap gap-2"><Button variant="outline" size="sm" onClick={() => onSelect(m.id)}></Button><Button variant="outline" size="sm" onClick={() => onCopy(m.address)}><Copy className="h-4 w-4" /></Button><Button size="sm" onClick={() => onOpen(m.id)}></Button></CardContent></Card>)}
{mailboxes.length === 0 && <EmptyState text={canApply ? "暂无邮箱账号,点击申请邮箱创建" : "暂无邮箱账号"} />} {mailboxes.length === 0 && <EmptyState text={canApply ? "暂无邮箱账号,点击申请邮箱创建" : "暂无邮箱账号"} />}
</div> </div>
<Card> {externalImapEnabled && <Card>
<CardHeader> <CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
@@ -655,7 +659,7 @@ function MailboxManagement({
) )
})} })}
</CardContent> </CardContent>
</Card> </Card>}
</div> </div>
) )
} }
+8 -5
View File
@@ -132,20 +132,23 @@ LANQIN_MAIL_AUTO_REFRESH=true
# 自动刷新间隔,单位:秒。 # 自动刷新间隔,单位:秒。
LANQIN_MAIL_REFRESH_SECONDS=30 LANQIN_MAIL_REFRESH_SECONDS=30
# 外部 IMAP 密码加密密钥。启用外部 IMAP 接入前必须设置为足够长的随机字符串 # 是否启用外部 IMAP 接入。也可在后台“系统设置 > 外部 IMAP”里开启,默认关闭
LANQIN_EXTERNAL_IMAP_ENABLED=false
# 外部 IMAP 密码加密密钥。启用外部 IMAP 接入前必须设置为足够长的随机字符串;也可在后台“系统设置 > 外部 IMAP”里配置。
LANQIN_EXTERNAL_IMAP_SECRET_KEY= LANQIN_EXTERNAL_IMAP_SECRET_KEY=
# 外部 IMAP 本地存储模式的后台同步间隔,单位:秒。 # 外部 IMAP 本地存储模式的后台同步间隔,单位:秒;也可在后台配置
LANQIN_EXTERNAL_IMAP_SYNC_SECONDS=300 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 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_ID=
LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET= 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_ID=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET= LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
+1 -1
View File
@@ -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 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。 - 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` 去重。 - 第三方客户端可通过 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` 中显式授权的地址。 - send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。
## 邮件客户端 TLS 证书 ## 邮件客户端 TLS 证书