diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..013bf64 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,98 @@ +# 工作流的名称,会显示在 GitHub Actions 页面 +name: Deploy to Cloudflare + +# 'on' 关键字定义了触发此工作流的事件 +on: + # 当有代码推送到 'main' 分支时,自动触发此工作流 + push: + branches: + - main + # 允许你在 GitHub Actions 页面手动点击按钮来触发此工作流 + workflow_dispatch: + +# 'jobs' 关键字定义了工作流中要执行的一系列任务 +jobs: + # 定义一个名为 'deploy' 的任务 + deploy: + # 指定此任务运行在最新版的 Ubuntu 虚拟环境中 + runs-on: ubuntu-latest + # 此任务在 GitHub UI 中显示的名称 + name: Deploy + # 'steps' 关键字定义了任务中要按顺序执行的一系列步骤 + steps: + # 第一步:检出代码 + # 使用官方的 'checkout' action 来获取你的仓库代码 + - name: Checkout + uses: actions/checkout@v4 + + # 第二步:设置 pnpm 包管理器 + # 使用 pnpm 官方的 'action-setup' 来安装和配置 pnpm + - name: Setup pnpm + uses: pnpm/action-setup@v4 + # 注意:此处已移除 "version: 8" 行,以允许 action 自动从 package.json 的 "packageManager" 字段读取版本 + + # 第三步:设置 Node.js 环境 + # 使用官方的 'setup-node' action 来安装指定版本的 Node.js + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + # 指定 Node.js 的版本 + node-version: '20' + # 为 pnpm 配置依赖缓存,以加快后续运行速度 + cache: 'pnpm' + + # 第四步:安装项目依赖 + # 执行 'pnpm install' 命令来安装所有在 'package.json' 和 'pnpm-lock.yaml' 中定义的依赖 + - name: Install dependencies + run: pnpm install + + # 第五步:手动安装 Wrangler + # 由于项目使用 pnpm workspace,我们需要手动安装 wrangler 并使用 '-w' 标志 + # 这可以避免 wrangler-action 自动安装时可能出现的冲突 + - name: Install Wrangler + run: pnpm add wrangler@4 -w + + # 第六步:构建项目 + # 执行在 'package.json' 中定义的 'build' 脚本,通常用于编译前端和后端代码 + - name: Build + run: pnpm run build + + # 第七步:配置 Wrangler 的 toml 文件 + # 这是关键的一步,在部署前动态地将占位符替换为真实的 secrets 值 + - name: Configure Wrangler + run: | + # 使用 sed 命令在 wrangler.toml 文件中查找并替换 D1 数据库 ID 的占位符 + sed -i "s/\${D1_DATABASE_ID}/${D1_DATABASE_ID}/g" wrangler.toml + # 使用 sed 命令在 wrangler.toml 文件中查找并替换 D1 数据库名称的占位符 + sed -i "s/\${D1_DATABASE_NAME}/${D1_DATABASE_NAME}/g" wrangler.toml + # 使用 sed 命令在 wrangler.toml 文件中查找并替换邮箱域名的占位符 + sed -i "s/\${VITE_EMAIL_DOMAIN}/${VITE_EMAIL_DOMAIN}/g" wrangler.toml + env: + # 从 GitHub Secrets 中读取 D1 数据库 ID 和名称,并将其作为环境变量注入到此步骤中 + D1_DATABASE_ID: ${{ secrets.D1_DATABASE_ID }} + D1_DATABASE_NAME: ${{ secrets.D1_DATABASE_NAME }} + VITE_EMAIL_DOMAIN: ${{ secrets.VITE_EMAIL_DOMAIN }} + + # 新增步骤:检查 Cloudflare 密钥是否存在 + - name: Check for Cloudflare credentials + id: cf_creds + run: | + if [ -z "${{ secrets.CF_API_TOKEN }}" ] || [ -z "${{ secrets.CF_ACCOUNT_ID }}" ]; then + echo "Cloudflare credentials not found, skipping deployment." + echo "has_creds=false" >> $GITHUB_ENV + else + echo "has_creds=true" >> $GITHUB_ENV + fi + + # 第八步:部署到 Cloudflare + # 仅在检测到密钥存在时执行 + - name: Deploy to Cloudflare + if: env.has_creds == 'true' + uses: cloudflare/wrangler-action@v3 + with: + # 传入 API 令牌用于身份验证 + apiToken: ${{ secrets.CF_API_TOKEN }} + # 传入 Cloudflare 账户 ID + accountId: ${{ secrets.CF_ACCOUNT_ID }} + # 明确指定要执行的命令是 'deploy' + command: deploy \ No newline at end of file diff --git a/README.en.md b/README.en.md index cc20fe1..2ef3182 100644 --- a/README.en.md +++ b/README.en.md @@ -121,7 +121,7 @@ We provide two deployment methods, you can choose according to your needs: -#### Option 2: Fork and Custom Deployment (Recommended for Advanced Users) +#### Option 2: Fork and Custom Deployment via Github Action (Recommended for Advanced Users)

