feat(external-imap): 新增外部 IMAP 全局开关与后台配置
- 支持在系统设置中启用/禁用外部 IMAP,并持久化相关密钥与 OAuth 配置。 - 在公共设置和前端页面中联动开关状态,未启用时隐藏外部 IMAP 相关功能并拦截后端接口。 - 补充外部 IMAP 默认关闭、配置说明与回调地址文档。
This commit is contained in:
@@ -172,10 +172,18 @@ export type SystemSettings = {
|
||||
userMailboxApplyEnabled: boolean
|
||||
userMailboxDomainIds: string[]
|
||||
reservedMailboxPrefixes: string
|
||||
externalImapEnabled: boolean
|
||||
externalImapSecretSet: boolean
|
||||
externalImapSyncSeconds: number
|
||||
externalImapAllowPrivateHosts: boolean
|
||||
externalImapGmailClientId: string
|
||||
externalImapGmailClientSecretSet: boolean
|
||||
externalImapOutlookClientId: string
|
||||
externalImapOutlookClientSecretSet: boolean
|
||||
}
|
||||
export type SystemSettingsPayload = Omit<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 PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; mailboxDomains?: PublicDomain[] }
|
||||
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
|
||||
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
|
||||
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
|
||||
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string }
|
||||
|
||||
@@ -980,7 +980,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base")
|
||||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about">("base")
|
||||
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
@@ -991,6 +991,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true)
|
||||
const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(false)
|
||||
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
|
||||
const [externalImapEnabled, setExternalImapEnabled] = React.useState(false)
|
||||
const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
if (!settings) return
|
||||
setSmtpRequireTls(settings.smtpRequireTls)
|
||||
@@ -1002,6 +1004,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
setMailAutoRefresh(settings.mailAutoRefresh)
|
||||
setUserMailboxApplyEnabled(settings.userMailboxApplyEnabled)
|
||||
setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
|
||||
setExternalImapEnabled(settings.externalImapEnabled)
|
||||
setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts)
|
||||
}, [settings])
|
||||
const save = useMutation({
|
||||
mutationFn: (form: FormData) => api.updateSystemSettings({
|
||||
@@ -1027,6 +1031,14 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
userMailboxApplyEnabled,
|
||||
userMailboxDomainIds,
|
||||
reservedMailboxPrefixes: fieldValue(form, "reservedMailboxPrefixes", settings?.reservedMailboxPrefixes || ""),
|
||||
externalImapEnabled,
|
||||
externalImapSecretKey: fieldValue(form, "externalImapSecretKey", ""),
|
||||
externalImapSyncSeconds: fieldNumber(form, "externalImapSyncSeconds", settings?.externalImapSyncSeconds || 300),
|
||||
externalImapAllowPrivateHosts,
|
||||
externalImapGmailClientId: fieldValue(form, "externalImapGmailClientId", settings?.externalImapGmailClientId || ""),
|
||||
externalImapGmailClientSecret: fieldValue(form, "externalImapGmailClientSecret", ""),
|
||||
externalImapOutlookClientId: fieldValue(form, "externalImapOutlookClientId", settings?.externalImapOutlookClientId || ""),
|
||||
externalImapOutlookClientSecret: fieldValue(form, "externalImapOutlookClientSecret", ""),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
|
||||
@@ -1060,6 +1072,14 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
settings.userMailboxApplyEnabled,
|
||||
(settings.userMailboxDomainIds || []).join(","),
|
||||
settings.reservedMailboxPrefixes,
|
||||
settings.externalImapEnabled,
|
||||
settings.externalImapSecretSet,
|
||||
settings.externalImapSyncSeconds,
|
||||
settings.externalImapAllowPrivateHosts,
|
||||
settings.externalImapGmailClientId,
|
||||
settings.externalImapGmailClientSecretSet,
|
||||
settings.externalImapOutlookClientId,
|
||||
settings.externalImapOutlookClientSecretSet,
|
||||
].join("|") : "loading"
|
||||
const tabs: { key: typeof settingsTab; label: string }[] = [
|
||||
...(canSettingsView ? [
|
||||
@@ -1067,6 +1087,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
{ key: "smtp" as const, label: "SMTP" },
|
||||
{ key: "storage" as const, label: "存储" },
|
||||
{ key: "mail" as const, label: "邮件" },
|
||||
{ key: "externalImap" as const, label: "外部 IMAP" },
|
||||
] : []),
|
||||
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
|
||||
...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []),
|
||||
@@ -1175,6 +1196,49 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
</CardContent>
|
||||
</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 === "security" && <Card>
|
||||
|
||||
@@ -138,12 +138,13 @@ export function MailPage() {
|
||||
const canManageLabels = hasPermission(user, "mail.labels.manage")
|
||||
const canDownloadAttachments = hasPermission(user, "mail.attachments.download")
|
||||
const canManageSignatures = hasPermission(user, "mail.signatures.manage")
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
|
||||
|
||||
const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
|
||||
const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail })
|
||||
const selectedExternalAccount = React.useMemo(() => externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId), [externalMailAccounts.data?.items, selectedExternalAccountId])
|
||||
const externalFolders = useQuery({ queryKey: ["mail-external-folders", selectedExternalAccountId], queryFn: () => api.externalFolders(selectedExternalAccountId), enabled: !!selectedExternalAccountId && canReadMail })
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail && externalImapEnabled })
|
||||
const selectedExternalAccount = React.useMemo(() => externalImapEnabled ? externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId) : undefined, [externalImapEnabled, externalMailAccounts.data?.items, selectedExternalAccountId])
|
||||
const externalFolders = useQuery({ queryKey: ["mail-external-folders", selectedExternalAccountId], queryFn: () => api.externalFolders(selectedExternalAccountId), enabled: !!selectedExternalAccountId && canReadMail && externalImapEnabled })
|
||||
const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
|
||||
const activeMailboxId = selectedMailbox?.id || ""
|
||||
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
|
||||
@@ -160,6 +161,11 @@ export function MailPage() {
|
||||
})
|
||||
const sendQueueAudit = useQuery({ queryKey: ["send-queue-audit", sendQueueAuditId], queryFn: () => api.sendQueueAudit(sendQueueAuditId), enabled: !!sendQueueAuditId && canViewSendQueue })
|
||||
const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false
|
||||
React.useEffect(() => {
|
||||
if (externalImapEnabled) return
|
||||
setSelectedExternalAccountId("")
|
||||
if (mailView === "external") setMailView("folder")
|
||||
}, [externalImapEnabled, mailView])
|
||||
const inboxProbe = useQuery({
|
||||
queryKey: ["mail-notifications", activeMailboxId],
|
||||
queryFn: () => api.messages("Inbox", "", "", activeMailboxId),
|
||||
@@ -184,9 +190,9 @@ export function MailPage() {
|
||||
queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : "", query),
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
enabled: !!selectedExternalAccountId && canReadMail && mailView === "external",
|
||||
enabled: !!selectedExternalAccountId && canReadMail && mailView === "external" && externalImapEnabled,
|
||||
})
|
||||
const detail = useQuery({ queryKey: ["message", selectedId, mailView, selectedExternalAccountId], queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail && (mailView !== "external" || !!selectedExternalAccountId) })
|
||||
const detail = useQuery({ queryKey: ["message", selectedId, mailView, selectedExternalAccountId], queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail && (mailView !== "external" || (!!selectedExternalAccountId && externalImapEnabled)) })
|
||||
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
||||
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
|
||||
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 visibleSendQueueItems = sendQueueItems
|
||||
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue)
|
||||
const externalAccountItems = externalMailAccounts.data?.items || []
|
||||
const externalFolderItems = externalFolders.data?.items || []
|
||||
const externalAccountItems = externalImapEnabled ? externalMailAccounts.data?.items || [] : []
|
||||
const externalFolderItems = externalImapEnabled ? externalFolders.data?.items || [] : []
|
||||
const labelItems = labels.data?.items || []
|
||||
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
|
||||
const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
||||
|
||||
@@ -102,15 +102,16 @@ export function ProfilePage() {
|
||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked })
|
||||
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
|
||||
const activeMailboxId = selectedMailbox?.id || ""
|
||||
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail })
|
||||
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
|
||||
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail && externalImapEnabled })
|
||||
React.useEffect(() => {
|
||||
if (!externalRunAccountId) return
|
||||
if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return
|
||||
setExternalRunAccountId("")
|
||||
}, [externalImapAccounts.data?.items, externalRunAccountId])
|
||||
const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId)
|
||||
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail })
|
||||
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail })
|
||||
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) })
|
||||
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats })
|
||||
|
||||
@@ -342,6 +343,7 @@ export function ProfilePage() {
|
||||
applyOptions={mailboxApplyOptions.data}
|
||||
applyPending={applyMailbox.isPending}
|
||||
selectedMailboxId={mailboxId}
|
||||
externalImapEnabled={externalImapEnabled}
|
||||
externalAccounts={externalImapAccounts.data?.items || []}
|
||||
externalPending={createExternalImap.isPending || updateExternalImap.isPending || deleteExternalImap.isPending || testExternalImap.isPending || syncExternalImap.isPending || syncExternalImapFolder.isPending || startExternalOAuth.isPending}
|
||||
selectedExternalRunAccountId={externalRunAccountId}
|
||||
@@ -559,6 +561,7 @@ function MailboxManagement({
|
||||
applyOptions,
|
||||
applyPending,
|
||||
selectedMailboxId,
|
||||
externalImapEnabled,
|
||||
externalAccounts,
|
||||
externalPending,
|
||||
selectedExternalRunAccountId,
|
||||
@@ -581,6 +584,7 @@ function MailboxManagement({
|
||||
applyOptions?: MailboxApplyOptions
|
||||
applyPending: boolean
|
||||
selectedMailboxId: string
|
||||
externalImapEnabled: boolean
|
||||
externalAccounts: ExternalImapAccount[]
|
||||
externalPending: boolean
|
||||
selectedExternalRunAccountId: string
|
||||
@@ -610,7 +614,7 @@ function MailboxManagement({
|
||||
{mailboxes.map((m) => <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 ? "暂无邮箱账号,点击申请邮箱创建" : "暂无邮箱账号"} />}
|
||||
</div>
|
||||
<Card>
|
||||
{externalImapEnabled && <Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -655,7 +659,7 @@ function MailboxManagement({
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user