修改文档和版本号

This commit is contained in:
eoao
2025-07-24 18:05:00 +08:00
parent 1d8dcc94e9
commit bf70e7dbd3
14 changed files with 463 additions and 81 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2025 LaziestRen Copyright (c) 2025 eoao
Permission is hereby granted, free of charge, to any person obtaining a copy of Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in this software and associated documentation files (the "Software"), to deal in
+181
View File
@@ -0,0 +1,181 @@
<p align="center">
<img src="doc/demo/logo.png" width="80px" />
</p>
<div align="center">
<h1>Cloud Mail</h1>
</div>
<div align="center">
<h4>A responsive email service built with Vue 3 that supports email sending and can be deployed on Cloudflare. 🎉</h4>
</div>
## Project Showcase
[**👉 Online Demo**](https://skymail.ink)
[**👉 Beginners Guide UI Deployment**](https://doc.skymail.ink)
| ![](/doc/demo/demo1.png) | ![](/doc/demo/demo2.png) |
|--------------------------|---------------------|
| ![](/doc/demo/demo3.png) | ![](/doc/demo/demo4.png) |
| ![](/doc/demo/demo5.png) | ![](/doc/demo/demo6.png) |
| ![](/doc/demo/demo7.png) | ![](/doc/demo/demo8.png) |
## Features
- **💻 Responsive Design**: Automatically adapts to both desktop and most mobile browsers.
- **📧 Email Sending**: Integrated with Resend for bulk email sending, embedded images, attachments, and status tracking.
- **🛡️ Admin Features**: Admins can manage users and emails, with RBAC permission control to limit access to features and resources.
- **🔀 Multiple Accounts**: Users can add multiple email accounts.
- **📦 Attachment Support**: Send and receive attachments, stored and downloaded via R2 object storage.
- **🔔 Email Push**: Forward received emails to Telegram bots or other email providers.
- **📈 Data Visualization**: Use Echarts to visualize system data, including user email growth.
- **⭐ Starred Emails**: Mark important emails for quick access.
- **🎨 Personalization**: Customize website title, login background, and transparency.
- **⚙️ Feature Settings**: Toggle on or off features like registration, email sending, and more, with the option to make the site private.
- **🤖 CAPTCHA**: Integrated with Turnstile CAPTCHA to prevent automated registration.
- **📜 More Features**: Under development...
## Tech Stack
- **Framework**: [Vue3](https://vuejs.org/) + [Element Plus](https://element-plus.org/)
- **Web Framework**: [Hono](https://hono.dev/)
- **ORM**: [Drizzle](https://orm.drizzle.team/)
- **Platform**: [Cloudflare Workers](https://developers.cloudflare.com/workers/)
- **Email Service**: [Resend](https://resend.com/)
- **Caching**: [Cloudflare KV](https://developers.cloudflare.com/kv/)
- **Database**: [Cloudflare D1](https://developers.cloudflare.com/d1/)
- **File Storage**: [Cloudflare R2](https://developers.cloudflare.com/r2/)
## Setup Guide
### System Requirements
Nodejs v18.20 +
Cloudflare account (with a bound domain)
**Clone the project to your local machine:**
``` shell
git clone https://github.com/eoao/cloud-mail
cd cloud-mail/mail-worker
```
**Install Dependencies:**
```shell
npm i
```
**Configure the Project**
mail-worker/wrangler.toml
```toml
[[d1_databases]]
binding = "db" # Default binding name for D1 database, cannot be changed
database_name = "" # Database name
database_id = "" # Database ID
[[kv_namespaces]]
binding = "kv" # Default binding name for KV storage, cannot be changed
id = "" # KV namespace ID
[[r2_buckets]]
binding = "r2" # Default binding name for R2 storage, cannot be changed
bucket_name = "" # R2 bucket name
[assets]
binding = "assets" # Static asset binding name, cannot be changed
directory = "./dist" # Directory for frontend Vue project build, default: dist
[vars]
orm_log = false
domain = [] # Configure email domains, example: ["example1.com", "example2.com"]
admin = "" # Admin email, example: "admin@example.com"
jwt_secret = "" # JWT secret for login tokens, choose a random string
```
**Deploy Remotely**
1. Create KV, D1 database, and R2 object storage in Cloudflare Console.
2. In the project directory `mail-worker/wrangler.toml`, configure the environment variables and database IDs/names.
3. Run the deployment command:
```shell
npm run deploy
```
4. In Cloudflare → Account Home → Your Domain → Email → Email Routing → Route Rules → Catch-all Address, edit and route to the worker.
5. In your browser, visit `https://your-project-domain/api/init/your-jwt-secret` to initialize or update the D1 and KV databases.
6. After deployment, log in to the site with the admin account to configure R2 domains, Turnstile keys, and more.
**Run Locally**
1. Run locally. Databases and object storage will automatically be set up, no manual creation needed. Data is stored in the `mail-worker/.wrangler` folder.
```shell
npm run dev
```
2. In your browser, visit `http://127.0.0.1:8787/api/init/your-jwt-secret` to initialize D1 and KV databases.
3. For local testing, you can set the R2 domain to `http://127.0.0.1:8787/api/file`.
**Email Sending**
1. Register on Resend, then click on “Domains” to add and verify your domain. Wait for verification.
2. Go to "API Keys" to create an API key, then copy the token and paste it in the project website settings.
3. Go to "Webhooks" and add a callback URL `https://your-project-domain/api/webhooks`.
Select the following events: ✅ (email.bounced, email.complained, email.delivered, email.delivery_delayed).
**Project Update**
After the update, run `https://your-project-domain/api/init/your-jwt-secret` to synchronize the database schema.
## Support
**Buy me a cup of milk tea**
<a href="https://afdian.com/a/eoao_"><img width="150" src="https://pic1.afdiancdn.com/static/img/welcome/button-sponsorme.png" alt=""></a>
**Special Sponsors**
[DartNode](https://dartnode.com)Providing cloud computing service resource support
[![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
## License
This project is licensed under the [MIT](LICENSE) license.
## Communication
[Telegram](https://t.me/cloud_mail_tg)
+26 -49
View File
@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img src="doc/demo/logo.png" width="10%" /> <img src="doc/demo/logo.png" width="80px" />
</p> </p>
<div align="center"> <div align="center">
@@ -8,13 +8,13 @@
<div align="center"> <div align="center">
<h4>使用Vue3开发的响应式简约邮箱服务,支持邮件发送附件收发,可以部署到Cloudflare云平台实现免费白嫖🎉</h4> <h4>使用Vue3开发的响应式简约邮箱服务,支持邮件发送附件收发,可以部署到Cloudflare云平台实现免费白嫖🎉</h4>
</div> </div>
<div align="center">
<span>简体中文 | <a href="/README-en.md" style="margin-left: 5px">English </a></span>
</div>
## 项目简介 ## 项目简介
只需要一个域名,就可以创建多个不同的邮箱,类似各大邮箱平台 QQ邮箱,谷歌邮箱等,本项目使用Cloud flare部署,Rsend推送邮件,无需服务器费用,搭建属于自己的邮箱服务 只需要一个域名,就可以创建多个不同的邮箱,类似各大邮箱平台 QQ邮箱,谷歌邮箱等,本项目使用Cloud flare部署,Resend推送邮件,无需服务器费用,搭建属于自己的邮箱服务
## 项目展示 ## 项目展示
@@ -94,7 +94,7 @@ Cloudflare 账号 (需要绑定域名)
**克隆项目到本地** **克隆项目到本地**
``` shell ``` shell
git clone https://github.com/LaziestRen/cloud-mail #拉取代码 git clone https://github.com/eoao/cloud-mail #拉取代码
cd cloud-mail/mail-worker #进入worker目录 cd cloud-mail/mail-worker #进入worker目录
``` ```
@@ -130,7 +130,7 @@ directory = "./dist" #前端vue项目打包的静态资源存放位置,
[vars] [vars]
orm_log = false orm_log = false
domain = [] #邮件域名可以配置多个示例: ["example1.com","example2.com"] domain = [] #邮件域名可以配置多个示例: ["example1.com","example2.com"]
admin = "" #管理员的邮箱 示例: admin@example.com admin = "" #管理员的邮箱 示例: "admin@example.com"
jwt_secret = "" #登录身份令牌的密钥,随便填一串字符串 jwt_secret = "" #登录身份令牌的密钥,随便填一串字符串
``` ```
@@ -140,7 +140,7 @@ jwt_secret = "" #登录身份令牌的密钥,随便填一串字符串
**远程部署** **远程部署**
1. 在 Cloudflare 控制台创建KVD1数据库,R2对象存储 1. 在 Cloudflare 控制台创建KVD1数据库,R2对象存储
2. 在项目目录 mail-worker/wrangler.toml 配置文件中配置对应环境变量,以及创建的数据库id和名称 2. 在项目目录 `mail-worker/wrangler.toml` 配置文件中配置对应环境变量,以及创建的数据库id和名称
3. 执行远程部署命令 3. 执行远程部署命令
```shell ```shell
@@ -149,7 +149,7 @@ jwt_secret = "" #登录身份令牌的密钥,随便填一串字符串
4. 在Cloudflare→账户主页→你的域名→电子邮件→电子邮件路由→路由规则→Catch-all地址,编辑发送到worker 4. 在Cloudflare→账户主页→你的域名→电子邮件→电子邮件路由→路由规则→Catch-all地址,编辑发送到worker
5. 浏览器输入 https://你的项目域名/api/init/你的jwt_secret 初始化或更新 d1和kv数据库 5. 浏览器输入 `https://你的项目域名/api/init/你的jwt_secret` 初始化或更新 d1和kv数据库
6. 部署完成登录网站,使用管理员账号可以在设置页面添加配置 R2域名 Turnstile密钥 等 6. 部署完成登录网站,使用管理员账号可以在设置页面添加配置 R2域名 Turnstile密钥 等
@@ -158,63 +158,40 @@ jwt_secret = "" #登录身份令牌的密钥,随便填一串字符串
**本地运行** **本地运行**
1. 本地运行,数据库,对象存储会自动安装,无需创建,数据库数据保存在 mail-worker/.wrangler文件夹 1. 本地运行,数据库,对象存储会自动安装,无需创建,数据库数据保存在 `mail-worker/.wrangler` 文件夹
```shell ```shell
npm run dev npm run dev
``` ```
2. 浏览器输入 http://127.0.0.1:8787/api/init/你的jwt_secret 初始化d1和kv数据库 2. 浏览器输入 `http://127.0.0.1:8787/api/init/你的jwt_secret` 初始化d1和kv数据库
3. 本地运行项目设置页面r2域名可设置为 http://127.0.0.1:8787/api/file 3. 本地运行项目设置页面r2域名可设置为 `http://127.0.0.1:8787/api/file`
**邮件发送** **邮件发送**
1. 在 resend 官网注册后,点击左侧 Domains 添加并验证你的域名,等待验证完成 1. 在 resend 官网注册后,点击左侧 Domains 添加并验证你的域名,等待验证完成
2. 点击左侧 Api Keys 创建立api key 复制token回到项目网站设置页面添加 resend token 2. 点击左侧 Api Keys 创建立api key 复制token回到项目网站设置页面添加 resend token
3. 点击左侧 Webhooks 添加回调地址 https://你的项目域名/api/webhooks 3. 点击左侧 Webhooks 添加回调地址 `https://你的项目域名/api/webhooks`
勾选✅ (email.bounced email.complained email.delivered email.delivery_delayed) 勾选✅ (email.bounced email.complained email.delivered email.delivery_delayed)
## 目录结构 **项目更新**
``` 更新后需要执行 `https://你的项目域名/api/init/你的jwt_secret` 来同步数据库结构
cloud-mail
├── mail-worker #worker后端项目 ## 赞助
│ ├── src
│ │ ├── api #接口层 **请我喝一杯奶茶**
│ │ ├── const #常量
│ │ ├── email #邮件接收 <a href="https://afdian.com/a/eoao_"><img width="150" src="https://pic1.afdiancdn.com/static/img/welcome/button-sponsorme.png" alt=""></a>
│ │ ├── entity #数据库实体层
│ │ ├── error #自定义异常
│ │ ├── hono #web框架配置 拦截器等
│ │ ├── init #数据库缓存初始化
│ │ ├── model #响应体数据封装
│ │ ├── security #身份认证层
│ │ ├── service #服务层
│ │ ├── utils #工具类
│ │ └── index.js #入口文件
│ ├── pageckge.json #项目依赖
│ └── wrangler.toml #项目配置
└── mail-vue #vue前端项目
├── src
│ ├── assets #静态资源字体等
│ ├── axios #axios配置
│ ├── components #自定义组件
│ ├── layout #主体布局组件
│ ├── request #api接口
│ ├── router #路由配置
│ ├── store #全局状态管理
│ ├── utils #工具类
│ ├── views #页面组件
│ ├── app.vue #根组件
│ ├── main.js #入口js
│ └── style.css #全局css
├── package.json #项目依赖
└── env.dev #项目配置
```
**特别赞助商**
[DartNode](https://dartnode.com):提供云计算服务资源支持
[![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
## 许可证 ## 许可证
+2 -1
View File
@@ -245,7 +245,8 @@ const en = {
language: 'Language', language: 'Language',
totalUserAccount: '{msg}', totalUserAccount: '{msg}',
sendBanned: 'Banned', sendBanned: 'Banned',
wrote: 'wrote' wrote: 'wrote',
support: 'Support'
} }
export default en export default en
+2 -1
View File
@@ -245,6 +245,7 @@ const zh = {
language: '网站语言', language: '网站语言',
totalUserAccount: '{msg} 个', totalUserAccount: '{msg} 个',
sendBanned: '已禁用', sendBanned: '已禁用',
wrote: '来信' wrote: '来信',
support: '赞助'
} }
export default zh export default zh
+1 -1
View File
@@ -39,7 +39,7 @@ export function fromNow(date) {
if (isToday) { if (isToday) {
if (diffSeconds < 60) return `Just now`; if (diffSeconds < 60) return `Just now`;
if (diffMinutes < 60) return `${diffMinutes} min ago`; if (diffMinutes < 60) return `${diffMinutes} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; if (diffHours < 2) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
return d.format('HH:mm'); return d.format('HH:mm');
} }
+13 -7
View File
@@ -159,20 +159,18 @@ function openHistory(regKey) {
regKeyHistory(regKey.regKeyId).then(list => { regKeyHistory(regKey.regKeyId).then(list => {
historyList.push(...list) historyList.push(...list)
if (list.length > 0) { if (list.length > 0) {
const email = list.reduce((a, b) => const email = list.reduce((a, b) =>
a.email.length > b.email.length ? a : b compareByLengthAndUpperCase(a, b, 'email')
).email; ).email;
emailColumnWidth.value = getTextWidth(email) + 30 emailColumnWidth.value = getTextWidth(email) + 30
emailColumnWidth.value = emailColumnWidth.value < 300 ? emailColumnWidth.value : 300 emailColumnWidth.value = emailColumnWidth.value < 300 ? emailColumnWidth.value : 300
const createTime = list.reduce((a, b) => const createTime = list.reduce((a, b) =>
a.createTime.length > b.email.createTime ? a : b compareByLengthAndUpperCase(a, b, 'createTime')
); ).createTime;
createTimeColumnWidth.value = getTextWidth(createTime) + 30 createTimeColumnWidth.value = getTextWidth(createTime)
} }
}).finally(() => { }).finally(() => {
@@ -182,6 +180,14 @@ function openHistory(regKey) {
showRegKeyHistory.value = true showRegKeyHistory.value = true
} }
const compareByLengthAndUpperCase = (a, b, key) => {
const getUpperCaseCount = (str) => (str.match(/[A-Z]/g) || []).length;
if (a[key].length === b[key].length) {
return getUpperCaseCount(a[key]) > getUpperCaseCount(b[key]) ? a : b;
}
return a[key].length > b[key].length ? a : b;
};
function formatUserCreateTime(regKey) { function formatUserCreateTime(regKey) {
const createTime = tzDayjs(regKey.createTime); const createTime = tzDayjs(regKey.createTime);
const currentYear = dayjs().year(); const currentYear = dayjs().year();
@@ -197,7 +203,7 @@ function formatUserCreateTime(regKey) {
} else { } else {
if (createYear === currentYear) { if (expireYear === currentYear) {
return createTime.format('MMM D, HH:mm'); return createTime.format('MMM D, HH:mm');
} else { } else {
return createTime.format('MMM D, YYYY HH:mm'); return createTime.format('MMM D, YYYY HH:mm');
+29 -16
View File
@@ -267,20 +267,29 @@
<div class="card-content"> <div class="card-content">
<div class="concerning-item"> <div class="concerning-item">
<span>{{$t('version')}} :</span> <span>{{$t('version')}} :</span>
<span>v1.3.1</span> <span>v1.5.0</span>
</div> </div>
<div class="concerning-item"> <div class="concerning-item">
<span>{{$t('community')}} : </span> <span>{{$t('community')}} : </span>
<el-button @click="jump('https://github.com/eoao/cloud-mail')">
Github
<template #icon>
<Icon icon="codicon:github-inverted" width="22" height="22" />
</template>
</el-button>
<el-button @click="jump('https://t.me/cloud_mail_tg')"> <el-button @click="jump('https://t.me/cloud_mail_tg')">
telegram Telegram
<template #icon> <template #icon>
<Icon icon="logos:telegram" width="30" height="30"/> <Icon icon="logos:telegram" width="30" height="30"/>
</template> </template>
</el-button> </el-button>
<el-button @click="jump('https://github.com/eoao/cloud-mail')"> </div>
github <div class="concerning-item">
<span>{{$t('support')}} : </span>
<el-button @click="jump('https://afdian.com/a/eoao_')" >
Afdian
<template #icon> <template #icon>
<Icon icon="codicon:github-inverted" width="22" height="22" /> <Icon color="#8261DB" icon="simple-icons:afdian" width="24" height="24" />
</template> </template>
</el-button> </el-button>
</div> </div>
@@ -505,9 +514,9 @@ const forwardEmail = ref([])
const forwardStatus = ref(0) const forwardStatus = ref(0)
const emailColumnWidth = ref(0) const emailColumnWidth = ref(0)
const tokenColumnWidth = ref(0) const tokenColumnWidth = ref(0)
const ruleType = ref(0) const ruleType = ref(0)
const ruleEmail = ref([]) const ruleEmail = ref([])
const resendList = computed(() => { const resendList = computed(() => {
let list = Object.keys(setting.value.resendTokens).map(key => { let list = Object.keys(setting.value.resendTokens).map(key => {
@@ -519,23 +528,27 @@ const resendList = computed(() => {
if (list.length > 0) { if (list.length > 0) {
const key = list.reduce((a, b) => const key = list.reduce((a, b) => compareByLengthAndUpperCase(a, b, 'key')).key;
a.key.length > b.key.length ? a : b emailColumnWidth.value = getTextWidth(key) + 30;
).key;
emailColumnWidth.value = getTextWidth(key) + 30 const value = list.reduce((a, b) => compareByLengthAndUpperCase(a, b, 'value')).value;
tokenColumnWidth.value = getTextWidth(value) + 30;
const value = list.reduce((a, b) =>
a.value.length > b.value.length ? a : b
).value;
tokenColumnWidth.value = getTextWidth(value) + 30
} }
return list; return list;
}); });
const compareByLengthAndUpperCase = (a, b, key) => {
const getUpperCaseCount = (str) => (str.match(/[A-Z]/g) || []).length;
if (a[key].length === b[key].length) {
return getUpperCaseCount(a[key]) > getUpperCaseCount(b[key]) ? a : b;
}
return a[key].length > b[key].length ? a : b;
};
settingQuery().then(settingData => { settingQuery().then(settingData => {
setting.value = settingData setting.value = settingData
resendTokenForm.domain = setting.value.domainList[0] resendTokenForm.domain = setting.value.domainList[0]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<title></title> <title></title>
<link rel="icon" href="/assets/favicon-C5dAZutX.svg" type="image/svg+xml"> <link rel="icon" href="/assets/favicon-C5dAZutX.svg" type="image/svg+xml">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script type="module" crossorigin src="/assets/index-Cjzn1ZdJ.js"></script> <script type="module" crossorigin src="/assets/index-mj4MUxg3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dx0iKe9o.css"> <link rel="stylesheet" crossorigin href="/assets/index-C5vjKzw7.css">
</head> </head>
<body> <body>
<div id="loading-first"> <div id="loading-first">
+3 -2
View File
@@ -6,7 +6,7 @@ const en = {
notEmail: 'Invalid email', notEmail: 'Invalid email',
notExistDomain: 'Email domain does not exist', notExistDomain: 'Email domain does not exist',
isDelAccount: 'This Email has been deleted', isDelAccount: 'This Email has been deleted',
isRegAccount: 'This Email is already signed up', isRegAccount: 'This Email is already registered',
accountLimit: 'Account limit reached', accountLimit: 'Account limit reached',
delMyAccount: 'Cannot delete your own account', delMyAccount: 'Cannot delete your own account',
noUserAccount: 'This email does not belong to the current user', noUserAccount: 'This email does not belong to the current user',
@@ -50,9 +50,10 @@ const en = {
starNotExistEmail: 'Starred email does not exist', starNotExistEmail: 'Starred email does not exist',
emptyBotToken: 'Verification token cannot be empty', emptyBotToken: 'Verification token cannot be empty',
botVerifyFail: 'Bot verification failed, please try again', botVerifyFail: 'Bot verification failed, please try again',
authExpired: 'Authentication expired, please log in again', authExpired: 'Authentication has expired. Please sign in again',
unauthorized: 'Unauthorized', unauthorized: 'Unauthorized',
bannedSend: 'You are banned from sending emails', bannedSend: 'You are banned from sending emails',
initSuccess: 'Successfully initialized',
perms: { perms: {
"邮件": "Email", "邮件": "Email",
"邮件发送": "Send email", "邮件发送": "Send email",
+1
View File
@@ -53,6 +53,7 @@ const zh = {
authExpired: '身份认证失效,请重新登录', authExpired: '身份认证失效,请重新登录',
unauthorized: '权限不足', unauthorized: '权限不足',
bannedSend: '你已被禁止发送邮件', bannedSend: '你已被禁止发送邮件',
initSuccess: 'Successfully initialized',
perms: { perms: {
"邮件": "邮件", "邮件": "邮件",
"邮件发送": "邮件发送", "邮件发送": "邮件发送",
+2 -1
View File
@@ -1,13 +1,14 @@
import settingService from '../service/setting-service'; import settingService from '../service/setting-service';
import emailUtils from '../utils/email-utils'; import emailUtils from '../utils/email-utils';
import {emailConst} from "../const/entity-const"; import {emailConst} from "../const/entity-const";
import { t } from '../i18n/i18n'
const init = { const init = {
async init(c) { async init(c) {
const secret = c.req.param('secret'); const secret = c.req.param('secret');
if (secret !== c.env.jwt_secret) { if (secret !== c.env.jwt_secret) {
return c.text('secret不匹配'); return c.text(t('initSuccess'));
} }
await this.intDB(c); await this.intDB(c);