mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-28 19:48:01 +08:00
fix: harden frontend-next mail rendering
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dompurify": "^3.3.0",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.6.0",
|
"radix-ui": "^1.6.0",
|
||||||
|
|||||||
Generated
+16
@@ -20,6 +20,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
dompurify:
|
||||||
|
specifier: ^3.3.0
|
||||||
|
version: 3.4.13
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.561.0
|
specifier: ^0.561.0
|
||||||
version: 0.561.0(react@19.2.7)
|
version: 0.561.0(react@19.2.7)
|
||||||
@@ -1267,6 +1270,9 @@ packages:
|
|||||||
'@types/react@19.2.17':
|
'@types/react@19.2.17':
|
||||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.2.0':
|
'@vitejs/plugin-react@5.2.0':
|
||||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1319,6 +1325,9 @@ packages:
|
|||||||
detect-node-es@1.1.0:
|
detect-node-es@1.1.0:
|
||||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||||
|
|
||||||
|
dompurify@3.4.13:
|
||||||
|
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.379:
|
electron-to-chromium@1.5.379:
|
||||||
resolution: {integrity: sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==}
|
resolution: {integrity: sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==}
|
||||||
|
|
||||||
@@ -2811,6 +2820,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0))':
|
'@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
@@ -2857,6 +2869,10 @@ snapshots:
|
|||||||
|
|
||||||
detect-node-es@1.1.0: {}
|
detect-node-es@1.1.0: {}
|
||||||
|
|
||||||
|
dompurify@3.4.13:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
electron-to-chromium@1.5.379: {}
|
electron-to-chromium@1.5.379: {}
|
||||||
|
|
||||||
enhanced-resolve@5.21.6:
|
enhanced-resolve@5.21.6:
|
||||||
|
|||||||
+48
-16
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||||
|
import DOMPurify from "dompurify"
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
Check,
|
Check,
|
||||||
@@ -171,6 +172,7 @@ function App() {
|
|||||||
setMails([])
|
setMails([])
|
||||||
setSelectedId("")
|
setSelectedId("")
|
||||||
setSettings(defaultAddressSettings)
|
setSettings(defaultAddressSettings)
|
||||||
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
@@ -178,6 +180,7 @@ function App() {
|
|||||||
setSettings({ ...defaultAddressSettings, fetched: true })
|
setSettings({ ...defaultAddressSettings, fetched: true })
|
||||||
setMails([])
|
setMails([])
|
||||||
setSelectedId("")
|
setSelectedId("")
|
||||||
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -899,10 +902,14 @@ function NavItem({
|
|||||||
function AddressPill({ address, showNotice }: { address: string; showNotice: (notice: Notice) => void }) {
|
function AddressPill({ address, showNotice }: { address: string; showNotice: (notice: Notice) => void }) {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const copyAddress = async () => {
|
const copyAddress = async () => {
|
||||||
await navigator.clipboard.writeText(address)
|
try {
|
||||||
setCopied(true)
|
await navigator.clipboard.writeText(address)
|
||||||
showNotice({ type: "success", text: "Address copied" })
|
setCopied(true)
|
||||||
window.setTimeout(() => setCopied(false), 1400)
|
showNotice({ type: "success", text: "Address copied" })
|
||||||
|
window.setTimeout(() => setCopied(false), 1400)
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({ type: "error", text: `Copy failed: ${getErrorMessage(error)}` })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1010,8 +1017,10 @@ function MessageReader({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = mail.message || mail.text || mail.raw || ""
|
const htmlBody = mail.html || (mail.message && /<\/?[a-z][\s\S]*>/i.test(mail.message) ? mail.message : "")
|
||||||
const isHtml = /<\/?[a-z][\s\S]*>/i.test(body)
|
const body = htmlBody || mail.text || mail.message || ""
|
||||||
|
const sender = mail.sender || mail.source
|
||||||
|
const safeHtml = htmlBody ? createSafeMailHtml(htmlBody) : ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="reader">
|
<section className="reader">
|
||||||
@@ -1034,10 +1043,10 @@ function MessageReader({
|
|||||||
</div>
|
</div>
|
||||||
<div className="reader-body">
|
<div className="reader-body">
|
||||||
<div className="reader-meta">
|
<div className="reader-meta">
|
||||||
<span className="sender-avatar large">{senderInitials(mail.source)}</span>
|
<span className="sender-avatar large">{senderInitials(sender)}</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1>{mail.subject || "No Subject"}</h1>
|
<h1>{mail.subject || "No Subject"}</h1>
|
||||||
<p>{mail.source || "Unknown sender"}</p>
|
<p>{sender || "Unknown sender"}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
to <span className="font-mono">{address}</span> · {formatDate(mail.created_at)}
|
to <span className="font-mono">{address}</span> · {formatDate(mail.created_at)}
|
||||||
</p>
|
</p>
|
||||||
@@ -1045,8 +1054,14 @@ function MessageReader({
|
|||||||
</div>
|
</div>
|
||||||
<Card className="message-card">
|
<Card className="message-card">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
{isHtml ? (
|
{safeHtml ? (
|
||||||
<iframe className="message-frame" title={`mail-${mail.id}`} sandbox="" srcDoc={body} />
|
<iframe
|
||||||
|
className="message-frame"
|
||||||
|
title={`mail-${mail.id}`}
|
||||||
|
sandbox=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
srcDoc={safeHtml}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<pre className="message-text">{body}</pre>
|
<pre className="message-text">{body}</pre>
|
||||||
)}
|
)}
|
||||||
@@ -1407,8 +1422,12 @@ function SettingsPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(jwt)
|
try {
|
||||||
showNotice({ type: "success", text: "Credential copied" })
|
await navigator.clipboard.writeText(jwt)
|
||||||
|
showNotice({ type: "success", text: "Credential copied" })
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({ type: "error", text: `Copy failed: ${getErrorMessage(error)}` })
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Copy className="size-4" />
|
<Copy className="size-4" />
|
||||||
@@ -1486,12 +1505,24 @@ function NoticeToast({ notice }: { notice: NonNullable<Notice> }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSafeMailHtml(html: string) {
|
||||||
|
const sanitized = DOMPurify.sanitize(html, {
|
||||||
|
FORBID_TAGS: ["base", "embed", "form", "iframe", "link", "meta", "object", "script"],
|
||||||
|
FORBID_ATTR: ["action", "formaction", "srcset"],
|
||||||
|
})
|
||||||
|
const policy = "default-src 'none'; img-src data: blob: cid:; style-src 'unsafe-inline'; form-action 'none'; base-uri 'none'"
|
||||||
|
return `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="${policy}"></head><body>${sanitized}</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
function useLocalStorageState<T extends string = string>(key: string, defaultValue: T) {
|
function useLocalStorageState<T extends string = string>(key: string, defaultValue: T) {
|
||||||
const [value, setValue] = useState<string>(() => localStorage.getItem(key) ?? defaultValue)
|
const [value, setValue] = useState<string>(() => {
|
||||||
|
if (typeof window === "undefined") return defaultValue
|
||||||
|
return window.localStorage.getItem(key) ?? defaultValue
|
||||||
|
})
|
||||||
const setStoredValue = useCallback(
|
const setStoredValue = useCallback(
|
||||||
(nextValue: string) => {
|
(nextValue: string) => {
|
||||||
setValue(nextValue)
|
setValue(nextValue)
|
||||||
localStorage.setItem(key, nextValue)
|
if (typeof window !== "undefined") window.localStorage.setItem(key, nextValue)
|
||||||
},
|
},
|
||||||
[key],
|
[key],
|
||||||
)
|
)
|
||||||
@@ -1500,8 +1531,9 @@ function useLocalStorageState<T extends string = string>(key: string, defaultVal
|
|||||||
|
|
||||||
function useLocalStorageArray(key: string) {
|
function useLocalStorageArray(key: string) {
|
||||||
const [value, setValue] = useState<string[]>(() => {
|
const [value, setValue] = useState<string[]>(() => {
|
||||||
|
if (typeof window === "undefined") return []
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key)
|
const raw = window.localStorage.getItem(key)
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []
|
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []
|
||||||
@@ -1513,7 +1545,7 @@ function useLocalStorageArray(key: string) {
|
|||||||
(nextValue: string[]) => {
|
(nextValue: string[]) => {
|
||||||
const deduped = Array.from(new Set(nextValue.filter(Boolean)))
|
const deduped = Array.from(new Set(nextValue.filter(Boolean)))
|
||||||
setValue(deduped)
|
setValue(deduped)
|
||||||
localStorage.setItem(key, JSON.stringify(deduped))
|
if (typeof window !== "undefined") window.localStorage.setItem(key, JSON.stringify(deduped))
|
||||||
},
|
},
|
||||||
[key],
|
[key],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export type MailItem = {
|
|||||||
source?: string
|
source?: string
|
||||||
address?: string
|
address?: string
|
||||||
subject?: string
|
subject?: string
|
||||||
|
sender?: string
|
||||||
|
html?: string
|
||||||
message?: string
|
message?: string
|
||||||
text?: string
|
text?: string
|
||||||
raw?: string
|
raw?: string
|
||||||
@@ -158,8 +160,8 @@ const normalizeOpenSettings = (payload: Record<string, unknown>): OpenSettings =
|
|||||||
title: String(payload.title || ""),
|
title: String(payload.title || ""),
|
||||||
prefix: String(payload.prefix || ""),
|
prefix: String(payload.prefix || ""),
|
||||||
addressRegex: String(payload.addressRegex || ""),
|
addressRegex: String(payload.addressRegex || ""),
|
||||||
minAddressLen: Number(payload.minAddressLen || 1),
|
minAddressLen: Number(payload.minAddressLen ?? 1),
|
||||||
maxAddressLen: Number(payload.maxAddressLen || 30),
|
maxAddressLen: Number(payload.maxAddressLen ?? 30),
|
||||||
needAuth: Boolean(payload.needAuth),
|
needAuth: Boolean(payload.needAuth),
|
||||||
enableUserCreateEmail: Boolean(payload.enableUserCreateEmail),
|
enableUserCreateEmail: Boolean(payload.enableUserCreateEmail),
|
||||||
disableAnonymousUserCreateEmail: Boolean(payload.disableAnonymousUserCreateEmail),
|
disableAnonymousUserCreateEmail: Boolean(payload.disableAnonymousUserCreateEmail),
|
||||||
@@ -354,7 +356,7 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listMails(limit: number, offset: number) {
|
async listMails(limit: number, offset: number) {
|
||||||
return this.request<MailListResponse>(`/api/mails?limit=${limit}&offset=${offset}`)
|
return this.request<MailListResponse>(`/api/parsed_mails?limit=${limit}&offset=${offset}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteMail(id: string | number) {
|
async deleteMail(id: string | number) {
|
||||||
|
|||||||
Reference in New Issue
Block a user