├── .gitignore ├── LICENSE ├── README.md ├── demo ├── demo1.png ├── demo2.png ├── demo3.png ├── demo4.png └── logo.png ├── mail-vue ├── .env.dev ├── .env.release ├── .env.remote ├── index.html ├── package-lock.json ├── package.json ├── public │ └── vite.svg ├── src │ ├── App.vue │ ├── assets │ │ ├── favicon.svg │ │ └── fonts │ │ │ └── HarmonyOS_Sans_SC_Regular.woff2 │ ├── axios │ │ └── index.js │ ├── components │ │ ├── email-scroll │ │ │ └── index.vue │ │ ├── hamburger │ │ │ └── index.vue │ │ └── loading │ │ │ └── index.vue │ ├── day │ │ └── day.js │ ├── layout │ │ ├── account │ │ │ └── index.vue │ │ ├── aside │ │ │ └── index.vue │ │ ├── header │ │ │ └── index.vue │ │ ├── index.vue │ │ └── main │ │ │ └── index.vue │ ├── main.js │ ├── request │ │ ├── account.js │ │ ├── email.js │ │ ├── login.js │ │ ├── send.js │ │ ├── setting.js │ │ ├── star.js │ │ └── user.js │ ├── router │ │ └── index.js │ ├── store │ │ ├── account.js │ │ ├── email.js │ │ ├── send.js │ │ ├── setting.js │ │ ├── ui.js │ │ ├── user.js │ │ └── writer.js │ ├── style.css │ ├── utils │ │ ├── convert-utils.js │ │ ├── file-utils.js │ │ ├── time-utils.js │ │ └── verify-utils.js │ └── views │ │ ├── content │ │ └── index.vue │ │ ├── email │ │ └── index.vue │ │ ├── login │ │ └── index.vue │ │ ├── setting │ │ └── index.vue │ │ ├── star │ │ └── index.vue │ │ ├── sys-setting │ │ └── index.vue │ │ └── test │ │ └── index.vue └── vite.config.js ├── mail-worker ├── .editorconfig ├── .prettierrc ├── dist │ ├── assets │ │ ├── HarmonyOS_Sans_SC_Regular-D3EGA0gC.woff2 │ │ ├── favicon-C5dAZutX.svg │ │ ├── index-BRburrSt.js │ │ └── index-CGZvsPhs.css │ ├── index.html │ └── vite.svg ├── package-lock.json ├── package.json ├── src │ ├── api │ │ ├── account-api.js │ │ ├── email-api.js │ │ ├── login-api.js │ │ ├── setting-api.js │ │ ├── star-api.js │ │ ├── test-api.js │ │ └── user-api.js │ ├── const │ │ ├── constant.js │ │ ├── entity-const.js │ │ └── kv-const.js │ ├── email │ │ └── email.js │ ├── entity │ │ ├── account.js │ │ ├── att.js │ │ ├── email.js │ │ ├── orm.js │ │ ├── setting.js │ │ ├── star.js │ │ └── user.js │ ├── error │ │ └── biz-error.js │ ├── hono │ │ ├── hono.js │ │ └── webs.js │ ├── index.js │ ├── init │ │ ├── init-cache.js │ │ └── init-db.js │ ├── model │ │ └── result.js │ ├── security │ │ ├── security.js │ │ └── user-context.js │ ├── service │ │ ├── account-service.js │ │ ├── att-service.js │ │ ├── email-service.js │ │ ├── login-service.js │ │ ├── r2-service.js │ │ ├── setting-service.js │ │ ├── star-service.js │ │ ├── turnstile-service.js │ │ └── user-service.js │ └── utils │ │ ├── crypto-utils.js │ │ ├── email-utils.js │ │ ├── file-utils.js │ │ ├── jwt-utils.js │ │ └── verify-utils.js ├── test │ └── index.spec.js ├── vitest.config.js ├── wrangler-dev.toml ├── wrangler-test.toml └── wrangler.toml └── package-lock.json /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | pnpm-debug.log* 7 | lerna-debug.log* 8 | 9 | node_modules 10 | dist-ssr 11 | *.local 12 | 13 | .vscode/* 14 | !.vscode/extensions.json 15 | .idea 16 | .DS_Store 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | 23 | mail-vue/node_modules 24 | mail-vue/dist 25 | .wrangler 26 | .venv -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 LaziestRen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |
6 |

Cloud Mail

7 |
8 |
9 |

一个使用Vue3开发的响应式简约邮箱服务, 可以部署到Cloudflare云平台实现免费白嫖🎉

10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | ## 在线演示 18 | 19 | 👉 https://skymail.ink 20 | 21 | | ![](demo/demo1.png) | ![](demo/demo2.png) | 22 | |--------------------------------------------------------|---------------------| 23 | | ![](demo/demo3.png) | ![](demo/demo4.png) | 24 | 25 | 26 | 27 | 28 | 29 | ## 功能介绍 30 | 31 | - **💰免费白嫖**:无需服务器,部署到Cloudflare Workers 免费使用,不要钱 32 | 33 | - **💻响应式设计**:响应式布局自动适配PC和大部分手机端浏览器 34 | 35 | - **🔀多号模式**:开启后一个用户可以添加多个邮箱,默认一用户一邮箱,类似各大邮箱平台 36 | 37 | - **📦附件接收**:支持接收附件,使用R2对象存储保存和下载文件 38 | 39 | - **⭐星标邮件**:标记重要邮件,以便快速查阅 40 | 41 | - **🎨个性化标题**:可以自定义网站标题 42 | 43 | - **⏱️轮询刷新**:轮询请求服务器自动获取最新邮件,可自定义间隔 44 | 45 | - **⚙️功能开关**:可以对注册,添加等功能关闭和开启,设为私人站点 46 | 47 | - **🤖人机验证**:集成Turnstile人机验证,防止人机批量注册 48 | 49 | - **📜更多功能**:正在开发中... 50 | 51 | 52 | 53 | ## 技术栈 54 | 55 | - **框架**:[Vue3](https://vuejs.org/) + [Element Plus](https://element-plus.org/) 56 | 57 | - **Web框架**:[Hono](https://hono.dev/) 58 | 59 | - **ORM:**[Drizzle](https://orm.drizzle.team/) 60 | 61 | - **平台:** [Cloudflare workers](https://developers.cloudflare.com/workers/) 62 | 63 | - **缓存**:[Cloudflare KV](https://developers.cloudflare.com/kv/) 64 | 65 | - **数据库**:[Cloudflare D1](https://developers.cloudflare.com/d1/) 66 | 67 | - **文件存储**:[Cloudflare R2](https://developers.cloudflare.com/r2/) 68 | 69 | 70 | 71 | 72 | 73 | ## 使用教程 74 | 75 | [**👉小白保姆教程-界面部署**](https://doc.skymail.ink) 76 | 77 | ### 环境要求 78 | 79 | 80 | 81 | Nodejs v18.20 + 82 | 83 | Cloudflare 账号 84 | 85 | 86 | **克隆项目到本地** 87 | ``` shell 88 | git clone https://github.com/LaziestRen/cloud-mail #拉取代码 89 | cd cloud-mail/mail-worker #进入worker目录 90 | ``` 91 | 92 | **安装依赖** 93 | ```shell 94 | npm i 95 | ``` 96 | 97 | **项目配置** 98 | 99 | ```toml 100 | [[d1_databases]] 101 | binding = "db" #d1数据库绑定名默认不可修改 102 | database_name = "" #d1数据库名字 103 | database_id = "" #d1数据库id 104 | 105 | [[kv_namespaces]] 106 | binding = "kv" #kv绑定名默认不可修改 107 | id = "" #kv数据库id 108 | 109 | #(可选) 110 | [[r2_buckets]] 111 | binding = "r2" #r2对象存储绑定名默认不可修改 112 | bucket_name = "" #r2对象存储桶的名字 113 | 114 | 115 | [assets] 116 | binding = "assets" #静态资源绑定名默认不可修改 117 | directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist 118 | 119 | [vars] 120 | domain = [] #邮件域名可以配置多个 示例: ["example1.com","example2.com"] 121 | admin = "" #管理员的邮箱 示例: admin@example.com 122 | jwt_secret = "" #jwt令牌的密钥,随便填一串字符串 123 | r2_domain = "" #r2对象存储桶的访问域名(可选) 124 | site_key = "" #Turnstile人机验证的站点密钥(可选) 125 | secret_key = "" #Turnstile人机验证的后端密钥(可选) 126 | 127 | ``` 128 | 129 | **本地运行** 130 | 131 | 本地开发环境,数据库会自动安装,无需创建 132 | 133 | ```shell 134 | npm run dev 135 | ``` 136 | 137 | 138 | **远程部署** 139 | 140 | 在Cloudflare控制台创建KV和D1数据库,可选:R2对象存储,Turnstile人机验证 (这两个不配置附件和人机验证不可用) 141 | 142 | 在 wrangler.toml 中配置对应环境变量 143 | 144 | ```shell 145 | npm run deploy 146 | ``` 147 | 148 | 然后进入域名管理->电子邮件->路由规则->Catch-all 地址. 这里选择发送到 worker, 然后选择创建的worker 149 | 150 | 151 | 152 | 153 | ## 目录结构 154 | 155 | ``` 156 | cloud-mail 157 | ├── mail-worker #worker后端项目 158 | │ ├── src 159 | │ │ ├── api #接口层 160 | │ │ ├── const #常量 161 | │ │ ├── email #邮件接收 162 | │ │ ├── entity #数据库实体层 163 | │ │ ├── error #自定义异常 164 | │ │ ├── hono #web框架配置 拦截器等 165 | │ │ ├── init #项目启动表创建 缓存初始化等 166 | │ │ ├── model #响应体数据封装 167 | │ │ ├── security #身份认证层 168 | │ │ ├── service #服务层 169 | │ │ ├── utils #工具类 170 | │ │ └── index.js #入口文件 171 | │ ├── pageckge.json #项目依赖 172 | │ └── wrangler.toml #项目配置 173 | └── mail-vue #vue前端项目 174 | ├── src 175 | │ ├── assets #静态资源字体等 176 | │ ├── axios #axios配置 177 | │ ├── components #自定义组件 178 | │ ├── day #dayjs配置 179 | │ ├── layout #主体布局组件 180 | │ ├── request #api接口 181 | │ ├── router #路由配置 182 | │ ├── store #全局状态管理 183 | │ ├── utils #工具类 184 | │ ├── views #页面组件 185 | │ ├── app.vue #根组件 186 | │ ├── main.js #入口js 187 | │ └── style.css #全局css 188 | ├── package.json #项目依赖 189 | └── env.dev #项目配置 190 | ``` 191 | 192 | 193 | 194 | ## 许可证 195 | 196 | 本项目采用 [MIT](LICENSE) 许可证 197 | 198 | 199 | 200 | 201 | 202 | -------------------------------------------------------------------------------- /demo/demo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaziestRen/cloud-mail/d4a5b297b34c72cdebe05eb589e30dea858189ec/demo/demo1.png -------------------------------------------------------------------------------- /demo/demo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaziestRen/cloud-mail/d4a5b297b34c72cdebe05eb589e30dea858189ec/demo/demo2.png -------------------------------------------------------------------------------- /demo/demo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaziestRen/cloud-mail/d4a5b297b34c72cdebe05eb589e30dea858189ec/demo/demo3.png -------------------------------------------------------------------------------- /demo/demo4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaziestRen/cloud-mail/d4a5b297b34c72cdebe05eb589e30dea858189ec/demo/demo4.png -------------------------------------------------------------------------------- /demo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaziestRen/cloud-mail/d4a5b297b34c72cdebe05eb589e30dea858189ec/demo/logo.png -------------------------------------------------------------------------------- /mail-vue/.env.dev: -------------------------------------------------------------------------------- 1 | NODE_ENV = 'dev' 2 | VITE_APP_TITLE = '开发环境' 3 | VITE_BASE_URL = 'http://127.0.0.1:8787/api' -------------------------------------------------------------------------------- /mail-vue/.env.release: -------------------------------------------------------------------------------- 1 | NODE_ENV = 'release' 2 | VITE_APP_TITLE = '发布环境' 3 | VITE_BASE_URL = '/api' -------------------------------------------------------------------------------- /mail-vue/.env.remote: -------------------------------------------------------------------------------- 1 | NODE_ENV = 'remote' 2 | VITE_APP_TITLE = '远程环境' 3 | VITE_BASE_URL = 'xxxxxx' -------------------------------------------------------------------------------- /mail-vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /mail-vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "video-admin-vue", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --mode dev", 8 | "remote": "vite --mode remote", 9 | "build": "vite build --mode release", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@iconify/vue": "^4.3.0", 14 | "@vueuse/core": "^12.0.0", 15 | "@wangeditor/editor": "^5.1.23", 16 | "@wangeditor/editor-for-vue": "^5.1.12", 17 | "axios": "^1.7.8", 18 | "date-time-format-timezone": "^1.0.22", 19 | "dayjs": "^1.11.13", 20 | "element-plus": "^2.9.5", 21 | "path": "^0.12.7", 22 | "pinia": "^3.0.2", 23 | "pinia-plugin-persistedstate": "^4.2.0", 24 | "postal-mime": "^2.4.3", 25 | "vue": "^3.5.13", 26 | "vue-router": "^4.5.0" 27 | }, 28 | "devDependencies": { 29 | "@vitejs/plugin-vue": "^5.2.1", 30 | "less": "^4.2.2", 31 | "sass": "^1.82.0", 32 | "terser": "^5.39.0", 33 | "vite": "6.2.6" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /mail-vue/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mail-vue/src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 21 | 22 | -------------------------------------------------------------------------------- /mail-vue/src/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mail-vue/src/assets/fonts/HarmonyOS_Sans_SC_Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaziestRen/cloud-mail/d4a5b297b34c72cdebe05eb589e30dea858189ec/mail-vue/src/assets/fonts/HarmonyOS_Sans_SC_Regular.woff2 -------------------------------------------------------------------------------- /mail-vue/src/axios/index.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import router from "@/router"; 3 | import {ElMessage} from 'element-plus'; 4 | 5 | let http = axios.create({ 6 | baseURL: import.meta.env.VITE_BASE_URL 7 | }); 8 | 9 | http.interceptors.request.use(config => { 10 | config.headers.Authorization = `${localStorage.getItem('token')}` 11 | return config 12 | }) 13 | 14 | http.interceptors.response.use((res) => { 15 | return new Promise((resolve, reject) => { 16 | const data = res.data 17 | if (data.code === 401) { 18 | ElMessage({ 19 | message: data.message, 20 | type: 'error', 21 | plain: true, 22 | }) 23 | localStorage.removeItem('token') 24 | router.push('/login') 25 | reject(data) 26 | } else if (data.code === 403) { 27 | ElMessage({ 28 | message: data.message, 29 | type: 'error', 30 | plain: true, 31 | }) 32 | reject(data) 33 | } else if (data.code !== 200) { 34 | ElMessage({ 35 | message: data.message, 36 | type: 'error', 37 | plain: true, 38 | }) 39 | setTimeout(() => { 40 | reject(data) 41 | }, 1) 42 | } 43 | setTimeout(() => { 44 | resolve(data.data) 45 | }, 1) 46 | }) 47 | }, 48 | (error) => { 49 | if (error.message.includes('Network Error')) { 50 | ElMessage({ 51 | message: '网络错误,请检查网络连接', 52 | type: 'error', 53 | plain: true, 54 | }) 55 | } else if (error.code === 'ECONNABORTED') { 56 | ElMessage({ 57 | message: '请求超时,请稍后重试', 58 | type: 'error', 59 | plain: true, 60 | }) 61 | ElMessage.error('') 62 | } else if (error.response) { 63 | ElMessage({ 64 | message: `服务器繁忙`, 65 | type: 'error', 66 | plain: true, 67 | }) 68 | } else { 69 | ElMessage({ 70 | message: '请求失败,请稍后再试', 71 | type: 'error', 72 | plain: true, 73 | }) 74 | } 75 | return Promise.reject(error) 76 | }) 77 | 78 | export default http 79 | 80 | 81 | -------------------------------------------------------------------------------- /mail-vue/src/components/email-scroll/index.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 311 | 312 | 495 | -------------------------------------------------------------------------------- /mail-vue/src/components/hamburger/index.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | 31 | 43 | -------------------------------------------------------------------------------- /mail-vue/src/components/loading/index.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 28 | 29 | -------------------------------------------------------------------------------- /mail-vue/src/day/day.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import 'dayjs/locale/zh-cn' 3 | import utc from 'dayjs/plugin/utc' 4 | import timezone from 'dayjs/plugin/timezone' 5 | import relativeTime from 'dayjs/plugin/relativeTime' 6 | 7 | dayjs.extend(relativeTime) 8 | dayjs.extend(utc) 9 | dayjs.extend(timezone) 10 | dayjs.locale('zh-cn') 11 | dayjs.tz.setDefault('Asia/Shanghai') 12 | 13 | function day(time) { 14 | return dayjs(time).add(8, 'hours') 15 | } 16 | 17 | export const fromNow = (time) => { 18 | return time ? day(time).fromNow() : '' 19 | } 20 | 21 | export default day -------------------------------------------------------------------------------- /mail-vue/src/layout/account/index.vue: -------------------------------------------------------------------------------- 1 | 110 | 283 | -------------------------------------------------------------------------------- /mail-vue/src/layout/aside/index.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 50 | 51 | 149 | -------------------------------------------------------------------------------- /mail-vue/src/layout/header/index.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 59 | 60 | 132 | -------------------------------------------------------------------------------- /mail-vue/src/layout/index.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 49 | 50 | 122 | -------------------------------------------------------------------------------- /mail-vue/src/layout/main/index.vue: -------------------------------------------------------------------------------- 1 | 12 | 51 | 52 | 130 | -------------------------------------------------------------------------------- /mail-vue/src/main.js: -------------------------------------------------------------------------------- 1 | import {createApp} from 'vue'; 2 | import App from './App.vue'; 3 | import router from './router'; 4 | import 'element-plus/dist/index.css'; 5 | import './style.css'; 6 | import ElementPlus from 'element-plus'; 7 | import { createPinia } from 'pinia'; 8 | import zhCn from 'element-plus/es/locale/lang/zh-cn'; 9 | import piniaPersistedState from 'pinia-plugin-persistedstate'; 10 | const pinia = createPinia().use(piniaPersistedState) 11 | const app = createApp(App).use(pinia) 12 | 13 | app.use(ElementPlus, { 14 | locale: zhCn, 15 | }); 16 | 17 | app.use(router) 18 | app.config.devtools = true; 19 | 20 | app.mount('#app'); 21 | -------------------------------------------------------------------------------- /mail-vue/src/request/account.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js' 2 | 3 | export function accountList(accountId, size) { 4 | return http.get('/account/list', {params: {accountId, size}}); 5 | } 6 | 7 | export function accountAdd(email,token) { 8 | return http.post('/account/add', {email,token}) 9 | } 10 | 11 | export function accountDelete(accountId) { 12 | return http.delete('/account/delete', {params: {accountId}}) 13 | } 14 | 15 | -------------------------------------------------------------------------------- /mail-vue/src/request/email.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function emailList(accountId, emailId, size) { 4 | return http.get('/email/list', {params: {accountId, emailId, size}}) 5 | } 6 | 7 | export function emailDelete(emailIds) { 8 | return http.delete('/email/delete?emailIds=' + emailIds) 9 | } 10 | 11 | export function attList(emailId) { 12 | return http.get('/email/attList', {params: {emailId}}) 13 | } 14 | 15 | export function emailLatest(emailId,accountId) { 16 | return http.get('/email/latest',{params: {emailId,accountId}}) 17 | } -------------------------------------------------------------------------------- /mail-vue/src/request/login.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function login(email, password) { 4 | return http.post('/login', {email: email, password: password}) 5 | } 6 | 7 | export function logout() { 8 | return http.delete('/logout') 9 | } 10 | 11 | export function register(form) { 12 | return http.post('/register', form) 13 | } -------------------------------------------------------------------------------- /mail-vue/src/request/send.js: -------------------------------------------------------------------------------- 1 | import http from "@/axios/index.js"; 2 | 3 | export function sendEmail(form) { 4 | return http.post('/send/sendEmail', form) 5 | } 6 | 7 | export function sendList(accountId, sendId, size) { 8 | return http.get('/send/list',{ params: {accountId, sendId, size}}) 9 | } 10 | 11 | export function sendDelete(sendIds) { 12 | return http.delete('/send/delete?sendIds=' + sendIds); 13 | } -------------------------------------------------------------------------------- /mail-vue/src/request/setting.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function settingSet(setting) { 4 | return http.put('/setting/set',setting) 5 | } 6 | 7 | export function settingQuery() { 8 | return http.get('/setting/query') 9 | } -------------------------------------------------------------------------------- /mail-vue/src/request/star.js: -------------------------------------------------------------------------------- 1 | import http from "@/axios/index.js"; 2 | 3 | export function starAdd(emailId) { 4 | return http.post('/star/add', {emailId}) 5 | } 6 | 7 | export function starCancel(emailId) { 8 | return http.delete('/star/cancel', {params: {emailId}}) 9 | } 10 | 11 | export function starList(emailId,size) { 12 | return http.get('/star/list', {params: {emailId,size}}) 13 | } -------------------------------------------------------------------------------- /mail-vue/src/request/user.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function loginUserInfo() { 4 | return http.get('/user/loginUserInfo') 5 | } 6 | 7 | export function resetPassword(password) { 8 | return http.put('/user/resetPassword', {password}) 9 | } 10 | 11 | export function userDelete() { 12 | return http.delete('/user/delete') 13 | } -------------------------------------------------------------------------------- /mail-vue/src/router/index.js: -------------------------------------------------------------------------------- 1 | import {createRouter, createWebHashHistory} from 'vue-router' 2 | import {loginUserInfo} from "@/request/user.js"; 3 | import {useUiStore} from "@/store/ui.js"; 4 | import {useAccountStore} from "@/store/account.js"; 5 | import {useUserStore} from "@/store/user.js"; 6 | import {useSettingStore} from "@/store/setting.js"; 7 | 8 | const routes = [ 9 | { 10 | path: '/', 11 | name: 'layout', 12 | redirect: '/email', 13 | component: () => import('@/layout/index.vue'), 14 | children: [ 15 | { 16 | path: '/email', 17 | name: 'email', 18 | component: () => import('@/views/email/index.vue'), 19 | meta: { 20 | title: '收件箱', 21 | name: 'email', 22 | menu: true 23 | } 24 | }, 25 | { 26 | path: '/content', 27 | name: 'content', 28 | component: () => import('@/views/content/index.vue'), 29 | meta: { 30 | title: '邮件详情', 31 | name: 'content', 32 | menu: false 33 | } 34 | }, 35 | { 36 | path: '/setting', 37 | name: 'setting', 38 | component: () => import('@/views/setting/index.vue'), 39 | meta: { 40 | title: '个人设置', 41 | name: 'setting', 42 | menu: true 43 | } 44 | }, 45 | { 46 | path: '/sys-setting', 47 | name: 'sys-setting', 48 | component: () => import('@/views/sys-setting/index.vue'), 49 | meta: { 50 | title: '系统设置', 51 | name: 'sys-setting', 52 | menu: true 53 | } 54 | }, 55 | { 56 | path: '/star', 57 | name: 'star', 58 | component: () => import('@/views/star/index.vue'), 59 | meta: { 60 | title: '星标邮件', 61 | name: 'star', 62 | menu: true 63 | } 64 | }, 65 | ] 66 | 67 | }, 68 | { 69 | path: '/login', 70 | name: 'login', 71 | component: () => import('@/views/login/index.vue') 72 | }, 73 | { 74 | path: '/test', 75 | name: 'test', 76 | component: () => import('@/views/test/index.vue') 77 | } 78 | ] 79 | 80 | 81 | const router = createRouter({ 82 | history: createWebHashHistory(import.meta.env.BASE_URL), 83 | routes 84 | }) 85 | 86 | let firstLogin = true 87 | 88 | router.beforeEach(async (to, from, next) => { 89 | 90 | const uiStore = useUiStore() 91 | 92 | if (uiStore.init && to.name === 'login') { 93 | await initSetting() 94 | } 95 | 96 | const token = localStorage.getItem('token') 97 | 98 | if (!token && to.name !== 'login') { 99 | return next({ 100 | name: 'login' 101 | }) 102 | } 103 | 104 | if (!token && to.name === 'login') { 105 | return next() 106 | } 107 | 108 | if (uiStore.init && to.name !== 'login') { 109 | await initSettingAndUserInfo() 110 | firstLogin = false 111 | return next() 112 | } 113 | 114 | if (firstLogin) { 115 | await initUserInfo() 116 | firstLogin = false 117 | return next() 118 | } 119 | 120 | next() 121 | 122 | }) 123 | 124 | router.afterEach((to) => { 125 | 126 | const uiStore = useUiStore() 127 | if (to.meta.menu) { 128 | if (['content', 'email'].includes(to.meta.name)) { 129 | uiStore.accountShow = window.innerWidth > 767; 130 | } else { 131 | uiStore.accountShow = false 132 | } 133 | } 134 | 135 | if (to.name === 'login') { 136 | firstLogin = true 137 | } 138 | 139 | if (window.innerWidth < 768) { 140 | uiStore.asideShow = false 141 | } 142 | }) 143 | 144 | 145 | async function initSettingAndUserInfo() { 146 | const uiStore = useUiStore() 147 | const userStore = useUserStore() 148 | const settingStore = useSettingStore() 149 | const accountStore = useAccountStore() 150 | const [setting,user] = await Promise.all([settingStore.initSetting(),loginUserInfo()]) 151 | uiStore.init = false 152 | accountStore.currentAccountId = user.accountId 153 | userStore.user = user 154 | } 155 | 156 | 157 | async function initUserInfo() { 158 | const uiStore = useUiStore() 159 | const userStore = useUserStore() 160 | try { 161 | const user = await loginUserInfo() 162 | const accountStore = useAccountStore() 163 | accountStore.currentAccountId = user.accountId 164 | userStore.user = user 165 | uiStore.loginLoading = false 166 | } catch (e) { 167 | uiStore.loginLoading = false 168 | throw e 169 | } 170 | } 171 | 172 | 173 | async function initSetting() { 174 | const settingStore = useSettingStore() 175 | const uiStore = useUiStore() 176 | try { 177 | await settingStore.initSetting(); 178 | uiStore.init = false 179 | } catch (e) { 180 | throw e 181 | } 182 | } 183 | 184 | export default router 185 | -------------------------------------------------------------------------------- /mail-vue/src/store/account.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useAccountStore = defineStore('account', { 4 | state: () => ({ 5 | currentAccountId: 0, 6 | }) 7 | }) -------------------------------------------------------------------------------- /mail-vue/src/store/email.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useEmailStore = defineStore('email', { 4 | state: () => ({ 5 | deleteIds: 0, 6 | starScroll: null, 7 | emailScroll: null, 8 | readEmail: {}, 9 | }), 10 | persist: { 11 | pick: ['readEmail'], 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /mail-vue/src/store/send.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useSendStore = defineStore('send', { 4 | state: () => ({ 5 | deleteId: 0 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /mail-vue/src/store/setting.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { settingQuery} from "@/request/setting.js"; 3 | 4 | export const useSettingStore = defineStore('setting', { 5 | state: () => ({ 6 | domainList: [], 7 | settings: { 8 | title: '-' 9 | } 10 | }), 11 | actions: { 12 | async initSetting() { 13 | if (this.domainList.length === 0) { 14 | const data = await settingQuery() 15 | this.domainList.push(...data.domainList) 16 | delete data.domainList 17 | this.settings = data 18 | } 19 | } 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /mail-vue/src/store/ui.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useUiStore = defineStore('ui', { 4 | state: () => ({ 5 | asideShow: window.innerWidth > 991, 6 | loginLoading: false, 7 | accountShow: false, 8 | init: true, 9 | }), 10 | persist: { 11 | pick: ['accountShow'], 12 | }, 13 | }) -------------------------------------------------------------------------------- /mail-vue/src/store/user.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useUserStore = defineStore('user', { 4 | state: () => ({ 5 | user: {}, 6 | }) 7 | }) -------------------------------------------------------------------------------- /mail-vue/src/store/writer.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useUserStore = defineStore('user', { 4 | state: () => ({ 5 | 6 | }) 7 | }) -------------------------------------------------------------------------------- /mail-vue/src/style.css: -------------------------------------------------------------------------------- 1 | 2 | * { 3 | margin: 0; 4 | padding: 0; 5 | box-sizing: border-box; 6 | } 7 | 8 | html, body { 9 | width: 100%; 10 | height: 100%; 11 | } 12 | 13 | #app { 14 | width: 100%; 15 | height: 100%; 16 | } 17 | 18 | @font-face { 19 | font-family: 'HarmonyOS'; 20 | src: url('@/assets/fonts/HarmonyOS_Sans_SC_Regular.woff2') format('woff2'); 21 | font-weight: normal; 22 | font-style: normal; 23 | font-display: swap; 24 | } 25 | 26 | :deep(.el-input__inner:focus) { 27 | background-color: transparent !important; 28 | border-color: #dcdfe6 !important; 29 | } 30 | 31 | body { 32 | font-family: 'HarmonyOS', -apple-system, BlinkMacSystemFont, 33 | 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 34 | 'Noto Sans', sans-serif; 35 | line-height: 1.5; 36 | color: #333; 37 | background-color: #fff; 38 | font-size: 14px; 39 | } 40 | 41 | * { 42 | -webkit-tap-highlight-color: transparent; 43 | } 44 | 45 | ul, ol { 46 | list-style: none; 47 | } 48 | 49 | img { 50 | max-width: 100%; 51 | height: auto; 52 | display: block; 53 | } 54 | 55 | button, input, select, textarea { 56 | font-family: inherit; 57 | font-size: inherit; 58 | outline: none; 59 | border: none; 60 | background: none; 61 | } 62 | 63 | *:focus { 64 | outline: none; 65 | } 66 | 67 | :root { 68 | --el-color-primary: #1890ff; 69 | --el-color-primary-dark-2: #1064c0; 70 | --el-color-primary-light-3: #4dabff; 71 | --el-color-primary-light-5: #69c0ff; 72 | --el-color-primary-light-7: #91d5ff; 73 | --el-color-primary-light-9: #e6f7ff; 74 | --el-text-color-regular: #333; 75 | } 76 | -------------------------------------------------------------------------------- /mail-vue/src/utils/convert-utils.js: -------------------------------------------------------------------------------- 1 | import {useSettingStore} from "@/store/setting.js"; 2 | 3 | export function cvtR2Url(key) { 4 | const settingStore = useSettingStore(); 5 | return 'https://' + settingStore.settings.r2Domain + '/' + key 6 | } -------------------------------------------------------------------------------- /mail-vue/src/utils/file-utils.js: -------------------------------------------------------------------------------- 1 | export function getExtName(fileName) { 2 | const index = fileName.lastIndexOf('.') 3 | return index !== -1 ? fileName.slice(index + 1).toLowerCase() : '' 4 | } 5 | 6 | export function formatBytes(bytes) { 7 | if (bytes === 0) return '0 B'; 8 | const k = 1024; 9 | const units = ['B', 'KB', 'MB', 'GB', 'TB']; 10 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 11 | const size = (bytes / Math.pow(k, i)).toFixed(2); 12 | return `${size} ${units[i]}`; 13 | } -------------------------------------------------------------------------------- /mail-vue/src/utils/time-utils.js: -------------------------------------------------------------------------------- 1 | export function sleep(ms) { 2 | return new Promise(resolve => setTimeout(resolve, ms)) 3 | } 4 | -------------------------------------------------------------------------------- /mail-vue/src/utils/verify-utils.js: -------------------------------------------------------------------------------- 1 | export function isEmail(email) { 2 | const reg = /^[a-zA-Z0-9]+@([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]+$/; 3 | return reg.test(email); 4 | } -------------------------------------------------------------------------------- /mail-vue/src/views/content/index.vue: -------------------------------------------------------------------------------- 1 | 59 | 158 | -------------------------------------------------------------------------------- /mail-vue/src/views/email/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 72 | 73 | -------------------------------------------------------------------------------- /mail-vue/src/views/login/index.vue: -------------------------------------------------------------------------------- 1 | 111 | 112 | 292 | 293 | 452 | -------------------------------------------------------------------------------- /mail-vue/src/views/setting/index.vue: -------------------------------------------------------------------------------- 1 | 36 | 122 | -------------------------------------------------------------------------------- /mail-vue/src/views/star/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 35 | -------------------------------------------------------------------------------- /mail-vue/src/views/sys-setting/index.vue: -------------------------------------------------------------------------------- 1 | 103 | 191 | -------------------------------------------------------------------------------- /mail-vue/src/views/test/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /mail-vue/vite.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig,loadEnv} from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import path from 'path' 4 | export default defineConfig(({mode}) => { 5 | const env = loadEnv(mode, process.cwd(), 'VITE') 6 | return { 7 | server: { 8 | host: true, 9 | port: 3001, 10 | hmr: true, 11 | }, 12 | base: env.VITE_STATIC_URL || '/', 13 | plugins: [vue() 14 | ], 15 | resolve: { 16 | alias: { 17 | '@': path.resolve(__dirname, 'src') 18 | } 19 | }, 20 | build: { 21 | outDir: '../mail-worker/dist', 22 | emptyOutDir: true, 23 | rollupOptions: { 24 | output: { 25 | manualChunks: () => 'all-in-one' 26 | } 27 | }, 28 | assetsInclude: ['**/*.json'], 29 | } 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /mail-worker/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /mail-worker/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": true 6 | } 7 | -------------------------------------------------------------------------------- /mail-worker/dist/assets/HarmonyOS_Sans_SC_Regular-D3EGA0gC.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaziestRen/cloud-mail/d4a5b297b34c72cdebe05eb589e30dea858189ec/mail-worker/dist/assets/HarmonyOS_Sans_SC_Regular-D3EGA0gC.woff2 -------------------------------------------------------------------------------- /mail-worker/dist/assets/favicon-C5dAZutX.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mail-worker/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /mail-worker/dist/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mail-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mail-cf", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "wrangler dev --config wrangler-dev.toml", 7 | "test": "wrangler deploy --config wrangler-test.toml", 8 | "deploy": "wrangler deploy", 9 | "start": "wrangler dev" 10 | }, 11 | "devDependencies": { 12 | "@cloudflare/vitest-pool-workers": "^0.7.5", 13 | "vitest": "~3.0.7", 14 | "wrangler": "^4.7.0" 15 | }, 16 | "dependencies": { 17 | "@cloudflare/vite-plugin": "^1.0.5", 18 | "dayjs": "^1.11.13", 19 | "drizzle-orm": "^0.42.0", 20 | "hono": "^4.7.5", 21 | "postal-mime": "^2.4.3", 22 | "uuid": "^11.1.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /mail-worker/src/api/account-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import accountService from '../service/account-service'; 3 | import result from '../model/result'; 4 | import userContext from '../security/user-context'; 5 | 6 | app.get('/account/list', async (c) => { 7 | const list = await accountService.list(c, c.req.query(), await userContext.getUserId(c)); 8 | return c.json(result.ok(list)); 9 | }); 10 | 11 | app.delete('/account/delete', async (c) => { 12 | await accountService.delete(c, c.req.query(), await userContext.getUserId(c)); 13 | return c.json(result.ok()); 14 | }); 15 | 16 | app.post('/account/add', async (c) => { 17 | const account = await accountService.add(c, await c.req.json(), await userContext.getUserId(c)); 18 | return c.json(result.ok(account)); 19 | }); 20 | 21 | -------------------------------------------------------------------------------- /mail-worker/src/api/email-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import emailService from '../service/email-service'; 3 | import result from '../model/result'; 4 | import userContext from '../security/user-context'; 5 | import attService from '../service/att-service'; 6 | 7 | app.get('/email/list', async (c) => { 8 | const data = await emailService.list(c, c.req.query(), await userContext.getUserId(c)); 9 | return c.json(result.ok(data)); 10 | }); 11 | 12 | app.get('/email/latest',async (c) => { 13 | const list = await emailService.latest(c, c.req.query(), await userContext.getUserId(c)); 14 | return c.json(result.ok(list)); 15 | }) 16 | 17 | app.delete('/email/delete', async (c) => { 18 | await emailService.delete(c, c.req.query(), await userContext.getUserId(c)); 19 | return c.json(result.ok()); 20 | }); 21 | 22 | app.get('/email/attList', async (c) => { 23 | const attList = await attService.list(c, c.req.query(), await userContext.getUserId(c)); 24 | return c.json(result.ok(attList)); 25 | }) 26 | 27 | -------------------------------------------------------------------------------- /mail-worker/src/api/login-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import loginService from '../service/login-service'; 3 | import result from '../model/result'; 4 | import userContext from '../security/user-context'; 5 | 6 | app.post('/login', async (c) => { 7 | const token = await loginService.login(c, await c.req.json()); 8 | return c.json(result.ok({ token: token })); 9 | }); 10 | 11 | app.post('/register', async (c) => { 12 | const jwt = await loginService.register(c, await c.req.json()); 13 | return c.json(result.ok(jwt)); 14 | }); 15 | 16 | app.delete('/logout', async (c) => { 17 | await loginService.logout(c, await userContext.getUserId(c)); 18 | return c.json(result.ok()); 19 | }); 20 | -------------------------------------------------------------------------------- /mail-worker/src/api/setting-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import result from '../model/result'; 3 | import settingService from '../service/setting-service'; 4 | 5 | app.put('/setting/set', async (c) => { 6 | await settingService.set(c, await c.req.json()); 7 | return c.json(result.ok()); 8 | }) 9 | 10 | app.get('/setting/query', async (c) => { 11 | const setting = await settingService.query(c); 12 | return c.json(result.ok(setting)); 13 | }) 14 | -------------------------------------------------------------------------------- /mail-worker/src/api/star-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import startService from '../service/star-service'; 3 | import userContext from '../security/user-context'; 4 | import result from '../model/result'; 5 | 6 | app.post('/star/add', async (c) => { 7 | await startService.add(c, await c.req.json(), await userContext.getUserId(c)); 8 | return c.json(result.ok()); 9 | }); 10 | 11 | app.get('/star/list', async (c) => { 12 | const data = await startService.list(c, c.req.query(), await userContext.getUserId(c)); 13 | return c.json(result.ok(data)); 14 | }); 15 | 16 | app.delete('/star/cancel', async (c) => { 17 | await startService.cancel(c, await c.req.query(), await userContext.getUserId(c)); 18 | return c.json(result.ok()); 19 | }); 20 | -------------------------------------------------------------------------------- /mail-worker/src/api/test-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | -------------------------------------------------------------------------------- /mail-worker/src/api/user-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import userService from '../service/user-service'; 3 | import result from '../model/result'; 4 | import userContext from '../security/user-context'; 5 | 6 | app.get('/user/loginUserInfo', async (c) => { 7 | const user = await userService.loginUserInfo(c, await userContext.getUserId(c)); 8 | return c.json(result.ok(user)); 9 | }); 10 | 11 | app.put('/user/resetPassword', async (c) => { 12 | await userService.resetPassword(c, await c.req.json(), await userContext.getUserId(c)); 13 | return c.json(result.ok()); 14 | }); 15 | 16 | app.delete('/user/delete', async (c) => { 17 | await userService.delete(c, await userContext.getUserId(c)); 18 | return c.json(result.ok()); 19 | }) 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /mail-worker/src/const/constant.js: -------------------------------------------------------------------------------- 1 | const constant = { 2 | 3 | TOKEN_HEADER: 'Authorization', 4 | JWT_UID: 'user_id:', 5 | JWT_TOKEN: 'token:', 6 | TOKEN_EXPIRE: 60 * 60 * 24 * 30, 7 | ATTACHMENT_PREFIX: 'attachments/' 8 | } 9 | 10 | export default constant 11 | -------------------------------------------------------------------------------- /mail-worker/src/const/entity-const.js: -------------------------------------------------------------------------------- 1 | export const userConst = { 2 | type: { 3 | COMMON: 1, 4 | ADMIN: 0, 5 | }, 6 | } 7 | 8 | export const settingConst = { 9 | register: { 10 | OPEN: 0, 11 | CLOSE: 1, 12 | }, 13 | receive: { 14 | OPEN: 0, 15 | CLOSE: 1, 16 | }, 17 | send: { 18 | OPEN: 0, 19 | CLOSE: 1, 20 | }, 21 | addEmail: { 22 | OPEN: 0, 23 | CLOSE: 1, 24 | }, 25 | manyEmail: { 26 | OPEN: 0, 27 | CLOSE: 1, 28 | }, 29 | registerVerify: { 30 | OPEN: 0, 31 | CLOSE: 1, 32 | }, 33 | addEmailVerify: { 34 | OPEN: 0, 35 | CLOSE: 1, 36 | } 37 | } 38 | 39 | export const isDel = { 40 | DELETE: 1, 41 | NORMAL: 0 42 | } 43 | -------------------------------------------------------------------------------- /mail-worker/src/const/kv-const.js: -------------------------------------------------------------------------------- 1 | const KvConst = { 2 | AUTH_INFO: 'auth-uid:', 3 | SETTING: 'setting:', 4 | } 5 | 6 | export default KvConst; 7 | -------------------------------------------------------------------------------- /mail-worker/src/email/email.js: -------------------------------------------------------------------------------- 1 | import PostalMime from 'postal-mime'; 2 | import emailService from '../service/email-service'; 3 | import accountService from '../service/account-service'; 4 | import settingService from '../service/setting-service'; 5 | import attService from '../service/att-service'; 6 | import r2Service from '../service/r2-service'; 7 | import constant from '../const/constant'; 8 | import { v4 as uuidv4 } from 'uuid'; 9 | import fileUtils from '../utils/file-utils'; 10 | 11 | export async function email(message, env, ctx) { 12 | 13 | try { 14 | 15 | if (!await settingService.isReceive({ env })) { 16 | return; 17 | } 18 | 19 | const account = await accountService.selectByEmailIncludeDel({ env: env }, message.to); 20 | 21 | const reader = message.raw.getReader(); 22 | let content = ''; 23 | 24 | while (true) { 25 | const { done, value } = await reader.read(); 26 | if (done) break; 27 | content += new TextDecoder().decode(value); 28 | } 29 | 30 | const email = await PostalMime.parse(content); 31 | 32 | const params = { 33 | sendEmail: email.from.address, 34 | name: email.from.name, 35 | receiveEmail: message.to, 36 | subject: email.subject, 37 | content: email.html, 38 | text: email.text, 39 | userId: account.userId, 40 | accountId: account.accountId 41 | }; 42 | 43 | const emailRow = await emailService.receive({ env }, params); 44 | 45 | if (!env.r2) { 46 | console.warn('r2对象存储未配置, 附件取消保存'); 47 | return; 48 | } 49 | 50 | if (email.attachments.length > 0) { 51 | 52 | let attachments = email.attachments.map(item => { 53 | let attachment = { ...item }; 54 | attachment.emailId = emailRow.emailId; 55 | attachment.userId = emailRow.userId; 56 | attachment.accountId = emailRow.accountId; 57 | attachment.key = constant.ATTACHMENT_PREFIX + uuidv4() + fileUtils.getExtFileName(item.filename); 58 | attachment.size = item.content.length ?? item.content.byteLength; 59 | return attachment; 60 | }); 61 | 62 | await attService.addAtt({ env }, attachments); 63 | 64 | for (let attachment of attachments) { 65 | await r2Service.putObj({ env }, attachment.key, attachment.content, { 66 | contentType: attachment.mimeType, 67 | contentDisposition: `attachment; filename="${attachment.filename}"` 68 | }); 69 | } 70 | 71 | } 72 | } catch (e) { 73 | console.error('邮件接收异常: ', e); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /mail-worker/src/entity/account.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | export const account = sqliteTable('account', { 4 | accountId: integer('account_id').primaryKey({ autoIncrement: true }), 5 | email: text('email').notNull(), 6 | status: integer('status').default(0).notNull(), 7 | latestEmailTime: text('latest_email_time'), 8 | createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`), 9 | userId: integer('user_id').notNull(), 10 | isDel: integer('is_del').default(0).notNull(), 11 | }); 12 | export default account 13 | -------------------------------------------------------------------------------- /mail-worker/src/entity/att.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | 4 | export const att = sqliteTable('attachments', { 5 | attId: integer('att_id').primaryKey({ autoIncrement: true }), 6 | userId: integer('user_id').notNull(), 7 | emailId: integer('email_id').notNull(), 8 | accountId: integer('account_id').notNull(), 9 | key: text('key').notNull(), 10 | filename: text('filename'), 11 | mimeType: text('mime_type'), 12 | size: integer('size'), 13 | disposition: text('disposition'), 14 | related: text('related'), 15 | contentId: text('content_id'), 16 | encoding: text('encoding'), 17 | createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(), 18 | }); 19 | 20 | -------------------------------------------------------------------------------- /mail-worker/src/entity/email.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | export const email = sqliteTable('email', { 4 | emailId: integer('email_id').primaryKey({ autoIncrement: true }), 5 | sendEmail: text('send_email'), 6 | name: text('name'), 7 | receiveEmail: text('receive_email').notNull(), 8 | accountId: integer('account_id').notNull(), 9 | userId: integer('user_id').notNull(), 10 | subject: text('subject'), 11 | text: text('text'), 12 | content: text('content'), 13 | createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(), 14 | isDel: integer('is_del').default(0).notNull() 15 | }); 16 | export default email 17 | -------------------------------------------------------------------------------- /mail-worker/src/entity/orm.js: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/d1'; 2 | 3 | export default function orm(c) { 4 | return drizzle(c.env.db,{logger: false}) 5 | } 6 | -------------------------------------------------------------------------------- /mail-worker/src/entity/setting.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | export const setting = sqliteTable('setting', { 3 | register: integer('register').default(0).notNull(), 4 | receive: integer('receive').default(0).notNull(), 5 | title: text('title').default('').notNull(), 6 | manyEmail: integer('many_email').default(1).notNull(), 7 | addEmail: integer('add_email').default(0).notNull(), 8 | autoRefreshTime: integer('auto_refresh_time').default(0).notNull(), 9 | registerVerify: integer('register_verify').default(1).notNull(), 10 | addEmailVerify: integer('add_email_verify').default(1).notNull() 11 | }); 12 | export default setting 13 | -------------------------------------------------------------------------------- /mail-worker/src/entity/star.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | 4 | export const star = sqliteTable('star', { 5 | starId: integer('star_id').primaryKey({ autoIncrement: true }), 6 | userId: integer('user_id').notNull(), 7 | emailId: integer('email_id').notNull(), 8 | createTime: text('create_time') 9 | .notNull() 10 | .default(sql`CURRENT_TIMESTAMP`), 11 | }); 12 | -------------------------------------------------------------------------------- /mail-worker/src/entity/user.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | const user = sqliteTable('user', { 4 | userId: integer('user_id').primaryKey({ autoIncrement: true }), 5 | email: text('email').notNull(), 6 | type: integer('type').default(1).notNull(), 7 | password: text('password').notNull(), 8 | salt: text('salt').notNull(), 9 | status: integer('status').default(0).notNull(), 10 | createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`), 11 | activeTime: text('active_time'), 12 | isDel: integer('is_del').default(0).notNull(), 13 | }); 14 | export default user 15 | -------------------------------------------------------------------------------- /mail-worker/src/error/biz-error.js: -------------------------------------------------------------------------------- 1 | class BizError extends Error { 2 | constructor(message, code) { 3 | super(message); 4 | this.code = code ? code : 501; 5 | this.name = 'BizError'; 6 | } 7 | } 8 | 9 | export default BizError; 10 | -------------------------------------------------------------------------------- /mail-worker/src/hono/hono.js: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | 3 | const app = new Hono(); 4 | import initDB from '../init/init-db'; 5 | import initCache from '../init/init-cache'; 6 | import verify from '../security/security'; 7 | import result from '../model/result'; 8 | import { cors } from 'hono/cors'; 9 | 10 | app.use('*', cors()); 11 | 12 | let initStatus = true; 13 | app.use('*', async (c, next) => { 14 | if (initStatus) { 15 | await initDB(c); 16 | initStatus = false; 17 | } 18 | await next(); 19 | }); 20 | 21 | 22 | let initCacheStatus = true; 23 | app.use('*', async (c, next) => { 24 | if (initCacheStatus) { 25 | await initCache(c); 26 | initCacheStatus = false; 27 | } 28 | await next(); 29 | }); 30 | 31 | 32 | app.use('*', async (c, next) => { 33 | if(await verify(c)) { 34 | await next(); 35 | } 36 | }); 37 | 38 | 39 | app.onError((err, c) => { 40 | if (err.name === 'BizError') { 41 | console.log(err.message); 42 | }else { 43 | console.error(err); 44 | } 45 | return c.json(result.fail(err.message, err.code)); 46 | }); 47 | 48 | export default app; 49 | 50 | 51 | -------------------------------------------------------------------------------- /mail-worker/src/hono/webs.js: -------------------------------------------------------------------------------- 1 | import app from './hono'; 2 | import '../api/email-api'; 3 | import '../api/user-api'; 4 | import '../api/login-api'; 5 | import '../api/setting-api'; 6 | import '../api/account-api'; 7 | import '../api/star-api'; 8 | import '../api/test-api'; 9 | export default app; 10 | -------------------------------------------------------------------------------- /mail-worker/src/index.js: -------------------------------------------------------------------------------- 1 | import app from './hono/webs'; 2 | import { email } from './email/email'; 3 | 4 | export default { 5 | fetch(req, env, ctx) { 6 | const url = new URL(req.url) 7 | console.log(url.pathname) 8 | if (url.pathname.startsWith('/api/')) { 9 | url.pathname = url.pathname.replace('/api', '') 10 | req = new Request(url.toString(), req) 11 | return app.fetch(req, env, ctx); 12 | } 13 | 14 | return env.assets.fetch(req); 15 | }, 16 | email: email 17 | }; 18 | -------------------------------------------------------------------------------- /mail-worker/src/init/init-cache.js: -------------------------------------------------------------------------------- 1 | import settingService from '../service/setting-service'; 2 | export default async function initCache(c) { 3 | await settingService.refresh(c) 4 | } 5 | -------------------------------------------------------------------------------- /mail-worker/src/init/init-db.js: -------------------------------------------------------------------------------- 1 | export default async function initDB(c) { 2 | await c.env.db.prepare(` 3 | CREATE TABLE IF NOT EXISTS email ( 4 | email_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 5 | send_email TEXT, 6 | name TEXT, 7 | receive_email TEXT NOT NULL, 8 | account_id INTEGER NOT NULL, 9 | user_id INTEGER NOT NULL, 10 | subject TEXT, 11 | content TEXT, 12 | text TEXT, 13 | create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, 14 | is_del INTEGER DEFAULT 0 NOT NULL 15 | ) 16 | `).run(); 17 | 18 | await c.env.db.prepare(` 19 | CREATE TABLE IF NOT EXISTS star ( 20 | star_id INTEGER PRIMARY KEY AUTOINCREMENT, 21 | user_id INTEGER NOT NULL, 22 | email_id INTEGER NOT NULL, 23 | create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL 24 | ) 25 | `).run(); 26 | 27 | await c.env.db.prepare(` 28 | CREATE TABLE IF NOT EXISTS attachments ( 29 | att_id INTEGER PRIMARY KEY AUTOINCREMENT, 30 | user_id INTEGER NOT NULL, 31 | email_id INTEGER NOT NULL, 32 | account_id INTEGER NOT NULL, 33 | key TEXT NOT NULL, 34 | filename TEXT, 35 | mime_type TEXT, 36 | size INTEGER, 37 | disposition TEXT, 38 | related TEXT, 39 | content_id TEXT, 40 | encoding TEXT, 41 | create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL 42 | ) 43 | `).run(); 44 | 45 | await c.env.db.prepare(` 46 | CREATE TABLE IF NOT EXISTS user ( 47 | user_id INTEGER PRIMARY KEY AUTOINCREMENT, 48 | email TEXT NOT NULL, 49 | type INTEGER DEFAULT 1 NOT NULL, 50 | password TEXT NOT NULL, 51 | salt TEXT NOT NULL, 52 | status INTEGER DEFAULT 0 NOT NULL, 53 | create_time DATETIME DEFAULT CURRENT_TIMESTAMP, 54 | active_time DATETIME, 55 | is_del INTEGER DEFAULT 0 NOT NULL 56 | ) 57 | `).run(); 58 | 59 | await c.env.db.prepare(` 60 | CREATE TABLE IF NOT EXISTS account ( 61 | account_id INTEGER PRIMARY KEY AUTOINCREMENT, 62 | email TEXT NOT NULL, 63 | status INTEGER DEFAULT 0 NOT NULL, 64 | latest_email_time DATETIME, 65 | create_time DATETIME DEFAULT CURRENT_TIMESTAMP, 66 | user_id INTEGER NOT NULL, 67 | is_del INTEGER DEFAULT 0 NOT NULL 68 | ) 69 | `).run(); 70 | 71 | await c.env.db.prepare(` 72 | CREATE TABLE IF NOT EXISTS setting ( 73 | register INTEGER NOT NULL, 74 | receive INTEGER NOT NULL, 75 | add_email INTEGER NOT NULL, 76 | many_email INTEGER NOT NULL, 77 | title TEXT NOT NULL, 78 | auto_refresh_time INTEGER NOT NULL, 79 | register_verify INTEGER NOT NULL, 80 | add_email_verify INTEGER NOT NULL 81 | ) 82 | `).run(); 83 | 84 | await c.env.db.prepare(` 85 | INSERT INTO setting (register, receive,add_email,many_email,title,auto_refresh_time,register_verify,add_email_verify) 86 | SELECT 0, 0, 0, 1, 'Cloud 邮箱', 0, 1, 1 87 | WHERE NOT EXISTS (SELECT 1 FROM setting) 88 | `).run(); 89 | 90 | } 91 | 92 | 93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /mail-worker/src/model/result.js: -------------------------------------------------------------------------------- 1 | const result = { 2 | ok(data) { 3 | return { code: 200, message: 'success', data: data ? data : null }; 4 | }, 5 | fail(message, code = 500) { 6 | return { code, message }; 7 | } 8 | }; 9 | export default result; 10 | -------------------------------------------------------------------------------- /mail-worker/src/security/security.js: -------------------------------------------------------------------------------- 1 | import BizError from '../error/biz-error'; 2 | import constant from '../const/constant'; 3 | import jwtUtils from '../utils/jwt-utils'; 4 | import KvConst from '../const/kv-const'; 5 | import { userConst } from '../const/entity-const'; 6 | import dayjs from 'dayjs'; 7 | 8 | const exclude = [ 9 | '/login', 10 | '/register', 11 | '/setting/query' 12 | ]; 13 | 14 | const adminPerm = [ 15 | '/setting/set' 16 | ] 17 | 18 | const verify = async (c) => { 19 | 20 | if (c.req.path.startsWith('/test')) { 21 | return true 22 | } 23 | 24 | if (exclude.includes(c.req.path)) { 25 | return true; 26 | } 27 | 28 | const jwt = c.req.header(constant.TOKEN_HEADER); 29 | 30 | const result = await jwtUtils.verifyToken(c, jwt) 31 | 32 | if (!result) { 33 | throw new BizError('身份认证失效,请重新登录',401) 34 | } 35 | 36 | const {userId, token} = result 37 | const authInfo = await c.env.kv.get(KvConst.AUTH_INFO + userId,{ type: 'json' }); 38 | 39 | if (!authInfo) { 40 | throw new BizError('身份认证失效,请重新登录',401) 41 | } 42 | 43 | if(!authInfo.tokens.includes(token)) { 44 | throw new BizError('身份认证失效,请重新登录',401) 45 | } 46 | 47 | if (adminPerm.includes(c.req.path) && c.env.admin !== authInfo.user.email) { 48 | throw new BizError('权限不足',403) 49 | } 50 | 51 | if (dayjs().diff(dayjs(authInfo.createTime), 'day') >= 1) { 52 | authInfo.refreshTime = new Date().toISOString() 53 | await c.env.kv.put(KvConst.AUTH_INFO + userId, JSON.stringify(authInfo), { expirationTtl: constant.TOKEN_EXPIRE }); 54 | } 55 | 56 | return true 57 | 58 | }; 59 | 60 | export default verify; 61 | -------------------------------------------------------------------------------- /mail-worker/src/security/user-context.js: -------------------------------------------------------------------------------- 1 | import JwtUtils from '../utils/jwt-utils'; 2 | import constant from '../const/constant'; 3 | 4 | const userContext = { 5 | async getUserId(c) { 6 | const jwt = c.req.header(constant.TOKEN_HEADER); 7 | const { userId } = await JwtUtils.verifyToken(c, jwt); 8 | return Number(userId); 9 | }, 10 | async getToken(c) { 11 | const jwt = c.req.header(constant.TOKEN_HEADER); 12 | const { token } = JwtUtils.verifyToken(c,jwt); 13 | return token; 14 | }, 15 | }; 16 | export default userContext; 17 | -------------------------------------------------------------------------------- /mail-worker/src/service/account-service.js: -------------------------------------------------------------------------------- 1 | import BizError from '../error/biz-error'; 2 | import verifyUtils from '../utils/verify-utils'; 3 | import emailUtils from '../utils/email-utils'; 4 | import userService from './user-service'; 5 | import emailService from './email-service'; 6 | import orm from '../entity/orm'; 7 | import account from '../entity/account'; 8 | import { and, asc, eq, gt } from 'drizzle-orm'; 9 | import { isDel } from '../const/entity-const'; 10 | import settingService from './setting-service'; 11 | import turnstileService from './turnstile-service'; 12 | 13 | const accountService = { 14 | 15 | async add(c, params, userId) { 16 | 17 | 18 | if (!await settingService.isAddEmail(c)) { 19 | throw new BizError('添加邮箱功能已关闭'); 20 | } 21 | 22 | let { email, token } = params; 23 | 24 | if (!email) { 25 | throw new BizError('邮箱不能为空'); 26 | } 27 | 28 | if (!verifyUtils.isEmail(email)) { 29 | throw new BizError('非法邮箱'); 30 | } 31 | 32 | if (!c.env.domain.includes(emailUtils.getDomain(email))) { 33 | throw new BizError('未配置改邮箱域名'); 34 | } 35 | 36 | const accountRow = await this.selectByEmailIncludeDel(c, email); 37 | 38 | if (accountRow && accountRow.isDel === isDel.DELETE) { 39 | throw new BizError('该邮箱已被注销'); 40 | } 41 | 42 | if (accountRow) { 43 | throw new BizError('该邮箱已被注册'); 44 | } 45 | 46 | if (await settingService.isAddEmailVerify(c)) { 47 | await turnstileService.verify(c, token); 48 | } 49 | 50 | return orm(c).insert(account).values({ email: email, userId: userId }).returning().get(); 51 | }, 52 | 53 | selectByEmailIncludeDel(c, email) { 54 | return orm(c).select().from(account).where(eq(account.email, email)).get(); 55 | }, 56 | 57 | selectByEmail(c, email) { 58 | return orm(c).select().from(account).where( 59 | and( 60 | eq(account.email, email), 61 | eq(account.isDel, isDel.NORMAL))) 62 | .get(); 63 | }, 64 | 65 | list(c, params, userId) { 66 | 67 | let { accountId, size } = params; 68 | 69 | accountId = Number(accountId); 70 | size = Number(size); 71 | 72 | if (size > 30) { 73 | size = 30; 74 | } 75 | 76 | if (!accountId) { 77 | accountId = 0; 78 | } 79 | return orm(c).select().from(account).where( 80 | and( 81 | eq(account.userId, userId), 82 | eq(account.isDel, isDel.NORMAL), 83 | gt(account.accountId, accountId))) 84 | .orderBy(asc(account.accountId)) 85 | .limit(size) 86 | .all(); 87 | }, 88 | 89 | async delete(c, params, userId) { 90 | 91 | let { accountId } = params; 92 | 93 | const user = await userService.selectById(c, userId); 94 | const accountRow = await this.selectById(c, accountId); 95 | 96 | if (accountRow.email === user.email) { 97 | throw new BizError('不可以删除自己的邮箱'); 98 | } 99 | 100 | if (accountRow.userId !== user.userId) { 101 | throw new BizError('该邮箱不属于当前用户'); 102 | } 103 | 104 | await orm(c).update(account).set({ isDel: isDel.DELETE }).where( 105 | and(eq(account.userId, userId), 106 | eq(account.accountId, accountId))) 107 | .run(); 108 | await emailService.removeByAccountId(c, accountId); 109 | }, 110 | 111 | selectById(c, accountId) { 112 | return orm(c).select().from(account).where( 113 | and(eq(account.accountId, accountId), 114 | eq(account.isDel, isDel.NORMAL))) 115 | .get(); 116 | }, 117 | 118 | async insert(c, params) { 119 | await orm(c).insert(account).values({ ...params }).returning(); 120 | }, 121 | 122 | async removeByUserId(c, userId) { 123 | await orm(c).update(account).set({ isDel: isDel.DELETE }).where(eq(account.userId, userId)).run(); 124 | } 125 | }; 126 | 127 | export default accountService; 128 | -------------------------------------------------------------------------------- /mail-worker/src/service/att-service.js: -------------------------------------------------------------------------------- 1 | import orm from '../entity/orm'; 2 | import { att } from '../entity/att'; 3 | import { and, eq } from 'drizzle-orm'; 4 | 5 | const attService = { 6 | async addAtt(c, params) { 7 | await orm(c).insert(att).values(params).run(); 8 | }, 9 | async list(c, params, userId) { 10 | const { emailId } = params; 11 | 12 | const list = await orm(c).select().from(att).where( 13 | and( 14 | eq(att.emailId, emailId), 15 | eq(att.userId, userId))) 16 | .all(); 17 | 18 | return list; 19 | } 20 | }; 21 | 22 | export default attService; 23 | -------------------------------------------------------------------------------- /mail-worker/src/service/email-service.js: -------------------------------------------------------------------------------- 1 | import orm from '../entity/orm'; 2 | import email from '../entity/email'; 3 | import { isDel } from '../const/entity-const'; 4 | import { and, desc, eq, gt, inArray, lt, sql } from 'drizzle-orm'; 5 | import { star } from '../entity/star'; 6 | 7 | const emailService = { 8 | 9 | async list(c, params, userId) { 10 | 11 | let { emailId, accountId, size } = params; 12 | size = Number(size); 13 | emailId = Number(emailId); 14 | 15 | if (size > 30) { 16 | size = 30; 17 | } 18 | 19 | if (!emailId) { 20 | emailId = 9999999999; 21 | } 22 | 23 | const list = await orm(c) 24 | .select({ 25 | ...email, 26 | starId: star.starId 27 | }) 28 | .from(email) 29 | .leftJoin( 30 | star, 31 | and( 32 | eq(star.emailId, email.emailId), 33 | eq(star.userId, userId) 34 | ) 35 | ) 36 | .where( 37 | and( 38 | lt(email.emailId, emailId), 39 | eq(email.accountId, accountId), 40 | eq(email.userId, userId), 41 | eq(email.isDel, isDel.NORMAL) 42 | ) 43 | ) 44 | .orderBy(desc(email.emailId)) 45 | .limit(size) 46 | .all(); 47 | 48 | const resultList = list.map(item => ({ 49 | ...item, 50 | isStar: item.starId != null ? 1 : 0 51 | })); 52 | 53 | return { list: resultList }; 54 | }, 55 | 56 | async delete(c, params, userId) { 57 | const { emailIds } = params; 58 | const emailIdList = emailIds.split(',').map(Number); 59 | await orm(c).update(email).set({ isDel: isDel.DELETE }).where( 60 | and( 61 | eq(email.userId, userId), 62 | inArray(email.emailId, emailIdList))) 63 | .run(); 64 | }, 65 | 66 | receive(c, params) { 67 | return orm(c).insert(email).values({ ...params }).returning().get(); 68 | }, 69 | 70 | async removeByAccountId(c, accountId) { 71 | await orm(c).update(email) 72 | .set({ isDel: isDel.DELETE }) 73 | .where(eq(email.accountId, accountId)) 74 | .run(); 75 | }, 76 | 77 | async removeByUserId(c, userId) { 78 | await orm(c).update(email).set({ isDel: isDel.DELETE }).where(eq(email.userId, userId)).run(); 79 | }, 80 | selectById(c, emailId) { 81 | return orm(c).select().from(email).where( 82 | and(eq(email.emailId, emailId), 83 | eq(email.isDel, isDel.NORMAL))) 84 | .get(); 85 | }, 86 | 87 | latest(c, params, userId) { 88 | let { emailId,accountId } = params; 89 | return orm(c).select().from(email).where( 90 | and( 91 | eq(email.userId, userId), 92 | eq(email.isDel, isDel.NORMAL), 93 | eq(email.accountId, accountId), 94 | gt(email.emailId, emailId) 95 | )) 96 | .orderBy(desc(email.emailId)) 97 | .limit(20); 98 | } 99 | 100 | }; 101 | 102 | export default emailService; 103 | -------------------------------------------------------------------------------- /mail-worker/src/service/login-service.js: -------------------------------------------------------------------------------- 1 | import BizError from '../error/biz-error'; 2 | import userService from './user-service'; 3 | import emailUtils from '../utils/email-utils'; 4 | import { isDel, userConst } from '../const/entity-const'; 5 | import JwtUtils from '../utils/jwt-utils'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | import KvConst from '../const/kv-const'; 8 | import constant from '../const/constant'; 9 | import userContext from '../security/user-context'; 10 | import verifyUtils from '../utils/verify-utils'; 11 | import accountService from './account-service'; 12 | import settingService from './setting-service'; 13 | import saltHashUtils from '../utils/crypto-utils'; 14 | import cryptoUtils from '../utils/crypto-utils'; 15 | import turnstileService from './turnstile-service'; 16 | 17 | 18 | const loginService = { 19 | 20 | async register(c, params) { 21 | 22 | const { email, password, token } = params; 23 | 24 | if (!await settingService.isRegister(c)) { 25 | throw new BizError('注册功能已关闭'); 26 | } 27 | 28 | if (!verifyUtils.isEmail(email)) { 29 | throw new BizError('非法邮箱'); 30 | } 31 | 32 | if (password.length < 6) { 33 | throw new BizError('密码必须大于6位'); 34 | } 35 | 36 | if (!c.env.domain.includes(emailUtils.getDomain(email))) { 37 | throw new BizError('非法邮箱域名'); 38 | } 39 | 40 | const userRow = await userService.selectByEmailIncludeDel(c, email); 41 | 42 | if (userRow && userRow.isDel === isDel.DELETE) { 43 | throw new BizError('该邮箱已被注销'); 44 | } 45 | 46 | if (userRow) { 47 | throw new BizError('该邮箱已被注册'); 48 | } 49 | 50 | if (await settingService.isRegisterVerify(c)) { 51 | await turnstileService.verify(c,token) 52 | } 53 | 54 | const { salt, hash } = await saltHashUtils.hashPassword(password); 55 | 56 | const userId = await userService.insert(c, { email, password: hash, salt, type: userConst.type.COMMON }); 57 | await accountService.insert(c, { userId: userId, email }); 58 | }, 59 | 60 | async login(c, params) { 61 | 62 | const { email, password } = params; 63 | 64 | if (!email || !password) { 65 | throw new BizError('邮箱和密码不能为空'); 66 | } 67 | 68 | const userRow = await userService.selectByEmail(c, email); 69 | 70 | if (!userRow) { 71 | throw new BizError('该邮箱不存在'); 72 | } 73 | 74 | if (!await cryptoUtils.verifyPassword(password, userRow.salt, userRow.password)) { 75 | throw new BizError('密码输入错误'); 76 | } 77 | 78 | const uuid = uuidv4(); 79 | const jwt = await JwtUtils.generateToken(c,{ userId: userRow.userId, token: uuid }); 80 | 81 | let authInfo = await c.env.kv.get(KvConst.AUTH_INFO + userRow.userId, { type: 'json' }); 82 | 83 | if (authInfo) { 84 | 85 | authInfo.tokens.push(uuid); 86 | 87 | } else { 88 | 89 | authInfo = { 90 | tokens: [], 91 | user: userRow, 92 | refreshTime: new Date().toISOString() 93 | }; 94 | 95 | authInfo.tokens.push(uuid); 96 | 97 | } 98 | 99 | await c.env.kv.put(KvConst.AUTH_INFO + userRow.userId, JSON.stringify(authInfo), { expirationTtl: constant.TOKEN_EXPIRE }); 100 | return jwt; 101 | }, 102 | 103 | async logout(c, userId) { 104 | const token = await userContext.getToken(c); 105 | const authInfo = await c.env.kv.get(KvConst.AUTH_INFO + userId, { type: 'json' }); 106 | const index = authInfo.tokens.findIndex(item => item === token); 107 | authInfo.tokens.splice(index, 1); 108 | await c.env.kv.put(KvConst.AUTH_INFO + userId, JSON.stringify(authInfo)); 109 | } 110 | 111 | }; 112 | 113 | export default loginService; 114 | -------------------------------------------------------------------------------- /mail-worker/src/service/r2-service.js: -------------------------------------------------------------------------------- 1 | const r2Service = { 2 | async putObj(c, key, content, metadata) { 3 | const body = typeof content === 'string' 4 | ? new TextEncoder().encode(content) 5 | : content; 6 | await c.env.r2.put(key, body, { 7 | httpMetadata: {...metadata} 8 | }); 9 | } 10 | }; 11 | export default r2Service; 12 | -------------------------------------------------------------------------------- /mail-worker/src/service/setting-service.js: -------------------------------------------------------------------------------- 1 | import KvConst from '../const/kv-const'; 2 | import setting from '../entity/setting'; 3 | import orm from '../entity/orm'; 4 | import { settingConst } from '../const/entity-const'; 5 | import BizError from "../error/biz-error"; 6 | 7 | const settingService = { 8 | 9 | async refresh(c) { 10 | const settingRow = await orm(c).select().from(setting).get(); 11 | await c.env.kv.put(KvConst.SETTING, JSON.stringify(settingRow)); 12 | }, 13 | 14 | async query(c) { 15 | const setting = await c.env.kv.get(KvConst.SETTING, { type: 'json' }); 16 | let domainList = c.env.domain; 17 | domainList = domainList.map(item => '@' + item); 18 | setting.domainList = domainList; 19 | setting.siteKey = c.env.site_key; 20 | setting.r2Domain = c.env.r2_domain; 21 | return setting; 22 | }, 23 | 24 | async set(c, params) { 25 | if (params.registerVerify === 0 || params.addEmailVerify === 0) { 26 | if (!c.env.site_key || !c.env.secret_key) { 27 | throw new BizError('Turnstile密钥未配置,不能开启人机验证') 28 | } 29 | } 30 | await orm(c).update(setting).set({ ...params }).returning().get(); 31 | await this.refresh(c); 32 | }, 33 | 34 | async isRegister(c) { 35 | const { register } = await this.query(c); 36 | return register === settingConst.register.OPEN; 37 | }, 38 | 39 | async isReceive(c) { 40 | const { receive } = await this.query(c); 41 | return receive === settingConst.receive.OPEN; 42 | }, 43 | 44 | async isAddEmail(c) { 45 | const { addEmail, manyEmail } = await this.query(c); 46 | return addEmail === settingConst.addEmail.OPEN && manyEmail === settingConst.manyEmail.OPEN; 47 | }, 48 | 49 | async isRegisterVerify(c) { 50 | const { registerVerify } = await this.query(c); 51 | return registerVerify === settingConst.registerVerify.OPEN; 52 | }, 53 | 54 | async isAddEmailVerify(c) { 55 | const { addEmailVerify } = await this.query(c); 56 | return addEmailVerify === settingConst.addEmailVerify.OPEN; 57 | } 58 | }; 59 | 60 | export default settingService; 61 | -------------------------------------------------------------------------------- /mail-worker/src/service/star-service.js: -------------------------------------------------------------------------------- 1 | import orm from '../entity/orm'; 2 | import { star } from '../entity/star'; 3 | import emailService from './email-service'; 4 | import BizError from '../error/biz-error'; 5 | import { and, desc, eq, lt, sql } from 'drizzle-orm'; 6 | import email from '../entity/email'; 7 | import { isDel } from '../const/entity-const'; 8 | 9 | const startService = { 10 | 11 | async add(c, params, userId) { 12 | const { emailId } = params; 13 | const email = await emailService.selectById(c, emailId); 14 | if (!email) { 15 | throw new BizError('星标的邮件不存在'); 16 | } 17 | if (!email.userId === userId) { 18 | throw new BizError('星标的邮件非当前用户所有'); 19 | } 20 | const exist = await orm(c).select().from(star).where( 21 | and( 22 | eq(star.userId, userId), 23 | eq(star.emailId, emailId))) 24 | .get() 25 | 26 | if (exist) { 27 | return 28 | } 29 | 30 | await orm(c).insert(star).values({ userId, emailId }).run(); 31 | }, 32 | 33 | async cancel(c, params, userId) { 34 | const { emailId } = params; 35 | await orm(c).delete(star).where( 36 | and( 37 | eq(star.userId, userId), 38 | eq(star.emailId, emailId))) 39 | .run(); 40 | }, 41 | 42 | async list(c, params, userId) { 43 | let { emailId, size } = params; 44 | emailId = Number(emailId); 45 | size = Number(size); 46 | 47 | if (!emailId) { 48 | emailId = 9999999999; 49 | } 50 | 51 | const list = await orm(c).select({ 52 | isStar: sql`1`.as('isStar'), 53 | starId: star.starId 54 | , ...email 55 | }).from(star) 56 | .leftJoin(email, eq(email.emailId, star.emailId)) 57 | .where( 58 | and( 59 | eq(star.userId, userId), 60 | eq(email.isDel, isDel.NORMAL), 61 | lt(star.emailId, emailId))) 62 | .orderBy(desc(star.emailId)) 63 | .limit(size) 64 | .all(); 65 | return { list }; 66 | } 67 | }; 68 | 69 | export default startService; 70 | -------------------------------------------------------------------------------- /mail-worker/src/service/turnstile-service.js: -------------------------------------------------------------------------------- 1 | import BizError from '../error/biz-error'; 2 | 3 | const turnstileService = { 4 | 5 | async verify(c, token) { 6 | 7 | if (!token) { 8 | throw new BizError('验证token不能为空'); 9 | } 10 | const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { 11 | method: 'POST', 12 | headers: { 13 | 'Content-Type': 'application/x-www-form-urlencoded' 14 | }, 15 | body: new URLSearchParams({ 16 | secret: c.env.secret_key, 17 | response: token, 18 | remoteip: c.req.header('cf-connecting-ip') 19 | }) 20 | }); 21 | 22 | const result = await res.json(); 23 | console.log(result) 24 | if (!result.success) { 25 | throw new BizError('人机验证失败,请重试',400) 26 | } 27 | } 28 | }; 29 | 30 | export default turnstileService; 31 | -------------------------------------------------------------------------------- /mail-worker/src/service/user-service.js: -------------------------------------------------------------------------------- 1 | import BizError from '../error/biz-error'; 2 | import accountService from './account-service'; 3 | import orm from '../entity/orm'; 4 | import user from '../entity/user'; 5 | import { eq, and } from 'drizzle-orm'; 6 | import { isDel } from '../const/entity-const'; 7 | import kvConst from '../const/kv-const'; 8 | import cryptoUtils from '../utils/crypto-utils'; 9 | 10 | const userService = { 11 | 12 | async loginUserInfo(c, userId) { 13 | let user = await userService.selectById(c, userId); 14 | let account = await accountService.selectByEmailIncludeDel(c, user.email); 15 | delete user.password; 16 | delete user.salt; 17 | user.accountId = account.accountId; 18 | user.type = c.env.admin === user.email ? 0 : 1; 19 | return user; 20 | }, 21 | 22 | async resetPassword(c, params, userId) { 23 | 24 | const { password } = params; 25 | 26 | if (password < 6) { 27 | throw new BizError('密码不能小于6位'); 28 | } 29 | const { salt, hash } = await cryptoUtils.hashPassword(password); 30 | await orm(c).update(user).set({ password: hash, salt: salt }).where(eq(user.userId, userId)).run(); 31 | }, 32 | 33 | selectByEmail(c, email) { 34 | return orm(c).select().from(user).where( 35 | and( 36 | eq(user.email, email), 37 | eq(user.isDel, isDel.NORMAL))) 38 | .get(); 39 | }, 40 | 41 | async insert(c, params) { 42 | const { userId } = await orm(c).insert(user).values({ ...params }).returning().get(); 43 | return userId; 44 | }, 45 | 46 | selectByEmailIncludeDel(c, email) { 47 | return orm(c).select().from(user).where(eq(user.email, email)).get(); 48 | }, 49 | 50 | selectById(c, userId) { 51 | return orm(c).select().from(user).where( 52 | and( 53 | eq(user.userId, userId), 54 | eq(user.isDel, isDel.NORMAL))) 55 | .get(); 56 | }, 57 | 58 | async delete(c, userId) { 59 | await orm(c).update(user).set({ isDel: isDel.DELETE }).where(eq(user.userId, userId)).run(); 60 | await Promise.all([ 61 | c.env.kv.delete(kvConst.AUTH_INFO + userId), 62 | accountService.removeByUserId(c, userId) 63 | ]); 64 | } 65 | }; 66 | 67 | export default userService; 68 | -------------------------------------------------------------------------------- /mail-worker/src/utils/crypto-utils.js: -------------------------------------------------------------------------------- 1 | const encoder = new TextEncoder(); 2 | 3 | const saltHashUtils = { 4 | 5 | generateSalt(length = 16) { 6 | const array = new Uint8Array(length); 7 | crypto.getRandomValues(array); 8 | return btoa(String.fromCharCode(...array)); 9 | }, 10 | 11 | 12 | async hashPassword(password) { 13 | const salt = this.generateSalt(); 14 | const hash = await this.genHashPassword(password, salt); 15 | return { salt, hash }; 16 | }, 17 | 18 | async genHashPassword(password, salt) { 19 | const data = encoder.encode(salt + password); 20 | const hashBuffer = await crypto.subtle.digest('SHA-256', data); 21 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 22 | return btoa(String.fromCharCode(...hashArray)); 23 | }, 24 | 25 | async verifyPassword(inputPassword, salt, storedHash) { 26 | const hash = await this.genHashPassword(inputPassword, salt); 27 | return hash === storedHash; 28 | } 29 | }; 30 | 31 | export default saltHashUtils; 32 | -------------------------------------------------------------------------------- /mail-worker/src/utils/email-utils.js: -------------------------------------------------------------------------------- 1 | const emailUtils = { 2 | getDomain(email) { 3 | if (typeof email !== 'string') return '' 4 | const parts = email.split('@') 5 | return parts.length === 2 ? parts[1] : '' 6 | } 7 | } 8 | 9 | export default emailUtils 10 | -------------------------------------------------------------------------------- /mail-worker/src/utils/file-utils.js: -------------------------------------------------------------------------------- 1 | const fileUtils = { 2 | getExtFileName(filename) { 3 | const index = filename.lastIndexOf('.'); 4 | return index !== -1 ? filename.slice(index) : ''; 5 | } 6 | }; 7 | 8 | export default fileUtils 9 | 10 | -------------------------------------------------------------------------------- /mail-worker/src/utils/jwt-utils.js: -------------------------------------------------------------------------------- 1 | const encoder = new TextEncoder(); 2 | const decoder = new TextDecoder(); 3 | 4 | const base64url = (input) => { 5 | const str = btoa(String.fromCharCode(...new Uint8Array(input))); 6 | return str.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); 7 | }; 8 | 9 | const base64urlDecode = (str) => { 10 | str = str.replace(/-/g, '+').replace(/_/g, '/'); 11 | while (str.length % 4) str += '='; 12 | return Uint8Array.from(atob(str), c => c.charCodeAt(0)); 13 | }; 14 | 15 | const jwtUtils = { 16 | async generateToken(c, payload, expiresInSeconds) { 17 | const header = { 18 | alg: 'HS256', 19 | typ: 'JWT' 20 | }; 21 | 22 | const now = Math.floor(Date.now() / 1000); 23 | const exp = expiresInSeconds ? now + expiresInSeconds : undefined; 24 | 25 | const fullPayload = { 26 | ...payload, 27 | iat: now, 28 | ...(exp ? { exp } : {}) 29 | }; 30 | 31 | const headerStr = base64url(encoder.encode(JSON.stringify(header))); 32 | const payloadStr = base64url(encoder.encode(JSON.stringify(fullPayload))); 33 | const data = `${headerStr}.${payloadStr}`; 34 | 35 | const key = await crypto.subtle.importKey( 36 | 'raw', 37 | encoder.encode(c.env.jwt_secret), 38 | { name: 'HMAC', hash: 'SHA-256' }, 39 | false, 40 | ['sign'] 41 | ); 42 | 43 | const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); 44 | const signatureStr = base64url(signature); 45 | 46 | return `${data}.${signatureStr}`; 47 | }, 48 | 49 | async verifyToken(c, token) { 50 | try { 51 | const [headerB64, payloadB64, signatureB64] = token.split('.'); 52 | 53 | if (!headerB64 || !payloadB64 || !signatureB64) return null; 54 | 55 | const data = `${headerB64}.${payloadB64}`; 56 | const key = await crypto.subtle.importKey( 57 | 'raw', 58 | encoder.encode(c.env.jwt_secret), 59 | { name: 'HMAC', hash: 'SHA-256' }, 60 | false, 61 | ['verify'] 62 | ); 63 | 64 | const valid = await crypto.subtle.verify( 65 | 'HMAC', 66 | key, 67 | base64urlDecode(signatureB64), 68 | encoder.encode(data) 69 | ); 70 | 71 | if (!valid) return null; 72 | 73 | const payloadJson = decoder.decode(base64urlDecode(payloadB64)); 74 | const payload = JSON.parse(payloadJson); 75 | 76 | const now = Math.floor(Date.now() / 1000); 77 | if (payload.exp && payload.exp < now) return null; 78 | 79 | return payload; 80 | 81 | } catch (err) { 82 | return null; 83 | } 84 | } 85 | }; 86 | 87 | export default jwtUtils; 88 | -------------------------------------------------------------------------------- /mail-worker/src/utils/verify-utils.js: -------------------------------------------------------------------------------- 1 | const verifyUtils = { 2 | isEmail(str) { 3 | return /^[a-zA-Z0-9]+@([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]+$/.test(str); 4 | } 5 | } 6 | 7 | export default verifyUtils 8 | -------------------------------------------------------------------------------- /mail-worker/test/index.spec.js: -------------------------------------------------------------------------------- 1 | import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test'; 2 | import { describe, it, expect } from 'vitest'; 3 | import worker from '../src'; 4 | 5 | describe('Hello World worker', () => { 6 | it('responds with Hello World! (unit style)', async () => { 7 | const request = new Request('http://example.com'); 8 | // Create an empty context to pass to `worker.fetch()`. 9 | const ctx = createExecutionContext(); 10 | const response = await worker.fetch(request, env, ctx); 11 | // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions 12 | await waitOnExecutionContext(ctx); 13 | expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); 14 | }); 15 | 16 | it('responds with Hello World! (integration style)', async () => { 17 | const response = await SELF.fetch('http://example.com'); 18 | expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /mail-worker/vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; 2 | 3 | export default defineWorkersConfig({ 4 | test: { 5 | poolOptions: { 6 | workers: { 7 | wrangler: { configPath: './wrangler.jsonc' }, 8 | }, 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /mail-worker/wrangler-dev.toml: -------------------------------------------------------------------------------- 1 | name = "cloud-mail-dev" 2 | main = "src/index.js" 3 | compatibility_date = "2025-04-09" 4 | 5 | [observability] 6 | enabled = true 7 | 8 | [dev] 9 | ip = "0.0.0.0" 10 | 11 | [[d1_databases]] 12 | binding = "db" 13 | database_name = "email" 14 | database_id = "a1c1a63a-6ef5-4e6d-8e8c-b6d9e8feb810" 15 | 16 | [[kv_namespaces]] 17 | binding = "kv" 18 | id = "2io01d4b299e481b9de060ece9e7785c" 19 | 20 | #[[r2_buckets]] 21 | #binding = "" 22 | #bucket_name = "" 23 | 24 | [assets] 25 | binding = "assets" 26 | directory = "./dist" 27 | 28 | [vars] 29 | domain = ["example.com", "example2.com"] 30 | admin = "admin@example.com" 31 | jwt_secret = "K8x@r!3XqZ7#bLm$9pV&yTfC1W*sD2Nv%Q@4Zh^gRb6&UsP!m" 32 | r2_domain = "" 33 | site_key = "" 34 | secret_key = "" 35 | -------------------------------------------------------------------------------- /mail-worker/wrangler-test.toml: -------------------------------------------------------------------------------- 1 | name = "cloud-mail-test" 2 | main = "src/index.js" 3 | compatibility_date = "2025-04-09" 4 | 5 | [observability] 6 | enabled = true 7 | head_sampling_rate = 1 8 | 9 | [[d1_databases]] 10 | binding = "db" 11 | database_name = "email-test" 12 | database_id = "" 13 | 14 | [[kv_namespaces]] 15 | binding = "kv" 16 | id = "" 17 | 18 | [[r2_buckets]] 19 | binding = "r2" 20 | bucket_name = "email-test" 21 | 22 | [assets] 23 | binding = "assets" 24 | directory = "./dist" 25 | 26 | [vars] 27 | domain = ["", ""] 28 | admin = "" 29 | jwt_secret = "" 30 | r2_domain = "" 31 | site_key = "" 32 | secret_key = "" 33 | 34 | -------------------------------------------------------------------------------- /mail-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "cloud-mail" 2 | main = "src/index.js" 3 | compatibility_date = "2025-04-09" 4 | 5 | [observability] 6 | enabled = true 7 | 8 | #[[d1_databases]] 9 | #binding = "db" #d1数据库绑定名默认不可修改 10 | #database_name = "" #d1数据库名字 11 | #database_id = "" #d1数据库id 12 | 13 | #[[kv_namespaces]] 14 | #binding = "kv" #kv绑定名默认不可修改 15 | #id = "" #kv数据库id 16 | 17 | #(可选) 18 | #[[r2_buckets]] 19 | #binding = "r2" #r2对象存储绑定名默认不可修改 20 | #bucket_name = "" #r2对象存储桶的名字 21 | 22 | [assets] 23 | binding = "assets" #静态资源绑定名默认不可修改 24 | directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist 25 | 26 | #[vars] 27 | #domain = [] #邮件域名可可配置多个 示例: ["example1.com","example2.com"] 28 | #admin = "" #管理员的邮箱 示例: admin@example.com 29 | #jwt_secret = "" #jwt令牌的密钥,随便填一串字符串 30 | #r2_domain = "" #r2对象存储桶的访问域名(可选) 31 | #site_key = "" #Turnstile人机验证的站点密钥(可选) 32 | #secret_key = "" #Turnstile人机验证的密钥(可选) 33 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloud-mail", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | --------------------------------------------------------------------------------