✅ Advantages:

@@ -129,33 +129,32 @@ We provide two deployment methods, you can choose according to your needs:
  • Can receive subsequent code updates
  • Fully customizable configuration
  • Better version control
  • +
  • Automatic deployment via GitHub Actions, more secure and convenient
  • ❌ Disadvantages:

    📋 Deployment Steps:

    1. Fork this project to your GitHub account
    2. -
    3. Create a D1 database in Cloudflare Dashboard, remember the database name and id
    4. -
    5. Modify the wrangler.toml file in the root directory: +
    6. Create a D1 database in your Cloudflare Dashboard and note down the database_name and database_id
    7. +
    8. In your GitHub repository, go to Settings > Secrets and variables > Actions
    9. +
    10. Click New repository secret and add the following five secrets:
        -
      • Modify name to your application name (optional)
      • -
      • Modify routes with your custom domain (required! Default worker.dev domain is not accessible in mainland China)
      • -
      • Modify database_name and database_id to your D1 database information (required!)
      • -
      • Modify VITE_EMAIL_DOMAIN to your domain list (optional, just for convenient copy-paste of email addresses on the webpage, doesn't affect email reception)
      • +
      • CF_API_TOKEN: Your Cloudflare API Token. You can create one here using the "Edit Cloudflare Workers" template.
      • +
      • CF_ACCOUNT_ID: Your Cloudflare Account ID. You can find it on the right side of the Workers page.
      • +
      • D1_DATABASE_ID: The ID of the D1 database you created in step 2.
      • +
      • D1_DATABASE_NAME: The name of the D1 database you created in step 2.
      • +
      • VITE_EMAIL_DOMAIN: Your list of domains, separated by commas (e.g., example.com,test.com).
    11. -
    12. In Cloudflare Dashboard, select "Workers & Pages"
    13. -
    14. Click "Create application" -> "Pages" -> "Connect to Git"
    15. -
    16. Select your forked repository
    17. -
    18. Configure build settings (usually auto-detected)
    19. -
    20. Click "Save and Deploy"
    21. -
    22. Configure Cloudflare Email routing to forward emails to your Worker
    23. +
    24. After completing the steps above, the project will be automatically deployed on every push to the main branch. You can also trigger the deployment manually from the Actions page.
    25. +
    26. After deployment, bind a custom domain to your Worker.
    27. +
    28. Finally, configure Cloudflare Email Routing to forward emails to your Worker.
    diff --git a/README.md b/README.md index cebbd69..deb61b4 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ ZMAIL 现在采用全新的一体化部署方式,前端和后端整合为一 -#### 方式二:Fork 后自定义部署(推荐进阶用户) +#### 方式二:Fork 后通过 Github Action 自定义部署(推荐进阶用户)

    ✅ 优点:

    @@ -127,33 +127,32 @@ ZMAIL 现在采用全新的一体化部署方式,前端和后端整合为一
  • 可以获得后续代码更新
  • 完全自定义配置
  • 更好的版本控制
  • +
  • 通过 GitHub Action 自动部署,更加安全便捷
  • ❌ 缺点:

    📋 部署步骤:

    1. Fork 本项目到您的 GitHub 账户
    2. -
    3. 在 Cloudflare Dashboard 中创建一个 D1 数据库,记住数据库的 name 和 id
    4. -
    5. 修改根目录下的 wrangler.toml 文件: +
    6. 在 Cloudflare Dashboard 中创建一个 D1 数据库,并记录下数据库的 database_name 和 database_id
    7. +
    8. 在您的 GitHub 仓库中, 前往 Settings > Secrets and variables > Actions
    9. +
    10. 点击 New repository secret 并添加以下五个密钥:
        -
      • 修改 name 为您的应用名称。(可选)
      • -
      • 修改 routes 中的自定义域名。(必须!默认的worker.dev域名在中国大陆无法访问)
      • -
      • 修改 database_name 和 database_id 为您的 D1 数据库信息。(必须!)
      • -
      • 修改 VITE_EMAIL_DOMAIN 为您的域名列表。(可选,只是方便网页上复制粘贴邮件地址使用,不设置也不影响接收邮件)
      • +
      • CF_API_TOKEN: 你的 Cloudflare API Token。你可以在 这里 创建,使用 "Edit Cloudflare Workers" 模板即可。
      • +
      • CF_ACCOUNT_ID: 你的 Cloudflare 账户 ID。你可以在 Workers 页面的右侧找到。
      • +
      • D1_DATABASE_ID: 你在第二步中创建的 D1 数据库的 ID。
      • +
      • D1_DATABASE_NAME: 你在第二步中创建的 D1 数据库的名称。
      • +
      • VITE_EMAIL_DOMAIN: 你的域名列表,多个域名用逗号 ',' 分割 (例如: example.com,test.com)。
    11. -
    12. 在 Cloudflare Dashboard 中选择 "Workers & Pages"
    13. -
    14. 点击 "Create application" -> "Pages" -> "Connect to Git"
    15. -
    16. 选择您 Fork 的仓库
    17. -
    18. 配置构建设置(通常会自动检测)
    19. -
    20. 点击 "Save and Deploy"
    21. -
    22. 配置 Cloudflare Email 路由,将邮件转发到您的 Worker
    23. +
    24. 完成以上步骤后,项目将在每次推送到 main 分支时自动部署。你也可以在 Actions 页面手动触发部署。
    25. +
    26. 部署完成后,为你的 Worker 绑定一个自定义域名。
    27. +
    28. 最后,配置 Cloudflare Email 路由,将邮件转发到你的 Worker。
    diff --git a/frontend/i18n/locales/en.json b/frontend/i18n/locales/en.json index f9912eb..cfa4a65 100644 --- a/frontend/i18n/locales/en.json +++ b/frontend/i18n/locales/en.json @@ -57,7 +57,10 @@ "notFound": "Mailbox not found or expired", "creatingNew": "Creating new mailbox...", "switch": "Switch Mailbox", - "savedMailboxes": "Saved Mailboxes" + "savedMailboxes": "Saved Mailboxes", + "confirmDeleteMailbox": "Are you sure you want to delete this saved mailbox?", + "confirmClearAllMailboxes": "Are you sure you want to clear all saved mailboxes? (The current mailbox will not be deleted)", + "clearAll": "Clear All" }, "email": { "inbox": "Inbox", @@ -90,7 +93,8 @@ "messages": "messages" }, "settings": { - "language": "Language" + "language": "Language", + "toggleTheme": "Toggle Theme" }, "errors": { "generic": "An error occurred, please try again later", diff --git a/frontend/i18n/locales/zh-CN.json b/frontend/i18n/locales/zh-CN.json index b0d9444..108b173 100644 --- a/frontend/i18n/locales/zh-CN.json +++ b/frontend/i18n/locales/zh-CN.json @@ -57,7 +57,10 @@ "notFound": "邮箱不存在或已过期", "creatingNew": "正在创建新邮箱...", "switch": "切换邮箱", - "savedMailboxes": "已保存的邮箱" + "savedMailboxes": "已保存的邮箱", + "confirmDeleteMailbox": "您确定要删除这个已保存的邮箱吗?", + "confirmClearAllMailboxes": "您确定要清空所有已保存的邮箱吗?(当前使用的邮箱不会被删除)", + "clearAll": "全部清除" }, "email": { "inbox": "收件箱", @@ -90,7 +93,8 @@ "messages": "封邮件" }, "settings": { - "language": "语言" + "language": "语言", + "toggleTheme": "切换主题" }, "errors": { "generic": "发生错误,请稍后重试", diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 9a25ee9..20a4a76 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -5,6 +5,7 @@ import LanguageSwitcher from './LanguageSwitcher'; import HeaderMailbox from './HeaderMailbox'; import Container from './Container'; import { getEmailDomains, getDefaultEmailDomain, EMAIL_DOMAINS, DEFAULT_EMAIL_DOMAIN } from '../config'; +import ThemeSwitcher from './ThemeSwitcher'; // 导入新增的主题切换组件 interface HeaderProps { mailbox: Mailbox | null; @@ -56,6 +57,8 @@ const Header: React.FC = ({ isLoading={isLoading} />
    + {/* 在这里添加主题切换组件 */} + = ({ }; // 切换域名 - const handleDomainChange = (e: React.ChangeEvent) => { + const handleDomainChange = async (e: React.ChangeEvent) => { setSelectedDomain(e.target.value); + // [fix] 切换域名后自动更换邮箱 + await handleRefreshMailbox(); }; // 按钮基础样式 @@ -231,15 +233,21 @@ const HeaderMailbox: React.FC = ({ /> @ - + {/* [fix]: 为select包裹一个relative容器,用于绝对定位自定义箭头 */} +
    + + {/* [fix]: 添加自定义的下拉箭头图标 */} + +
    {showDropdown && ( -
    -
    + // [fix]: 将 bg-white 替换为 bg-popover 和 text-popover-foreground 以支持黑暗模式 +
    +
    {t('mailbox.savedMailboxes') || "已保存的邮箱"} +
    {savedMailboxes.map((m) => ( - +
    + + {m.address !== currentMailbox.address && ( + + )} +
    ))}
    diff --git a/frontend/src/components/ThemeSwitcher.tsx b/frontend/src/components/ThemeSwitcher.tsx new file mode 100644 index 0000000..a6ca5b3 --- /dev/null +++ b/frontend/src/components/ThemeSwitcher.tsx @@ -0,0 +1,50 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +/** + * 主题切换组件 + * 用于在明亮和暗黑模式之间切换 + */ +const ThemeSwitcher: React.FC = () => { + const { t } = useTranslation(); + + // 从 localStorage 获取初始主题,如果不存在则默认为 'light' + const [theme, setTheme] = useState(() => { + const savedTheme = localStorage.getItem('theme'); + return savedTheme || 'light'; + }); + + // 使用 useEffect 监听 theme 状态的变化,并应用到 元素上 + useEffect(() => { + const root = window.document.documentElement; + // 先移除旧的 class,以防万一 + root.classList.remove(theme === 'light' ? 'dark' : 'light'); + // 添加当前主题的 class + root.classList.add(theme); + // 将当前主题保存到 localStorage + localStorage.setItem('theme', theme); + }, [theme]); + + // 定义切换主题的函数 + const toggleTheme = () => { + setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light')); + }; + + return ( + + ); +}; + +export default ThemeSwitcher; \ No newline at end of file diff --git a/frontend/src/contexts/MailboxContext.tsx b/frontend/src/contexts/MailboxContext.tsx index 47173b0..8f39da1 100644 --- a/frontend/src/contexts/MailboxContext.tsx +++ b/frontend/src/contexts/MailboxContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useState, useEffect, ReactNode, useRef } from 'react'; -import { - createRandomMailbox, - getMailboxFromLocalStorage, +import { + createRandomMailbox, + getMailboxFromLocalStorage, saveMailboxToLocalStorage, removeMailboxFromLocalStorage, getEmails, @@ -82,7 +82,7 @@ export const MailboxProvider: React.FC = ({ children }) => const [successMessage, setSuccessMessage] = useState(null); const errorTimeoutRef = useRef(null); const successTimeoutRef = useRef(null); - + // 清除提示的定时器 useEffect(() => { return () => { @@ -94,13 +94,13 @@ export const MailboxProvider: React.FC = ({ children }) => } }; }, []); - + // 初始化:检查本地存储或创建新邮箱 useEffect(() => { const initMailbox = async () => { // 检查本地存储中是否有未过期的邮箱 const savedMailbox = getMailboxFromLocalStorage(); - + if (savedMailbox) { setMailbox(savedMailbox); setIsLoading(false); @@ -109,32 +109,36 @@ export const MailboxProvider: React.FC = ({ children }) => await createNewMailbox(); } }; - + initMailbox(); }, []); - + // 创建新邮箱 const createNewMailbox = async () => { try { // 清除之前的错误和成功信息 setErrorMessage(null); setSuccessMessage(null); - + console.log('createNewMailbox: Started'); setIsLoading(true); - + console.log('createNewMailbox: Calling createRandomMailbox...'); const result = await createRandomMailbox(); console.log('createNewMailbox: createRandomMailbox result:', result); - + if (result.success && result.mailbox) { console.log('createNewMailbox: Setting new mailbox:', result.mailbox); setMailbox(result.mailbox); saveMailboxToLocalStorage(result.mailbox); + // [fix]: 创建新邮箱后,清空旧的邮件列表和缓存 + setEmails([]); + setSelectedEmail(null); + clearEmailCache(); } else { console.error('createNewMailbox: Failed to create mailbox:', result.error); setErrorMessage(t('mailbox.createFailed')); - + // 3秒后清除错误信息 if (errorTimeoutRef.current) { window.clearTimeout(errorTimeoutRef.current); @@ -151,30 +155,30 @@ export const MailboxProvider: React.FC = ({ children }) => setIsLoading(false); } }; - + // 删除邮箱 const deleteMailbox = async () => { if (!mailbox) return; - + try { // 清除之前的错误和成功信息 setErrorMessage(null); setSuccessMessage(null); - + // 调用API删除邮箱 const result = await apiDeleteMailbox(mailbox.address); - + if (result.success) { // 显示成功信息 setSuccessMessage(t('mailbox.deleteSuccess')); - + // 清除本地数据 setMailbox(null); setEmails([]); setSelectedEmail(null); removeMailboxFromLocalStorage(); clearEmailCache(); - + // 3秒后清除成功信息 if (successTimeoutRef.current) { window.clearTimeout(successTimeoutRef.current); @@ -182,13 +186,13 @@ export const MailboxProvider: React.FC = ({ children }) => successTimeoutRef.current = window.setTimeout(() => { setSuccessMessage(null); }, 3000); - + // 创建新邮箱 await createNewMailbox(); } else { // 显示错误信息 setErrorMessage(t('mailbox.deleteFailed')); - + // 3秒后清除错误信息 if (errorTimeoutRef.current) { window.clearTimeout(errorTimeoutRef.current); @@ -199,10 +203,10 @@ export const MailboxProvider: React.FC = ({ children }) => } } catch (error) { console.error('Error deleting mailbox:', error); - + // 显示错误信息 setErrorMessage(t('mailbox.deleteFailed')); - + // 3秒后清除错误信息 if (errorTimeoutRef.current) { window.clearTimeout(errorTimeoutRef.current); @@ -212,37 +216,24 @@ export const MailboxProvider: React.FC = ({ children }) => }, 3000); } }; - + // 刷新邮件列表 const refreshEmails = async () => { if (!mailbox) return; - + // 防止重复请求 if (isEmailsLoading) return; - + setIsEmailsLoading(true); - + try { const result = await getEmails(mailbox.address); - + if (result.success) { setEmails(result.emails); } else if (result.notFound) { - // 如果邮箱不存在,清除本地缓存并创建新邮箱 - try { - // 直接调用handleMailboxNotFound函数 - await handleMailboxNotFound(); - } catch (error) { - // 出错时也尝试清除缓存并创建新邮箱 - setMailbox(null); - setEmails([]); - setSelectedEmail(null); - removeMailboxFromLocalStorage(); - clearEmailCache(); - - // 刷新页面 - window.location.href = '/'; - } + // [fix]: 如果邮箱不存在,调用 handleMailboxNotFound 进行平滑处理,而不是强制刷新页面 + await handleMailboxNotFound(); } } catch (error) { // 错误处理 @@ -251,64 +242,48 @@ export const MailboxProvider: React.FC = ({ children }) => setIsEmailsLoading(false); } }; - + // 自动刷新邮件 useEffect(() => { - if (!mailbox) return; - + if (!mailbox || isLoading) return; + // 首次加载邮件(无论autoRefresh是否开启) refreshEmails(); - + // 如果自动刷新开启,则设置定时器 let intervalId: number | undefined; if (autoRefresh) { intervalId = window.setInterval(refreshEmails, AUTO_REFRESH_INTERVAL); } - + return () => { if (intervalId) { clearInterval(intervalId); } }; - }, [mailbox, autoRefresh]); - - // 处理邮箱不存在的情况 + }, [mailbox, autoRefresh, isLoading]); + + // [fix]: 重构处理邮箱不存在的逻辑,避免页面刷新 const handleMailboxNotFound = async () => { - try { - // 清除之前的错误和成功信息 - setErrorMessage(null); - setSuccessMessage(null); - - setSuccessMessage(t('mailbox.creatingNew')); - - // 清除当前邮箱信息 - setMailbox(null); - setEmails([]); - setSelectedEmail(null); - removeMailboxFromLocalStorage(); - clearEmailCache(); - - // 创建新邮箱 - try { - const result = await createRandomMailbox(); - - if (result.success && result.mailbox) { - // 直接保存到localStorage,而不是通过setMailbox触发状态更新 - saveMailboxToLocalStorage(result.mailbox); - - // 直接刷新页面,让页面重新加载时从localStorage获取新邮箱 - window.location.href = '/'; // 使用href而不是reload,确保导航到首页 - } else { - throw new Error('Failed to create mailbox'); - } - } catch (error) { - throw error; - } - } catch (error) { - throw error; + // 设置提示信息,告知用户正在创建新邮箱 + setSuccessMessage(t('mailbox.creatingNew')); + + // 清除当前无效的邮箱信息 + removeMailboxFromLocalStorage(); + clearEmailCache(); + + // 异步创建新邮箱,并更新应用状态 + await createNewMailbox(); + + // 3秒后清除提示信息 + if (successTimeoutRef.current) { + window.clearTimeout(successTimeoutRef.current); } + successTimeoutRef.current = window.setTimeout(() => { + setSuccessMessage(null); + }, 3000); }; - + // 添加邮件到缓存 const addToEmailCache = (emailId: string, email: Email, attachments: any[]) => { setEmailCache(prev => ({ @@ -319,7 +294,7 @@ export const MailboxProvider: React.FC = ({ children }) => timestamp: Date.now() } })); - + // 保存到localStorage try { const mailboxAddress = mailbox?.address; @@ -339,11 +314,11 @@ export const MailboxProvider: React.FC = ({ children }) => console.error('Error saving email cache to localStorage:', error); } }; - + // 清除邮件缓存 const clearEmailCache = () => { setEmailCache({}); - + // 清除localStorage中的缓存 try { const mailboxAddress = mailbox?.address; @@ -355,15 +330,15 @@ export const MailboxProvider: React.FC = ({ children }) => console.error('Error clearing email cache from localStorage:', error); } }; - + // 从localStorage加载邮件缓存 useEffect(() => { if (!mailbox) return; - + try { const cacheKey = `emailCache_${mailbox.address}`; const cachedData = localStorage.getItem(cacheKey); - + if (cachedData) { const parsedCache = JSON.parse(cachedData); setEmailCache(parsedCache); @@ -372,13 +347,13 @@ export const MailboxProvider: React.FC = ({ children }) => console.error('Error loading email cache from localStorage:', error); } }, [mailbox]); - + // 设置邮箱并保存到localStorage const handleSetMailbox = (newMailbox: Mailbox) => { setMailbox(newMailbox); saveMailboxToLocalStorage(newMailbox); }; - + return ( = ({ children }) => successMessage }} > - {/* 错误和成功提示 */} + {/* [feat] 将错误和成功提示移至此处,作为全局通知组件 */} {(errorMessage || successMessage) && (
    {errorMessage || successMessage} diff --git a/worker/src/database.ts b/worker/src/database.ts index 3899c4d..403a7ae 100644 --- a/worker/src/database.ts +++ b/worker/src/database.ts @@ -128,6 +128,7 @@ export async function getMailboxes(db: D1Database, ipAddress: string): Promise { + // [feat] 由于外键设置了 ON DELETE CASCADE,直接删除邮箱即可级联删除相关邮件和附件 await db.prepare(`DELETE FROM mailboxes WHERE address = ?`).bind(address).run(); } @@ -137,39 +138,39 @@ export async function deleteMailbox(db: D1Database, address: string): Promise { - try { - // 获取所有孤立附件的ID列表 - const orphanedAttachments = await db.prepare(`SELECT id, is_large FROM attachments a WHERE NOT EXISTS (SELECT 1 FROM emails e WHERE e.id = a.email_id)`).all(); - - if (!orphanedAttachments.results || orphanedAttachments.results.length === 0) { - return 0; + // [refactor] 优化孤立附件的清理逻辑 + try { + // 一次性查询所有孤立附件及其分块信息 + const orphanedAttachmentsResult = await db.prepare(` + SELECT a.id + FROM attachments a + LEFT JOIN emails e ON a.email_id = e.id + WHERE e.id IS NULL + `).all<{ id: string }>(); + + if (!orphanedAttachmentsResult.results || orphanedAttachmentsResult.results.length === 0) { + return 0; + } + + const attachmentIds = orphanedAttachmentsResult.results.map(row => row.id); + const placeholders = attachmentIds.map(() => '?').join(','); + + console.log(`找到 ${attachmentIds.length} 个孤立附件,准备清理...`); + + // 批量删除附件分块 + await db.prepare(`DELETE FROM attachment_chunks WHERE attachment_id IN (${placeholders})`).bind(...attachmentIds).run(); + console.log(`已清理孤立附件的所有分块`); + + // 批量删除附件记录 + const deleteResult = await db.prepare(`DELETE FROM attachments WHERE id IN (${placeholders})`).bind(...attachmentIds).run(); + const deletedCount = deleteResult.meta?.changes || 0; + console.log(`已清理 ${deletedCount} 个孤立附件记录`); + + return deletedCount; + } catch (error) { + console.error('清理孤立附件时出错:', error); + return 0; } - - console.log(`找到 ${orphanedAttachments.results.length} 个孤立附件,准备清理...`); - - // 清理每个孤立附件 - for (const attachment of orphanedAttachments.results) { - const attachmentId = attachment.id as string; - const isLarge = !!attachment.is_large; - - // 如果是大型附件,先删除所有分块 - if (isLarge) { - await db.prepare(`DELETE FROM attachment_chunks WHERE attachment_id = ?`).bind(attachmentId).run(); - console.log(`已清理孤立附件 ${attachmentId} 的所有分块`); - } - } - - // 删除所有孤立附件记录 - const result = await db.prepare(`DELETE FROM attachments WHERE NOT EXISTS (SELECT 1 FROM emails e WHERE e.id = attachments.email_id)`).run(); - - const deletedCount = result.meta?.changes || 0; - console.log(`已清理 ${deletedCount} 个孤立附件记录`); - - return deletedCount; - } catch (error) { - console.error('清理孤立附件时出错:', error); - return 0; - } } /** @@ -179,37 +180,12 @@ async function cleanupOrphanedAttachments(db: D1Database): Promise { */ export async function cleanupExpiredMailboxes(db: D1Database): Promise { const now = getCurrentTimestamp(); - - // 获取过期邮箱的ID列表 - const expiredMailboxes = await db.prepare(`SELECT id FROM mailboxes WHERE expires_at <= ?`).bind(now).all(); - - if (expiredMailboxes.results && expiredMailboxes.results.length > 0) { - const mailboxIds = expiredMailboxes.results.map(row => row.id as string); - - // 记录日志 - console.log(`找到 ${mailboxIds.length} 个过期邮箱,准备清理...`); - - // 由于设置了外键级联删除,删除邮箱时会自动删除相关的邮件和附件 - // 但为了确保附件被正确清理,我们先获取这些邮箱中的所有邮件ID - for (const mailboxId of mailboxIds) { - const emails = await db.prepare(`SELECT id FROM emails WHERE mailbox_id = ?`).bind(mailboxId).all(); - - if (emails.results && emails.results.length > 0) { - const emailIds = emails.results.map(row => row.id as string); - console.log(`邮箱 ${mailboxId} 中有 ${emailIds.length} 封邮件需要清理`); - - // 清理每封邮件的附件 - for (const emailId of emailIds) { - await cleanupAttachments(db, emailId); - } - } - } - } - - // 删除过期邮箱(会级联删除邮件和附件) + // [refactor] 由于数据库 schema 中设置了 ON DELETE CASCADE, + // 删除 mailboxes 表中的记录会自动删除 emails, attachments, 和 attachment_chunks 中所有相关的记录。 + // 这大大简化了清理逻辑,并提高了性能。 const result = await db.prepare(`DELETE FROM mailboxes WHERE expires_at <= ?`).bind(now).run(); - // 清理可能存在的孤立附件 + // 清理可能由于异常情况产生的孤立附件 await cleanupOrphanedAttachments(db); return result.meta?.changes || 0; @@ -224,23 +200,9 @@ export async function cleanupExpiredMails(db: D1Database): Promise { const now = getCurrentTimestamp(); const oneDayAgo = now - 24 * 60 * 60; // 24小时前的时间戳(秒) - // 获取过期邮件的ID列表 - const expiredEmails = await db.prepare(`SELECT id FROM emails WHERE received_at <= ?`).bind(oneDayAgo).all(); - - if (expiredEmails.results && expiredEmails.results.length > 0) { - const emailIds = expiredEmails.results.map(row => row.id as string); - console.log(`找到 ${emailIds.length} 封过期邮件,准备清理...`); - - // 清理每封邮件的附件 - for (const emailId of emailIds) { - await cleanupAttachments(db, emailId); - } - } - - // 删除过期邮件(会级联删除附件) + // [refactor] 同样利用 ON DELETE CASCADE 特性简化逻辑 const result = await db.prepare(`DELETE FROM emails WHERE received_at <= ?`).bind(oneDayAgo).run(); - // 清理可能存在的孤立附件 await cleanupOrphanedAttachments(db); return result.meta?.changes || 0; @@ -252,23 +214,9 @@ export async function cleanupExpiredMails(db: D1Database): Promise { * @returns 删除的邮件数量 */ export async function cleanupReadMails(db: D1Database): Promise { - // 获取已读邮件的ID列表 - const readEmails = await db.prepare(`SELECT id FROM emails WHERE is_read = 1`).all(); - - if (readEmails.results && readEmails.results.length > 0) { - const emailIds = readEmails.results.map(row => row.id as string); - console.log(`找到 ${emailIds.length} 封已读邮件,准备清理...`); - - // 清理每封邮件的附件 - for (const emailId of emailIds) { - await cleanupAttachments(db, emailId); - } - } - - // 删除已读邮件(会级联删除附件) + // [refactor] 同样利用 ON DELETE CASCADE 特性简化逻辑 const result = await db.prepare(`DELETE FROM emails WHERE is_read = 1`).run(); - // 清理可能存在的孤立附件 await cleanupOrphanedAttachments(db); return result.meta?.changes || 0; @@ -280,26 +228,24 @@ export async function cleanupReadMails(db: D1Database): Promise { * @param emailId 邮件ID */ async function cleanupAttachments(db: D1Database, emailId: string): Promise { + // [refactor] 利用 ON DELETE CASCADE,此函数在删除邮件时不再需要手动调用。 + // 但保留此函数以备其他需要单独清理附件的场景。 try { - // 获取邮件的所有附件 - const attachments = await db.prepare(`SELECT id, is_large FROM attachments WHERE email_id = ?`).bind(emailId).all(); + // 获取邮件的所有附件ID + const attachmentsResult = await db.prepare(`SELECT id FROM attachments WHERE email_id = ?`).bind(emailId).all<{ id: string }>(); - if (attachments.results && attachments.results.length > 0) { - console.log(`邮件 ${emailId} 有 ${attachments.results.length} 个附件需要清理`); + if (attachmentsResult.results && attachmentsResult.results.length > 0) { + const attachmentIds = attachmentsResult.results.map(row => row.id); + const placeholders = attachmentIds.map(() => '?').join(','); + + console.log(`邮件 ${emailId} 有 ${attachmentIds.length} 个附件需要清理`); - for (const attachment of attachments.results) { - const attachmentId = attachment.id as string; - const isLarge = !!attachment.is_large; - - // 如果是大型附件,先删除所有分块 - if (isLarge) { - await db.prepare(`DELETE FROM attachment_chunks WHERE attachment_id = ?`).bind(attachmentId).run(); - console.log(`已清理附件 ${attachmentId} 的所有分块`); - } - } + // 批量删除所有分块 + await db.prepare(`DELETE FROM attachment_chunks WHERE attachment_id IN (${placeholders})`).bind(...attachmentIds).run(); + console.log(`已清理附件的所有分块`); - // 删除所有附件记录 - await db.prepare(`DELETE FROM attachments WHERE email_id = ?`).bind(emailId).run(); + // 批量删除所有附件记录 + await db.prepare(`DELETE FROM attachments WHERE id IN (${placeholders})`).bind(...attachmentIds).run(); console.log(`已清理邮件 ${emailId} 的所有附件`); } } catch (error) { @@ -558,9 +504,6 @@ async function getAttachmentContent(db: D1Database, attachmentId: string, chunks * @param id 邮件ID */ export async function deleteEmail(db: D1Database, id: string): Promise { - // 先清理邮件的所有附件 - await cleanupAttachments(db, id); - - // 然后删除邮件 + // [refactor] 由于外键设置了 ON DELETE CASCADE,直接删除邮件即可 await db.prepare(`DELETE FROM emails WHERE id = ?`).bind(id).run(); } \ No newline at end of file diff --git a/wrangler.toml b/wrangler.toml index 2d29e6f..a6f8e9b 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -8,14 +8,16 @@ compatibility_date = "2025-03-01" keep_vars = true # 启用 D1 数据库 +# 注意:这里的 binding 是固定的 "DB",database_name 和 database_id 将由 GitHub Action 动态替换 [[d1_databases]] binding = "DB" -database_name = "zmail" # 替换为您的实际的数据库 NAME -database_id = "d77b6f63-43ad-46a9-8a49-d4a2706be53e" # 需要替换为您的实际数据库 ID +database_name = "${D1_DATABASE_NAME}" # 这是 D1 数据库名称的占位符 +database_id = "${D1_DATABASE_ID}" # 这是 D1 数据库 ID 的占位符 # 设置域名 +# 注意:这里的 VITE_EMAIL_DOMAIN 将由 GitHub Action 动态替换 [vars] -VITE_EMAIL_DOMAIN = "mdzz.uk,zaunist.com" # 替换为您的域名列表,多个域名使用逗号 , 分割。这里的域名只是用来网页上展示,方便复制粘贴使用。 +VITE_EMAIL_DOMAIN = "${VITE_EMAIL_DOMAIN}" # 这是邮箱域名的占位符 # 配置邮件处理 [triggers]