Merge pull request #24 from gujiangjiang/main

feat: 添加Github Action实现自动化部署
This commit is contained in:
阿杰鲁 authored and GitHub committed 2025-10-10 05:02:20 +08:00
commit 37fe76965c
12 files changed
+428 -271

No files matched your search

+98
View File
@@ -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
+14 -15
View File
@@ -121,7 +121,7 @@ We provide two deployment methods, you can choose according to your needs:
</ol>
</div>
#### Option 2: Fork and Custom Deployment (Recommended for Advanced Users)
#### Option 2: Fork and Custom Deployment via Github Action (Recommended for Advanced Users)
<div style="background-color: #2d2d2d; color: #ffffff; padding: 15px; border-radius: 5px; margin: 15px 0;">
<h4>✅ Advantages:</h4>
@@ -129,33 +129,32 @@ We provide two deployment methods, you can choose according to your needs:
<li>Can receive subsequent code updates</li>
<li>Fully customizable configuration</li>
<li>Better version control</li>
<li>Automatic deployment via GitHub Actions, more secure and convenient</li>
</ul>
<h4>❌ Disadvantages:</h4>
<ul>
<li>Need to manually modify configuration files</li>
<li>Requires some technical knowledge</li>
<li>Need to manually create database</li>
<li>Need to manually create database and configure secrets</li>
</ul>
<h4>📋 Deployment Steps:</h4>
<ol>
<li>Fork this project to your GitHub account</li>
<li>Create a D1 database in Cloudflare Dashboard, remember the database name and id</li>
<li>Modify the <code>wrangler.toml</code> file in the root directory:
<li>Create a D1 database in your Cloudflare Dashboard and note down the <strong>database_name</strong> and <strong>database_id</strong></li>
<li>In your GitHub repository, go to <strong>Settings</strong> > <strong>Secrets and variables</strong> > <strong>Actions</strong></li>
<li>Click <strong>New repository secret</strong> and add the following five secrets:
<ul>
<li>Modify <code>name</code> to your application name (optional)</li>
<li>Modify <code>routes</code> with your custom domain (required! Default worker.dev domain is not accessible in mainland China)</li>
<li>Modify <code>database_name</code> and <code>database_id</code> to your D1 database information (required!)</li>
<li>Modify <code>VITE_EMAIL_DOMAIN</code> to your domain list (optional, just for convenient copy-paste of email addresses on the webpage, doesn't affect email reception)</li>
<li><code>CF_API_TOKEN</code>: Your Cloudflare API Token. You can create one <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank">here</a> using the "Edit Cloudflare Workers" template.</li>
<li><code>CF_ACCOUNT_ID</code>: Your Cloudflare Account ID. You can find it on the right side of the Workers page.</li>
<li><code>D1_DATABASE_ID</code>: The ID of the D1 database you created in step 2.</li>
<li><code>D1_DATABASE_NAME</code>: The name of the D1 database you created in step 2.</li>
<li><code>VITE_EMAIL_DOMAIN</code>: Your list of domains, separated by commas (e.g., example.com,test.com).</li>
</ul>
</li>
<li>In Cloudflare Dashboard, select "Workers & Pages"</li>
<li>Click "Create application" -> "Pages" -> "Connect to Git"</li>
<li>Select your forked repository</li>
<li>Configure build settings (usually auto-detected)</li>
<li>Click "Save and Deploy"</li>
<li>Configure Cloudflare Email routing to forward emails to your Worker</li>
<li>After completing the steps above, the project will be automatically deployed on every push to the <code>main</code> branch. You can also trigger the deployment manually from the Actions page.</li>
<li>After deployment, bind a custom domain to your Worker.</li>
<li>Finally, configure Cloudflare Email Routing to forward emails to your Worker.</li>
</ol>
</div>
+14 -15
View File
@@ -119,7 +119,7 @@ ZMAIL 现在采用全新的一体化部署方式,前端和后端整合为一
</ol>
</div>
#### 方式二:Fork 后自定义部署(推荐进阶用户)
#### 方式二:Fork 后通过 Github Action 自定义部署(推荐进阶用户)
<div style="background-color: #2d2d2d; color: #ffffff; padding: 15px; border-radius: 5px; margin: 15px 0;">
<h4>✅ 优点:</h4>
@@ -127,33 +127,32 @@ ZMAIL 现在采用全新的一体化部署方式,前端和后端整合为一
<li>可以获得后续代码更新</li>
<li>完全自定义配置</li>
<li>更好的版本控制</li>
<li>通过 GitHub Action 自动部署,更加安全便捷</li>
</ul>
<h4>❌ 缺点:</h4>
<ul>
<li>需要手动修改配置文件</li>
<li>需要一定的技术基础</li>
<li>需要手动创建数据库</li>
<li>需要手动创建数据库和配置密钥</li>
</ul>
<h4>📋 部署步骤:</h4>
<ol>
<li>Fork 本项目到您的 GitHub 账户</li>
<li>在 Cloudflare Dashboard 中创建一个 D1 数据库,记住数据库的 name 和 id</li>
<li>修改根目录下的 <code>wrangler.toml</code> 文件:
<li>在 Cloudflare Dashboard 中创建一个 D1 数据库,并记录下数据库的 <strong>database_name</strong> 和 <strong>database_id</strong></li>
<li>在您的 GitHub 仓库中, 前往 <strong>Settings</strong> > <strong>Secrets and variables</strong> > <strong>Actions</strong></li>
<li>点击 <strong>New repository secret</strong> 并添加以下五个密钥:
<ul>
<li>修改 <code>name</code> 为您的应用名称。(可选)</li>
<li>修改 <code>routes</code> 中的自定义域名。(必须!默认的worker.dev域名在中国大陆无法访问)</li>
<li>修改 <code>database_name</code> 和 <code>database_id</code> 为您的 D1 数据库信息。(必须!)</li>
<li>修改 <code>VITE_EMAIL_DOMAIN</code> 为您的域名列表。(可选,只是方便网页上复制粘贴邮件地址使用,不设置也不影响接收邮件)</li>
<li><code>CF_API_TOKEN</code>: 你的 Cloudflare API Token。你可以在 <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank">这里</a> 创建,使用 "Edit Cloudflare Workers" 模板即可。</li>
<li><code>CF_ACCOUNT_ID</code>: 你的 Cloudflare 账户 ID。你可以在 Workers 页面的右侧找到。</li>
<li><code>D1_DATABASE_ID</code>: 你在第二步中创建的 D1 数据库的 ID。</li>
<li><code>D1_DATABASE_NAME</code>: 你在第二步中创建的 D1 数据库的名称。</li>
<li><code>VITE_EMAIL_DOMAIN</code>: 你的域名列表,多个域名用逗号 ',' 分割 (例如: example.com,test.com)。</li>
</ul>
</li>
<li>在 Cloudflare Dashboard 中选择 "Workers & Pages"</li>
<li>点击 "Create application" -> "Pages" -> "Connect to Git"</li>
<li>选择您 Fork 的仓库</li>
<li>配置构建设置(通常会自动检测)</li>
<li>点击 "Save and Deploy"</li>
<li>配置 Cloudflare Email 路由,将邮件转发到您的 Worker</li>
<li>完成以上步骤后,项目将在每次推送到 <code>main</code> 分支时自动部署。你也可以在 Actions 页面手动触发部署。</li>
<li>部署完成后,为你的 Worker 绑定一个自定义域名。</li>
<li>最后,配置 Cloudflare Email 路由,将邮件转发到你的 Worker。</li>
</ol>
</div>
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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": "发生错误,请稍后重试",
+3
View File
@@ -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<HeaderProps> = ({
isLoading={isLoading}
/>
<div className="ml-3 pl-3 border-l border-muted-foreground/20 flex items-center">
{/* 在这里添加主题切换组件 */}
<ThemeSwitcher />
<LanguageSwitcher />
<a
href="https://github.com/zaunist/zmail"
+34 -20
View File
@@ -198,8 +198,10 @@ const HeaderMailbox: React.FC<HeaderMailboxProps> = ({
};
// 切换域名
const handleDomainChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const handleDomainChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedDomain(e.target.value);
// [fix] 切换域名后自动更换邮箱
await handleRefreshMailbox();
};
// 按钮基础样式
@@ -231,15 +233,21 @@ const HeaderMailbox: React.FC<HeaderMailboxProps> = ({
/>
<span className="flex items-center px-2 py-1 text-sm border-y border-r rounded-r-md bg-muted">
@
<select
value={selectedDomain}
onChange={handleDomainChange}
className="bg-transparent border-none focus:outline-none"
>
{domains.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
{/* [fix]: 为select包裹一个relative容器,用于绝对定位自定义箭头 */}
<div className="relative">
<select
value={selectedDomain}
onChange={handleDomainChange}
// [fix]: 添加 appearance-none 移除原生样式,并增加padding-right为箭头留出空间
className="appearance-none bg-transparent border-none focus:outline-none pl-1 pr-5"
>
{domains.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
{/* [fix]: 添加自定义的下拉箭头图标 */}
<i className="fas fa-chevron-down absolute right-0 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none"></i>
</div>
</span>
</div>
<button
@@ -278,17 +286,23 @@ const HeaderMailbox: React.FC<HeaderMailboxProps> = ({
<div className="flex items-center">
{/* 电脑端邮箱地址显示 */}
<div className="hidden sm:flex items-center">
<code className="bg-muted px-2 py-1 rounded text-sm font-medium">
<code className="bg-muted px-2 py-1 rounded text-sm font-medium flex items-center">
{mailbox.address}@
<select
value={selectedDomain}
onChange={handleDomainChange}
className="bg-transparent border-none focus:outline-none px-0 py-0 font-medium"
>
{domains.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
{/* [fix]: 为select包裹一个relative容器,用于绝对定位自定义箭头 */}
<div className="relative">
<select
value={selectedDomain}
onChange={handleDomainChange}
// [fix]: 添加 appearance-none 移除原生样式,并增加padding-right为箭头留出空间
className="appearance-none bg-transparent border-none focus:outline-none pl-1 pr-4 font-medium"
>
{domains.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
{/* [fix]: 添加自定义的下拉箭头图标 */}
<i className="fas fa-chevron-down absolute right-0 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none"></i>
</div>
</code>
{/* 添加邮箱切换组件 */}
+77 -11
View File
@@ -1,5 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
// [feat]: 导入 deleteMailbox API 函数
import { deleteMailbox as apiDeleteMailbox } from '../utils/api';
interface MailboxSwitcherProps {
currentMailbox: Mailbox;
@@ -103,6 +105,51 @@ const MailboxSwitcher: React.FC<MailboxSwitcherProps> = ({
setShowDropdown(false);
};
// 删除单个已保存的邮箱(包括后端)
const handleDeleteMailbox = async (address: string) => {
if (window.confirm(t('mailbox.confirmDeleteMailbox'))) {
// 调用API删除后端的邮箱
const result = await apiDeleteMailbox(address);
if (result.success) {
// 从前端列表和localStorage中移除
const updatedMailboxes = savedMailboxes.filter(m => m.address !== address);
setSavedMailboxes(updatedMailboxes);
localStorage.setItem('savedMailboxes', JSON.stringify(updatedMailboxes));
} else {
// 如果删除失败,可以给用户一个提示
alert(t('mailbox.deleteFailed'));
}
}
};
// [feat]: 清空所有已保存的邮箱(包括后端数据)
const handleClearAllMailboxes = async () => {
if (window.confirm(t('mailbox.confirmClearAllMailboxes'))) {
// 找出所有需要删除的邮箱(即列表中,非当前正在使用的邮箱)
const mailboxesToDelete = savedMailboxes.filter(m => m.address !== currentMailbox.address);
// 如果没有需要删除的,直接返回
if(mailboxesToDelete.length === 0) {
setShowDropdown(false);
return;
}
// 并行执行所有后端删除请求
const deletePromises = mailboxesToDelete.map(m => apiDeleteMailbox(m.address));
await Promise.all(deletePromises);
// 从前端列表中只保留当前使用的邮箱
const currentMailboxToKeep = savedMailboxes.find(m => m.address === currentMailbox.address);
const mailboxesToKeep = currentMailboxToKeep ? [currentMailboxToKeep] : [];
// 更新UI和localStorage
setSavedMailboxes(mailboxesToKeep);
localStorage.setItem('savedMailboxes', JSON.stringify(mailboxesToKeep));
setShowDropdown(false);
}
};
// 如果没有保存的邮箱或者只有当前邮箱,不显示切换按钮
if (savedMailboxes.length <= 1) {
return null;
@@ -120,21 +167,40 @@ const MailboxSwitcher: React.FC<MailboxSwitcherProps> = ({
</button>
{showDropdown && (
<div className="absolute top-9 left-0 bg-white border rounded-md shadow-lg p-1 z-20 min-w-[200px]">
<div className="text-xs font-medium px-2 py-1 text-muted-foreground">
// [fix]: 将 bg-white 替换为 bg-popover 和 text-popover-foreground 以支持黑暗模式
<div className="absolute top-9 left-0 bg-popover text-popover-foreground border rounded-md shadow-lg p-1 z-20 min-w-[250px]">
<div className="text-xs font-medium px-2 py-1 text-muted-foreground flex justify-between items-center">
{t('mailbox.savedMailboxes') || "已保存的邮箱"}
<button
onClick={handleClearAllMailboxes}
className="text-red-500 hover:text-red-700 text-xs"
title={t('mailbox.clearAll') || "全部清除"}
>
<i className="fas fa-trash-alt mr-1"></i>
{t('mailbox.clearAll') || "全部清除"}
</button>
</div>
<div className="max-h-[400px] overflow-y-auto">
{savedMailboxes.map((m) => (
<button
key={m.address}
onClick={() => handleSwitchMailbox(m)}
className={`w-full text-left text-sm px-2 py-1.5 hover:bg-muted rounded-sm transition-colors truncate ${
m.address === currentMailbox.address ? 'bg-primary/10 text-primary font-medium' : ''
}`}
>
{m.address}@{domain}
</button>
<div key={m.address} className="flex items-center justify-between hover:bg-muted rounded-sm">
<button
onClick={() => handleSwitchMailbox(m)}
className={`w-full text-left text-sm px-2 py-1.5 transition-colors truncate ${
m.address === currentMailbox.address ? 'bg-primary/10 text-primary font-medium' : ''
}`}
>
{m.address}@{domain}
</button>
{m.address !== currentMailbox.address && (
<button
onClick={() => handleDeleteMailbox(m.address)}
className="p-2 text-red-500 hover:text-red-700"
title={t('common.delete') || "删除"}
>
<i className="fas fa-trash-alt text-xs"></i>
</button>
)}
</div>
))}
</div>
</div>
+50
View File
@@ -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 状态的变化,并应用到 <html> 元素上
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 (
<button
onClick={toggleTheme}
className="w-8 h-8 flex items-center justify-center rounded-md transition-all duration-200 hover:bg-primary/20 hover:text-primary hover:scale-110"
aria-label={t('settings.toggleTheme', '切换主题')}
title={t('settings.toggleTheme', '切换主题')}
>
{/* 根据当前主题显示不同的图标 */}
{theme === 'light' ? (
<i className="fas fa-moon text-base"></i> // 亮色模式下,显示月亮图标以切换到暗色
) : (
<i className="fas fa-sun text-base"></i> // 暗色模式下,显示太阳图标以切换到亮色
)}
</button>
);
};
export default ThemeSwitcher;
+67 -92
View File
@@ -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<MailboxProviderProps> = ({ children }) =>
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const errorTimeoutRef = useRef<number | null>(null);
const successTimeoutRef = useRef<number | null>(null);
// 清除提示的定时器
useEffect(() => {
return () => {
@@ -94,13 +94,13 @@ export const MailboxProvider: React.FC<MailboxProviderProps> = ({ children }) =>
}
};
}, []);
// 初始化:检查本地存储或创建新邮箱
useEffect(() => {
const initMailbox = async () => {
// 检查本地存储中是否有未过期的邮箱
const savedMailbox = getMailboxFromLocalStorage();
if (savedMailbox) {
setMailbox(savedMailbox);
setIsLoading(false);
@@ -109,32 +109,36 @@ export const MailboxProvider: React.FC<MailboxProviderProps> = ({ 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<MailboxProviderProps> = ({ 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<MailboxProviderProps> = ({ 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<MailboxProviderProps> = ({ 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<MailboxProviderProps> = ({ 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<MailboxProviderProps> = ({ 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<MailboxProviderProps> = ({ children }) =>
timestamp: Date.now()
}
}));
// 保存到localStorage
try {
const mailboxAddress = mailbox?.address;
@@ -339,11 +314,11 @@ export const MailboxProvider: React.FC<MailboxProviderProps> = ({ 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<MailboxProviderProps> = ({ 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<MailboxProviderProps> = ({ children }) =>
console.error('Error loading email cache from localStorage:', error);
}
}, [mailbox]);
// 设置邮箱并保存到localStorage
const handleSetMailbox = (newMailbox: Mailbox) => {
setMailbox(newMailbox);
saveMailboxToLocalStorage(newMailbox);
};
return (
<MailboxContext.Provider
value={{
@@ -404,7 +379,7 @@ export const MailboxProvider: React.FC<MailboxProviderProps> = ({ children }) =>
successMessage
}}
>
{/* 错误和成功提示 */}
{/* [feat] 将错误和成功提示移至此处,作为全局通知组件 */}
{(errorMessage || successMessage) && (
<div className="fixed top-4 right-4 z-50 p-3 rounded-md shadow-lg max-w-md" style={{ backgroundColor: errorMessage ? '#FEE2E2' : '#ECFDF5', color: errorMessage ? '#991B1B' : '#065F46' }}>
{errorMessage || successMessage}
+54 -111
View File
@@ -128,6 +128,7 @@ export async function getMailboxes(db: D1Database, ipAddress: string): Promise<M
* @param address 邮箱地址
*/
export async function deleteMailbox(db: D1Database, address: string): Promise<void> {
// [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<vo
* @returns 删除的附件数量
*/
async function cleanupOrphanedAttachments(db: D1Database): Promise<number> {
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<number> {
*/
export async function cleanupExpiredMailboxes(db: D1Database): Promise<number> {
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<number> {
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<number> {
* @returns 删除的邮件数量
*/
export async function cleanupReadMails(db: D1Database): Promise<number> {
// 获取已读邮件的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<number> {
* @param emailId 邮件ID
*/
async function cleanupAttachments(db: D1Database, emailId: string): Promise<void> {
// [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<void> {
// 先清理邮件的所有附件
await cleanupAttachments(db, id);
// 然后删除邮件
// [refactor] 由于外键设置了 ON DELETE CASCADE,直接删除邮件即可
await db.prepare(`DELETE FROM emails WHERE id = ?`).bind(id).run();
}
+5 -3
View File
@@ -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]