feat(external-imap): 新增外部 IMAP 全局开关与后台配置
- 支持在系统设置中启用/禁用外部 IMAP,并持久化相关密钥与 OAuth 配置。 - 在公共设置和前端页面中联动开关状态,未启用时隐藏外部 IMAP 相关功能并拦截后端接口。 - 补充外部 IMAP 默认关闭、配置说明与回调地址文档。
This commit is contained in:
@@ -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` | 空 |
|
||||||
|
|
||||||
|
|||||||
@@ -308,28 +308,36 @@ func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, per
|
|||||||
|
|
||||||
func systemSettingsPayload(settings SystemSettings) map[string]any {
|
func systemSettingsPayload(settings SystemSettings) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"publicHostname": settings.PublicHostname,
|
"publicHostname": settings.PublicHostname,
|
||||||
"publicBaseUrl": settings.PublicBaseURL,
|
"publicBaseUrl": settings.PublicBaseURL,
|
||||||
"smtpHost": settings.SMTPHost,
|
"smtpHost": settings.SMTPHost,
|
||||||
"smtpPort": settings.SMTPPort,
|
"smtpPort": settings.SMTPPort,
|
||||||
"smtpUsername": settings.SMTPUsername,
|
"smtpUsername": settings.SMTPUsername,
|
||||||
"smtpPassword": "",
|
"smtpPassword": "",
|
||||||
"smtpRequireTls": settings.SMTPRequireTLS,
|
"smtpRequireTls": settings.SMTPRequireTLS,
|
||||||
"maildirRoot": settings.MaildirRoot,
|
"maildirRoot": settings.MaildirRoot,
|
||||||
"maildirScanSeconds": settings.MaildirScanSeconds,
|
"maildirScanSeconds": settings.MaildirScanSeconds,
|
||||||
"sessionTtlHours": settings.SessionTTLHours,
|
"sessionTtlHours": settings.SessionTTLHours,
|
||||||
"allowInsecureHttp": settings.AllowInsecureHTTP,
|
"allowInsecureHttp": settings.AllowInsecureHTTP,
|
||||||
"openRegistration": settings.OpenRegistration,
|
"openRegistration": settings.OpenRegistration,
|
||||||
"twoFactorEnabled": settings.TwoFactorEnabled,
|
"twoFactorEnabled": settings.TwoFactorEnabled,
|
||||||
"turnstileEnabled": settings.TurnstileEnabled,
|
"turnstileEnabled": settings.TurnstileEnabled,
|
||||||
"turnstileSiteKey": settings.TurnstileSiteKey,
|
"turnstileSiteKey": settings.TurnstileSiteKey,
|
||||||
"turnstileSecretKey": "",
|
"turnstileSecretKey": "",
|
||||||
"catchAllEnabled": settings.CatchAllEnabled,
|
"catchAllEnabled": settings.CatchAllEnabled,
|
||||||
"mailAutoRefresh": settings.MailAutoRefresh,
|
"mailAutoRefresh": settings.MailAutoRefresh,
|
||||||
"mailRefreshSeconds": settings.MailRefreshSeconds,
|
"mailRefreshSeconds": settings.MailRefreshSeconds,
|
||||||
"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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -10,63 +10,80 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SystemSettings struct {
|
type SystemSettings struct {
|
||||||
PublicHostname string `json:"publicHostname"`
|
PublicHostname string `json:"publicHostname"`
|
||||||
PublicBaseURL string `json:"publicBaseUrl"`
|
PublicBaseURL string `json:"publicBaseUrl"`
|
||||||
SMTPHost string `json:"smtpHost"`
|
SMTPHost string `json:"smtpHost"`
|
||||||
SMTPPort string `json:"smtpPort"`
|
SMTPPort string `json:"smtpPort"`
|
||||||
SMTPUsername string `json:"smtpUsername"`
|
SMTPUsername string `json:"smtpUsername"`
|
||||||
SMTPPasswordSet bool `json:"smtpPasswordSet"`
|
SMTPPasswordSet bool `json:"smtpPasswordSet"`
|
||||||
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
||||||
MaildirRoot string `json:"maildirRoot"`
|
MaildirRoot string `json:"maildirRoot"`
|
||||||
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
||||||
SessionTTLHours int `json:"sessionTtlHours"`
|
SessionTTLHours int `json:"sessionTtlHours"`
|
||||||
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
||||||
OpenRegistration bool `json:"openRegistration"`
|
OpenRegistration bool `json:"openRegistration"`
|
||||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||||
TurnstileSecretSet bool `json:"turnstileSecretSet"`
|
TurnstileSecretSet bool `json:"turnstileSecretSet"`
|
||||||
CatchAllEnabled bool `json:"catchAllEnabled"`
|
CatchAllEnabled bool `json:"catchAllEnabled"`
|
||||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||||
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
||||||
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 {
|
||||||
PublicHostname string `json:"publicHostname"`
|
PublicHostname string `json:"publicHostname"`
|
||||||
PublicBaseURL string `json:"publicBaseUrl"`
|
PublicBaseURL string `json:"publicBaseUrl"`
|
||||||
SMTPHost string `json:"smtpHost"`
|
SMTPHost string `json:"smtpHost"`
|
||||||
SMTPPort string `json:"smtpPort"`
|
SMTPPort string `json:"smtpPort"`
|
||||||
SMTPUsername string `json:"smtpUsername"`
|
SMTPUsername string `json:"smtpUsername"`
|
||||||
SMTPPassword string `json:"smtpPassword"`
|
SMTPPassword string `json:"smtpPassword"`
|
||||||
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
||||||
MaildirRoot string `json:"maildirRoot"`
|
MaildirRoot string `json:"maildirRoot"`
|
||||||
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
||||||
SessionTTLHours int `json:"sessionTtlHours"`
|
SessionTTLHours int `json:"sessionTtlHours"`
|
||||||
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
||||||
OpenRegistration bool `json:"openRegistration"`
|
OpenRegistration bool `json:"openRegistration"`
|
||||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||||
TurnstileSecretKey string `json:"turnstileSecretKey"`
|
TurnstileSecretKey string `json:"turnstileSecretKey"`
|
||||||
CatchAllEnabled bool `json:"catchAllEnabled"`
|
CatchAllEnabled bool `json:"catchAllEnabled"`
|
||||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||||
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
||||||
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 {
|
||||||
OpenRegistration bool `json:"openRegistration"`
|
OpenRegistration bool `json:"openRegistration"`
|
||||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||||
PublicHostname string `json:"publicHostname"`
|
PublicHostname string `json:"publicHostname"`
|
||||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||||
MailRefreshMs int `json:"mailRefreshMs"`
|
MailRefreshMs int `json:"mailRefreshMs"`
|
||||||
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
|
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
|
||||||
|
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublicDomain struct {
|
type PublicDomain struct {
|
||||||
@@ -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")
|
||||||
@@ -248,28 +286,36 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) systemSettingsSnapshot() SystemSettings {
|
func (a *App) systemSettingsSnapshot() SystemSettings {
|
||||||
return SystemSettings{
|
return SystemSettings{
|
||||||
PublicHostname: a.cfg.PublicHostname,
|
PublicHostname: a.cfg.PublicHostname,
|
||||||
PublicBaseURL: a.cfg.PublicBaseURL,
|
PublicBaseURL: a.cfg.PublicBaseURL,
|
||||||
SMTPHost: a.cfg.SMTPHost,
|
SMTPHost: a.cfg.SMTPHost,
|
||||||
SMTPPort: a.cfg.SMTPPort,
|
SMTPPort: a.cfg.SMTPPort,
|
||||||
SMTPUsername: a.cfg.SMTPUsername,
|
SMTPUsername: a.cfg.SMTPUsername,
|
||||||
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
|
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
|
||||||
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
|
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
|
||||||
MaildirRoot: a.cfg.MaildirRoot,
|
MaildirRoot: a.cfg.MaildirRoot,
|
||||||
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
|
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
|
||||||
SessionTTLHours: a.cfg.SessionTTLHours,
|
SessionTTLHours: a.cfg.SessionTTLHours,
|
||||||
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
|
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
|
||||||
OpenRegistration: a.cfg.OpenRegistration,
|
OpenRegistration: a.cfg.OpenRegistration,
|
||||||
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
|
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
|
||||||
TurnstileEnabled: a.cfg.TurnstileEnabled,
|
TurnstileEnabled: a.cfg.TurnstileEnabled,
|
||||||
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
|
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
|
||||||
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
|
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
|
||||||
CatchAllEnabled: a.cfg.CatchAllEnabled,
|
CatchAllEnabled: a.cfg.CatchAllEnabled,
|
||||||
MailAutoRefresh: a.cfg.MailAutoRefresh,
|
MailAutoRefresh: a.cfg.MailAutoRefresh,
|
||||||
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
|
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
|
||||||
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()
|
||||||
@@ -342,28 +406,36 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
|||||||
|
|
||||||
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"publicHostname": cfg.PublicHostname,
|
"publicHostname": cfg.PublicHostname,
|
||||||
"publicBaseUrl": cfg.PublicBaseURL,
|
"publicBaseUrl": cfg.PublicBaseURL,
|
||||||
"smtpHost": cfg.SMTPHost,
|
"smtpHost": cfg.SMTPHost,
|
||||||
"smtpPort": cfg.SMTPPort,
|
"smtpPort": cfg.SMTPPort,
|
||||||
"smtpUsername": cfg.SMTPUsername,
|
"smtpUsername": cfg.SMTPUsername,
|
||||||
"smtpPassword": cfg.SMTPPassword,
|
"smtpPassword": cfg.SMTPPassword,
|
||||||
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
|
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
|
||||||
"maildirRoot": cfg.MaildirRoot,
|
"maildirRoot": cfg.MaildirRoot,
|
||||||
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
|
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
|
||||||
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
|
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
|
||||||
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
|
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
|
||||||
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
|
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
|
||||||
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
|
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
|
||||||
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
|
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
|
||||||
"turnstileSiteKey": cfg.TurnstileSiteKey,
|
"turnstileSiteKey": cfg.TurnstileSiteKey,
|
||||||
"turnstileSecretKey": cfg.TurnstileSecretKey,
|
"turnstileSecretKey": cfg.TurnstileSecretKey,
|
||||||
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
|
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
|
||||||
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
|
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
|
||||||
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
|
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
|
||||||
"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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 证书
|
||||||
|
|||||||
Reference in New Issue
Block a user