feat(mail): 增加新邮件通知提醒

- 新增收件箱轮询探测,用于发现新邮件到达。
- 在收到新邮件时触发站内提示、桌面通知和提示音。
- 补充通知状态缓存与自动刷新联动,避免重复提醒。
This commit is contained in:
LanQin
2026-06-16 22:45:10 +08:00
parent 9a8c38f51d
commit 9a0e039767
+91 -3
View File
@@ -54,6 +54,7 @@ type MailFilter = "all" | "unread" | "starred" | "attachments"
type MailView = "folder" | "starred" | "label" type MailView = "folder" | "starred" | "label"
type MailListResponse = { items?: MailMessage[]; nextCursor?: string } type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
type MailNotificationState = { latestId: string; latestReceivedAt: string }
type MailMenuItem = type MailMenuItem =
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number } | { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number }
| { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number } | { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number }
@@ -88,6 +89,8 @@ export function MailPage() {
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null) const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null) const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
const themeMountedRef = React.useRef(false) const themeMountedRef = React.useRef(false)
const mailNotifyStateRef = React.useRef<Record<string, MailNotificationState>>({})
const mailAudioContextRef = React.useRef<AudioContext | null>(null)
const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes }) const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
@@ -97,6 +100,14 @@ export function MailPage() {
const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId }) const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId })
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId }) const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId })
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId }) const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId })
const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false
const inboxProbe = useQuery({
queryKey: ["mail-notifications", activeMailboxId],
queryFn: () => api.messages("Inbox", "", "", activeMailboxId),
enabled: !!activeMailboxId,
refetchInterval: mailRefreshInterval,
refetchIntervalInBackground: true,
})
const messages = useInfiniteQuery({ const messages = useInfiniteQuery({
queryKey: ["messages", activeMailboxId, mailView, folder, selectedLabelId, query], queryKey: ["messages", activeMailboxId, mailView, folder, selectedLabelId, query],
queryFn: ({ pageParam }) => { queryFn: ({ pageParam }) => {
@@ -224,27 +235,82 @@ export function MailPage() {
themeMountedRef.current = true themeMountedRef.current = true
}, [darkMode]) }, [darkMode])
React.useEffect(() => {
const unlock = () => {
const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (AudioContextCtor && !mailAudioContextRef.current) {
const ctx = new AudioContextCtor()
mailAudioContextRef.current = ctx
if (ctx.state === "suspended") void ctx.resume()
}
if ("Notification" in window && Notification.permission === "default") {
void Notification.requestPermission()
}
}
window.addEventListener("pointerdown", unlock, { once: true })
window.addEventListener("keydown", unlock, { once: true })
return () => {
window.removeEventListener("pointerdown", unlock)
window.removeEventListener("keydown", unlock)
}
}, [])
React.useEffect(() => {
if (!activeMailboxId || !inboxProbe.data?.items) return
const items = inboxProbe.data.items
const latest = items[0]
const nextState = { latestId: latest?.id || "", latestReceivedAt: latest?.receivedAt || "" }
const prevState = mailNotifyStateRef.current[activeMailboxId]
if (!prevState) {
mailNotifyStateRef.current[activeMailboxId] = nextState
return
}
const newMessages = items.filter((item) => item.receivedAt > prevState.latestReceivedAt && item.id !== prevState.latestId)
mailNotifyStateRef.current[activeMailboxId] = nextState
if (newMessages.length === 0) return
const first = newMessages[0]
const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${first.subject || "(无主题)"}`
const description = newMessages.length > 1 ? `${first.from} 等发来新邮件` : `${first.from}${first.snippet ? ` · ${first.snippet}` : ""}`
toast({ title, description })
playIncomingMailSound(mailAudioContextRef)
if ("Notification" in window && Notification.permission === "granted") {
const notification = new Notification(title, {
body: description,
tag: `lanqin-mail-${activeMailboxId}`,
})
notification.onclick = () => {
window.focus()
setMailView("folder")
setFolder("Inbox")
setSelectedId(first.id)
notification.close()
}
}
}, [activeMailboxId, inboxProbe.data?.items, toast])
React.useEffect(() => { React.useEffect(() => {
const events = new EventSource("/api/events", { withCredentials: true }) const events = new EventSource("/api/events", { withCredentials: true })
events.addEventListener("sync", () => { events.addEventListener("sync", () => {
qc.invalidateQueries({ queryKey: ["folders"] }) qc.invalidateQueries({ queryKey: ["folders"] })
qc.invalidateQueries({ queryKey: ["mail-stats"] }) qc.invalidateQueries({ queryKey: ["mail-stats"] })
qc.invalidateQueries({ queryKey: ["labels"] }) qc.invalidateQueries({ queryKey: ["labels"] })
qc.invalidateQueries({ queryKey: ["mail-notifications"] })
}) })
return () => events.close() return () => events.close()
}, [qc]) }, [qc])
React.useEffect(() => { React.useEffect(() => {
if (!publicSettings.data?.mailAutoRefresh) return if (!publicSettings.data?.mailAutoRefresh) return
const interval = Math.max(publicSettings.data.mailRefreshMs || 30000, 5000)
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
qc.invalidateQueries({ queryKey: ["messages"] }) qc.invalidateQueries({ queryKey: ["messages"] })
qc.invalidateQueries({ queryKey: ["folders"] }) qc.invalidateQueries({ queryKey: ["folders"] })
qc.invalidateQueries({ queryKey: ["mail-stats"] }) qc.invalidateQueries({ queryKey: ["mail-stats"] })
qc.invalidateQueries({ queryKey: ["labels"] }) qc.invalidateQueries({ queryKey: ["labels"] })
}, interval) qc.invalidateQueries({ queryKey: ["mail-notifications"] })
}, mailRefreshInterval || 30000)
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [publicSettings.data?.mailAutoRefresh, publicSettings.data?.mailRefreshMs, qc]) }, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc])
const selected = detail.data const selected = detail.data
const allMessages = messages.data?.pages.flatMap((page) => page.items || []) || [] const allMessages = messages.data?.pages.flatMap((page) => page.items || []) || []
@@ -1334,6 +1400,28 @@ function ToolbarButton({ label, children, onClick, disabled }: { label: string;
function splitEmails(s: string) { return s.split(/[;,\s]+/).map((v) => v.trim()).filter(Boolean) } function splitEmails(s: string) { return s.split(/[;,\s]+/).map((v) => v.trim()).filter(Boolean) }
function markdownToHtml(value: string) { return DOMPurify.sanitize(marked.parse(value, { async: false, breaks: true })) } function markdownToHtml(value: string) { return DOMPurify.sanitize(marked.parse(value, { async: false, breaks: true })) }
function playIncomingMailSound(ref: React.MutableRefObject<AudioContext | null>) {
const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!AudioContextCtor) return
const ctx = ref.current || new AudioContextCtor()
ref.current = ctx
if (ctx.state === "suspended") void ctx.resume()
const now = ctx.currentTime
const gain = ctx.createGain()
gain.gain.setValueAtTime(0.0001, now)
gain.gain.exponentialRampToValueAtTime(0.12, now + 0.02)
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.45)
gain.connect(ctx.destination)
for (const [index, frequency] of [880, 1175].entries()) {
const osc = ctx.createOscillator()
const start = now + index * 0.14
osc.type = "sine"
osc.frequency.setValueAtTime(frequency, start)
osc.connect(gain)
osc.start(start)
osc.stop(start + 0.18)
}
}
function withPrefix(subject: string, prefix: string) { return subject.toLowerCase().startsWith(prefix.toLowerCase()) ? subject : `${prefix} ${subject}` } function withPrefix(subject: string, prefix: string) { return subject.toLowerCase().startsWith(prefix.toLowerCase()) ? subject : `${prefix} ${subject}` }
function quoteMessage(message: MailMessage) { function quoteMessage(message: MailMessage) {
const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "") const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")