Compare commits

..
216 Commits
Author SHA1 Message Date
vanishcode 76843a1aef feat: change delete user button color to danger for better visibility 2026-08-04 10:45:52 +08:00
vanishcode 59ad0264db test: enhance passkey support tests and update wrangler config 2026-08-04 10:45:52 +08:00
vanishcode da0b954a17 feat(web): add passkey authentication UI 2026-08-04 10:45:52 +08:00
vanishcode 4e91689026 feat(worker): add passkey authentication 2026-08-04 10:45:52 +08:00
vanishcode 53d24b4f06 chore(worker): enable Node.js compatibility 2026-08-04 10:45:52 +08:00
vitalii.semianchuk 121c13704a fix: null check logic, silenced flag inversion, sequential query perf
- role-service had if (!userIds && userIds.length === 0) which crashes
  with TypeError when userIds is null — changed && to ||
- oauth-service was setting silenced based on the already-inverted
  active value instead of the original silenced field from the API
- email-service was awaiting three queries individually then passing
  the resolved values to Promise.all, making them run sequentially
  instead of in parallel
2026-08-02 14:15:06 +08:00
eoao 43ed02392e feat: improve add user dialog 2026-06-09 19:20:37 +08:00
eoao 4ea9527cb7 revert: add login background darken factor 2026-06-09 17:41:36 +08:00
eoao 4bf5c97017 style: align dividers 2026-06-09 17:05:02 +08:00
eoao 82ae8f07d5 chore: reduce scheduled tasks 2026-06-09 17:04:46 +08:00
eoao 679cb4c035 fix: mask Telegram bot token 2026-05-23 06:53:52 +08:00
eoao 629a32c651 fix: validation not working 2026-05-23 04:32:33 +08:00
eoao 80a0cb65a5 ci: restore automatic database creation
This reverts commit edba6b80d3.
2026-05-12 21:55:00 +08:00
eoao a84b2ae469 style: reorder personalization settings options 2026-05-12 22:14:17 +08:00
Esing 22c35119c0 feat: add login background darken factor 2026-05-12 22:12:02 +08:00
eoao edba6b80d3 ci: remove automatic database creation 2026-05-12 13:21:06 +08:00
呼乎户 6ae3d43f9e fix: rename CF_EMAIL to CLOUDFLARE_EMAIL in deployment workflow (#364) 2026-05-12 12:58:27 +08:00
eoao 40fc68e56c fix: fix missing fields in database initialization 2026-05-12 12:41:22 +08:00
eoao e68099edb3 fix: update Cloudflare email sending default status 2026-05-10 18:36:22 +08:00
eoao fdf4259701 feat: convert attachment path to ArrayBuffer 2026-05-10 18:33:54 +08:00
eoao f6eaf6d0fd feat: use ArrayBuffer for attachment sending 2026-05-10 17:43:54 +08:00
eoao 421a963a92 ci: add environment variables 2026-05-10 17:34:12 +08:00
eoao 53c4db3bc9 chore: normalize configuration casing 2026-05-10 17:26:23 +08:00
eoao f227bd75ca feat: add Cloudflare email sending to system settings 2026-05-10 17:23:12 +08:00
eoao 23b1d5e6be fix: resolve worker attachment sending failure 2026-05-10 14:11:23 +08:00
eoao 3a0ded3d01 ci: update nodejs version 2026-05-10 13:54:56 +08:00
eoao db0e930bd1 feat: add Cloudflare Email Sending support 2026-05-10 13:42:03 +08:00
eoao 1f898e8f65 chore: update wrangler 2026-05-10 13:36:33 +08:00
eoao 421dc3b10a chore: add comments to configuration 2026-05-10 13:22:16 +08:00
eoao fbf889ab12 ci: add environment variables 2026-05-10 13:16:36 +08:00
eoao 44bbf9c3da feat: add analytics data cache toggle 2026-05-10 13:12:22 +08:00
eoao 80242d5c24 feat: add scheduled cache refresh task configuration 2026-05-10 12:58:02 +08:00
eoao 09516c9cc8 feat: add analytics page data caching 2026-05-10 12:56:02 +08:00
eoao 84e7e2e9d8 fix: resolve TG message character limit overflow 2026-05-10 01:02:02 +08:00
eoao 65680c46fd feat: update verification code recognition logic 2026-05-10 00:58:11 +08:00
eoao ead8f9e2b8 feat: optimize verification code recognition strategy 2026-05-10 00:55:27 +08:00
eoao 7423a25fd6 feat: update verification code recognition to 8 digits 2026-05-10 00:48:31 +08:00
eoao 5f45563c49 chore: update README.md 2026-05-10 00:46:55 +08:00
eoao a45324a742 chore: update version 2026-05-10 00:44:22 +08:00
eoao ac2c17e31c chore: remove default AI model from config file 2026-05-10 00:29:33 +08:00
eoao 6189a91000 fix: resolve system settings display refresh bug 2026-05-10 00:28:32 +08:00
eoao a46192b214 feat: display verification code in frontend email list 2026-05-10 00:26:45 +08:00
eoao 92e18ea683 feat: AI verification code recognition settings 2026-05-10 02:17:42 +08:00
eoao baa70a863c feat: add Telegram message verification code copy 2026-05-10 01:15:50 +08:00
eoao d48b139aed feat: AI-powered email verification code recognition 2026-05-10 00:26:45 +08:00
eoao c96e349fda chore: update dependencies 2026-05-09 00:23:22 +08:00
eoao 984b734d4c feat: hide login domain 2026-05-09 00:18:54 +08:00
eoao a1c47d73e8 style: style optimization 2026-05-09 00:12:44 +08:00
eoao 1b95707819 fix: permission vulnerability 2026-05-08 23:58:03 +08:00
eoao c14615d1ef style: style optimization 2026-05-08 23:55:56 +08:00
eoao 8bf9adbd13 feat: add blacklist description text 2026-05-08 23:44:22 +08:00
eoao 24f271a1ed feat: add Chinese-English language toggle 2026-05-08 23:35:05 +08:00
eoao f0bf6da963 fix: blacklist domain check 2026-05-08 23:26:22 +08:00
eoao 522db4ee3a fix: pin pnpm version to 9 to resolve CI setup error 2026-05-08 22:57:02 +08:00
eoao b60add170c feat: add email blacklist filtering 2026-05-08 22:40:17 +08:00
eoao 154f536c61 style: update initial loading indicator 2026-05-07 02:20:27 +08:00
eoao e92a44b069 fix: repair failed user check for email favorites 2026-04-06 23:03:58 +08:00
eoao 980b62f6ac 新增 github action 部署变量
x
2026-03-21 23:27:50 +08:00
eoao af18ad3562 新增设置登录项目github图标隐藏 2026-03-21 23:19:36 +08:00
eoao 9ab766879e 修复编辑器select组件样式污染 2026-02-11 15:58:18 +08:00
eoao dafb8dda01 修复TG一些邮件解析为空和格式优化 2026-02-08 10:02:33 +08:00
eoao 216f7272d8 修改一些已知BUG
优化站内发件无需第三方

修改一些已知BUG
2026-02-08 00:40:52 +08:00
eoao 24e37288e5 新增邮件转发 2026-02-07 12:24:45 +08:00
eoao ee1b76cd1b 新增站内发送权限 2026-02-07 08:40:19 +08:00
eoao 5da6b44ad6 优化站内发件无需第三方 2026-02-07 20:02:01 +08:00
eoao 3ea917a572 修复用户删除状态导致的收件异常 2026-02-03 21:14:43 +08:00
eoao 5d2b49ff70 修复可能导致收件失败问题回退优化自动刷新 2026-01-28 22:13:23 +08:00
eoao bfb896bae6 删除 GitHub Actions 设置邮件接收步骤 2026-01-24 00:06:00 +08:00
eoao 1d4979bc43 删除新邮件底部弹窗 2026-01-23 16:58:59 +08:00
eoao 9f5076e3b5 新增更多部署错误提示 2026-01-23 00:22:00 +08:00
eoao d7c3cf27b0 新增邮件和用户列表右键菜单 2026-01-22 23:50:00 +08:00
eoao 31dbf1a9e6 新增批量删除用户 2026-01-22 22:22:00 +08:00
eoao 8a47418c91 新增邮箱列表置顶排序 2026-01-20 21:46:00 +08:00
ifsheldon cbae3ffbdf fix: Handle HTTP redirects in workflow curl requests (#240)
* update workflow

* fix workflow
2026-01-22 15:41:07 +08:00
eoao d3235bdd17 优化降低所需配置简化部署 2026-01-19 19:22:00 +08:00
eoao ec3bd76666 优化邮件列表改为虚拟滚动列表 2026-01-15 19:46:00 +08:00
eoao 5c11d16bdf 新增全部邮件自动刷新 2026-01-14 00:00:37 +08:00
eoao 7694278045 优化自动刷新邮件请求次数 2026-01-12 22:33:00 +08:00
eoao 97b2be8d25 优化部署流程和变量错误提示 2026-01-11 21:06:00 +08:00
eoao dc38535ea8 修复账号ID导致的BUG 2026-01-07 22:02:00 +08:00
eoao 693e3aa9f1 修改README 2026-01-02 23:09:00 +08:00
eoao 6f83a440dc 修复附件和背景保存异常 2026-01-03 00:01:47 +08:00
eoao e723af4229 修复邮件接收异常 2026-01-01 19:16:43 +08:00
eoao 88a4b9dff5 KV存储改为默认开启
保存

保存
2026-01-01 15:11:41 +08:00
eoao 69632c8cc6 修改自动刷新邮件失效 2025-12-10 23:09:00 +08:00
eoao 9ef1edb341 修改README.md 2025-12-07 22:09:00 +08:00
eoao dcd9e9c95f 新增显示所有邮箱邮件 2025-12-07 22:08:00 +08:00
eoao 70923fee59 修复切换邮箱自动刷新错乱 2025-11-23 13:29:11 +08:00
eoao c61bda1828 修复其他列表显示已读图标问题 2025-11-18 23:22:00 +08:00
eoao 0c4be2b723 新增自动刷新邮件弹窗通知 2025-11-18 22:44:00 +08:00
eoao 9a089da3b7 新增邮件显示已读未读功能 2025-11-18 22:08:00 +08:00
eoao 0395992b5b 新增注册邮箱名自定义字符过滤 2025-11-16 12:56:38 +08:00
eoao 025dc1fbdb 设置登录背景加载超时时间 2025-11-09 08:08:00 +08:00
eoao 56b42d29b3 新增设置注册邮箱前缀最小位数 2025-11-08 22:25:00 +08:00
eoao a343a4a08f 修复 Delete workflow 限不足时报错 2025-11-08 21:44:00 +08:00
eoao 8c6cdd0279 新增 LinuxDo Oauth2 登录 2025-11-08 21:33:00 +08:00
eoao 3cf347ad7a 修改README.md 2025-11-06 22:59:00 +08:00
eoao 6cd3dcb7ef 发送邮件正文图片链接改为内置附件 2025-11-06 22:55:00 +08:00
eoao 4d00f0aeb7 修复<>字符导致TG消息发送失败 2025-10-25 08:35:00 +08:00
eoao 37cad0f281 修复一些附件文件名无后缀导致报错 2025-10-25 08:22:00 +08:00
eoao a4a1db22e8 新增TG自定义是否显示文本消息 2025-10-23 23:55:00 +08:00
eoao 7980e6a444 修复TG一些邮件和图片不显示 2025-10-23 23:11:00 +08:00
eoao d5a5bf8c29 修改README文档 2025-10-23 17:58:27 +08:00
eoao aded699314 新增自定义显示TG邮件消息内容 2025-10-23 00:22:45 +08:00
eoao 57638aa8e1 新增TG邮件查看HTML 2025-10-22 21:33:45 +08:00
eoao fdf9e95d67 新增KV数据库存储附件 2025-10-21 20:45:20 +08:00
eoao dad4a22356 修复发送成功无法记录联系人 2025-10-18 22:32:00 +08:00
eoao 083dc8daab 修复无法接收无人邮件 2025-10-13 00:22:00 +08:00
eoao 1b441774a4 新增最近联系人功能 2025-10-10 22:22:00 +08:00
eoao 8ee140743a 邮件内容样式优化 2025-09-27 00:22:00 +08:00
eoao ad36be00a2 补充修复一些邮件接收异常 2025-09-26 17:55:38 +08:00
eoao 46cfd61b12 修复一些邮件接收异常 2025-09-26 00:10:17 +08:00
eoao 88da574dfb Github Action 支持无R2, 自动获取初始化地址 2025-09-25 22:23:00 +08:00
eoao 172f34dfe3 图标改成项目内置 2025-09-24 00:22:00 +08:00
eoao 86fddf4a5d 修复邮件数量统计错误 2025-09-22 21:32:23 +08:00
eoao 82bd308d56 新增全部用户查看删除邮箱 2025-09-21 19:20:00 +08:00
eoao 457304eafb 修复请求影响延迟 2025-09-14 11:49:37 +08:00
eoao b21b63aea7 修复删除S3文件没有MD5异常 2025-09-06 21:33:30 +08:00
eoao 062c9f7dff 修复浏览器无法初始化地址 2025-09-06 19:24:14 +08:00
eoao 8b74656c83 修复Turnstile加载失败 2025-09-06 12:01:40 +08:00
eoao 2c6803475b PWA注册改为异步避免影响加载速度 2025-09-05 17:26:08 +08:00
eoao 25a27ec038 修复批量删除邮件时漏掉附件 2025-09-05 16:24:54 +08:00
eoao b046f49ea2 修复使用S3有时无法接收邮件 2025-09-05 16:23:46 +08:00
梦念逍遥 2fd34b407e 修复npm缓存错误 (#112) 2025-09-04 12:35:21 +08:00
eoao 613582c13e 修改版本和图片域名转换 2025-09-04 01:22:00 +08:00
eoao 4d31dbc9c4 邮件拒收退回 2025-09-04 01:01:00 +08:00
eoao c72230e812 修改报错提示 2025-09-04 00:40:00 +08:00
eoao 2390143dc6 禁用Workbox 2025-09-04 00:29:34 +08:00
eoao b4954e948b 优化多个地方 2025-09-03 08:31:44 +08:00
eoao 97a7950808 修改图标和权限*拦截邮件 2025-09-01 08:22:22 +08:00
eoao 6f25991ff2 删除静态资源转为部署时自动打包 2025-08-31 12:07:21 +08:00
eoao 86a80900c1 新增支持s3协议对象存储 2025-08-31 12:04:19 +08:00
eoao 562528e968 新增pwa 2025-08-30 07:36:38 +08:00
eoao 45994c24e3 添加静态资源 2025-08-28 22:28:00 +08:00
eoao ce09d4ac46 饼状图不在统计noreply 2025-08-28 22:23:00 +08:00
eoao 6219a1474e 部署报错详细提示 2025-08-28 22:22:00 +08:00
eoao 375613c219 新增批量删除邮件 2025-08-28 22:11:00 +08:00
eoao 344389cf0f 腾讯EdgeOne部署配置 2025-08-27 17:35:11 +08:00
eoao fc0a5564f4 添加配置浏览器缓存所有静态资源 2025-08-26 23:44:22 +08:00
eoao 00fd14ad88 修改登录背景图片加载显示 2025-08-26 23:34:22 +08:00
eoao 01cef2355d 邮件列表加载动画优化和打包拆分 2025-08-26 23:10:55 +08:00
eoao de0bb288ec 修复登录界面首次进入加载背景异常 2025-08-22 22:02:00 +08:00
eoao 799cc4277f 修复浏览器刷新时有黑影闪过 2025-08-22 19:03:53 +08:00
eoao 0eae1bd9b8 修复写邮件按钮位置靠右 2025-08-22 17:22:40 +08:00
eoao 8cd75868bc 修复全部邮件权限无效问题 2025-08-22 01:05:00 +08:00
eoao ff8ba81661 新增深色模式 2025-08-22 00:13:56 +08:00
eoao d3325f0fe3 Update issue templates 2025-08-19 16:01:41 +08:00
eoao ea8ad14049 修复切换语言时间格式没有变化 2025-09-21 19:26:50 +08:00
LLLLLLiuleiandliulei 6dc126d3b0 对字符串domain尝试转换为json (#79)
Co-authored-by: liulei <[email protected]>
2025-08-13 01:53:10 +08:00
eoao ab977ae64a API Token 有效时间改成无限 2025-08-11 10:44:33 +08:00
eoao 181dcd87a3 修复其他语言下英语样式仍是中文的 2025-08-10 20:32:57 +08:00
eoao 6a11324ef9 修复login_domain字段可能初始化失败 2025-08-10 19:27:41 +08:00
eoao 46127a8d67 新增支持API添加用户,查询邮件 2025-08-10 11:37:46 +08:00
eoao 2437ff4599 修复超级管理员账号会被禁用发件 2025-08-04 17:58:06 +08:00
eoao 658c1776d5 新增公告弹窗和无人收件开关 2025-08-03 21:03:54 +08:00
eoao 3a40cfc63b 新增规则触发人机验证和加载失败提示 2025-07-29 20:00:00 +08:00
eoao d03b7ca21d 修复无法自适应浏览器语言 2025-07-29 14:09:00 +08:00
eoao 12833cd901 新增权限域名限制和链接背景上传 2025-07-26 23:11:00 +08:00
Abner 0e86b93e0c 修复 wrangler 配置文件占位符替换时序问题 (#57)
- 移除 telemetry 命令的配置文件参数避免在替换前读取配置
- 重新组织工作流步骤,确保配置文件占位符替换在所有 wrangler 命令之前执行
- 添加调试信息显示 R2_BUCKET_NAME 值和配置文件内容
- 修复 R2 bucket name 验证错误
- 调整数据库初始化等待时间从 10 秒增加到 15 秒
2025-07-27 10:23:21 +08:00
eoao 775d976f10 修改文档和版本号 2025-07-24 18:05:00 +08:00
eoao 7267f03482 修复注册码权限无效bug 2025-07-22 23:33:46 +08:00
eoao bbcff4908d 新增中英文切换 2025-07-22 22:02:23 +08:00
eoao 249edb9e19 修改注册密码长度限制 2025-07-18 21:18:12 +08:00
eoao 196fb4a6b5 修改版本号 2025-07-18 01:16:44 +08:00
eoao bd28de5522 修复请求响应过慢 2025-07-16 20:07:39 +08:00
eoao 7762670c78 修复可选注册码报错
(cherry picked from commit b40c62f94d6e33814db1498534d585b5b8d4303f)
2025-07-16 20:38:00 +08:00
eoao 12e39c5f9c 修复注册密码提示问题 2025-07-16 20:30:00 +08:00
eoao 254325431b 新增注册码、草稿箱、权限拦截邮件、发件菜单隐藏 2025-07-15 20:09:56 +08:00
eoao 7e5a383bc6 修复gmail附件和回复编辑器图片不显示 2025-07-10 09:00:14 +08:00
Abner f2c12216c8 fix 部署描述及action部署在未配置变量时输出成功提示 2025-07-09 19:26:36 +08:00
eoaoandAbner 1ecce5af21 新增 GitHub Actions 自动化部署
Co-authored-by: Abner <[email protected]>
2025-07-09 16:35:01 +08:00
eoao ab6ea3847e 修复邮件图片移动端无法自适应 2025-07-07 22:43:57 +08:00
eoao 5bc8dfdf00 修复收件内嵌图片只能显示一张 2025-07-07 22:38:56 +08:00
eoao b0daf8f5fd 优化移动端404图片大小 2025-07-05 23:28:17 +08:00
eoao cfebeb1fda 修复邮件列表截取文字加载图片导致大量爆红 2025-07-04 21:11:41 +08:00
eoao fc249dde6e 删除配置 2025-07-04 20:22:59 +08:00
eoao 3385635b26 修改版本号 2025-07-04 20:16:33 +08:00
MengNianxiaoyao e426ee7cbe 修正错误文本
(cherry picked from commit 773f0cdcbb05eb27eeeb73e86d18a4f8e44cdb57)
2025-07-04 18:55:01 +08:00
eoao e2aeaf3783 优化登录身份缓存机制 2025-07-04 20:02:00 +08:00
eoao 7a0711071d 截取邮箱名做发件名防止出现空名 2025-06-30 22:44:30 +08:00
eoao 22b56781d5 修复星标邮件列表不同步和图表空数据不为0 2025-06-30 15:27:59 +08:00
eoao 52e5915d93 调整移动动端空状态大小、加载时标题改成空 2025-06-29 22:01:39 +08:00
eoao c72213b97d 修复邮件列表发件无法搜索和不显示邮箱 2025-06-29 14:25:39 +08:00
eoao 33141fe7dd 邮件详情样式优化和查询优化 2025-06-29 11:57:53 +08:00
eoao bd244e48ec 修复纯文本邮件文字不会自动行 2025-06-28 22:01:42 +08:00
eoao cd8767c1ce 修改版本号 2025-06-28 21:43:24 +08:00
eoao 99bbdb3df1 修复某些情况取消星标不同步 2025-06-28 21:35:43 +08:00
eoao 2bef5ee0f6 新增点击复制邮箱功能 2025-06-28 20:22:52 +08:00
eoao 136c0496b8 新增tg和第三方邮件转发 2025-06-28 13:35:37 +08:00
eoao 010e0451ab 优化编辑器表情底部空白和table不显示线 2025-06-25 19:30:07 +08:00
eoao 6af78c21ea 饼状图不再统计空名 2025-06-25 11:46:34 +08:00
eoao 9aa8d3a410 新增无人收件和支持一些字符邮箱 2025-06-25 10:39:21 +08:00
eoao 621859c454 修复富文本居中图片后内容页还是靠左 2025-06-21 08:36:41 +08:00
eoao f9956507fe 修复数据可视化最后一天为0 2025-06-20 00:52:17 +08:00
eoao 7ec485907a 新增打包文件 2025-06-17 09:23:00 +08:00
eoao bb6440b387 设置柱状图默认值 2025-06-17 09:23:00 +08:00
eoao b6142483ed 修改版本号 2025-06-17 09:21:00 +08:00
eoao 605829f180 新增邮件群发和数据可视化 2025-06-17 09:20:00 +08:00
eoao ea309f9171 修改图片压缩规则,优化样式 2025-06-06 19:20:59 +08:00
eoao 8054c0390c 修复透明度覆盖影响设置问题,优化样式 2025-06-06 00:23:31 +08:00
eoao 1c40dc8338 修复星标不能查看,附件等多个bug,优化样式 2025-06-05 23:09:05 +08:00
eoao cdcd22962f 修复邮件正文不是utc+8时间 2025-06-05 15:02:57 +08:00
eoao 9f9dc3d019 修改文档和修改项目错字 2025-06-05 08:53:24 +08:00
eoao 0692f8ec5e 修复无法接收邮件问题 2025-06-04 22:12:54 +08:00
eoao dac0383bab 修改登录页面 2025-06-04 00:20:53 +08:00
eoao 3190b47bb9 修复自动刷新问题 2025-06-03 13:29:52 +08:00
eoao 9b8598da57 修复人机验证不显示问题 2025-06-03 12:58:33 +08:00
eoao 462bb132e2 修复内容显示问题 2025-06-03 07:53:22 +08:00
eoao 2341d14b2d 新增邮件发送和管理员功能 2025-05-29 17:38:50 +08:00
eoao de742d31e7 添加教程,更新打包文件
删除描述
2025-04-29 04:49:33 +08:00
eoao 59c5be2623 修复a标签变成黑色问题 2025-04-27 17:19:37 +08:00
eoao aa02573217 修改错字 2025-04-27 11:47:53 +08:00
eoao ba2a56e1f0 修改配置 2025-04-27 09:32:25 +08:00
eoao 741f839d09 添加说明和MIT 2025-04-27 09:09:43 +08:00
eoao ba3442ccb1 添加打包文件 2025-04-26 17:33:50 +08:00
eoao 24ba4772e6 保存 2025-04-26 17:24:39 +08:00
71 changed files with 1483 additions and 1658 deletions
+12 -3
View File
@@ -25,9 +25,13 @@ jobs:
KV_NAMESPACE_ID: ${{ secrets.KV_NAMESPACE_ID || vars.KV_NAMESPACE_ID }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME || vars.R2_BUCKET_NAME }}
PROJECT_LINK: ${{ secrets.PROJECT_LINK || vars.PROJECT_LINK }}
AI_MODEL: ${{ secrets.AI_MODEL || vars.AI_MODEL || '@cf/meta/llama-3.1-8b-instruct-fast' }}
AI_MODEL: ${{ secrets.AI_MODEL || vars.AI_MODEL || '@cf/meta/llama-3.1-8b-instruct' }}
CLOUDFLARE_EMAIL: ${{ secrets.CF_EMAIL || vars.CF_EMAIL || false }}
ANALYSIS_CACHE: ${{ secrets.ANALYSIS_CACHE || vars.ANALYSIS_CACHE || false }}
LINUXDO_CLIENT_ID: ${{ secrets.LINUXDO_CLIENT_ID || vars.LINUXDO_CLIENT_ID }}
LINUXDO_CLIENT_SECRET: ${{ secrets.LINUXDO_CLIENT_SECRET || vars.LINUXDO_CLIENT_SECRET }}
LINUXDO_CALLBACK_URL: ${{ secrets.LINUXDO_CALLBACK_URL || vars.LINUXDO_CALLBACK_URL }}
LINUXDO_SWITCH: ${{ secrets.LINUXDO_SWITCH || vars.LINUXDO_SWITCH }}
outputs:
@@ -41,12 +45,12 @@ jobs:
- name: ⚙ 设置 pnpm / Set up pnpm
uses: pnpm/[email protected]
with:
version: 11
version: 9
- name: ⚙ 设置 Node.js / Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
node-version: "22"
cache: "pnpm"
cache-dependency-path: "./mail-worker/pnpm-lock.yaml"
@@ -103,6 +107,10 @@ jobs:
sed -i '/^project_link = /d' "$CONFIG_FILE"
fi
if [ -z "$LINUXDO_CLIENT_ID" ] || [ -z "$LINUXDO_CLIENT_SECRET" ]; then
sed -i '/^linuxdo_client_id = /,/^linuxdo_switch = /d' "$CONFIG_FILE"
fi
if [ -z "$CUSTOM_DOMAIN" ]; then
sed -i '/\[\[routes\]\]/,/^$/d' "$CONFIG_FILE"
fi
@@ -127,6 +135,7 @@ jobs:
sed -i "s|\${LINUXDO_CLIENT_ID}|${LINUXDO_CLIENT_ID}|g" "$CONFIG_FILE"
sed -i "s|\${LINUXDO_CLIENT_SECRET}|${LINUXDO_CLIENT_SECRET}|g" "$CONFIG_FILE"
sed -i "s|\${LINUXDO_CALLBACK_URL}|${LINUXDO_CALLBACK_URL}|g" "$CONFIG_FILE"
sed -i "s|\${LINUXDO_SWITCH}|${LINUXDO_SWITCH}|g" "$CONFIG_FILE"
echo "✅ Environment setup completed."
-1
View File
@@ -25,4 +25,3 @@ mail-vue/node_modules
mail-vue/dist
.wrangler
.venv
git-tools.js
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2025 aslost
Copyright (c) 2025 eoao
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
+38
View File
@@ -0,0 +1,38 @@
## Github Action 部署
**配置 Github 仓库**
1. Fork 或克隆仓库 [https://github.com/eoao/cloud-mail](https://github.com/eoao/cloud-mail)
2. 进入您的 GitHub 仓库设置
3. 转到 Settings → Secrets and variables → Actions → New Repository secrets
4. 添加以下 Secrets
| Secret 名称 | 必需 | 用途 |
| ----------------------- | :--: | ----------------------------------------------------- |
| `CLOUDFLARE_API_TOKEN` | ✅ | Cloudflare API 令牌(需要 Workers 和相关资源权限) |
| `CLOUDFLARE_ACCOUNT_ID` | ✅ | Cloudflare 账户 ID |
| `D1_DATABASE_ID` | ✅ | 您的 D1 数据库的 ID |
| `KV_NAMESPACE_ID` | ✅ | 您的 KV 命名空间的 ID |
| `R2_BUCKET_NAME` | ✅ | 您的 R2 存储桶的名称 |
| `DOMAIN` | ✅ | 您要用于邮件服务的域名(例如 `["xx.xx"],多域名用,分隔` |
| `ADMIN` | ✅ | 您的管理员邮箱地址(例如 `[email protected]` |
| `JWT_SECRET` | ✅ | 用于生成和验证 JWT 的随机长字符串 |
| `INIT_URL` | ❌ | (可选)部署后用于初始化数据库的 Worker URL(格式参考下述手动初始化) |
---
**获取 Cloudflare API 令牌**
1. 访问 [Cloudflare Dashboard](https://dash.cloudflare.com/profile/api-tokens)
2. 创建新的 API 令牌
3. 选择"编辑 Cloudflare Workers"模板,并参照下表添加相应权限
![dc2e1dc8dcd217644759c46c6c705de1](https://i.miji.bid/2025/07/07/dc2e1dc8dcd217644759c46c6c705de1.png)
4. 保存令牌并复制到 GitHub Secrets 中的 `CLOUDFLARE_API_TOKEN`
**获取 Cloudflare 账户 ID**
1. 账户 ID 可以在 Cloudflare 仪表盘的账户设置中找到。
2. 复制到 GitHub Secrets 中的 `CLOUDFLARE_ACCOUNT_ID`
**运行工作流**
1. 然后在Action页面手动运行工作流,后续同步上游后会自动部署到 Cloudflare Workers。如未配置 `INIT_URL`,则需要手动访问 `https://你的项目域名/api/init/你的jwt_secret` 进行数据库初始化。
2. 自动同步上游可使用bot或者手动点击Sync Upstream按钮。
+1
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@iconify/vue": "^4.3.0",
"@simplewebauthn/browser": "^13.3.0",
"@vueuse/components": "^14.1.0",
"@vueuse/core": "^12.0.0",
"axios": "1.15.2",
+15 -4
View File
@@ -11,6 +11,9 @@ importers:
'@iconify/vue':
specifier: ^4.3.0
version: 4.3.0([email protected])
'@simplewebauthn/browser':
specifier: ^13.3.0
version: 13.3.0
'@vueuse/components':
specifier: ^14.1.0
version: 14.1.0([email protected])
@@ -19,7 +22,7 @@ importers:
version: 12.8.2
axios:
specifier: 1.15.2
version: 1.15.2
version: 1.15.2([email protected])
compressorjs:
specifier: ^1.2.1
version: 1.2.1
@@ -1061,6 +1064,9 @@ packages:
cpu: [x64]
os: [win32]
'@simplewebauthn/[email protected]':
resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==}
'@surma/[email protected]':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -2423,6 +2429,7 @@ packages:
[email protected]:
resolution: {integrity: sha512-LvyteQoXeQiuILbzqv13LbyBna/TEv2Ha+4ZWK2AwGHUzZ8+IBaZS0TJkCgn5izSPLcgZwXy9yyTrewCb2u/MA==}
engines: {node: '>= 16'}
deprecated: This version is NOT deprecated. Previous deprecation was a mistake.
peerDependencies:
vue: ^3.0.0
@@ -3500,6 +3507,8 @@ snapshots:
'@rollup/[email protected]':
optional: true
'@simplewebauthn/[email protected]': {}
'@surma/[email protected]':
dependencies:
ejs: 3.1.10
@@ -3704,9 +3713,9 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
[email protected]:
[email protected]([email protected]):
dependencies:
follow-redirects: 1.15.11
follow-redirects: 1.15.11([email protected])
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
@@ -4078,7 +4087,9 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
debug: 4.4.1
[email protected]:
dependencies:
-6
View File
@@ -1,6 +0,0 @@
packages:
- '.'
allowBuilds:
'@parcel/watcher': true
esbuild: true
vue-demi: true
+40 -28
View File
@@ -38,17 +38,16 @@
:key="keyCount"
>
<template #default="{ data: item, index }" >
<div :class="['email-row', props.type, { 'right-checked': item.rightChecked }]"
<div :class="'email-row ' + props.type"
:data-checked="item.checked"
@click="jumpDetails(item)"
v-if="!item.expand"
:key="item.emailId"
@contextmenu="handleContextmenu($event, item)"
:style="item.rightChecked ? 'background: #FDF6EC' : ''"
>
<el-checkbox :class=" props.type === 'all-email' ? 'all-email-checkbox' : 'checkbox'"
v-model="item.checked"
:disabled="!item.checked && isSelectMax"
@click.stop></el-checkbox>
v-model="item.checked" @click.stop></el-checkbox>
<div @click.stop="starChange(item)" class="pc-star" v-if="showStar">
<Icon v-if="item.isStar" icon="fluent-color:star-16" width="20" height="20"/>
<Icon v-else icon="solar:star-line-duotone" width="18" height="18"/>
@@ -90,7 +89,7 @@
</slot>
</span>
</span>
<span class="email-content">{{ item.text || '\u200B' }}</span>
<span class="email-content">{{ item.formatText || '\u200B' }}</span>
</div>
<div class="user-info" v-if="showUserInfo">
<div class="user">
@@ -325,9 +324,7 @@ const dropdownRef = ref(null);
const dropdownCloseLock = ref(false);
const dropdownShow = ref(false);
const rightClickEmail = ref({});
const MAX_SELECT_COUNT = 95;
const checkedEmailCount = ref(0);
const isSelectMax = computed(() => checkedEmailCount.value >= MAX_SELECT_COUNT);
let timer = null
const position = ref(
DOMRect.fromRect({
@@ -554,6 +551,37 @@ const accountShow = computed(() => {
return uiStore.accountShow && settingStore.settings.manyEmail === 0
})
function htmlToText(email) {
if (email.content) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = email.content.replace(
/<(img|iframe|object|embed|video|audio|source|link)[^>]*>/gi, ''
);
const scriptsAndStyles = tempDiv.querySelectorAll('script, style, title');
scriptsAndStyles.forEach(el => el.remove());
let text = tempDiv.textContent || tempDiv.innerText || '';
text = text.replace(/\s+/g, ' ').trim();
return cleanSpace(text)
}
if (email.text) {
return cleanSpace(email.text)
} else {
return ''
}
}
function cleanSpace(text) {
return text
.replace(/[\u200B-\u200F\uFEFF\u034F\u200B-\u200F\u00A0\u3000\u00AD]/g, '')// 移除零宽空格
.replace(/\s+/g, ' ') // 多空白合并成一个空格
.trim();
}
function starChange(email) {
if (!email.isStar) {
@@ -703,6 +731,7 @@ function addItem(email) {
return false;
}
email.formatText = htmlToText(email);
email.formatCreateTime = fromNow(email.formatCreateTime);
if (props.timeSort) {
@@ -741,19 +770,7 @@ function addItem(email) {
}
function handleCheckAllChange(val) {
if (val) {
let count = 0;
emailList.forEach(item => {
if (count < MAX_SELECT_COUNT) {
item.checked = true;
count++;
} else {
item.checked = false;
}
});
} else {
emailList.forEach(item => item.checked = false);
}
emailList.forEach(item => item.checked = val);
isIndeterminate.value = false;
}
@@ -769,9 +786,8 @@ function getSelectedDraftsIds() {
function updateCheckStatus() {
const checkedCount = emailList.filter(item => item.checked).length;
checkedEmailCount.value = checkedCount;
const atMax = checkedCount >= MAX_SELECT_COUNT;
checkAll.value = emailList.length > 0 && (checkedCount === emailList.length || atMax);
isIndeterminate.value = checkedCount > 0 && !checkAll.value;
checkAll.value = checkedCount === emailList.length;
isIndeterminate.value = checkedCount > 0 && checkedCount < emailList.length;
}
function jumpDetails(email) {
@@ -856,6 +872,7 @@ function getEmailList(refresh = false) {
function handleList(list) {
list.forEach(email => {
email.formatText = htmlToText(email)
email.formatCreateTime = fromNow(email.createTime);
email.test = t('received')
const statusIconMap = {
@@ -1220,11 +1237,6 @@ function loadData() {
z-index: 0;
}
&.right-checked,
&.right-checked:hover {
background-color: var(--email-right-click-background);
}
/*&[data-checked="true"] {
background-color: #c2dbff;
}*/
+21 -29
View File
@@ -30,7 +30,6 @@ const en = {
change: 'Change',
changePwdBtn: 'Change',
username: 'Username',
trustLevel: 'Level',
password: 'Password',
delAccount: 'Delete Account',
delAccountMsg: 'This will permanently delete your account and data. It cannot be reactivated',
@@ -135,13 +134,25 @@ const en = {
hasAccount: 'Already have an account? \u200B',
regSwitch: 'Sign up',
loginSwitch: 'Sign in',
websiteSetting: 'General',
websiteSetting: 'Website',
websiteReg: 'Sign Up',
loginDomain: 'Hide Sign-In Domain',
syncDelete: 'Sync Delete',
syncDeleteDesc: 'Permanently delete emails and email addresses instead of marking them as deleted.',
passkeyLogin: 'Passkey',
passkeySetting: 'Passkey',
passkeyNotConfigured: 'No Passkey configured',
passkeyConfigured: 'Passkey configured',
passkeyCreatedAt: 'Created: {time}',
passkeyCreate: 'Create Passkey',
passkeyReplace: 'Replace',
passkeyDelete: 'Delete Passkey',
passkeyCurrentPassword: 'Enter your current password',
passkeyUnsupported: 'Passkeys are not supported by this browser or device',
passkeyLoginFailed: 'Passkey verification failed. Please try again',
passkeyCreateSuccess: 'Passkey created successfully',
passkeyDeleteSuccess: 'Passkey deleted successfully',
passkeyDeleteConfirm: 'Delete the current Passkey?',
multipleEmail: 'Multiple Email Address',
multipleEmailDesc: 'Enable this feature to allow users to add multiple addresses.',
multipleEmailDesc: 'Enable this feature to allow users to add multiple email',
customization: 'Customization',
websiteTitle: 'Title',
loginBoxOpacity: 'Login Box Opacity',
@@ -154,13 +165,6 @@ const en = {
resendToken: 'Resend Token',
cloudflareEmailSending: 'Cloudflare Email Sending',
blackList: 'Blocklist',
autoClean: 'Auto Cleanup',
autoCleanDesc: 'Automatically delete emails older than the configured days. 0 means disabled.',
autoCleanDays: 'Retention Days',
autoCleanRetain: 'Retain {days} days',
autoCleanExclude: 'Excluded Users',
autoCleanExcludeDesc: 'Enter user emails that should not be cleaned up',
dayUnit: ' days',
codeRecognition: 'Verification Code Recognition',
codeRecognitionRules: 'Recognition Rules',
codeRecognitionRulesDesc: 'Only recognize emails containing the following info. Empty means all.',
@@ -172,13 +176,6 @@ const en = {
disable: 'Disable',
disabled: 'Disabled',
otherEmail: 'Forwarding to External Email',
webhook: 'Webhook',
webhookDesc: 'POST received emails as JSON to this URL',
webhookUrl: 'Request URL',
webhookIpNotSupported: 'IP addresses are not supported. Use a domain name.',
webhookSecret: 'Secret (Optional)',
webhookRetry: 'Retries',
webhookFormat: 'Payload format',
forwardingRules: 'Forwarding Rules',
forwardAll: 'All',
rules: 'Rules',
@@ -192,12 +189,12 @@ const en = {
addResendTokenDesc: 'Input to add; leave empty to delete',
addOsDomain: 'Add Domain',
domainDesc: 'Domain',
addTurnstileSecret: 'Add Turnstile secret',
addTurnstileSecret: 'Add turnstile secret',
backgroundTitle: 'Change Background',
tgBotDesc: 'Forward received emails to a Telegram bot',
tgBotToken: 'Bot token',
toBotTokenDesc: 'Multiple user chat_ids, separated by commas',
otherEmailDesc: 'Emails can be forwarded to an external address, but must be verified via Cloudflare.',
otherEmailDesc: 'emails can be forwarded to external email, but must be verified via cloudflare',
otherEmailInputDesc: 'Separate multiple email addresses with commas',
forwardingRulesDesc: 'Rule-based forwarding only forwards emails received by the specified address',
ruleEmailsInputDesc: 'Separate multiple email addresses with commas',
@@ -268,7 +265,7 @@ const en = {
timeoutErrorMsg: 'Timeout. Try again later',
serverBusyErrorMsg: 'Server busy. Please try again later',
reqFailErrorMsg: 'Request failed. Try again later',
message: 'Mail',
message: 'Message',
language: 'Language',
totalUserAccount: '{msg}',
sendBanned: 'Banned',
@@ -282,14 +279,13 @@ const en = {
availableDomains: 'Available Domains',
availableDomainsDesc: 'Restrict users to email domains specified. Domains not on the approved list will be blocked from registration, adding email addresses, and sending/receiving emails. If left blank, all domains will be allowed by default',
backgroundUrlDesc: 'Image URL',
localUpload: 'Local upload',
localUpload: ' Local upload',
imageLink: 'Image URL',
imageLinkErrorMsg: 'Invalid image URL',
backgroundWarning: 'Image file size affects website load speed',
rulesVerify: 'Rules',
rulesVerifyTitle: 'Trigger After {count} Daily Uses per IP',
botVerifyMsg: 'Please verify that you are human',
oauthLogin: 'OAuth2',
noticeTitle: 'Notice',
noticePopup: 'Sign-In Popup',
icon: 'Icon',
@@ -354,11 +350,7 @@ const en = {
searchUser: 'Search by user',
searchEmail: 'Search by Email',
searchSender: 'Search by Sender',
userEmail: 'Email Address',
oauthSetting: 'OAuth',
clientId: 'Client ID',
clientSecret: 'Client Secret',
notOwner: 'Base email does not belong to you',
userEmail: 'Email Address'
}
export default en
+16 -24
View File
@@ -30,7 +30,6 @@ const zh = {
change: '修改',
changePwdBtn: '修改密码',
username: '用户名',
trustLevel: '等级',
password: '密码',
delAccount: '删除账户',
delAccountMsg: '此操作将永久删除您的账户及其所有数据,无法恢复',
@@ -135,11 +134,23 @@ const zh = {
hasAccount: '已有账号?',
regSwitch: '创建账号',
loginSwitch: '去登录',
websiteSetting: '基本设置',
websiteSetting: '网站设置',
websiteReg: '用户注册',
loginDomain: '隐藏登录域名',
syncDelete: '同步删除',
syncDeleteDesc: '开启后收件箱和邮箱不再假删',
passkeyLogin: 'Passkey 登录',
passkeySetting: 'Passkey',
passkeyNotConfigured: '尚未创建 Passkey',
passkeyConfigured: '已创建 Passkey',
passkeyCreatedAt: '创建时间:{time}',
passkeyCreate: '创建 Passkey',
passkeyReplace: '重新创建',
passkeyDelete: '删除 Passkey',
passkeyCurrentPassword: '请输入当前密码',
passkeyUnsupported: '当前浏览器或设备不支持 Passkey',
passkeyLoginFailed: 'Passkey 验证失败,请重试',
passkeyCreateSuccess: 'Passkey 创建成功',
passkeyDeleteSuccess: 'Passkey 删除成功',
passkeyDeleteConfirm: '确认删除当前 Passkey',
multipleEmail: '多号模式',
multipleEmailDesc: '开启后账号栏出现一个用户可以添加多个邮箱',
customization: '个性化设置',
@@ -154,13 +165,6 @@ const zh = {
resendToken: 'Resend Token',
cloudflareEmailSending: 'Cloudflare 邮件发送',
blackList: '邮件黑名单',
autoClean: '自动清理',
autoCleanDesc: '自动删除超过指定天数的邮件,0 表示关闭',
autoCleanDays: '保留天数',
autoCleanRetain: '保留{days}天',
autoCleanExclude: '排除用户',
autoCleanExcludeDesc: '输入用户邮箱,这些用户的邮件不会被清理',
dayUnit: '天',
codeRecognition: '识别验证码',
codeRecognitionRules: '识别规则',
codeRecognitionRulesDesc: '只识别包含以下信息的邮件,为空识别全部',
@@ -172,13 +176,6 @@ const zh = {
disable: '关闭',
disabled: '已关闭',
otherEmail: '第三方邮箱',
webhook: 'Webhook',
webhookDesc: '收到邮件后向指定地址 POST JSON',
webhookUrl: '请求地址',
webhookIpNotSupported: '不能使用 IP 地址,请填写域名',
webhookSecret: '密钥(可选)',
webhookRetry: '重试次数',
webhookFormat: '格式说明',
forwardingRules: '转发规则',
forwardAll: '全部转发',
rules: '规则转发',
@@ -289,7 +286,6 @@ const zh = {
rulesVerify: '规则',
rulesVerifyTitle: 'IP 每天使用 {count} 次后触发',
botVerifyMsg: '请完成人机验证',
oauthLogin: 'OAuth2',
noticeTitle: '网站公告',
noticePopup: '登录弹窗',
icon: '图标',
@@ -354,10 +350,6 @@ const zh = {
searchUser: '搜索用户',
searchEmail: '搜索邮箱',
searchSender: '搜索发件人',
userEmail: '用户邮箱',
oauthSetting: 'OAuth',
clientId: '客户端 ID',
clientSecret: '客户端密钥',
notOwner: '基础邮箱不属于您',
userEmail: '用户邮箱'
}
export default zh
+1 -18
View File
@@ -295,9 +295,6 @@ addCollection({
},
"search": {
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" d=\"m17 17l4 4M3 11a8 8 0 1 0 16 0a8 8 0 0 0-16 0\"/>"
},
"arrow-up-right": {
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" d=\"M6 19L19 6m0 0v12.48M19 6H6.52\"/>"
}
}
})
@@ -709,18 +706,4 @@ addCollection({
}
}
})
addCollection({
"prefix": "devicon",
"lastModified": 1774935148,
"aliases": {},
"width": 128,
"height": 128,
"icons": {
"github": {
"body": "<g fill=\"#181616\"><path fill-rule=\"evenodd\" d=\"M64 5.103c-33.347 0-60.388 27.035-60.388 60.388c0 26.682 17.303 49.317 41.297 57.303c3.017.56 4.125-1.31 4.125-2.905c0-1.44-.056-6.197-.082-11.243c-16.8 3.653-20.345-7.125-20.345-7.125c-2.747-6.98-6.705-8.836-6.705-8.836c-5.48-3.748.413-3.67.413-3.67c6.063.425 9.257 6.223 9.257 6.223c5.386 9.23 14.127 6.562 17.573 5.02c.542-3.903 2.107-6.568 3.834-8.076c-13.413-1.525-27.514-6.704-27.514-29.843c0-6.593 2.36-11.98 6.223-16.21c-.628-1.52-2.695-7.662.584-15.98c0 0 5.07-1.623 16.61 6.19C53.7 35 58.867 34.327 64 34.304c5.13.023 10.3.694 15.127 2.033c11.526-7.813 16.59-6.19 16.59-6.19c3.287 8.317 1.22 14.46.593 15.98c3.872 4.23 6.215 9.617 6.215 16.21c0 23.194-14.127 28.3-27.574 29.796c2.167 1.874 4.097 5.55 4.097 11.183c0 8.08-.07 14.583-.07 16.572c0 1.607 1.088 3.49 4.148 2.897c23.98-7.994 41.263-30.622 41.263-57.294C124.388 32.14 97.35 5.104 64 5.104z\" clip-rule=\"evenodd\"/><path d=\"M26.484 91.806c-.133.3-.605.39-1.035.185c-.44-.196-.685-.605-.543-.906c.13-.31.603-.395 1.04-.188c.44.197.69.61.537.91zm2.446 2.729c-.287.267-.85.143-1.232-.28c-.396-.42-.47-.983-.177-1.254c.298-.266.844-.14 1.24.28c.394.426.472.984.17 1.255zm2.382 3.477c-.37.258-.976.017-1.35-.52c-.37-.538-.37-1.183.01-1.44c.373-.258.97-.025 1.35.507c.368.545.368 1.19-.01 1.452zm3.261 3.361c-.33.365-1.036.267-1.552-.23c-.527-.487-.674-1.18-.343-1.544c.336-.366 1.045-.264 1.564.23c.527.486.686 1.18.333 1.543zm4.5 1.951c-.147.473-.825.688-1.51.486c-.683-.207-1.13-.76-.99-1.238c.14-.477.823-.7 1.512-.485c.683.206 1.13.756.988 1.237m4.943.361c.017.498-.563.91-1.28.92c-.723.017-1.308-.387-1.315-.877c0-.503.568-.91 1.29-.924c.717-.013 1.306.387 1.306.88zm4.598-.782c.086.485-.413.984-1.126 1.117c-.7.13-1.35-.172-1.44-.653c-.086-.498.422-.997 1.122-1.126c.714-.123 1.354.17 1.444.663zm0 0\"/></g>"
},
"google": {
"body": "<path fill=\"#fff\" d=\"M44.59 4.21a63.28 63.28 0 0 0 4.33 120.9a67.6 67.6 0 0 0 32.36.35a57.13 57.13 0 0 0 25.9-13.46a57.44 57.44 0 0 0 16-26.26a74.3 74.3 0 0 0 1.61-33.58H65.27v24.69h34.47a29.72 29.72 0 0 1-12.66 19.52a36.2 36.2 0 0 1-13.93 5.5a41.3 41.3 0 0 1-15.1 0A37.2 37.2 0 0 1 44 95.74a39.3 39.3 0 0 1-14.5-19.42a38.3 38.3 0 0 1 0-24.63a39.25 39.25 0 0 1 9.18-14.91A37.17 37.17 0 0 1 76.13 27a34.3 34.3 0 0 1 13.64 8q5.83-5.8 11.64-11.63c2-2.09 4.18-4.08 6.15-6.22A61.2 61.2 0 0 0 87.2 4.59a64 64 0 0 0-42.61-.38\"/><path fill=\"#e33629\" d=\"M44.59 4.21a64 64 0 0 1 42.61.37a61.2 61.2 0 0 1 20.35 12.62c-2 2.14-4.11 4.14-6.15 6.22Q95.58 29.23 89.77 35a34.3 34.3 0 0 0-13.64-8a37.17 37.17 0 0 0-37.46 9.74a39.25 39.25 0 0 0-9.18 14.91L8.76 35.6A63.53 63.53 0 0 1 44.59 4.21\"/><path fill=\"#f8bd00\" d=\"M3.26 51.5a63 63 0 0 1 5.5-15.9l20.73 16.09a38.3 38.3 0 0 0 0 24.63q-10.36 8-20.73 16.08a63.33 63.33 0 0 1-5.5-40.9\"/><path fill=\"#587dbd\" d=\"M65.27 52.15h59.52a74.3 74.3 0 0 1-1.61 33.58a57.44 57.44 0 0 1-16 26.26c-6.69-5.22-13.41-10.4-20.1-15.62a29.72 29.72 0 0 0 12.66-19.54H65.27c-.01-8.22 0-16.45 0-24.68\"/><path fill=\"#319f43\" d=\"M8.75 92.4q10.37-8 20.73-16.08A39.3 39.3 0 0 0 44 95.74a37.2 37.2 0 0 0 14.08 6.08a41.3 41.3 0 0 0 15.1 0a36.2 36.2 0 0 0 13.93-5.5c6.69 5.22 13.41 10.4 20.1 15.62a57.13 57.13 0 0 1-25.9 13.47a67.6 67.6 0 0 1-32.36-.35a63 63 0 0 1-23-11.59A63.7 63.7 0 0 1 8.75 92.4\"/>"
}
}
})
+3 -7
View File
@@ -77,7 +77,7 @@
</el-scrollbar>
<el-dialog v-model="showAdd" :title="$t('addAccount')">
<div class="container">
<el-input v-model="addForm.email" ref="addRef" type="text" :placeholder="$t('emailAccount')" autocomplete="off" @keyup.enter="submit">
<el-input v-model="addForm.email" ref="addRef" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append>
<div @click.stop="openSelect">
<el-select
@@ -116,7 +116,7 @@
</el-dialog>
<el-dialog v-model="setNameShow" :title="$t('changeUserName')">
<div class="container">
<el-input v-model="accountName" type="text" :placeholder="$t('username')" autocomplete="off" @keyup.enter="setName">
<el-input v-model="accountName" type="text" :placeholder="$t('username')" autocomplete="off">
</el-input>
<el-button class="btn" type="primary" @click="setName" :loading="setNameLoading"
>{{ $t('save') }}
@@ -229,8 +229,6 @@ function getSkeletonRows() {
function setName() {
if (setNameLoading.value) return
let name = accountName.value
if (name === account.name) {
@@ -429,8 +427,6 @@ function getAccountList() {
function submit() {
if (addLoading.value) return
if (!addForm.email) {
ElMessage({
message: t('emptyEmailMsg'),
@@ -486,6 +482,7 @@ function submit() {
addLoading.value = true
accountAdd(addForm.email + addForm.suffix, verifyToken).then(account => {
addLoading.value = false
showAdd.value = false
addForm.email = ''
accounts.push(account)
verifyToken = ''
@@ -496,7 +493,6 @@ function submit() {
plain: true
})
verifyShow.value = false
showAdd.value = false
userStore.refreshUserInfo()
}).catch(res => {
if (res.code === 400) {
+1 -1
View File
@@ -80,7 +80,7 @@ const routers = {
}
}],
'setting:query': [{
path: '/system-settings',
path: '/system-setting',
name: 'sys-setting',
component: () => import('@/views/sys-setting/index.vue'),
meta: {
+2 -2
View File
@@ -1,7 +1,7 @@
import http from '@/axios/index.js';
export function emailList(accountId, allReceive, emailId, timeSort, size, type, full) {
return http.get('/email/list', {params: {accountId, allReceive, emailId, timeSort, size, type, full}})
export function emailList(accountId, allReceive, emailId, timeSort, size, type) {
return http.get('/email/list', {params: {accountId, allReceive, emailId, timeSort, size, type}})
}
export function emailDelete(emailIds) {
+2 -10
View File
@@ -1,15 +1,7 @@
import http from '@/axios/index.js';
export function oauthLinuxDoLogin(code, redirectUri) {
return http.post('/oauth/linuxDo/login',{code, redirectUri})
}
export function oauthGithubLogin(code, redirectUri) {
return http.post('/oauth/github/login',{code, redirectUri})
}
export function oauthGoogleLogin(code, redirectUri) {
return http.post('/oauth/google/login',{code, redirectUri})
export function oauthLinuxDoLogin(code) {
return http.post('/oauth/linuxDo/login',{code})
}
export function oauthBindUser(form) {
+25
View File
@@ -0,0 +1,25 @@
import http from '@/axios/index.js';
export function passkeyAuthOptions() {
return http.post('/passkey/auth/options')
}
export function passkeyAuthVerify(transactionId, response) {
return http.post('/passkey/auth/verify', {transactionId, response})
}
export function passkeyStatus() {
return http.get('/my/passkey')
}
export function passkeyRegOptions(password) {
return http.post('/my/passkey/reg/options', {password})
}
export function passkeyRegVerify(transactionId, response) {
return http.post('/my/passkey/reg/verify', {transactionId, response})
}
export function passkeyDelete(password) {
return http.delete('/my/passkey', {data: {password}})
}
+2 -2
View File
@@ -8,6 +8,6 @@ export function starCancel(emailId) {
return http.delete('/star/cancel', {params: {emailId}})
}
export function starList(emailId,size,full) {
return http.get('/star/list', {params: {emailId,size,full}})
export function starList(emailId,size) {
return http.get('/star/list', {params: {emailId,size}})
}
+4 -4
View File
@@ -22,7 +22,7 @@ const routes = [
}
},
{
path: '/mail',
path: '/message',
name: 'content',
component: () => import('@/views/content/index.vue'),
meta: {
@@ -100,16 +100,16 @@ router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (!token && !to.path.startsWith('/login')) {
if (!token && to.name !== 'login') {
return next({name: 'login'})
}
if (!token && to.path.startsWith('/login')) {
if (!token && to.name === 'login') {
loadBackground(next)
return
}
if (token && to.path.startsWith('/login')) {
if (token && to.name === 'login') {
return next(from.path)
}
-55
View File
@@ -1,5 +1,4 @@
import { defineStore } from 'pinia'
import { EmailUnreadEnum } from '@/enums/email-enum.js'
export const useEmailStore = defineStore('email', {
state: () => ({
@@ -16,62 +15,8 @@ export const useEmailStore = defineStore('email', {
showUnread: false
},
sendScroll: null,
detailMap: {},
}),
persist: {
pick: ['contentData'],
},
actions: {
fetchList(request) {
request(1).then(data => {
const list = Array.isArray(data) ? data : data?.list
this.applyFullList(list)
}).catch(e => {
console.error(e)
})
return request(0)
},
applyFullList(list) {
if (!list?.length) return
const currentId = this.contentData.email?.emailId
for (const item of list) {
if (!item?.emailId) continue
if (!item.attList) item.attList = []
// 完整列表可能早于「标已读」返回,避免把本地已读状态盖回未读
const prev = this.detailMap[item.emailId]
const keepRead = prev?.unread === EmailUnreadEnum.READ
|| (currentId === item.emailId && this.contentData.email?.unread === EmailUnreadEnum.READ)
if (keepRead) {
item.unread = EmailUnreadEnum.READ
}
this.detailMap[item.emailId] = item
if (currentId && item.emailId === currentId) {
this.contentData.email = item
}
}
},
toContentEmail(email) {
const id = email?.emailId
if (id && this.detailMap[id]) {
return this.detailMap[id]
}
return {
...email,
emailId: id || 0,
content: '',
text: '',
attList: [],
recipient: email?.recipient || '[]',
}
},
markListRead(emailId) {
const scrolls = [this.emailScroll, this.starScroll, this.sendScroll]
for (const scroll of scrolls) {
const list = scroll?.emailList
if (!list?.length) continue
const item = list.find(e => e.emailId === emailId)
if (item) item.unread = EmailUnreadEnum.READ
}
},
},
})
-2
View File
@@ -128,7 +128,6 @@ button, input, select, textarea {
--el-text-color-regular: var(--el-text-color-primary);
--email-scroll-content-color: rgba(25, 41, 59, 0.4);
--email-hover-background: #F2F6FC;
--email-right-click-background: #FDF6EC;
--login-border: none;
--form-desc-color: #71717a;
--login-switch-color: #006be6;
@@ -158,7 +157,6 @@ button, input, select, textarea {
--choose-account-background: #39393A;
--email-scroll-content-color: rgba(255, 255, 255, 0.3);
--email-hover-background: #1D1E1F;
--email-right-click-background: var(--email-hover-background);
--login-border: none;
--form-desc-color: #8D9095;
--login-switch-color: rgb(102, 177, 255);
-13
View File
@@ -5,17 +5,4 @@ export function isEmail(email) {
export function isDomain(str) {
return /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/.test(str);
}
export function isIpUrl(url) {
if (!url) return false
try {
const { hostname } = new URL(url.startsWith('http') ? url : 'https://' + url)
if (/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/.test(hostname)) {
return true
}
return hostname.includes(':')
} catch {
return false
}
}
+6 -8
View File
@@ -62,10 +62,10 @@
<el-dialog v-model="showBathDelete" :title="$t('clearEmail')" width="335"
@closed="closedClear">
<div class="clear-email">
<el-input v-model="clearParams.sendName" :placeholder="$t('sender')" @keyup.enter="batchDelete"/>
<el-input v-model="clearParams.subject" :placeholder="$t('subject')" @keyup.enter="batchDelete"/>
<el-input v-model="clearParams.sendEmail" :placeholder="$t('sendEmailAddress')" @keyup.enter="batchDelete"/>
<el-input v-model="clearParams.toEmail" :placeholder="$t('toEmail')" @keyup.enter="batchDelete"/>
<el-input v-model="clearParams.sendName" :placeholder="$t('sender')"/>
<el-input v-model="clearParams.subject" :placeholder="$t('subject')"/>
<el-input v-model="clearParams.sendEmail" :placeholder="$t('sendEmailAddress')"/>
<el-input v-model="clearParams.toEmail" :placeholder="$t('toEmail')"/>
<el-date-picker popper-class="my-date-picker"
v-model="clearTime"
type="daterange"
@@ -193,8 +193,6 @@ function openBathDelete() {
function batchDelete() {
if (clearLoading.value) return
if (clearTime.value) {
clearParams.startTime = toUtc(clearTime.value[0]).format("YYYY-MM-DD HH:mm:ss")
clearParams.endTime = toUtc(clearTime.value[1]).add(1, 'day').format("YYYY-MM-DD HH:mm:ss")
@@ -282,7 +280,7 @@ function typeSelectChange() {
}
function jumpContent(email) {
emailStore.contentData.email = emailStore.toContentEmail(email)
emailStore.contentData.email = email
emailStore.contentData.delType = 'physics'
emailStore.contentData.showStar = false
emailStore.contentData.showReply = false
@@ -291,7 +289,7 @@ function jumpContent(email) {
function getEmailList(emailId, size) {
return emailStore.fetchList(full => allEmailList({emailId, size, full, ...params}))
return allEmailList({emailId, size, ...params})
}
async function latest() {
+27 -78
View File
@@ -34,11 +34,11 @@
<el-alert v-if="email.status === 4" :closable="false" :title="$t('complained')" class="email-msg" type="warning" show-icon />
<el-alert v-if="email.status === 5" :closable="false" :title="$t('delayed')" class="email-msg" type="warning" show-icon />
</div>
<el-scrollbar class="htm-scrollbar" :class="!email.attList?.length ? 'bottom-distance' : ''">
<el-scrollbar class="htm-scrollbar" :class="email.attList.length === 0 ? 'bottom-distance' : ''">
<ShadowHtml class="shadow-html" :html="formatImage(email.content)" v-if="email.content" />
<pre v-else class="email-text" >{{email.text}}</pre>
</el-scrollbar>
<div class="att" v-if="email.attList?.length > 0">
<div class="att" v-if="email.attList.length > 0">
<div class="att-title">
<span>{{$t('attachments')}}</span>
<span>{{$t('attCount',{total: email.attList.length})}}</span>
@@ -75,7 +75,7 @@
</template>
<script setup>
import ShadowHtml from '@/components/shadow-html/index.vue'
import {computed, reactive, ref, watch, onMounted, onUnmounted} from "vue";
import {reactive, ref, watch, onMounted, onUnmounted} from "vue";
import {useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import {emailDelete, emailRead} from "@/request/email.js";
@@ -98,13 +98,7 @@ const settingStore = useSettingStore();
const accountStore = useAccountStore();
const emailStore = useEmailStore();
const router = useRouter()
const email = computed(() => emailStore.contentData.email || {
emailId: 0,
attList: [],
content: '',
text: '',
recipient: '[]',
})
const email = emailStore.contentData.email
const showPreview = ref(false)
const srcList = reactive([])
@@ -113,67 +107,23 @@ watch(() => accountStore.currentAccountId, () => {
handleBack()
})
let readRequesting = false
function tryMarkRead() {
if (!emailStore.contentData.showUnread || readRequesting) return
const current = email.value
if (!current?.emailId || current.unread !== EmailUnreadEnum.UNREAD) return
// 等详情数据就绪(detailMap 已写入,或正文已有内容)再标已读
const full = emailStore.detailMap[current.emailId]
const detailReady = !!full || !!(current.content || current.text)
if (!detailReady) return
readRequesting = true
const emailId = current.emailId
current.unread = EmailUnreadEnum.READ
if (emailStore.detailMap[emailId]) {
emailStore.detailMap[emailId].unread = EmailUnreadEnum.READ
}
emailStore.markListRead(emailId)
emailRead([emailId]).finally(() => {
readRequesting = false
})
}
watch(
() => [
email.value?.emailId,
email.value?.content,
email.value?.text,
emailStore.detailMap[email.value?.emailId]
],
() => tryMarkRead(),
{ flush: 'post' }
)
onMounted(() => {
tryMarkRead()
window.addEventListener('keydown', handleKeyDown);
if (emailStore.contentData.showUnread && email.unread === EmailUnreadEnum.UNREAD) {
email.unread = EmailUnreadEnum.READ;
emailRead([email.emailId]);
}
})
onUnmounted(() => {
emailStore.contentData.showUnread = false;
readRequesting = false
window.removeEventListener('keydown', handleKeyDown);
})
function handleKeyDown(event) {
if (event.key !== 'Escape') return;
if (showPreview.value) return;
if (document.querySelector('.el-message-box')) return;
const writeBox = document.querySelector('.write-box');
if (writeBox && writeBox.offsetParent !== null) return;
handleBack();
}
function openReply() {
uiStore.writerRef.openReply(email.value)
uiStore.writerRef.openReply(email)
}
function openForward() {
uiStore.writerRef.openForward(email.value)
uiStore.writerRef.openForward(email)
}
function toMessage(message) {
@@ -199,33 +149,32 @@ function isImage(filename) {
}
function formateReceive(recipient) {
if (!recipient) return ''
recipient = JSON.parse(recipient)
return recipient.map(item => item.address).join(', ')
}
function changeStar() {
if (email.value.isStar) {
email.value.isStar = 0;
starCancel(email.value.emailId).then(() => {
email.value.isStar = 0;
emailStore.cancelStarEmailId = email.value.emailId
if (email.isStar) {
email.isStar = 0;
starCancel(email.emailId).then(() => {
email.isStar = 0;
emailStore.cancelStarEmailId = email.emailId
setTimeout(() => emailStore.cancelStarEmailId = 0)
emailStore.starScroll?.deleteEmail([email.value.emailId])
emailStore.starScroll?.deleteEmail([email.emailId])
}).catch((e) => {
console.error(e)
email.value.isStar = 1;
email.isStar = 1;
})
} else {
email.value.isStar = 1;
starAdd(email.value.emailId).then(() => {
email.value.isStar = 1;
emailStore.addStarEmailId = email.value.emailId
email.isStar = 1;
starAdd(email.emailId).then(() => {
email.isStar = 1;
emailStore.addStarEmailId = email.emailId
setTimeout(() => emailStore.addStarEmailId = 0)
emailStore.starScroll?.addItem(email.value)
emailStore.starScroll?.addItem(email)
}).catch((e) => {
console.error(e)
email.value.isStar = 0;
email.isStar = 0;
})
}
}
@@ -241,23 +190,23 @@ const handleDelete = () => {
type: 'warning'
}).then(() => {
if (emailStore.contentData.delType === 'logic') {
emailDelete(email.value.emailId).then(() => {
emailDelete(email.emailId).then(() => {
ElMessage({
message: t('delSuccessMsg'),
type: 'success',
plain: true,
})
emailStore.deleteIds = [email.value.emailId]
emailStore.deleteIds = [email.emailId]
})
} else {
allEmailDelete(email.value.emailId).then(() => {
allEmailDelete(email.emailId).then(() => {
ElMessage({
message: t('delSuccessMsg'),
type: 'success',
plain: true,
})
emailStore.deleteIds = [email.value.emailId]
emailStore.deleteIds = [email.emailId]
})
}
+3 -5
View File
@@ -64,12 +64,12 @@ function changeTimeSort() {
}
function jumpContent(email) {
emailStore.contentData.email = emailStore.toContentEmail(email)
emailStore.contentData.email = email
emailStore.contentData.delType = 'logic'
emailStore.contentData.showUnread = true
emailStore.contentData.showStar = true
emailStore.contentData.showReply = true
router.push('/mail')
router.push('/message')
}
const existIds = new Set();
@@ -141,9 +141,7 @@ function cancelStar(email) {
function getEmailList(emailId, size) {
const accountId = accountStore.currentAccountId;
const allReceive = accountStore.currentAccount.allReceive;
return emailStore.fetchList(full =>
emailList(accountId, allReceive, emailId, params.timeSort, size, 0, full)
).then(data => {
return emailList(accountId, allReceive, emailId, params.timeSort, size, 0).then(data => {
data.latestEmail.reqAccountId = accountId;
data.latestEmail.allReceive = allReceive;
return data;
+110 -89
View File
@@ -14,8 +14,8 @@
<span class="form-desc" v-if="show === 'login'">{{ $t('loginTitle') }}</span>
<span class="form-desc" v-else>{{ $t('regTitle') }}</span>
<div v-show="show === 'login'">
<el-input :class="!hideLoginDomain ? 'email-input' : ''" v-model="form.email"
type="text" :placeholder="$t('emailAccount')" autocomplete="off" @keyup.enter="submit">
<el-input ref="loginEmailInput" :class="!hideLoginDomain ? 'email-input' : ''" v-model="form.email"
type="text" :placeholder="$t('emailAccount')" autocomplete="off" @keydown.tab="handleEmailTab">
<template #append v-if="!hideLoginDomain">
<div @click.stop="openSelect">
<el-select
@@ -39,20 +39,25 @@
</div>
</template>
</el-input>
<el-input v-model="form.password" :placeholder="$t('password')" type="password" autocomplete="off" @keyup.enter="submit">
<el-input ref="loginPasswordInput" v-model="form.password" :placeholder="$t('password')" type="password"
autocomplete="off" @keydown.tab="handlePasswordTab" @keyup.enter="submit">
</el-input>
<el-button class="btn" type="primary" @click="submit" :loading="loginLoading"
>{{ $t('loginBtn') }}
</el-button>
<el-button v-for="p in oauthProviders" :key="p.key" class="btn" style="margin-top: 10px" @click="oauthLogin(p.key)">
<el-avatar v-if="p.iconType === 'image'" :src="p.icon" :size="18" style="margin-right: 10px" />
<Icon v-else :icon="p.icon" width="18" height="18" style="margin-right: 10px" />
{{ p.label }}
<div class="login-actions">
<el-button class="btn" type="primary" @click="submit" :loading="loginLoading"
>{{ $t('loginBtn') }}
</el-button>
<el-button v-if="settingStore.settings.passkey === 1" class="btn" @click="submitPasskey" :loading="passkeyLoading">
<Icon icon="material-symbols:passkey-outline-rounded" width="18" height="18"/>
{{ $t('passkeyLogin') }}
</el-button>
</div>
<el-button class="btn" v-if="settingStore.settings.linuxdoSwitch" style="margin-top: 10px" @click="linuxDoLogin">
<el-avatar src="/image/linuxdo.webp" :size="18" style="margin-right: 10px" />LinuxDo
</el-button>
</div>
<div v-show="show !== 'login'">
<el-input :class="!hideLoginDomain ? 'email-input' : ''" v-model="registerForm.email" type="text" :placeholder="$t('emailAccount')"
autocomplete="off" @keyup.enter="submitRegister">
autocomplete="off">
<template #append v-if="!hideLoginDomain">
<div @click.stop="openSelect">
<el-select
@@ -76,13 +81,13 @@
</div>
</template>
</el-input>
<el-input v-model="registerForm.password" :placeholder="$t('password')" type="password" autocomplete="off" @keyup.enter="submitRegister"/>
<el-input v-model="registerForm.password" :placeholder="$t('password')" type="password" autocomplete="off"/>
<el-input v-model="registerForm.confirmPassword" :placeholder="$t('confirmPwd')" type="password"
autocomplete="off" @keyup.enter="submitRegister"/>
autocomplete="off"/>
<el-input v-if="settingStore.settings.regKey === 0" v-model="registerForm.code" :placeholder="$t('regKey')"
type="text" autocomplete="off" @keyup.enter="submitRegister"/>
type="text" autocomplete="off"/>
<el-input v-if="settingStore.settings.regKey === 2" v-model="registerForm.code"
:placeholder="$t('regKeyOptional')" type="text" autocomplete="off" @keyup.enter="submitRegister"/>
:placeholder="$t('regKeyOptional')" type="text" autocomplete="off"/>
<div v-show="verifyShow"
class="register-turnstile"
:data-sitekey="settingStore.settings.siteKey"
@@ -96,10 +101,8 @@
<el-button class="btn" style="margin: 0" type="primary" @click="submitRegister" :loading="registerLoading"
>{{ $t('regBtn') }}
</el-button>
<el-button v-for="p in oauthProviders" :key="p.key" class="btn" style="margin-top: 10px" @click="oauthLogin(p.key)">
<el-avatar v-if="p.iconType === 'image'" :src="p.icon" :size="18" style="margin-right: 10px" />
<Icon v-else :icon="p.icon" width="18" height="18" style="margin-right: 10px" />
{{ p.label }}
<el-button v-if="settingStore.settings.linuxdoSwitch" class="btn" style="margin-top: 10px" @click="linuxDoLogin">
<el-avatar src="/image/linuxdo.webp" :size="18" style="margin-right: 10px" />LinuxDo
</el-button>
</div>
<template v-if="settingStore.settings.register === 0">
@@ -112,7 +115,7 @@
</div>
<el-dialog class="bind-dialog" v-model="showBindForm" title="注册邮箱" >
<div class="bind-container">
<el-input :class="!hideLoginDomain ? 'email-input' : ''" v-model="bindForm.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off" @keyup.enter="bind">
<el-input :class="!hideLoginDomain ? 'email-input' : ''" v-model="bindForm.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append v-if="!hideLoginDomain">
<div @click.stop="openSelect">
<el-select
@@ -136,9 +139,9 @@
</template>
</el-input>
<el-input v-if="settingStore.settings.regKey === 0" v-model="bindForm.code" :placeholder="$t('regKey')"
type="text" autocomplete="off" @keyup.enter="bind"/>
type="text" autocomplete="off"/>
<el-input v-if="settingStore.settings.regKey === 2" v-model="bindForm.code"
:placeholder="$t('regKeyOptional')" type="text" autocomplete="off" @keyup.enter="bind"/>
:placeholder="$t('regKeyOptional')" type="text" autocomplete="off"/>
<el-button class="btn" type="primary" @click="bind" :loading="bindLoading"
>绑定
</el-button>
@@ -152,7 +155,6 @@
<script setup>
import router from "@/router";
import {useRoute} from "vue-router";
import {computed, nextTick, reactive, ref} from "vue";
import {login} from "@/request/login.js";
import {register} from "@/request/login.js";
@@ -167,37 +169,23 @@ import {cvtR2Url} from "@/utils/convert.js";
import {loginUserInfo} from "@/request/my.js";
import {permsToRouter} from "@/perm/perm.js";
import {useI18n} from "vue-i18n";
import {oauthBindUser, oauthLinuxDoLogin, oauthGithubLogin, oauthGoogleLogin} from "@/request/ouath.js";
import {oauthBindUser, oauthLinuxDoLogin} from "@/request/ouath.js";
import {browserSupportsWebAuthn, startAuthentication} from "@simplewebauthn/browser";
import {passkeyAuthOptions, passkeyAuthVerify} from "@/request/passkey.js";
const {t} = useI18n();
const accountStore = useAccountStore();
const userStore = useUserStore();
const uiStore = useUiStore();
const settingStore = useSettingStore();
const route = useRoute();
const loginLoading = ref(false)
const passkeyLoading = ref(false)
const bindLoading = ref(false)
const oauthLoading = ref(false);
const showBindForm = ref(false);
const show = ref('login')
const oauthKeys = ['linuxdo', 'github', 'google']
const oauthProvider = computed(() => {
const fromState = route.query.state
if (oauthKeys.includes(fromState)) return fromState
const fromStore = sessionStorage.getItem('oauthProvider')
return oauthKeys.includes(fromStore) ? fromStore : null
})
const oauthProviders = computed(() => {
const allProviders = [
{ key: 'google', label: 'Google', icon: 'devicon:google', iconType: 'iconify' },
{ key: 'github', label: 'GitHub', icon: 'codicon:github-inverted', iconType: 'iconify' },
{ key: 'linuxdo', label: 'LinuxDo', icon: '/image/linuxdo.webp', iconType: 'image' },
]
return allProviders.filter(p => settingStore.settings[p.key + 'Switch'] === 0)
})
const loginEmailInput = ref()
const loginPasswordInput = ref()
const bindForm = reactive({
email: '',
@@ -223,6 +211,18 @@ const registerLoading = ref(false)
suffix.value = domainList[0]
const verifyShow = ref(false)
let verifyToken = ''
function handleEmailTab(event) {
if (event.shiftKey) return
event.preventDefault()
loginPasswordInput.value?.focus()
}
function handlePasswordTab(event) {
if (!event.shiftKey) return
event.preventDefault()
loginEmailInput.value?.focus()
}
let turnstileId = null
let botJsError = ref(false)
let verifyErrorCount = 0
@@ -285,63 +285,51 @@ const getEmailName = (email) => {
return email.split('@')[0]
}
function oauthLogin(provider) {
const clientId = settingStore.settings[provider + 'ClientId']
const redirectUri = encodeURIComponent(window.location.origin + '/login')
sessionStorage.setItem('oauthProvider', provider)
const authorizeUrls = {
linuxdo: `https://connect.linux.do/oauth2/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid+profile+email&state=${provider}`,
github: `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&scope=user:email&state=${provider}`,
google: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid+profile+email&state=${provider}`,
}
window.location.href = authorizeUrls[provider]
function linuxDoLogin() {
const clientId = settingStore.settings.linuxdoClientId
const redirectUri = encodeURIComponent(settingStore.settings.linuxdoCallbackUrl)
window.location.href =
`https://connect.linux.do/oauth2/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid+profile+email`
}
const loginFns = {
linuxdo: oauthLinuxDoLogin,
github: oauthGithubLogin,
google: oauthGoogleLogin,
}
linuxDoGetUser();
oauthGetUser();
async function oauthGetUser() {
async function linuxDoGetUser() {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
if (!code || !oauthProvider.value) return
const provider = oauthProvider.value
oauthLoading.value = true
sessionStorage.removeItem('oauthProvider')
window.history.replaceState({}, '', window.location.origin + window.location.pathname)
if (code) {
loginFns[provider](code, window.location.origin + '/login').then(data => {
oauthLoading.value = true
oauthLinuxDoLogin(code).then(data => {
bindForm.oauthUserId = data.userInfo.oauthUserId;
bindForm.oauthUserId = data.userInfo.oauthUserId;
if (!data.token) {
showBindForm.value = true
if (!data.token) {
showBindForm.value = true
oauthLoading.value = false
ElMessage({
message: '请注册绑定一个邮箱',
type: 'warning',
duration: 4000,
plain: true,
})
return;
}
saveToken(data.token);
}).catch(() => {
oauthLoading.value = false
ElMessage({
message: '请注册绑定一个邮箱',
type: 'warning',
duration: 4000,
plain: true,
})
return;
}
})
}
saveToken(data.token);
}).catch(() => {
oauthLoading.value = false
})
const cleanUrl = window.location.origin + window.location.pathname
window.history.replaceState({}, '', cleanUrl)
}
function bind() {
if (bindLoading.value) return
if (!bindForm.email) {
ElMessage({
message: t('emptyEmailMsg'),
@@ -399,8 +387,6 @@ function bind() {
const submit = () => {
if (loginLoading.value) return
if (!form.email) {
ElMessage({
message: t('emptyEmailMsg'),
@@ -438,6 +424,35 @@ const submit = () => {
})
}
async function submitPasskey() {
if (!browserSupportsWebAuthn()) {
ElMessage({
message: t('passkeyUnsupported'),
type: 'error',
plain: true,
})
return
}
passkeyLoading.value = true
try {
const {transactionId, options} = await passkeyAuthOptions()
const response = await startAuthentication({optionsJSON: options})
const data = await passkeyAuthVerify(transactionId, response)
await saveToken(data.token)
} catch (error) {
if (!error?.code) {
ElMessage({
message: t('passkeyLoginFailed'),
type: 'error',
plain: true,
})
}
} finally {
passkeyLoading.value = false
}
}
async function saveToken(token) {
localStorage.setItem('token', token)
refreshWebsiteConfig()
@@ -471,8 +486,6 @@ function refreshWebsiteConfig() {
function submitRegister() {
if (registerLoading.value) return
if (!registerForm.email) {
ElMessage({
message: t('emptyEmailMsg'),
@@ -677,6 +690,15 @@ function submitRegister() {
border-radius: 6px;
}
.login-actions {
display: flex;
gap: 10px;
.btn {
flex: 1;
}
}
.form-desc {
margin-top: 5px;
margin-bottom: 18px;
@@ -782,7 +804,6 @@ function submitRegister() {
width: 100px;
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.custom-style {
+2 -4
View File
@@ -62,7 +62,7 @@
</el-scrollbar>
<el-dialog v-model="showAdd" :title="$t('addRegKey')">
<div class="container">
<el-input v-model="addForm.code" :placeholder="$t('regKey')" @keyup.enter="submit">
<el-input v-model="addForm.code" :placeholder="$t('regKey')">
<template #suffix>
<Icon @click.stop="genCode" class="gen-code" icon="bitcoin-icons:refresh-filled" width="24" height="24"/>
</template>
@@ -75,7 +75,7 @@
type="date"
:placeholder="$t('validUntil')"
/>
<el-input-number v-model="addForm.count" :min="1" :max="99999" @keyup.enter="submit"/>
<el-input-number v-model="addForm.count" :min="1" :max="99999"/>
<el-button class="btn" type="primary" @click="submit" :loading="addLoading"
>{{ $t('add') }}
</el-button>
@@ -308,8 +308,6 @@ function clearNotUse() {
function submit() {
if (addLoading.value) return
if (!addForm.code) {
ElMessage({
message: $('emptyRegKeyMsg'),
+2 -3
View File
@@ -68,9 +68,9 @@
</template>
<div class="dialog-box">
<el-input class="dialog-input" v-model="form.name" type="text" :maxlength="12" :placeholder="$t('roleName')"
autocomplete="off" @keyup.enter="roleFormClick"/>
autocomplete="off"/>
<el-input class="dialog-input" v-model="form.description" :maxlength="30" type="text"
:placeholder="$t('description')" autocomplete="off" @keyup.enter="roleFormClick"/>
:placeholder="$t('description')" autocomplete="off"/>
<el-input-tag class="dialog-input" tag-type="warning" v-model="form.banEmail"
@add-tag="banEmailAddTag" type="text" :placeholder="$t('emailInterception')" autocomplete="off"/>
<el-select
@@ -233,7 +233,6 @@ function banEmailAddTag(val) {
function roleFormClick() {
if (permLoading.value) return
if (dialogType.type === 'add') {
addRole()
} else {
+3 -5
View File
@@ -56,11 +56,11 @@ function changeTimeSort() {
}
function jumpContent(email) {
emailStore.contentData.email = emailStore.toContentEmail(email)
emailStore.contentData.email = email
emailStore.contentData.delType = 'logic'
emailStore.contentData.showStar = true
emailStore.contentData.showReply = true
router.push('/mail')
router.push('/message')
}
function addStar(email) {
@@ -74,9 +74,7 @@ function cancelStar(email) {
function getEmailList(emailId, size) {
const accountId = accountStore.currentAccountId;
const allReceive = accountStore.currentAccount.allReceive;
return emailStore.fetchList(full =>
emailList(accountId, allReceive, emailId, params.timeSort, size, 1, full)
).then(data => {
return emailList(accountId, allReceive, emailId, params.timeSort, size, 1).then(data => {
data.latestEmail.reqAccountId = accountId;
data.latestEmail.allReceive = allReceive;
return data;
+135 -5
View File
@@ -30,6 +30,25 @@
</div>
</div>
</div>
<div class="passkey" v-if="settingStore.settings.passkey === 1">
<div class="title">{{$t('passkeySetting')}}</div>
<div class="passkey-status">
<div>
<div>{{ passkey.registered ? $t('passkeyConfigured') : $t('passkeyNotConfigured') }}</div>
<div class="passkey-time" v-if="passkey.registered && passkey.createTime">
{{$t('passkeyCreatedAt', {time: passkey.createTime})}}
</div>
</div>
<div class="passkey-actions">
<el-button type="primary" :loading="passkeyLoading" @click="openPasskeyAction('register')">
{{ passkey.registered ? $t('passkeyReplace') : $t('passkeyCreate') }}
</el-button>
<el-button v-if="passkey.registered" :loading="passkeyLoading" @click="openPasskeyAction('delete')">
{{$t('passkeyDelete')}}
</el-button>
</div>
</div>
</div>
<div class="language">
<div class="title">{{$t('language')}}</div>
<el-select
@@ -48,16 +67,25 @@
{{$t('delAccountMsg')}}
</div>
<div>
<el-button type="primary" @click="deleteConfirm">{{$t('deleteUserBtn')}}</el-button>
<el-button type="danger" @click="deleteConfirm">{{$t('deleteUserBtn')}}</el-button>
</div>
</div>
<el-dialog v-model="pwdShow" :title="$t('changePassword')" width="340">
<div class="update-pwd">
<el-input type="password" :placeholder="$t('newPassword')" v-model="form.password" autocomplete="off" @keyup.enter="submitPwd"/>
<el-input type="password" :placeholder="$t('confirmPassword')" v-model="form.newPwd" autocomplete="off" @keyup.enter="submitPwd"/>
<el-input type="password" :placeholder="$t('newPassword')" v-model="form.password" autocomplete="off"/>
<el-input type="password" :placeholder="$t('confirmPassword')" v-model="form.newPwd" autocomplete="off"/>
<el-button type="primary" :loading="setPwdLoading" @click="submitPwd">{{$t('save')}}</el-button>
</div>
</el-dialog>
<el-dialog v-model="passkeyPwdShow"
:title="passkeyAction === 'delete' ? $t('passkeyDelete') : $t('passkeySetting')" width="340">
<div class="update-pwd">
<div v-if="passkeyAction === 'delete'">{{$t('passkeyDeleteConfirm')}}</div>
<el-input type="password" :placeholder="$t('passkeyCurrentPassword')" v-model="passkeyPassword"
autocomplete="current-password" @keyup.enter="submitPasskeyAction"/>
<el-button type="primary" :loading="passkeyLoading" @click="submitPasskeyAction">{{$t('confirm')}}</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup>
@@ -69,6 +97,13 @@ import {accountSetName} from "@/request/account.js";
import {useAccountStore} from "@/store/account.js";
import {useI18n} from "vue-i18n";
import {useSettingStore} from "@/store/setting.js";
import {browserSupportsWebAuthn, startRegistration} from "@simplewebauthn/browser";
import {
passkeyDelete,
passkeyRegOptions,
passkeyRegVerify,
passkeyStatus
} from "@/request/passkey.js";
const { t } = useI18n()
const accountStore = useAccountStore()
@@ -78,11 +113,80 @@ const setPwdLoading = ref(false)
const setNameShow = ref(false)
const accountName = ref(null)
const langSelect = ref(settingStore.lang)
const passkey = reactive({registered: false, createTime: null})
const passkeyLoading = ref(false)
const passkeyPwdShow = ref(false)
const passkeyPassword = ref('')
const passkeyAction = ref('register')
defineOptions({
name: 'setting'
})
if (settingStore.settings.passkey === 1) {
loadPasskeyStatus()
}
async function loadPasskeyStatus() {
try {
const status = await passkeyStatus()
passkey.registered = status.registered
passkey.createTime = status.createTime
} catch (_) {
// The shared HTTP interceptor already displays the server error.
}
}
function openPasskeyAction(action) {
if (action === 'register' && !browserSupportsWebAuthn()) {
ElMessage({
message: t('passkeyUnsupported'),
type: 'error',
plain: true,
})
return
}
passkeyAction.value = action
passkeyPassword.value = ''
passkeyPwdShow.value = true
}
async function submitPasskeyAction() {
if (!passkeyPassword.value) {
ElMessage({
message: t('emptyPwdMsg'),
type: 'error',
plain: true,
})
return
}
passkeyLoading.value = true
try {
if (passkeyAction.value === 'delete') {
await passkeyDelete(passkeyPassword.value)
passkey.registered = false
passkey.createTime = null
ElMessage({message: t('passkeyDeleteSuccess'), type: 'success', plain: true})
} else {
const {transactionId, options} = await passkeyRegOptions(passkeyPassword.value)
const response = await startRegistration({optionsJSON: options})
const status = await passkeyRegVerify(transactionId, response)
passkey.registered = status.registered
passkey.createTime = status.createTime
ElMessage({message: t('passkeyCreateSuccess'), type: 'success', plain: true})
}
passkeyPwdShow.value = false
passkeyPassword.value = ''
} catch (error) {
if (!error?.code) {
ElMessage({message: t('passkeyLoginFailed'), type: 'error', plain: true})
}
} finally {
passkeyLoading.value = false
}
}
function showSetName() {
accountName.value = userStore.user.name
setNameShow.value = true
@@ -160,8 +264,6 @@ const deleteConfirm = () => {
function submitPwd() {
if (setPwdLoading.value) return
if (!form.password) {
ElMessage({
message: t('emptyPwdMsg'),
@@ -288,6 +390,34 @@ function submitPwd() {
}
}
.passkey {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 40px;
.passkey-status {
display: block;
}
.passkey-time {
color: var(--regular-text-color);
font-size: 12px;
margin-top: 6px;
}
.passkey-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
.el-button {
margin-left: 0;
}
}
}
.del-email {
font-size: 14px;
display: flex;
+3 -7
View File
@@ -2,7 +2,7 @@
<emailScroll type="star" ref="scroll"
:allow-star="false"
:cancel-success="cancelStar"
:getEmailList="getEmailList"
:getEmailList="starList"
:emailDelete="emailDelete"
:star-add="starAdd"
:star-cancel="starCancel"
@@ -28,15 +28,11 @@ const scroll = ref({})
const emailStore = useEmailStore();
function jumpContent(email) {
emailStore.contentData.email = emailStore.toContentEmail(email)
emailStore.contentData.email = email
emailStore.contentData.delType = 'logic'
emailStore.contentData.showStar = true
emailStore.contentData.showReply = true
router.push('/mail')
}
function getEmailList(emailId, size) {
return emailStore.fetchList(full => starList(emailId, size, full))
router.push('/message')
}
function cancelStar(email) {
+78 -365
View File
@@ -6,7 +6,7 @@
<el-scrollbar class="scroll" v-if="!firstLoading">
<div class="scroll-body">
<div class="card-grid">
<!-- Basic Settings Card -->
<!-- Website Settings Card -->
<div class="settings-card">
<div class="card-title">{{ $t('websiteSetting') }}</div>
<div class="card-content">
@@ -24,6 +24,13 @@
v-model="setting.loginDomain"/>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('passkeyLogin') }}</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="1" :inactive-value="0"
v-model="setting.passkey"/>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('regKey') }}</span></div>
<div>
@@ -61,18 +68,6 @@
v-model="setting.manyEmail"/>
</div>
</div>
<div class="setting-item">
<div>
<span>{{ $t('syncDelete') }}</span>
<el-tooltip effect="dark" :content="$t('syncDeleteDesc')">
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.syncDelete"/>
</div>
</div>
<div class="setting-item">
<div>
<span>{{ $t('emailPrefix') }}</span>
@@ -213,63 +208,6 @@
</el-button>
</div>
</div>
<div class="setting-item">
<div>
<span>{{ $t('autoClean') }}</span>
<el-tooltip effect="dark" :content="$t('autoCleanDesc')">
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</div>
<div class="forward">
<span>{{ setting.autoCleanDays > 0 ? $t('autoCleanRetain', { days: setting.autoCleanDays }) : $t('disabled') }}</span>
<el-button class="opt-button" style="margin-top: 0" @click="openAutoClean" size="small"
type="primary">
<Icon icon="fluent:settings-48-regular" width="16" height="16"/>
</el-button>
</div>
</div>
</div>
</div>
<div class="settings-card">
<div class="card-title">{{ $t('emailPush') }}</div>
<div class="card-content">
<div class="setting-item">
<div><span>{{ $t('tgBot') }}</span></div>
<div class="forward">
<span>{{ setting.tgBotStatus === 0 ? $t('enabled') : $t('disabled') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openTgSetting">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('otherEmail') }}</span></div>
<div class="forward">
<span>{{ setting.forwardStatus === 0 ? $t('enabled') : $t('disabled') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openThirdEmailSetting">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('webhook') }}</span></div>
<div class="forward">
<span>{{ setting.webhookStatus === 0 ? $t('enabled') : $t('disabled') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openWebhookSetting">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('forwardingRules') }}</span></div>
<div class="forward">
<span>{{ setting.ruleType === 0 ? $t('forwardAll') : $t('rules') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openForwardRules">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
</div>
</div>
@@ -314,6 +252,39 @@
</div>
</div>
<div class="settings-card">
<div class="card-title">{{ $t('emailPush') }}</div>
<div class="card-content">
<div class="setting-item">
<div><span>{{ $t('tgBot') }}</span></div>
<div class="forward">
<span>{{ setting.tgBotStatus === 0 ? $t('enabled') : $t('disabled') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openTgSetting">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('otherEmail') }}</span></div>
<div class="forward">
<span>{{ setting.forwardStatus === 0 ? $t('enabled') : $t('disabled') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openThirdEmailSetting">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('forwardingRules') }}</span></div>
<div class="forward">
<span>{{ setting.ruleType === 0 ? $t('forwardAll') : $t('rules') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openForwardRules">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
</div>
</div>
<!-- Turnstile Verification Card -->
<div class="settings-card">
<div class="card-title">{{ $t('turnstileSetting') }}</div>
@@ -421,26 +392,6 @@
</div>
</div>
<!-- OAuth Login Card -->
<div class="settings-card">
<div class="card-title">{{ $t('oauthLogin') }}</div>
<div class="card-content">
<div class="setting-item" v-for="p in oauthPlatforms" :key="p.key">
<div>
<el-avatar v-if="p.iconType === 'image'" :src="p.icon" :size="22" class="oauth-icon"/>
<Icon v-else :icon="p.icon" width="22" height="22" class="oauth-icon"/>
<span>{{ p.label }}</span>
</div>
<div class="forward">
<span>{{ setting[p.key + 'Switch'] === 0 ? $t('enabled') : $t('disabled') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openOauthSetting(p)">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
</div>
</div>
<div class="settings-card about">
<div class="card-title">{{ $t('about') }}</div>
<div class="card-content">
@@ -497,13 +448,13 @@
<!-- Dialogs remain the same -->
<el-dialog v-model="editTitleShow" :title="$t('changeTitle')" width="340" @closed="editTitle = setting.title">
<form @submit.prevent>
<el-input type="text" :placeholder="$t('websiteTitle')" v-model="editTitle" @keyup.enter="saveTitle"/>
<form>
<el-input type="text" :placeholder="$t('websiteTitle')" v-model="editTitle"/>
<el-button type="primary" :loading="settingLoading" @click="saveTitle">{{ $t('save') }}</el-button>
</form>
</el-dialog>
<el-dialog v-model="resendTokenFormShow" :title="$t('resendToken')" width="340" @closed="cleanResendTokenForm">
<form @submit.prevent>
<form>
<el-select style="margin-bottom: 15px" v-model="resendTokenForm.domain" placeholder="Select">
<el-option
v-for="item in settingStore.domainList"
@@ -512,39 +463,25 @@
:value="item"
/>
</el-select>
<el-input type="text" :placeholder="$t('addResendTokenDesc')" v-model="resendTokenForm.token" @keyup.enter="saveResendToken"/>
<el-input type="text" :placeholder="$t('addResendTokenDesc')" v-model="resendTokenForm.token"/>
<el-button type="primary" :loading="settingLoading" @click="saveResendToken">{{ $t('save') }}</el-button>
</form>
</el-dialog>
<el-dialog v-model="r2DomainShow" :title="$t('addOsDomain')" width="340"
@closed="r2DomainInput = setting.r2Domain">
<form @submit.prevent>
<el-input type="text" :placeholder="$t('domainDesc')" v-model="r2DomainInput" @keyup.enter="saveR2domain"/>
<form>
<el-input type="text" :placeholder="$t('domainDesc')" v-model="r2DomainInput"/>
<el-button type="primary" :loading="settingLoading" @click="saveR2domain">{{ $t('save') }}</el-button>
</form>
</el-dialog>
<el-dialog v-model="turnstileShow" :title="$t('addTurnstileSecret')" width="340"
@closed="turnstileForm.secretKey = '';turnstileForm.siteKey = ''">
<form @submit.prevent>
<el-input type="text" placeholder="Site Key" v-model="turnstileForm.siteKey" @keyup.enter="saveTurnstileKey"/>
<el-input type="text" style="margin-top: 15px" placeholder="Secret Key" v-model="turnstileForm.secretKey" @keyup.enter="saveTurnstileKey"/>
<form>
<el-input type="text" placeholder="Site Key" v-model="turnstileForm.siteKey"/>
<el-input type="text" style="margin-top: 15px" placeholder="Secret Key" v-model="turnstileForm.secretKey"/>
<el-button type="primary" :loading="settingLoading" @click="saveTurnstileKey">{{ $t('save') }}</el-button>
</form>
</el-dialog>
<el-dialog v-model="oauthSettingShow" :title="$t('oauthSetting') + ' - ' + oauthForm.label" width="340"
@closed="oauthForm.clientId = ''; oauthForm.clientSecret = ''; oauthForm.switch = 1">
<div class="dialog-content">
<el-input type="text" :placeholder="$t('clientId')" v-model="oauthForm.clientId"/>
<el-input type="text" style="margin-top: 15px" :placeholder="$t('clientSecret')" v-model="oauthForm.clientSecret"/>
</div>
<template #footer>
<div class="dialog-footer">
<el-switch v-model="oauthForm.switch" :active-value="0" :inactive-value="1" :active-text="$t('enable')"
:inactive-text="$t('disable')"/>
<el-button type="primary" :loading="settingLoading" @click="saveOauth">{{ $t('save') }}</el-button>
</div>
</template>
</el-dialog>
<el-dialog
v-model="showSetBackground"
class="cut-dialog"
@@ -562,7 +499,7 @@
</span>
</template>
<el-input :placeholder="$t('backgroundUrlDesc')" v-model="backgroundUrl" v-if="!localUpShow"
class="background-url" @keyup.enter="saveBackground"/>
class="background-url"/>
<el-image
v-if="localUpShow"
:preview-src-list="[backgroundImage]"
@@ -594,10 +531,10 @@
</div>
</template>
<div class="forward-set-body">
<el-input :placeholder="setting.tgBotToken || $t('tgBotToken')" v-model="tgBotToken" @keyup.enter="tgBotSave"></el-input>
<el-input :placeholder="setting.tgBotToken || $t('tgBotToken')" v-model="tgBotToken"></el-input>
<el-input-tag tag-type="warning" :placeholder="$t('toBotTokenDesc')" v-model="tgChatId"
@add-tag="addChatTag"></el-input-tag>
<el-input tag-type="warning" :placeholder="$t('customDomainDesc')" v-model="customDomain" @keyup.enter="tgBotSave"></el-input>
<el-input tag-type="warning" :placeholder="$t('customDomainDesc')" v-model="customDomain" ></el-input>
<div class="tg-msg-label">
<span>{{t('from')}}</span>
<el-select v-model="tgMsgFrom" >
@@ -668,48 +605,6 @@
</div>
</template>
</el-dialog>
<el-dialog
v-model="webhookShow"
class="forward-dialog"
@closed="webhookFormatShow = false"
>
<template #header>
<div class="forward-head">
<span class="forward-set-title">{{ $t('webhook') }}</span>
<el-tooltip effect="dark" :content="$t('webhookDesc')">
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</div>
</template>
<div class="forward-set-body">
<el-input :placeholder="$t('webhookUrl')" v-model="webhookUrl" @keyup.enter="webhookSave"></el-input>
<el-input :placeholder="$t('webhookSecret')" v-model="webhookSecret" @keyup.enter="webhookSave"></el-input>
<div class="tg-msg-label">
<span>{{ t('webhookRetry') }}</span>
<el-input-number v-model="webhookRetry" :min="0" :max="5" controls-position="right"/>
</div>
<div v-show="webhookFormatShow" class="webhook-format">
<pre>Content-Type: application/json
Authorization: &lt;secret&gt;</pre>
<pre>{{ webhookPayloadExample }}</pre>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-switch v-model="webhookStatus" :active-value="0" :inactive-value="1" :active-text="$t('enable')"
:inactive-text="$t('disable')"/>
<div class="webhook-footer-right">
<div class="webhook-format-title" @click="webhookFormatShow = !webhookFormatShow">
<span>{{ $t('webhookFormat') }}</span>
<Icon class="webhook-format-icon" :class="{ open: webhookFormatShow }" icon="mingcute:down-small-fill" width="18" height="18"/>
</div>
<el-button :loading="settingLoading" type="primary" @click="webhookSave">
{{ $t('save') }}
</el-button>
</div>
</div>
</template>
</el-dialog>
<el-dialog
v-model="forwardRulesShow"
class="forward-dialog"
@@ -748,23 +643,23 @@ Authorization: &lt;secret&gt;</pre>
</el-dialog>
<el-dialog v-model="regVerifyCountShow" :title="$t('rulesVerifyTitle',{count: regVerifyCount})"
@closed="regVerifyCount = setting.regVerifyCount">
<form @submit.prevent>
<el-input-number type="text" v-model="regVerifyCount" :min="1" @keyup.enter="saveRegVerifyCount">
<form>
<el-input-number type="text" v-model="regVerifyCount" :min="1">
</el-input-number>
<el-button type="primary" :loading="settingLoading" @click="saveRegVerifyCount">{{ $t('save') }}</el-button>
</form>
</el-dialog>
<el-dialog v-model="addVerifyCountShow" :title="$t('rulesVerifyTitle',{count: addVerifyCount})"
@closed="addVerifyCount = setting.addVerifyCount">
<form @submit.prevent>
<el-input-number type="text" v-model="addVerifyCount" :min="1" @keyup.enter="saveAddVerifyCount"/>
<form>
<el-input-number type="text" v-model="addVerifyCount" :min="1"/>
<el-button type="primary" :loading="settingLoading" @click="saveAddVerifyCount">{{ $t('save') }}</el-button>
</form>
</el-dialog>
<el-dialog top="5vh" v-model="noticePopupShow" :title="$t('noticePopup')" class="notice-popup"
@closed="resetNoticeForm">
<form @submit.prevent>
<el-input v-model="noticeForm.noticeTitle" :placeholder="t('titleDesc')" @keyup.enter="saveNoticePopup"/>
<form>
<el-input v-model="noticeForm.noticeTitle" :placeholder="t('titleDesc')"/>
<div class="notice-line-item">
<el-select v-model="noticeForm.noticeType">
<template #prefix>
@@ -835,13 +730,13 @@ Authorization: &lt;secret&gt;</pre>
</template>
</el-dialog>
<el-dialog v-model="addS3Show" :title="t('s3Configuration')" width="340" @closed="resetAddS3Form">
<form @submit.prevent>
<el-input class="dialog-input" type="text" placeholder="Bucket" v-model="s3.bucket" @keyup.enter="saveS3"/>
<el-input class="dialog-input" type="text" placeholder="Endpoint" v-model="s3.endpoint" @keyup.enter="saveS3"/>
<el-input class="dialog-input" type="text" placeholder="Region" v-model="s3.region" @keyup.enter="saveS3"/>
<form>
<el-input class="dialog-input" type="text" placeholder="Bucket" v-model="s3.bucket"/>
<el-input class="dialog-input" type="text" placeholder="Endpoint" v-model="s3.endpoint"/>
<el-input class="dialog-input" type="text" placeholder="Region" v-model="s3.region"/>
<el-input class="dialog-input" type="text" :placeholder="setting.s3AccessKey || 'Access Key'"
v-model="s3.s3AccessKey" @keyup.enter="saveS3"/>
<el-input style="margin-bottom: 10px" type="text" :placeholder="setting.s3SecretKey || 'Secret Key'" v-model="s3.s3SecretKey" @keyup.enter="saveS3"/>
v-model="s3.s3AccessKey"/>
<el-input style="margin-bottom: 10px" type="text" :placeholder="setting.s3SecretKey || 'Secret Key'" v-model="s3.s3SecretKey"/>
<div class="force-path-style">
<div class="force-path-style-left">
<span>ForcePathStyle</span>
@@ -861,7 +756,7 @@ Authorization: &lt;secret&gt;</pre>
<el-dialog v-model="emailPrefixShow" :title="t('emailPrefix')" @closed="resetEmailPrefix" >
<div class="email-prefix">
<div>{{ t('atLeast') }}</div>
<el-input-number v-model="minEmailPrefix" :min="1" :max="20" style="width: 150px" @keyup.enter="saveEmailPrefix" >
<el-input-number v-model="minEmailPrefix" :min="1" :max="20" style="width: 150px" >
<template #suffix>
<span>{{ t('character') }}</span>
</template>
@@ -895,22 +790,6 @@ Authorization: &lt;secret&gt;</pre>
</el-form>
<el-button type="primary" style="width: 100%;" :loading="settingLoading" @click="saveBlackList">{{ $t('save') }}</el-button>
</el-dialog>
<el-dialog v-model="autoCleanShow" :title="t('autoClean')" class="forward-dialog" @closed="resetAutoClean">
<el-form>
<el-form-item :label="t('autoCleanDays')" label-position="top">
<el-input-number v-model="autoCleanDays" :min="0" :max="3650" style="width: 100%"/>
</el-form-item>
<el-form-item :label="t('autoCleanExclude')" label-position="top">
<el-input-tag
tag-type="warning"
:placeholder="$t('autoCleanExcludeDesc')"
v-model="autoCleanExclude"
@add-tag="autoCleanExcludeAddTag"
/>
</el-form-item>
</el-form>
<el-button type="primary" style="width: 100%;" :loading="settingLoading" @click="saveAutoClean">{{ $t('save') }}</el-button>
</el-dialog>
<el-dialog v-model="aiCodeFilterShow" class="forward-dialog" @closed="resetAiCodeFilter">
<template #header>
<div class="forward-head">
@@ -939,10 +818,10 @@ import {useUiStore} from "@/store/ui.js";
import {useUserStore} from "@/store/user.js";
import {useAccountStore} from "@/store/account.js";
import {Icon} from "@iconify/vue";
import {cvtR2Url, toOssDomain} from "@/utils/convert.js";
import {cvtR2Url} from "@/utils/convert.js";
import {storeToRefs} from "pinia";
import {debounce} from 'lodash-es'
import {isDomain, isEmail, isIpUrl} from "@/utils/verify-utils.js";
import {isDomain, isEmail} from "@/utils/verify-utils.js";
import loading from "@/components/loading/index.vue";
import {getTextWidth} from "@/utils/text.js";
import {fileToBase64} from "@/utils/file-utils.js"
@@ -953,7 +832,7 @@ defineOptions({
name: 'sys-setting'
})
const currentVersion = 'v3.2.0'
const currentVersion = 'v3.0.0'
const hasUpdate = ref(false)
let getUpdateErrorCount = 1;
const {t, locale} = useI18n();
@@ -966,14 +845,12 @@ const userStore = useUserStore();
const editTitleShow = ref(false)
const resendTokenFormShow = ref(false)
const blackFormShow = ref(false)
const autoCleanShow = ref(false)
const aiCodeFilterShow = ref(false)
const r2DomainShow = ref(false)
const turnstileShow = ref(false)
const tgSettingShow = ref(false)
const noticePopupShow = ref(false)
const thirdEmailShow = ref(false)
const webhookShow = ref(false)
const forwardRulesShow = ref(false)
const emailPrefixShow = ref(false)
const showResendList = ref(false)
@@ -987,8 +864,6 @@ const r2DomainInput = ref('')
const loginOpacity = ref(0)
const minEmailPrefix = ref(0)
const emailPrefixFilter = ref([])
const autoCleanDays = ref(0)
const autoCleanExclude = ref([])
const backgroundUrl = ref('')
let backgroundFile = {}
const showSetBackground = ref(false)
@@ -1007,20 +882,6 @@ const turnstileForm = reactive({
secretKey: ''
})
const oauthPlatforms = [
{ key: 'google', label: 'Google', icon: 'devicon:google', iconType: 'iconify' },
{ key: 'github', label: 'GitHub', icon: 'codicon:github-inverted', iconType: 'iconify' },
{ key: 'linuxdo', label: 'LinuxDo', icon: '/image/linuxdo.webp', iconType: 'image' },
]
const oauthSettingShow = ref(false)
const oauthForm = reactive({
key: '',
label: '',
clientId: '',
clientSecret: '',
switch: 1,
})
const s3 = reactive({
bucket: '',
endpoint: '',
@@ -1069,23 +930,6 @@ const tgBotStatus = ref(0)
const tgBotToken = ref('')
const forwardEmail = ref([])
const forwardStatus = ref(0)
const webhookUrl = ref('')
const webhookStatus = ref(1)
const webhookRetry = ref(0)
const webhookSecret = ref('')
const webhookFormatShow = ref(false)
const webhookPayloadExample = `{
"emailId": 1,
"sendEmail": "[email protected]",
"sendName": "hello",
"toEmail": "[email protected]",
"toName": "admin",
"subject": "Hello",
"text": "Hello",
"content": "<div>Hello</div>",
"code": "123456",
"createTime": "2099-12-30 23:59:59"
}`
const emailColumnWidth = ref(0)
const tokenColumnWidth = ref(0)
const ruleType = ref(0)
@@ -1106,6 +950,7 @@ function getSettings() {
settingReady.value = false
settingQuery().then(settingData => {
setting.value = settingData
settingStore.settings.passkey = settingData.passkey
settingStore.domainList = settingData.domainList;
resendTokenForm.domain = setting.value.domainList[0]
loginOpacity.value = setting.value.loginOpacity
@@ -1271,26 +1116,10 @@ function openThirdEmailSetting() {
thirdEmailShow.value = true
}
function openWebhookSetting() {
webhookStatus.value = setting.value.webhookStatus
webhookUrl.value = setting.value.webhookUrl || ''
webhookRetry.value = setting.value.webhookRetry ?? 0
webhookSecret.value = setting.value.webhookSecret || ''
webhookShow.value = true
}
function openEmailPrefix() {
emailPrefixShow.value = true
}
function openAutoClean() {
autoCleanDays.value = setting.value.autoCleanDays ?? 0
autoCleanExclude.value = setting.value.autoCleanExclude
? setting.value.autoCleanExclude.split(',').filter(Boolean)
: []
autoCleanShow.value = true
}
function openForwardRules() {
ruleType.value = setting.value.ruleType
ruleEmail.value = []
@@ -1394,29 +1223,6 @@ function forwardEmailSave() {
editSetting(form)
}
function webhookSave() {
let retry = Number(webhookRetry.value)
if (isNaN(retry) || retry < 0) {
retry = 0
}
const url = toOssDomain(webhookUrl.value.trim())
if (isIpUrl(url)) {
ElMessage({
message: t('webhookIpNotSupported'),
type: 'warning',
plain: true
})
return
}
const form = {
webhookStatus: webhookStatus.value,
webhookUrl: url,
webhookRetry: retry,
webhookSecret: webhookSecret.value.trim()
}
editSetting(form)
}
function ruleEmailSave() {
const form = {
@@ -1444,13 +1250,6 @@ function resetBlackList() {
blackListForm.value.blackSubject = setting.value.blackSubject ? setting.value.blackSubject.split(',') : []
}
function resetAutoClean() {
autoCleanDays.value = setting.value.autoCleanDays ?? 0
autoCleanExclude.value = setting.value.autoCleanExclude
? setting.value.autoCleanExclude.split(',').filter(Boolean)
: []
}
function resetAiCodeFilter() {
aiCodeFilter.value = setting.value.aiCodeFilter ? setting.value.aiCodeFilter.split(',') : []
}
@@ -1462,13 +1261,6 @@ function saveEmailPrefix() {
editSetting(form, true)
}
function saveAutoClean() {
editSetting({
autoCleanDays: autoCleanDays.value,
autoCleanExclude: autoCleanExclude.value.join(',')
}, true)
}
function saveAiCodeFilter() {
editSetting({aiCodeFilter: aiCodeFilter.value + ''})
}
@@ -1515,20 +1307,6 @@ function banEmailAddTag(val) {
})
}
function autoCleanExcludeAddTag(val) {
const emails = Array.from(new Set(
val.split(/[,]/).map(item => item.trim()).filter(item => item)
));
autoCleanExclude.value.splice(autoCleanExclude.value.length - 1, 1)
emails.forEach(email => {
if (isEmail(email) && !autoCleanExclude.value.includes(email)) {
autoCleanExclude.value.push(email)
}
})
}
function aiCodeFilterAddTag(val) {
const emails = Array.from(new Set(
val.split(/[,]/).map(item => item.trim()).filter(item => item)
@@ -1562,23 +1340,6 @@ function delBackground() {
})
}
function openOauthSetting(p) {
oauthForm.key = p.key
oauthForm.label = p.label
oauthForm.clientId = setting.value[p.key + 'ClientId'] || ''
oauthForm.clientSecret = setting.value[p.key + 'ClientSecret'] || ''
oauthForm.switch = setting.value[p.key + 'Switch'] ?? 1
oauthSettingShow.value = true
}
function saveOauth() {
const form = {}
form[oauthForm.key + 'ClientId'] = oauthForm.clientId
form[oauthForm.key + 'ClientSecret'] = oauthForm.clientSecret
form[oauthForm.key + 'Switch'] = oauthForm.switch
editSetting(form)
}
function saveTurnstileKey() {
const settingForm = {}
settingForm.siteKey = turnstileForm.siteKey
@@ -1588,8 +1349,6 @@ function saveTurnstileKey() {
async function saveBackground() {
if (settingLoading.value) return
let image = ''
if (localUpShow.value) {
@@ -1735,7 +1494,6 @@ function editSetting(settingForm, refreshStatus = true) {
turnstileShow.value = false
tgSettingShow.value = false
thirdEmailShow.value = false
webhookShow.value = false
forwardRulesShow.value = false
addVerifyCountShow.value = false
regVerifyCountShow.value = false
@@ -1743,8 +1501,6 @@ function editSetting(settingForm, refreshStatus = true) {
addS3Show.value = false
emailPrefixShow.value = false
aiCodeFilterShow.value = false
autoCleanShow.value = false
oauthSettingShow.value = false
}).catch((e) => {
loginOpacity.value = setting.value.loginOpacity
setting.value = {...setting.value, ...JSON.parse(backup)}
@@ -1803,11 +1559,14 @@ function editSetting(settingForm, refreshStatus = true) {
}
.card-grid {
display: flex;
flex-direction: column;
align-items: center;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
padding: 20px;
gap: 20px;
@media (max-width: 500px) {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
@media (max-width: 1023px) {
gap: 15px;
padding: 15px;
@@ -1837,19 +1596,17 @@ function editSetting(settingForm, refreshStatus = true) {
.settings-card {
background-color: var(--el-bg-color);
border-radius: 6px;
border-radius: 8px;
border: 1px solid var(--el-border-color);
transition: all 300ms;
overflow: hidden;
max-width: 900px;
width: 100%;
}
.card-title {
font-size: 15px;
font-weight: bold;
padding: 15px 20px 12px;
padding: 10px 20px;
border-bottom: 1px solid var(--el-border-color);
}
@@ -1880,14 +1637,6 @@ function editSetting(settingForm, refreshStatus = true) {
}
}
.oauth-icon {
width: 22px !important;
height: 22px !important;
min-width: 22px;
flex-shrink: 0;
margin-right: 2px;
}
.r2domain-item {
display: flex;
gap: 10px;
@@ -1930,29 +1679,6 @@ function editSetting(settingForm, refreshStatus = true) {
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.webhook-footer-right {
display: flex;
align-items: center;
gap: 12px;
}
.webhook-format-title {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
color: var(--el-color-primary);
font-size: 13px;
}
.webhook-format-icon {
transition: transform 0.2s;
&.open {
transform: rotate(180deg);
}
}
.notice-popup-item {
@@ -2111,19 +1837,6 @@ function editSetting(settingForm, refreshStatus = true) {
.el-select {
width: v-bind(tgMsgLabelWidth);
}
.el-input-number {
width: 120px;
}
}
.webhook-format pre {
margin: 8px 0 0;
padding: 10px;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
border-radius: 4px;
background: var(--el-fill-color-light);
}
}
+30 -68
View File
@@ -45,19 +45,9 @@
<el-table-column show-overflow-tooltip :tooltip-formatter="tableRowFormatter" :label="$t('tabEmailAddress')"
:min-width="emailWidth">
<template #default="props">
<div style="display: flex;gap: 5px;align-items: center">
<div style="display: flex;gap: 5px">
<div class="email-row">{{ props.row.email }}</div>
<template v-if="oauthPlatform(props.row)">
<el-avatar v-if="oauthPlatform(props.row).iconType === 'image'"
:src="oauthPlatform(props.row).icon"
:size="16"
class="oauth-platform-icon"/>
<Icon v-else
:icon="oauthPlatform(props.row).icon"
width="16"
height="16"
class="oauth-platform-icon"/>
</template>
<el-tag type="warning" v-if="props.row.username">L</el-tag>
</div>
</template>
</el-table-column>
@@ -145,7 +135,7 @@
</el-scrollbar>
<el-dialog class="dialog" v-model="setPwdShow" :title="$t('changePassword')" @closed="resetUserForm">
<div class="dialog-box">
<el-input v-model="userForm.password" type="password" :placeholder="$t('newPassword')" autocomplete="off" @keyup.enter="updatePwd">
<el-input v-model="userForm.password" type="password" :placeholder="$t('newPassword')" autocomplete="off">
</el-input>
<el-button class="btn" type="primary" :loading="settingLoading" @click="updatePwd"
>{{ $t('save') }}
@@ -165,24 +155,22 @@
</el-dialog>
<el-dialog v-model="showAdd" :title="$t('addUser')" @closed="resetAddForm">
<div class="container">
<el-input v-model="addForm.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off" @keyup.enter="submit">
<el-input v-model="addForm.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append>
<div @click.stop="openSelect">
<div inert>
<el-select
ref="mySelect"
v-model="addForm.suffix"
:placeholder="$t('select')"
class="select"
>
<el-option
v-for="item in domainList"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</div>
<el-select
ref="mySelect"
v-model="addForm.suffix"
:placeholder="$t('select')"
class="select"
>
<el-option
v-for="item in domainList"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<div>
<span>{{ addForm.suffix }}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
@@ -190,7 +178,7 @@
</div>
</template>
</el-input>
<el-input type="password" v-model="addForm.password" :placeholder="$t('password')" @keyup.enter="submit"/>
<el-input type="password" v-model="addForm.password" :placeholder="$t('password')"/>
<el-select v-model="addForm.type" :placeholder="$t('perm')">
<el-option v-for="item in roleList" :label="item.name" :value="item.roleId" :key="item.roleId"/>
</el-select>
@@ -239,12 +227,12 @@
</el-dialog>
<el-dialog class="account-dialog" v-model="detailsShow" :title="t('userDetails')" >
<div class="details">
<div v-if="userDetails.platform || userDetails.username" class="oauth-details">
<el-avatar :src="userDetails.avatar" :size="30" class="oauth-avatar"/>
<span>{{ $t('username') }}{{ userDetails.username }}</span>
<span v-if="userDetails.trustLevel != null">
{{ $t('trustLevel') }}<el-tag type="success">{{ userDetails.trustLevel }}</el-tag>
</span>
<div v-if="userDetails.username"><span class="details-item-title">LinuxDo:</span>
<el-avatar :src="userDetails.avatar" :size="30" class="linuxdo-avatar" />
<span style="margin: 0 10px">用户名{{userDetails.username}}</span>
<span>
等级<el-tag type="success">{{userDetails.trustLevel}}</el-tag>
</span>
</div>
<div v-if="!sendNumShow"><span
class="details-item-title">{{ $t('tabSent') }}:</span>{{ userDetails.sendEmailCount }}
@@ -408,19 +396,6 @@ const {t, locale} = useI18n();
const roleStore = useRoleStore()
const userStore = useUserStore()
const settingStore = useSettingStore()
const oauthPlatformMap = {
google: { key: 'google', label: 'Google', icon: 'devicon:google', iconType: 'iconify' },
github: { key: 'github', label: 'GitHub', icon: 'codicon:github-inverted', iconType: 'iconify' },
linuxdo: { key: 'linuxdo', label: 'LinuxDo', icon: '/image/linuxdo.webp', iconType: 'image' },
}
function oauthPlatform(row) {
if (row?.platform && oauthPlatformMap[row.platform]) {
return oauthPlatformMap[row.platform]
}
// 旧数据可能只有 username、无 platform
if (row?.username) return oauthPlatformMap.linuxdo
return null
}
const filteredValue = ['normal', 'del']
const filters = [{text: t('active'), value: 'normal'}, {text: t('deleted'), value: 'del'}]
const preserveExpanded = ref(false)
@@ -707,6 +682,8 @@ const openSelect = () => {
function resetAddForm() {
addForm.email = ''
addForm.suffix = settingStore.domainList[0]
addForm.type = null
addForm.password = ''
}
@@ -716,8 +693,6 @@ function openAdd() {
function submit() {
if (addLoading.value) return
if (!addForm.email) {
ElMessage({
message: t('emptyEmailMsg'),
@@ -768,7 +743,7 @@ function submit() {
form.email = form.email + form.suffix
userAdd(form).then(() => {
addLoading.value = false
showAdd.value = false
addForm.email = ''
ElMessage({
message: t('addSuccessMsg'),
type: "success",
@@ -920,7 +895,6 @@ function httpSetStatus(user) {
}
function setType() {
if (settingLoading.value) return
settingLoading.value = true
userSetType({type: userForm.type, userId: userForm.userId}).then(() => {
chooseUser.type = userForm.type
@@ -949,8 +923,6 @@ function search() {
function updatePwd() {
if (settingLoading.value) return
if (!userForm.password) {
ElMessage({
message: t('emptyPwdMsg'),
@@ -1175,19 +1147,9 @@ function adjustWidth() {
}
}
.oauth-details {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
:deep(.oauth-avatar) {
flex-shrink: 0;
}
:deep(.oauth-platform-icon) {
flex-shrink: 0;
:deep(.linuxdo-avatar) {
position: relative !important;
top: 10px;
}
.account-pagination {
+1
View File
@@ -16,6 +16,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.882.0",
"@cloudflare/vite-plugin": "1.6.0",
"@simplewebauthn/server": "^13.3.2",
"dayjs": "^1.11.13",
"drizzle-orm": "^0.42.0",
"hono": "4.12.16",
+223 -10
View File
@@ -14,6 +14,9 @@ importers:
'@cloudflare/vite-plugin':
specifier: 1.6.0
version: 1.6.0([email protected])([email protected](@types/[email protected]))([email protected])([email protected])
'@simplewebauthn/server':
specifier: ^13.3.2
version: 13.3.2
dayjs:
specifier: ^1.11.13
version: 1.11.18
@@ -44,10 +47,10 @@ importers:
devDependencies:
'@cloudflare/vitest-pool-workers':
specifier: ^0.7.5
version: 0.7.8(@vitest/[email protected])(@vitest/[email protected])([email protected](@types/[email protected]))
version: 0.7.8(@vitest/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected]))
vitest:
specifier: ~3.0.7
version: 3.0.9(@types/[email protected])
version: 3.0.9(@types/[email protected])([email protected])
wrangler:
specifier: ^4.90.0
version: 4.90.0
@@ -815,6 +818,9 @@ packages:
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
'@hexagon/[email protected]':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@img/[email protected]':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
@@ -1095,9 +1101,52 @@ packages:
'@jridgewell/[email protected]':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@levischuck/[email protected]':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
'@mjackson/[email protected]':
resolution: {integrity: sha512-9ZJnk/DJjt805uv5PPv11haJIW+HHf3YEEyVXv+8iLQxLD/iXA68FH220XoiTPBC4gCg5q+IMadDw8qPqlA5wg==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==}
'@peculiar/[email protected]':
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
'@poppinss/[email protected]':
resolution: {integrity: sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==}
@@ -1251,6 +1300,10 @@ packages:
'@selderee/[email protected]':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@simplewebauthn/[email protected]':
resolution: {integrity: sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==}
engines: {node: '>=20.0.0'}
'@sindresorhus/[email protected]':
resolution: {integrity: sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==}
engines: {node: '>=18'}
@@ -1640,6 +1693,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==}
[email protected]:
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -2158,6 +2215,13 @@ packages:
[email protected]:
resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
[email protected]:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
[email protected]:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
[email protected]:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
@@ -2173,6 +2237,9 @@ packages:
resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
[email protected]:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
@@ -2290,9 +2357,16 @@ packages:
[email protected]:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
[email protected]:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
[email protected]:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
[email protected]:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
[email protected]:
resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==}
@@ -2334,6 +2408,7 @@ packages:
[email protected]:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
[email protected]:
@@ -2342,6 +2417,7 @@ packages:
[email protected]:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
[email protected]:
@@ -3006,7 +3082,7 @@ snapshots:
- utf-8-validate
- workerd
'@cloudflare/[email protected](@vitest/[email protected])(@vitest/[email protected])([email protected](@types/[email protected]))':
'@cloudflare/[email protected](@vitest/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected]))':
dependencies:
'@vitest/runner': 3.0.9
'@vitest/snapshot': 3.0.9
@@ -3016,7 +3092,7 @@ snapshots:
esbuild: 0.17.19
miniflare: 3.20250310.0
semver: 7.7.2
vitest: 3.0.9(@types/[email protected])
vitest: 3.0.9(@types/[email protected])([email protected])
wrangler: 3.114.1
zod: 3.25.76
transitivePeerDependencies:
@@ -3317,6 +3393,8 @@ snapshots:
'@fastify/[email protected]': {}
'@hexagon/[email protected]': {}
'@img/[email protected]': {}
'@img/[email protected]':
@@ -3497,8 +3575,110 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@levischuck/[email protected]': {}
'@mjackson/[email protected]': {}
'@peculiar/[email protected]':
dependencies:
'@peculiar/asn1-schema': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
'@peculiar/asn1-x509-attr': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
'@peculiar/asn1-cms': 2.8.0
'@peculiar/asn1-pkcs8': 2.8.0
'@peculiar/asn1-rsa': 2.8.0
'@peculiar/asn1-schema': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
'@peculiar/asn1-cms': 2.8.0
'@peculiar/asn1-pfx': 2.8.0
'@peculiar/asn1-pkcs8': 2.8.0
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
'@peculiar/asn1-x509-attr': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
'@peculiar/utils': 2.0.3
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/utils': 2.0.3
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
tslib: 2.8.1
'@peculiar/[email protected]':
dependencies:
'@peculiar/asn1-cms': 2.8.0
'@peculiar/asn1-csr': 2.8.0
'@peculiar/asn1-ecc': 2.8.0
'@peculiar/asn1-pkcs9': 2.8.0
'@peculiar/asn1-rsa': 2.8.0
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
pvtsutils: 1.3.6
reflect-metadata: 0.2.2
tslib: 2.8.1
tsyringe: 4.10.0
'@poppinss/[email protected]':
dependencies:
kleur: 4.1.5
@@ -3604,6 +3784,17 @@ snapshots:
selderee: 0.11.0
optional: true
'@simplewebauthn/[email protected]':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.11
'@peculiar/asn1-android': 2.8.0
'@peculiar/asn1-ecc': 2.8.0
'@peculiar/asn1-rsa': 2.8.0
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
'@peculiar/x509': 1.14.3
'@sindresorhus/[email protected]': {}
'@smithy/[email protected]':
@@ -4168,6 +4359,12 @@ snapshots:
dependencies:
printable-characters: 1.0.42
[email protected]:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.5
tslib: 2.8.1
[email protected]: {}
[email protected]: {}
@@ -4241,9 +4438,11 @@ snapshots:
[email protected]: {}
[email protected]:
[email protected]([email protected]):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 10.2.0
[email protected]: {}
@@ -4641,6 +4840,12 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
tslib: 2.8.1
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
@@ -4657,6 +4862,8 @@ snapshots:
[email protected]:
optional: true
[email protected]: {}
[email protected]: {}
[email protected](@react-email/[email protected]([email protected]([email protected]))([email protected])):
@@ -4828,8 +5035,14 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
tslib: 1.14.1
[email protected]: {}
[email protected]:
@@ -4887,10 +5100,10 @@ snapshots:
[email protected]: {}
[email protected](@types/[email protected]):
[email protected](@types/[email protected])([email protected]):
dependencies:
cac: 6.7.14
debug: 4.4.1
debug: 4.4.1([email protected])
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.3.5(@types/[email protected])
@@ -4920,7 +5133,7 @@ snapshots:
'@types/node': 24.3.0
fsevents: 2.3.3
[email protected](@types/[email protected]):
[email protected](@types/[email protected])([email protected]):
dependencies:
'@vitest/expect': 3.0.9
'@vitest/mocker': 3.0.9([email protected](@types/[email protected]))
@@ -4930,7 +5143,7 @@ snapshots:
'@vitest/spy': 3.0.9
'@vitest/utils': 3.0.9
chai: 5.3.3
debug: 4.4.1
debug: 4.4.1([email protected])
expect-type: 1.2.2
magic-string: 0.30.18
pathe: 2.0.3
@@ -4940,7 +5153,7 @@ snapshots:
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 6.3.5(@types/[email protected])
vite-node: 3.0.9(@types/[email protected])
vite-node: 3.0.9(@types/[email protected])([email protected])
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.3.0
-6
View File
@@ -1,6 +0,0 @@
packages:
- '.'
allowBuilds:
esbuild: true
sharp: true
workerd: true
-10
View File
@@ -7,16 +7,6 @@ app.post('/oauth/linuxDo/login', async (c) => {
return c.json(result.ok(loginInfo))
});
app.post('/oauth/github/login', async (c) => {
const loginInfo = await oauthService.githubLogin(c, await c.req.json());
return c.json(result.ok(loginInfo))
});
app.post('/oauth/google/login', async (c) => {
const loginInfo = await oauthService.googleLogin(c, await c.req.json());
return c.json(result.ok(loginInfo))
});
app.put('/oauth/bindUser', async (c) => {
const loginInfo = await oauthService.bindUser(c, await c.req.json());
return c.json(result.ok(loginInfo))
+32
View File
@@ -0,0 +1,32 @@
import app from '../hono/hono';
import result from '../model/result';
import userContext from '../security/user-context';
import passkeyService from '../service/passkey-service';
app.post('/passkey/auth/options', async (c) => {
return c.json(result.ok(await passkeyService.authenticationOptions(c)));
});
app.post('/passkey/auth/verify', async (c) => {
const token = await passkeyService.verifyAuthentication(c, await c.req.json());
return c.json(result.ok({ token }));
});
app.get('/my/passkey', async (c) => {
return c.json(result.ok(await passkeyService.status(c, userContext.getUserId(c))));
});
app.post('/my/passkey/reg/options', async (c) => {
const data = await passkeyService.registrationOptions(c, userContext.getUserId(c), await c.req.json());
return c.json(result.ok(data));
});
app.post('/my/passkey/reg/verify', async (c) => {
const data = await passkeyService.verifyRegistration(c, userContext.getUserId(c), await c.req.json());
return c.json(result.ok(data));
});
app.delete('/my/passkey', async (c) => {
await passkeyService.delete(c, userContext.getUserId(c), await c.req.json());
return c.json(result.ok());
});
-8
View File
@@ -112,10 +112,6 @@ export const settingConst = {
OPEN: 0,
CLOSE: 1,
},
webhookStatus: {
OPEN: 0,
CLOSE: 1,
},
ruleType: {
ALL: 0,
RULE: 1
@@ -136,10 +132,6 @@ export const settingConst = {
OPEN: 0,
CLOSE: 1
},
syncDelete: {
OPEN: 0,
CLOSE: 1
},
authRefresh: {
OPEN: 1,
CLOSE: 0
+14 -24
View File
@@ -1,3 +1,5 @@
import { emailConst } from '../const/entity-const';
const analysisDao = {
async numberCount(c) {
const { results } = await c.env.db.prepare(`
@@ -24,7 +26,7 @@ const analysisDao = {
SUM(CASE WHEN type = 0 AND is_del = 0 THEN 1 ELSE 0 END) AS normalReceiveTotal,
SUM(CASE WHEN type = 1 AND is_del = 0 THEN 1 ELSE 0 END) AS normalSendTotal
FROM
email
email where status != ${emailConst.status.SAVING}
) e
CROSS JOIN (
SELECT
@@ -47,18 +49,16 @@ const analysisDao = {
},
async userDayCount(c, diffHours) {
const { tzMod, tzBack } = this.tzModifiers(diffHours);
const { results } = await c.env.db.prepare(`
SELECT
DATE(create_time, '${tzMod}') AS date,
DATE(create_time,'+${diffHours} hours') AS date,
COUNT(*) AS total
FROM
user
WHERE
create_time >= datetime('now', '${tzMod}', 'start of day', '-15 days', '${tzBack}')
AND create_time < datetime('now', '${tzMod}', 'start of day', '${tzBack}')
DATE(create_time,'+${diffHours} hours') BETWEEN DATE('now', '-15 days', '+${diffHours} hours') AND DATE('now','-1 day','+${diffHours} hours')
GROUP BY
DATE(create_time, '${tzMod}')
DATE(create_time,'+${diffHours} hours')
ORDER BY
date ASC
`).all();
@@ -66,19 +66,17 @@ const analysisDao = {
},
async receiveDayCount(c, diffHours) {
const { tzMod, tzBack } = this.tzModifiers(diffHours);
const { results } = await c.env.db.prepare(`
SELECT
DATE(create_time, '${tzMod}') AS date,
DATE(create_time,'+${diffHours} hours') AS date,
COUNT(*) AS total
FROM
email
WHERE
type = 0
AND create_time >= datetime('now', '${tzMod}', 'start of day', '-15 days', '${tzBack}')
AND create_time < datetime('now', '${tzMod}', 'start of day', '${tzBack}')
DATE(create_time,'+${diffHours} hours') BETWEEN DATE('now', '-15 days', '+${diffHours} hours') AND DATE('now','-1 day','+${diffHours} hours')
AND type = 0
GROUP BY
DATE(create_time, '${tzMod}')
DATE(create_time,'+${diffHours} hours')
ORDER BY
date ASC
`).all();
@@ -86,29 +84,21 @@ const analysisDao = {
},
async sendDayCount(c, diffHours) {
const { tzMod, tzBack } = this.tzModifiers(diffHours);
const { results } = await c.env.db.prepare(`
SELECT
DATE(create_time, '${tzMod}') AS date,
DATE(create_time,'+${diffHours} hours') AS date,
COUNT(*) AS total
FROM
email
WHERE
type = 1
AND create_time >= datetime('now', '${tzMod}', 'start of day', '-15 days', '${tzBack}')
AND create_time < datetime('now', '${tzMod}', 'start of day', '${tzBack}')
DATE(create_time,'+${diffHours} hours') BETWEEN DATE('now', '-15 days', '+${diffHours} hours') AND DATE('now','-1 day','+${diffHours} hours')
AND type = 1
GROUP BY
DATE(create_time, '${tzMod}')
DATE(create_time,'+${diffHours} hours')
ORDER BY
date ASC
`).all();
return results;
},
tzModifiers(diffHours) {
const tzMod = diffHours >= 0 ? `+${diffHours} hours` : `${diffHours} hours`;
const tzBack = (-diffHours) >= 0 ? `+${-diffHours} hours` : `${-diffHours} hours`;
return { tzMod, tzBack };
}
};
+1 -18
View File
@@ -11,7 +11,6 @@ import roleService from '../service/role-service';
import userService from '../service/user-service';
import telegramService from '../service/telegram-service';
import aiService from '../service/ai-service';
import webhookService from '../service/webhook-service';
export async function email(message, env, ctx) {
@@ -23,10 +22,6 @@ export async function email(message, env, ctx) {
tgBotStatus,
forwardStatus,
forwardEmail,
webhookStatus,
webhookUrl,
webhookRetry,
webhookSecret,
ruleEmail,
ruleType,
r2Domain,
@@ -62,14 +57,7 @@ export async function email(message, env, ctx) {
return;
}
let account = await accountService.selectByEmailIncludeDel({ env: env }, message.to);
if (!account) {
const baseEmail = emailUtils.getBaseEmail(message.to);
if (baseEmail && baseEmail !== message.to) {
account = await accountService.selectByEmailIncludeDel({ env: env }, baseEmail);
}
}
const account = await accountService.selectByEmailIncludeDel({ env: env }, message.to);
if (!account && noRecipient === settingConst.noRecipient.CLOSE) {
message.setReject('Recipient not found');
@@ -191,11 +179,6 @@ export async function email(message, env, ctx) {
}
//转发到 Webhook
if (webhookStatus === settingConst.webhookStatus.OPEN && webhookUrl) {
await webhookService.sendEmail({ env }, emailRow, webhookUrl, webhookRetry, webhookSecret);
}
} catch (e) {
console.error('邮件接收异常: ', e);
throw e
-1
View File
@@ -11,7 +11,6 @@ export const oauth = sqliteTable('oauth', {
trustLevel: integer('trust_level'),
silenced: integer('silenced'),
createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(),
platform: text('platform'),
userId: integer('user_id').default(0).notNull()
});
@@ -0,0 +1,15 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const passkeyCredential = sqliteTable('passkey_credential', {
passkeyId: integer('passkey_id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().unique(),
userHandle: text('user_handle').notNull().unique(),
credentialId: text('credential_id').notNull().unique(),
publicKey: text('public_key').notNull(),
counter: integer('counter').default(0).notNull(),
transports: text('transports').default('[]').notNull(),
createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(),
});
export default passkeyCredential;
+2 -17
View File
@@ -35,6 +35,7 @@ export const setting = sqliteTable('setting', {
notice: integer('notice').default(0).notNull(),
noRecipient: integer('no_recipient').default(1).notNull(),
loginDomain: integer('login_domain').default(0).notNull(),
passkey: integer('passkey').default(0).notNull(),
bucket: text('bucket').default('').notNull(),
region: text('region').default('').notNull(),
endpoint: text('endpoint').default('').notNull(),
@@ -51,22 +52,6 @@ export const setting = sqliteTable('setting', {
blackContent: text('black_content').default('').notNull(),
blackFrom: text('black_from').default('').notNull(),
aiCode: integer('ai_code').default(1).notNull(),
syncDelete: integer('sync_delete').default(1).notNull(),
aiCodeFilter: text('ai_code_filter').default('').notNull(),
linuxdoClientId: text('linuxdo_client_id').default('').notNull(),
linuxdoClientSecret: text('linuxdo_client_secret').default('').notNull(),
linuxdoSwitch: integer('linuxdo_switch').default(1).notNull(),
githubClientId: text('github_client_id').default('').notNull(),
githubClientSecret: text('github_client_secret').default('').notNull(),
githubSwitch: integer('github_switch').default(1).notNull(),
googleClientId: text('google_client_id').default('').notNull(),
googleClientSecret: text('google_client_secret').default('').notNull(),
googleSwitch: integer('google_switch').default(1).notNull(),
autoCleanDays: integer('auto_clean_days').default(0).notNull(),
autoCleanExclude: text('auto_clean_exclude').default('').notNull(),
webhookUrl: text('webhook_url').default('').notNull(),
webhookStatus: integer('webhook_status').default(1).notNull(),
webhookRetry: integer('webhook_retry').default(0).notNull(),
webhookSecret: text('webhook_secret').default('').notNull()
aiCodeFilter: text('ai_code_filter').default('').notNull()
});
export default setting
+3 -7
View File
@@ -14,19 +14,15 @@ app.onError((err, c) => {
}
if (err.message === `Cannot read properties of undefined (reading 'get')`) {
return c.json(result.fail('KV数据库未绑定<br/>KV database not bound',502));
return c.json(result.fail('KV数据库未绑定 KV database not bound',502));
}
if (err.message === `Cannot read properties of undefined (reading 'put')`) {
return c.json(result.fail('KV数据库未绑定<br/>KV database not bound',502));
return c.json(result.fail('KV数据库未绑定 KV database not bound',502));
}
if (err.message === `Cannot read properties of undefined (reading 'prepare')`) {
return c.json(result.fail('D1数据库未绑定<br/>D1 database not bound',502));
}
if (err.message?.includes('D1_ERROR: no such column')) {
return c.json(result.fail('请按照文档更新数据库<br/>Please update the database as documented',502));
return c.json(result.fail('D1数据库未绑定 D1 database not bound',502));
}
return c.json(result.fail(err.message, err.code));
+1
View File
@@ -20,4 +20,5 @@ import '../api/reg-key-api'
import '../api/public-api'
import '../api/telegram-api'
import '../api/oauth-api'
import '../api/passkey-api'
export default app;
+5 -2
View File
@@ -1,9 +1,13 @@
const en = {
passkeyDisabled: 'Passkey sign-in is disabled',
passkeyChallengeInvalid: 'The Passkey request expired. Please try again',
passkeyVerifyFailed: 'Passkey verification failed',
passkeyNotFound: 'No matching Passkey was found',
passkeyOriginInvalid: 'Passkeys are unavailable on this site',
IncorrectPwd: 'Incorrect password',
addAccountDisabled: 'Add Email Address feature is disabled',
regDisabled: 'Sign up is disabled',
emptyEmail: 'Email cannot be empty',
emptyAccountId: 'accountId cannot be empty',
notEmail: 'Invalid email',
notExistDomain: 'Email domain does not exist',
isDelAccount: 'This Email has been deleted',
@@ -16,7 +20,6 @@ const en = {
noOsDomainSendAtt: 'Cannot send attachments: object storage domain not configured',
noOsSendAtt: 'Cannot send attachments: object storage not configured',
disabledSend: 'Email sending feature is disabled',
oauthDisabled: 'This OAuth login is not enabled',
daySendLimit: 'Daily send limit reached',
totalSendLimit: 'Total send limit reached',
daySendLack: 'Not enough remaining sends today',
+5 -2
View File
@@ -1,9 +1,13 @@
const zh = {
passkeyDisabled: 'Passkey 登录未启用',
passkeyChallengeInvalid: 'Passkey 请求已过期,请重试',
passkeyVerifyFailed: 'Passkey 验证失败',
passkeyNotFound: '未找到可用的 Passkey',
passkeyOriginInvalid: '当前站点无法使用 Passkey',
IncorrectPwd: '密码输入错误',
addAccountDisabled: '添加邮箱功能已关闭',
regDisabled: '注册功能已关闭',
emptyEmail: '邮箱不能为空',
emptyAccountId: 'accountId不能为空',
notEmail: '非法邮箱',
notExistDomain: '不存在的邮箱域名',
isDelAccount: '该邮箱已被注销',
@@ -16,7 +20,6 @@ const zh = {
noOsDomainSendAtt: '对象存储域名未配置不能发送附件',
noOsSendAtt: '对象存储未配置不能发送附件',
disabledSend: '邮件发送功能已停用',
oauthDisabled: '该 OAuth 登录未启用',
daySendLimit: '发送次数已到达每日限制',
totalSendLimit: '发送次数已到达限制',
daySendLack: '当日剩余发送次数不足',
+2 -3
View File
@@ -4,7 +4,7 @@ import userService from './service/user-service';
import verifyRecordService from './service/verify-record-service';
import emailService from './service/email-service';
import kvObjService from './service/kv-obj-service';
import oauthService from './service/oauth-service';
import oauthService from "./service/oauth-service";
import analysisService from './service/analysis-service';
export default {
async fetch(req, env, ctx) {
@@ -33,8 +33,7 @@ export default {
await verifyRecordService.clearRecord({ env })
await userService.resetDaySendCount({ env })
await emailService.completeReceiveAll({ env })
await emailService.autoClean({ env })
await analysisService.refreshEchartsCache({ env })
await oauthService.clearNoBindOathUser({ env })
await analysisService.refreshEchartsCache({ env })
},
};
+29 -73
View File
@@ -30,89 +30,45 @@ const dbInit = {
await this.v2_9DB(c);
await this.v3_0DB(c);
await this.v3_1DB(c);
await this.v3_2DB(c);
await this.v3_3DB(c);
await settingService.refresh(c);
return c.text('success');
},
async v3_3DB(c) {
try {
await c.env.db.batch([
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN auto_clean_days INTEGER NOT NULL DEFAULT 0;`),
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN auto_clean_exclude TEXT NOT NULL DEFAULT '';`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_create_time ON email(create_time)`)
]);
} catch (e) {
console.warn(`跳过字段:${e.message}`);
}
try {
await c.env.db.batch([
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN webhook_url TEXT NOT NULL DEFAULT '';`),
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN webhook_status INTEGER NOT NULL DEFAULT 1;`),
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN webhook_retry INTEGER NOT NULL DEFAULT 0;`),
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT '';`)
]);
} catch (e) {
console.warn(`跳过字段:${e.message}`);
}
},
async v3_2DB(c) {
try {
await c.env.db.batch([
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN linuxdo_client_id TEXT NOT NULL DEFAULT '';`),
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN linuxdo_client_secret TEXT NOT NULL DEFAULT '';`),
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN github_client_id TEXT NOT NULL DEFAULT '';`),
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN github_client_secret TEXT NOT NULL DEFAULT '';`),
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN google_client_id TEXT NOT NULL DEFAULT '';`),
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN google_client_secret TEXT NOT NULL DEFAULT '';`),
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN linuxdo_switch INTEGER NOT NULL DEFAULT 1;`),
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN github_switch INTEGER NOT NULL DEFAULT 1;`),
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN google_switch INTEGER NOT NULL DEFAULT 1;`)
]);
} catch (e) {
console.warn(`跳过字段:${e.message}`);
}
try {
await c.env.db.batch([
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_list_user ON email(user_id, type, is_del, email_id)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_list_account ON email(user_id, account_id, type, is_del, email_id)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_star_user_email ON star(user_id, email_id)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_star_email_user ON star(email_id, user_id)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_name_nocase ON email(name COLLATE NOCASE)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_subject_nocase ON email(subject COLLATE NOCASE)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_user_email_nocase ON user(email COLLATE NOCASE)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_to_email_nocase ON email(to_email COLLATE NOCASE)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_send_email_nocase ON email(send_email COLLATE NOCASE)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_noone_id ON email(email_id) WHERE status = 7`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_type_id ON email(type, email_id)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_account_user_del_sort ON account(user_id, is_del, sort, account_id)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_saving_account ON email(account_id) WHERE status = 6`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_type_name ON email(type, name)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_email_type_create_time ON email(type, create_time)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_user_create_time ON user(create_time)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_user_type ON user(type)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_attachments_email_type ON attachments(email_id, type)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_role_perm_role ON role_perm(role_id)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_oauth_oauth_user_id ON oauth(oauth_user_id)`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_oauth_user_id ON oauth(user_id)`)
]);
} catch (e) {
console.warn(`跳过索引:${e.message}`);
}
},
async v3_1DB(c) {
try {
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN sync_delete INTEGER NOT NULL DEFAULT 0;`).run();
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN passkey INTEGER NOT NULL DEFAULT 0;`).run();
} catch (e) {
console.warn(`跳过字段:${e.message}`);
}
},
await c.env.db.batch([
c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS passkey_credential (
passkey_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
user_handle TEXT NOT NULL UNIQUE,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL DEFAULT '[]',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`),
c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS passkey_challenge (
challenge_id TEXT PRIMARY KEY,
type TEXT NOT NULL,
challenge TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
user_handle TEXT NOT NULL DEFAULT '',
origin TEXT NOT NULL,
rp_id TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`),
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_passkey_challenge_expires ON passkey_challenge(expires_at)`),
]);
},
async v3_0DB(c) {
try {
-38
View File
@@ -1,38 +0,0 @@
import { getTableColumns, sql } from 'drizzle-orm';
import email from '../entity/email';
export const EMAIL_LIST_TEXT_LEN = 300;
/** 去掉换行/回车/制表符,并压缩连续空格、标签间空白 */
function sqlStripWhitespace(column) {
return sql`trim(replace(replace(replace(replace(replace(replace(
coalesce(${column}, ''),
char(13), ''),
char(10), ''),
char(9), ' '),
' ', ' '),
' ', ' '),
'> <', '><'))`;
}
/** 完整查询:全部字段 */
export const emailListColumns = getTableColumns(email);
/** 摘要查询:列表 + 详情头部;有 text 则不读 content,没有才查 content(去空白),响应里不返回 content */
export const emailBriefColumns = {
emailId: email.emailId,
sendEmail: email.sendEmail,
name: email.name,
subject: email.subject,
code: email.code,
recipient: email.recipient,
toEmail: email.toEmail,
type: email.type,
status: email.status,
message: email.message,
unread: email.unread,
createTime: email.createTime,
isDel: email.isDel,
content: sql`CASE WHEN trim(coalesce(${email.text}, '')) != '' THEN NULL ELSE ${sqlStripWhitespace(email.content)} END`.as('content'),
text: sql`substr(coalesce(${email.text}, ''), 1, ${EMAIL_LIST_TEXT_LEN})`.as('text'),
};
+2 -1
View File
@@ -18,7 +18,8 @@ const exclude = [
'/public/genToken',
'/telegram',
'/test',
'/oauth'
'/oauth',
'/passkey/auth/'
];
const requirePerms = [
@@ -57,14 +57,6 @@ const accountService = {
throw new BizError(t('isRegAccount'));
}
if (email.includes('+')) {
const baseEmail = emailUtils.getBaseEmail(email);
const baseAccount = await this.selectByEmailIncludeDel(c, baseEmail);
if (!baseAccount || baseAccount.userId !== userId) {
throw new BizError(t('notOwner'));
}
}
const userRow = await userService.selectById(c, userId);
const roleRow = await roleService.selectById(c, userRow.type);
@@ -163,12 +155,6 @@ const accountService = {
throw new BizError(t('noUserAccount'));
}
const { syncDelete } = await settingService.query(c);
if (syncDelete === settingConst.syncDelete.OPEN) {
await this.physicsDelete(c, { accountId });
return;
}
await orm(c).update(account).set({ isDel: isDel.DELETE }).where(
and(eq(account.userId, userId),
eq(account.accountId, accountId)))
+2 -2
View File
@@ -19,7 +19,7 @@ const aiService = {
return '';
}
const result = await ai.run(c.env.ai_model || '@cf/meta/llama-3.1-8b-instruct-fast', {
const result = await ai.run(c.env.ai_model || '@cf/meta/llama-3.1-8b-instruct', {
messages: [
{
role: 'system',
@@ -35,7 +35,7 @@ const aiService = {
});
const content = typeof result === 'string' ? result : result?.response || '';
const json = typeof content === 'string' ? JSON.parse(content) : content;
const json = JSON.parse(content);
if (typeof json.code !== 'string') {
return '';
}
+1 -5
View File
@@ -241,11 +241,7 @@ const attService = {
const delKeyList = attListResult.flatMap(r => r.results ? r.results.map(row => row.key) : []);
if (delKeyList.length > 0) {
try {
await this.batchDelete(c, delKeyList);
} catch (e) {
console.error('删除附件文件失败:', e);
}
await this.batchDelete(c, delKeyList);
}
},
+126 -275
View File
@@ -1,8 +1,7 @@
import orm from '../entity/orm';
import email from '../entity/email';
import { emailListColumns, emailBriefColumns, EMAIL_LIST_TEXT_LEN } from '../lib/email-list-columns';
import { attConst, emailConst, isDel, settingConst } from '../const/entity-const';
import { and, desc, eq, gt, inArray, notInArray, lt, count, asc, sql, ne, or, like, lte, gte } from 'drizzle-orm';
import { and, desc, eq, gt, inArray, lt, count, asc, sql, ne, or, like, lte, gte } from 'drizzle-orm';
import { star } from '../entity/star';
import settingService from './setting-service';
import accountService from './account-service';
@@ -28,50 +27,36 @@ const emailService = {
async list(c, params, userId) {
let { emailId, type, accountId, size, timeSort, allReceive, full } = params;
let { emailId, type, accountId, size, timeSort, allReceive } = params;
size = Number(size);
type = Number(type);
emailId = Number(emailId) || 0;
emailId = Number(emailId);
timeSort = Number(timeSort);
accountId = Number(accountId);
allReceive = Number(allReceive);
full = Number(full);
if (isNaN(type)) {
type = 0;
}
if (isNaN(accountId)) {
throw new BizError(t('emptyAccountId'));
}
if (isNaN(size)) {
size = 10;
}
if (isNaN(full)) {
full = 1;
}
full = full === 1;
if (size > 50) {
size = 50;
}
if (!emailId) {
if (timeSort) {
emailId = 0;
} else {
emailId = 9999999999;
}
}
if (isNaN(allReceive)) {
let accountRow = await accountService.selectById(c, accountId);
allReceive = accountRow.allReceive;
}
const filters = this.emailListFilters({ userId, accountId, type, allReceive, emailId, timeSort });
const countFilters = this.emailListFilters({ userId, accountId, type, allReceive, withCursor: false });
const columns = full ? emailListColumns : emailBriefColumns;
const query = orm(c)
.select({
...columns,
...email,
starId: star.starId
})
.from(email)
@@ -81,12 +66,20 @@ const emailService = {
eq(star.emailId, email.emailId),
eq(star.userId, userId)
)
)
.innerJoin(
).leftJoin(
account,
eq(account.accountId, email.accountId)
)
.where(and(...filters));
.where(
and(
allReceive ? eq(1,1) : eq(email.accountId, accountId),
eq(email.userId, userId),
timeSort ? gt(email.emailId, emailId) : lt(email.emailId, emailId),
eq(email.type, type),
eq(email.isDel, isDel.NORMAL),
eq(account.isDel, isDel.NORMAL)
)
);
if (timeSort) {
query.orderBy(asc(email.emailId));
@@ -97,23 +90,26 @@ const emailService = {
const listQuery = query.limit(size).all();
const totalQuery = orm(c).select({ total: count() }).from(email)
.innerJoin(
.leftJoin(
account,
eq(account.accountId, email.accountId)
)
.where(and(...countFilters))
.get();
.where(
and(
allReceive ? eq(1,1) : eq(email.accountId, accountId),
eq(email.userId, userId),
eq(email.type, type),
eq(email.isDel, isDel.NORMAL),
eq(account.isDel, isDel.NORMAL)
)
).get();
const latestEmailQuery = orm(c).select({
emailId: email.emailId,
accountId: email.accountId,
userId: email.userId,
}).from(email).where(
const latestEmailQuery = orm(c).select().from(email).where(
and(
allReceive ? eq(1,1) : eq(email.accountId, accountId),
eq(email.userId, userId),
eq(email.type, type),
eq(email.isDel, isDel.NORMAL),
allReceive ? undefined : eq(email.accountId, accountId)
eq(email.isDel, isDel.NORMAL)
))
.orderBy(desc(email.emailId)).limit(1).get();
@@ -124,11 +120,8 @@ const emailService = {
isStar: item.starId != null ? 1 : 0
}));
if (full) {
await this.emailAddAtt(c, list);
} else {
this.applyListText(list);
}
await this.emailAddAtt(c, list);
if (!latestEmail) {
latestEmail = {
@@ -141,98 +134,9 @@ const emailService = {
return { list, total: totalRow.total, latestEmail };
},
toListText(item) {
const raw = emailUtils.formatText(item.text) || emailUtils.htmlToText(item.content);
return raw.replace(/\s+/g, ' ').trim().slice(0, EMAIL_LIST_TEXT_LEN);
},
applyListText(list) {
for (const item of list) {
item.text = this.toListText(item);
delete item.content;
}
return list;
},
emailListFilters({ userId, accountId, type, allReceive, emailId, timeSort, withCursor = true }) {
const conditions = [
eq(email.userId, userId),
eq(email.type, type),
eq(email.isDel, isDel.NORMAL),
eq(account.isDel, isDel.NORMAL),
];
if (!allReceive) {
conditions.push(eq(email.accountId, accountId));
}
if (withCursor && emailId) {
conditions.push(timeSort ? gt(email.emailId, emailId) : lt(email.emailId, emailId));
}
return conditions;
},
allEmailListFilters({ emailId, name, subject, accountEmail, userEmail, type, timeSort, withCursor = true }) {
const conditions = [];
if (type === 'send') {
conditions.push(eq(email.type, emailConst.type.SEND));
}
if (type === 'receive') {
conditions.push(eq(email.type, emailConst.type.RECEIVE));
}
if (type === 'delete') {
conditions.push(eq(email.isDel, isDel.DELETE));
}
if (type === 'noone') {
conditions.push(eq(email.status, emailConst.status.NOONE));
}
if (userEmail) {
conditions.push(sql`${user.email} COLLATE NOCASE LIKE ${userEmail + '%'}`);
}
if (accountEmail) {
conditions.push(
or(
sql`${email.toEmail} COLLATE NOCASE LIKE ${accountEmail + '%'}`,
sql`${email.sendEmail} COLLATE NOCASE LIKE ${accountEmail + '%'}`,
)
);
}
if (name) {
conditions.push(sql`${email.name} COLLATE NOCASE LIKE ${name + '%'}`);
}
if (subject) {
conditions.push(sql`${email.subject} COLLATE NOCASE LIKE ${subject + '%'}`);
}
if (withCursor && emailId) {
conditions.push(timeSort ? gt(email.emailId, emailId) : lt(email.emailId, emailId));
}
return conditions;
},
async delete(c, params, userId) {
const { emailIds } = params;
const emailIdList = emailIds.split(',').map(Number);
const { syncDelete } = await settingService.query(c);
if (syncDelete === settingConst.syncDelete.OPEN) {
const owned = await orm(c).select({ emailId: email.emailId }).from(email)
.where(and(eq(email.userId, userId), inArray(email.emailId, emailIdList)))
.all();
const ownedIds = owned.map(row => row.emailId);
if (ownedIds.length) {
await this.physicsDelete(c, { emailIds: ownedIds.join(',') });
}
return;
}
await orm(c).update(email).set({ isDel: isDel.DELETE }).where(
and(
eq(email.userId, userId),
@@ -644,29 +548,8 @@ const emailService = {
//查询所有收件人账号信息
let accountList = await orm(c).select().from(account).where(inArray(account.email, receiveEmail)).all();
// 对于含+未精确匹配的收件人,获取基础地址账号
const plusEmails = receiveEmail.filter(
e => e.includes('+') && !accountList.some(a => a.email === e)
);
const baseAccounts = [];
if (plusEmails.length > 0) {
const baseEmails = [...new Set(
plusEmails.map(e => emailUtils.getBaseEmail(e)).filter(Boolean)
)];
const existing = new Set(accountList.map(a => a.email));
const needed = baseEmails.filter(e => !existing.has(e));
if (needed.length > 0) {
const rows = await orm(c).select().from(account)
.where(inArray(account.email, needed)).all();
baseAccounts.push(...rows);
}
}
// 合并精确匹配和基础地址匹配的账号用于权限查询
const allAccounts = [...accountList, ...baseAccounts];
//查询所有收件人权限身份
const userIds = allAccounts.map(accountRow => accountRow.userId);
const userIds = accountList.map(accountRow => accountRow.userId);
let roleList = await roleService.selectByUserIds(c, userIds);
//封装数据库准备保存到数据库
@@ -682,13 +565,7 @@ const emailService = {
emailValues.toName = emailUtils.getName(email);
emailValues.emailId = null;
let accountRow = allAccounts.find(accountRow => accountRow.email === email);
// 精确匹配不到时回退到主地址(去掉 +tag)
if (!accountRow && email.includes('+')) {
const baseEmail = emailUtils.getBaseEmail(email);
accountRow = allAccounts.find(accountRow => accountRow.email === baseEmail);
}
const accountRow = accountList.find(accountRow => accountRow.email === email);
//如果收件人存在就把邮件信息改成收件人的
if (accountRow) {
@@ -832,8 +709,8 @@ const emailService = {
allReceive = accountRow.allReceive;
}
let list = await orm(c).select({ ...emailBriefColumns }).from(email)
.innerJoin(
let list = await orm(c).select({...email}).from(email)
.leftJoin(
account,
eq(account.accountId, email.accountId)
)
@@ -843,13 +720,15 @@ const emailService = {
eq(email.userId, userId),
eq(email.isDel, isDel.NORMAL),
eq(account.isDel, isDel.NORMAL),
allReceive ? undefined : eq(email.accountId, accountId),
allReceive ? eq(1,1) : eq(email.accountId, accountId),
eq(email.type, emailConst.type.RECEIVE)
))
.orderBy(desc(email.emailId))
.limit(20);
return this.applyListText(list);
await this.emailAddAtt(c, list);
return list;
},
async physicsDelete(c, params) {
@@ -892,49 +771,85 @@ const emailService = {
async allList(c, params) {
let { emailId, size, name, subject, accountEmail, userEmail, type, timeSort, full } = params;
let { emailId, size, name, subject, accountEmail, userEmail, type, timeSort } = params;
size = Number(size);
emailId = Number(emailId) || 0;
emailId = Number(emailId);
timeSort = Number(timeSort);
full = Number(full);
if (type === undefined) {
type = 'receive';
}
if (isNaN(size)) {
size = 10;
}
if (size > 50) {
size = 50;
}
if (isNaN(full)) {
full = 1;
if (!emailId) {
if (timeSort) {
emailId = 0;
} else {
emailId = 9999999999;
}
}
full = full === 1;
const conditions = [];
const filters = this.allEmailListFilters({ emailId, name, subject, accountEmail, userEmail, type, timeSort });
const countFilters = this.allEmailListFilters({ emailId, name, subject, accountEmail, userEmail, type, timeSort, withCursor: false });
const columns = full ? emailListColumns : emailBriefColumns;
if (type === 'send') {
conditions.push(eq(email.type, emailConst.type.SEND));
}
const query = orm(c).select({ ...columns, userEmail: user.email })
if (type === 'receive') {
conditions.push(eq(email.type, emailConst.type.RECEIVE));
}
if (type === 'delete') {
conditions.push(eq(email.isDel, isDel.DELETE));
}
if (type === 'noone') {
conditions.push(eq(email.status, emailConst.status.NOONE));
}
if (userEmail) {
conditions.push(sql`${user.email} COLLATE NOCASE LIKE ${'%'+ userEmail + '%'}`);
}
if (accountEmail) {
conditions.push(
or(
sql`${email.toEmail} COLLATE NOCASE LIKE ${'%'+ accountEmail + '%'}`,
sql`${email.sendEmail} COLLATE NOCASE LIKE ${'%'+ accountEmail + '%'}`,
)
)
}
if (name) {
conditions.push(sql`${email.name} COLLATE NOCASE LIKE ${'%'+ name + '%'}`);
}
if (subject) {
conditions.push(sql`${email.subject} COLLATE NOCASE LIKE ${'%'+ subject + '%'}`);
}
conditions.push(ne(email.status, emailConst.status.SAVING));
const countConditions = [...conditions];
if (timeSort) {
conditions.unshift(gt(email.emailId, emailId));
} else {
conditions.unshift(lt(email.emailId, emailId));
}
const query = orm(c).select({ ...email, userEmail: user.email })
.from(email)
.leftJoin(user, eq(email.userId, user.userId))
.where(and(...filters));
.where(and(...conditions));
// count 不搜用户时无需 join user
const queryCount = userEmail
? orm(c).select({ total: count() })
.from(email)
.leftJoin(user, eq(email.userId, user.userId))
.where(and(...countFilters))
: orm(c).select({ total: count() })
.from(email)
.where(and(...countFilters));
const queryCount = orm(c).select({ total: count() })
.from(email)
.leftJoin(user, eq(email.userId, user.userId))
.where(and(...countConditions));
if (timeSort) {
query.orderBy(asc(email.emailId));
@@ -944,21 +859,16 @@ const emailService = {
const listQuery = query.limit(size).all();
const totalQuery = queryCount.get();
const latestEmailQuery = orm(c).select({
emailId: email.emailId,
accountId: email.accountId,
userId: email.userId,
}).from(email)
.where(eq(email.type, emailConst.type.RECEIVE))
const latestEmailQuery = orm(c).select().from(email)
.where(and(
eq(email.type, emailConst.type.RECEIVE),
ne(email.status, emailConst.status.SAVING)
))
.orderBy(desc(email.emailId)).limit(1).get();
let [list, totalRow, latestEmail] = await Promise.all([listQuery, totalQuery, latestEmailQuery]);
if (full) {
await this.emailAddAtt(c, list);
} else {
this.applyListText(list);
}
await this.emailAddAtt(c, list);
if (!latestEmail) {
latestEmail = {
@@ -975,17 +885,20 @@ const emailService = {
const { emailId } = params;
let list = await orm(c).select({ ...emailBriefColumns, userEmail: user.email }).from(email)
let list = await orm(c).select({...email, userEmail: user.email}).from(email)
.leftJoin(user, eq(email.userId, user.userId))
.where(
and(
gt(email.emailId, emailId),
eq(email.type, emailConst.type.RECEIVE)
eq(email.type, emailConst.type.RECEIVE),
ne(email.status, emailConst.status.SAVING)
))
.orderBy(desc(email.emailId))
.limit(20);
return this.applyListText(list);
await this.emailAddAtt(c, list);
return list;
},
async emailAddAtt(c, list) {
@@ -1015,70 +928,8 @@ const emailService = {
},
async completeReceiveAll(c) {
// 用 EXISTS 走 status=6 部分索引 + account 主键;避免 IN (SELECT account_id FROM account) 触发全盘扫描
await c.env.db.prepare(
`UPDATE email
SET status = ${emailConst.status.RECEIVE}
WHERE status = ${emailConst.status.SAVING}
AND EXISTS (SELECT 1 FROM account WHERE account.account_id = email.account_id)`
).run();
await c.env.db.prepare(
`UPDATE email
SET status = ${emailConst.status.NOONE}
WHERE status = ${emailConst.status.SAVING}`
).run();
},
async autoClean(c) {
const { autoCleanDays, autoCleanExclude } = await settingService.query(c);
const days = Number(autoCleanDays);
if (!days || days <= 0) {
return;
}
const cutoff = dayjs().subtract(days, 'day').format('YYYY-MM-DD HH:mm:ss');
const excludeEmails = String(autoCleanExclude || '')
.split(/[,]/)
.map(item => item.trim())
.filter(Boolean);
let excludeUserIds = [];
if (excludeEmails.length) {
const rows = await orm(c)
.select({ userId: user.userId })
.from(user)
.where(sql`lower(${user.email}) IN (${sql.join(excludeEmails.map(email => sql`${email.toLowerCase()}`), sql`, `)})`)
.all();
excludeUserIds = rows.map(row => row.userId);
}
const batchSize = 95;
while (true) {
const conditions = [lt(email.createTime, cutoff)];
if (excludeUserIds.length) {
conditions.push(notInArray(email.userId, excludeUserIds));
}
const rows = await orm(c)
.select({ emailId: email.emailId })
.from(email)
.where(and(...conditions))
.limit(batchSize)
.all();
if (!rows.length) {
break;
}
const emailIds = rows.map(row => row.emailId);
await this.physicsDelete(c, { emailIds: emailIds.join(',') });
if (rows.length < batchSize) {
break;
}
}
await c.env.db.prepare(`UPDATE email as e SET status = ${emailConst.status.RECEIVE} WHERE status = ${emailConst.status.SAVING} AND EXISTS (SELECT 1 FROM account WHERE account_id = e.account_id)`).run();
await c.env.db.prepare(`UPDATE email as e SET status = ${emailConst.status.NOONE} WHERE status = ${emailConst.status.SAVING} AND NOT EXISTS (SELECT 1 FROM account WHERE account_id = e.account_id)`).run();
},
async batchDelete(c, params) {
+12 -4
View File
@@ -213,6 +213,18 @@ const loginService = {
throw new BizError(t('notExistUser'));
}
if (!await cryptoUtils.verifyPassword(password, userRow.salt, userRow.password) && !noVerifyPwd) {
throw new BizError(t('IncorrectPwd'));
}
return this.loginUser(c, userRow);
},
async loginUser(c, userRow) {
if (!userRow) {
throw new BizError(t('notExistUser'));
}
if(userRow.isDel === isDel.DELETE) {
throw new BizError(t('isDelUser'));
}
@@ -221,10 +233,6 @@ const loginService = {
throw new BizError(t('isBanUser'));
}
if (!await cryptoUtils.verifyPassword(password, userRow.salt, userRow.password) && !noVerifyPwd) {
throw new BizError(t('IncorrectPwd'));
}
const uuid = uuidv4();
const jwt = await JwtUtils.generateToken(c,{ userId: userRow.userId, token: uuid });
+11 -124
View File
@@ -5,8 +5,6 @@ import { eq, inArray } from 'drizzle-orm';
import userService from "./user-service";
import loginService from "./login-service";
import cryptoUtils from "../utils/crypto-utils";
import settingService from "./setting-service";
import {t} from '../i18n/i18n';
const oauthService = {
@@ -34,16 +32,16 @@ const oauthService = {
async linuxDoLogin(c, params) {
const { code, redirectUri } = params;
const { code } = params;
const setting = await settingService.query(c);
this.assertEnabled(setting, 'linuxdoSwitch');
let token = '';
let userInfo = {}
const reqParams = new URLSearchParams()
reqParams.append('client_id', setting.linuxdoClientId)
reqParams.append('client_secret', setting.linuxdoClientSecret)
reqParams.append('client_id', c.env.linuxdo_client_id)
reqParams.append('client_secret', c.env.linuxdo_client_secret)
reqParams.append('code', code)
reqParams.append('redirect_uri', redirectUri)
reqParams.append('redirect_uri', c.env.linuxdo_callback_url)
reqParams.append('grant_type', 'authorization_code')
const tokenRes = await fetch("https://connect.linux.do/oauth2/token", {
@@ -56,7 +54,7 @@ const oauthService = {
throw new BizError(tokenRes.statusText)
}
const token = await tokenRes.json()
token = await tokenRes.json()
const userRes = await fetch('https://connect.linux.do/api/user', {
headers: {
@@ -68,128 +66,23 @@ const oauthService = {
throw new BizError(userRes.statusText)
}
const userInfo = await userRes.json();
userInfo = await userRes.json();
userInfo.oauthUserId = String(userInfo.id);
userInfo.active = userInfo.active ? 0 : 1;
userInfo.silenced = userInfo.silenced ? 0 : 1;
userInfo.trustLevel = userInfo.trust_level;
userInfo.avatar = userInfo.avatar_url;
userInfo.platform = 'linuxdo';
return await this.saveAndLogin(c, userInfo)
},
async githubLogin(c, params) {
const { code, redirectUri } = params;
const setting = await settingService.query(c);
this.assertEnabled(setting, 'githubSwitch');
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
client_id: setting.githubClientId,
client_secret: setting.githubClientSecret,
code: code,
redirect_uri: redirectUri
})
});
if (!tokenRes.ok) {
throw new BizError(tokenRes.statusText);
}
const token = await tokenRes.json();
if (token.error) {
throw new BizError(token.error_description || token.error);
}
const userRes = await fetch('https://api.github.com/user', {
headers: {
Authorization: 'Bearer ' + token.access_token,
'User-Agent': 'cloud-mail'
}
});
if (!userRes.ok) {
throw new BizError(userRes.statusText);
}
const userInfo = await userRes.json();
userInfo.oauthUserId = String(userInfo.id);
userInfo.username = userInfo.login;
userInfo.avatar = userInfo.avatar_url;
userInfo.platform = 'github';
return await this.saveAndLogin(c, userInfo);
},
async googleLogin(c, params) {
const { code, redirectUri } = params;
const setting = await settingService.query(c);
this.assertEnabled(setting, 'googleSwitch');
const reqParams = new URLSearchParams()
reqParams.append('client_id', setting.googleClientId)
reqParams.append('client_secret', setting.googleClientSecret)
reqParams.append('code', code)
reqParams.append('redirect_uri', redirectUri)
reqParams.append('grant_type', 'authorization_code')
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: reqParams.toString()
});
if (!tokenRes.ok) {
throw new BizError(tokenRes.statusText);
}
const token = await tokenRes.json();
const userRes = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: {
Authorization: 'Bearer ' + token.access_token
}
});
if (!userRes.ok) {
throw new BizError(userRes.statusText);
}
const userInfo = await userRes.json();
userInfo.oauthUserId = String(userInfo.sub);
userInfo.username = userInfo.email;
userInfo.name = userInfo.name;
userInfo.avatar = userInfo.picture;
userInfo.platform = 'google';
return await this.saveAndLogin(c, userInfo);
},
async saveAndLogin(c, userInfo) {
const oauthRow = await this.saveUser(c, userInfo);
const oauthRow = await this.saveUser(c, userInfo);
const userRow = await userService.selectByIdIncludeDel(c, oauthRow.userId);
if (!userRow) {
return { userInfo: oauthRow, token: null };
return { userInfo: oauthRow, token: null }
}
const JwtToken = await loginService.login(c, { email: userRow.email, password: null }, true);
return { userInfo: oauthRow, token: JwtToken };
return { userInfo: oauthRow, token: JwtToken }
},
async saveUser(c, userInfo) {
@@ -204,12 +97,6 @@ const oauthService = {
},
assertEnabled(setting, switchKey) {
if (setting[switchKey] !== 0) {
throw new BizError(t('oauthDisabled'));
}
},
async getById(c, oauthUserId) {
return await orm(c).select().from(oauth).where(eq(oauth.oauthUserId, oauthUserId)).get();
},
+257
View File
@@ -0,0 +1,257 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { eq } from 'drizzle-orm';
import BizError from '../error/biz-error';
import orm from '../entity/orm';
import passkeyCredential from '../entity/passkey-credential';
import cryptoUtils from '../utils/crypto-utils';
import settingService from './setting-service';
import userService from './user-service';
import loginService from './login-service';
import { t } from '../i18n/i18n.js';
const CHALLENGE_TTL = 5 * 60 * 1000;
const PASSKEY_ENABLED = 1;
const passkeyService = {
async status(c, userId) {
const credential = await this.findByUserId(c, userId);
return {
registered: !!credential,
createTime: credential?.createTime || null,
};
},
async registrationOptions(c, userId, params) {
await this.ensureEnabled(c);
const user = await this.verifyCurrentPassword(c, userId, params?.password);
const { origin, rpID } = this.getRelyingParty(c);
const userHandleBytes = new Uint8Array(32);
crypto.getRandomValues(userHandleBytes);
const userHandle = isoBase64URL.fromBuffer(userHandleBytes);
const options = await generateRegistrationOptions({
rpName: (await settingService.query(c)).title || 'Cloud Mail',
rpID,
userID: userHandleBytes,
userName: user.email,
userDisplayName: user.email,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'required',
userVerification: 'required',
},
});
const transactionId = await this.saveChallenge(c, {
type: 'registration',
challenge: options.challenge,
userId,
userHandle,
origin,
rpID,
});
return { transactionId, options };
},
async verifyRegistration(c, userId, params) {
await this.ensureEnabled(c);
const challenge = await this.consumeChallenge(c, params?.transactionId, 'registration');
if (!challenge || challenge.user_id !== userId) {
throw new BizError(t('passkeyChallengeInvalid'));
}
let verification;
try {
verification = await verifyRegistrationResponse({
response: params?.response,
expectedChallenge: challenge.challenge,
expectedOrigin: challenge.origin,
expectedRPID: challenge.rp_id,
requireUserVerification: true,
});
} catch (error) {
console.warn(JSON.stringify({
message: 'Passkey registration verification failed',
error: error instanceof Error ? error.message : String(error),
}));
throw new BizError(t('passkeyVerifyFailed'));
}
if (!verification.verified || !verification.registrationInfo) {
throw new BizError(t('passkeyVerifyFailed'));
}
const credential = verification.registrationInfo.credential;
const values = {
userId,
userHandle: challenge.user_handle,
credentialId: credential.id,
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
counter: credential.counter,
transports: JSON.stringify(credential.transports || params?.response?.response?.transports || []),
createTime: new Date().toISOString(),
};
await orm(c).insert(passkeyCredential).values(values).onConflictDoUpdate({
target: passkeyCredential.userId,
set: values,
}).run();
return this.status(c, userId);
},
async authenticationOptions(c) {
await this.ensureEnabled(c);
const { origin, rpID } = this.getRelyingParty(c);
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'required',
});
const transactionId = await this.saveChallenge(c, {
type: 'authentication',
challenge: options.challenge,
origin,
rpID,
});
return { transactionId, options };
},
async verifyAuthentication(c, params) {
await this.ensureEnabled(c);
const challenge = await this.consumeChallenge(c, params?.transactionId, 'authentication');
if (!challenge) {
throw new BizError(t('passkeyChallengeInvalid'));
}
const credential = await this.findByCredentialId(c, params?.response?.id);
if (!credential || params?.response?.response?.userHandle !== credential.userHandle) {
throw new BizError(t('passkeyNotFound'));
}
let verification;
try {
verification = await verifyAuthenticationResponse({
response: params.response,
expectedChallenge: challenge.challenge,
expectedOrigin: challenge.origin,
expectedRPID: challenge.rp_id,
requireUserVerification: true,
credential: {
id: credential.credentialId,
publicKey: isoBase64URL.toBuffer(credential.publicKey),
counter: credential.counter,
transports: this.parseTransports(credential.transports),
},
});
} catch (error) {
console.warn(JSON.stringify({
message: 'Passkey authentication verification failed',
error: error instanceof Error ? error.message : String(error),
}));
throw new BizError(t('passkeyVerifyFailed'));
}
if (!verification.verified) {
throw new BizError(t('passkeyVerifyFailed'));
}
await orm(c).update(passkeyCredential).set({
counter: verification.authenticationInfo.newCounter,
}).where(eq(passkeyCredential.passkeyId, credential.passkeyId)).run();
const user = await userService.selectByIdIncludeDel(c, credential.userId);
return loginService.loginUser(c, user);
},
async delete(c, userId, params) {
await this.verifyCurrentPassword(c, userId, params?.password);
await orm(c).delete(passkeyCredential).where(eq(passkeyCredential.userId, userId)).run();
},
async ensureEnabled(c) {
const { passkey } = await settingService.query(c);
if (passkey !== PASSKEY_ENABLED) {
throw new BizError(t('passkeyDisabled'));
}
},
async verifyCurrentPassword(c, userId, password) {
if (!password) {
throw new BizError(t('emailAndPwdEmpty'));
}
const user = await userService.selectByIdIncludeDel(c, userId);
if (!user || !await cryptoUtils.verifyPassword(password, user.salt, user.password)) {
throw new BizError(t('IncorrectPwd'));
}
return user;
},
async findByUserId(c, userId) {
return orm(c).select().from(passkeyCredential).where(eq(passkeyCredential.userId, userId)).get();
},
async findByCredentialId(c, credentialId) {
if (!credentialId) return null;
return orm(c).select().from(passkeyCredential).where(eq(passkeyCredential.credentialId, credentialId)).get();
},
parseTransports(transports) {
try {
return JSON.parse(transports || '[]');
} catch (_) {
return [];
}
},
getRelyingParty(c) {
try {
const requestUrl = new URL(c.req.url);
const origin = new URL(c.req.header('Origin') || requestUrl.origin);
const loopbackHosts = ['localhost', '127.0.0.1', '[::1]'];
const originIsLoopback = loopbackHosts.includes(origin.hostname);
const requestIsLoopback = loopbackHosts.includes(requestUrl.hostname);
const secureOrigin = origin.protocol === 'https:'
|| (origin.protocol === 'http:' && originIsLoopback);
const trustedOrigin = origin.origin === requestUrl.origin
|| (originIsLoopback && requestIsLoopback);
if (secureOrigin && trustedOrigin) {
return { origin: origin.origin, rpID: origin.hostname };
}
} catch (_) {
throw new BizError(t('passkeyOriginInvalid'));
}
throw new BizError(t('passkeyOriginInvalid'));
},
async saveChallenge(c, challenge) {
const transactionId = crypto.randomUUID();
const now = Date.now();
await c.env.db.batch([
c.env.db.prepare('DELETE FROM passkey_challenge WHERE expires_at <= ?').bind(now),
c.env.db.prepare(`
INSERT INTO passkey_challenge
(challenge_id, type, challenge, user_id, user_handle, origin, rp_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
transactionId,
challenge.type,
challenge.challenge,
challenge.userId || 0,
challenge.userHandle || '',
challenge.origin,
challenge.rpID,
now + CHALLENGE_TTL,
),
]);
return transactionId;
},
async consumeChallenge(c, transactionId, type) {
if (!transactionId) return null;
return c.env.db.prepare(`
DELETE FROM passkey_challenge
WHERE challenge_id = ? AND type = ? AND expires_at > ?
RETURNING *
`).bind(transactionId, type, Date.now()).first();
},
};
export default passkeyService;
+17 -10
View File
@@ -9,7 +9,6 @@ import BizError from '../error/biz-error';
import {t} from '../i18n/i18n'
import verifyRecordService from './verify-record-service';
import userContext from '../security/user-context';
import domainUtils from '../utils/domain-uitls';
const settingService = {
@@ -49,7 +48,18 @@ const settingService = {
domainList = domainList.map(item => '@' + item);
setting.domainList = domainList;
let linuxdoSwitch = c.env.linuxdo_switch;
let projectLink = c.env.project_link;
if (typeof linuxdoSwitch === 'string' && linuxdoSwitch === 'true') {
linuxdoSwitch = true
} else if (linuxdoSwitch === true) {
linuxdoSwitch = true
} else {
linuxdoSwitch = false
}
if (typeof projectLink === 'string' && projectLink === 'false') {
projectLink = false
} else if (projectLink === false) {
@@ -60,6 +70,10 @@ const settingService = {
setting.projectLink = projectLink;
setting.linuxdoClientId = c.env.linuxdo_client_id;
setting.linuxdoCallbackUrl = c.env.linuxdo_callback_url;
setting.linuxdoSwitch = linuxdoSwitch;
setting.emailPrefixFilter = setting.emailPrefixFilter.split(",").filter(Boolean);
c.set?.('setting', setting);
@@ -125,12 +139,7 @@ const settingService = {
params.aiCodeFilter = params.aiCodeFilter + '';
}
if (params.webhookUrl !== undefined) {
params.webhookUrl = domainUtils.toOssDomain(params.webhookUrl) || '';
}
params.resendTokens = JSON.stringify(resendTokens);
await orm(c).update(setting).set({ ...params }).returning().get();
await this.refresh(c);
},
@@ -219,12 +228,10 @@ const settingService = {
noticeOffset: settingRow.noticeOffset,
notice: settingRow.notice,
loginDomain: settingRow.loginDomain,
passkey: settingRow.passkey,
linuxdoClientId: settingRow.linuxdoClientId,
linuxdoCallbackUrl: settingRow.linuxdoCallbackUrl,
linuxdoSwitch: settingRow.linuxdoSwitch,
githubClientId: settingRow.githubClientId,
githubSwitch: settingRow.githubSwitch,
googleClientId: settingRow.googleClientId,
googleSwitch: settingRow.googleSwitch,
minEmailPrefix: settingRow.minEmailPrefix,
projectLink: settingRow.projectLink
};
+17 -24
View File
@@ -4,7 +4,6 @@ import emailService from './email-service';
import BizError from '../error/biz-error';
import { and, desc, eq, lt, sql, inArray } from 'drizzle-orm';
import email from '../entity/email';
import { emailListColumns, emailBriefColumns } from '../lib/email-list-columns';
import { isDel } from '../const/entity-const';
import attService from "./att-service";
import { t } from '../i18n/i18n'
@@ -42,48 +41,42 @@ const starService = {
},
async list(c, params, userId) {
let { emailId, size, full } = params;
emailId = Number(emailId) || 0;
let { emailId, size } = params;
emailId = Number(emailId);
size = Number(size);
full = Number(full) === 1;
const columns = full ? emailListColumns : emailBriefColumns;
if (!emailId) {
emailId = 9999999999;
}
const list = await orm(c).select({
isStar: sql`1`.as('isStar'),
starId: star.starId,
...columns
starId: star.starId
, ...email
}).from(star)
.leftJoin(email, eq(email.emailId, star.emailId))
.where(
and(
eq(star.userId, userId),
eq(email.isDel, isDel.NORMAL),
emailId ? lt(star.emailId, emailId) : undefined))
lt(star.emailId, emailId)))
.orderBy(desc(star.emailId))
.limit(size)
.all();
if (full) {
const emailIds = list.map(item => item.emailId);
const attsList = await attService.selectByEmailIds(c, emailIds);
list.forEach(emailRow => {
emailRow.attList = attsList.filter(attsRow => attsRow.emailId === emailRow.emailId);
});
} else {
list.forEach(emailRow => {
emailRow.text = emailService.toListText(emailRow);
delete emailRow.content;
});
}
const emailIds = list.map(item => item.emailId);
const attsList = await attService.selectByEmailIds(c, emailIds);
list.forEach(emailRow => {
const atts = attsList.filter(attsRow => attsRow.emailId === emailRow.emailId);
emailRow.attList = atts;
});
return { list };
},
async removeByEmailIds(c, emailIds) {
await orm(c).delete(star).where(inArray(star.emailId, emailIds)).run();
},
async removeByUserIds(c, userIds) {
await orm(c).delete(star).where(inArray(star.userId, userIds)).run();
}
};
+6 -35
View File
@@ -3,7 +3,7 @@ import accountService from './account-service';
import orm from '../entity/orm';
import user from '../entity/user';
import { and, asc, count, desc, eq, inArray, sql } from 'drizzle-orm';
import { emailConst, isDel, roleConst, settingConst, userConst } from '../const/entity-const';
import { emailConst, isDel, roleConst, userConst } from '../const/entity-const';
import kvConst from '../const/kv-const';
import KvConst from '../const/kv-const';
import cryptoUtils from '../utils/crypto-utils';
@@ -18,8 +18,6 @@ import { t } from '../i18n/i18n'
import reqUtils from '../utils/req-utils';
import {oauth} from "../entity/oauth";
import oauthService from "./oauth-service";
import settingService from './setting-service';
import starService from './star-service';
const userService = {
@@ -70,7 +68,7 @@ const userService = {
selectByEmail(c, email) {
return orm(c).select().from(user).where(
and(
sql`${user.email} COLLATE NOCASE = ${email}`,
eq(user.email, email),
eq(user.isDel, isDel.NORMAL)))
.get();
},
@@ -97,12 +95,6 @@ const userService = {
},
async delete(c, userId) {
const { syncDelete } = await settingService.query(c);
if (syncDelete === settingConst.syncDelete.OPEN) {
await this.physicsDelete(c, { userIds: String(userId) });
await c.env.kv.delete(kvConst.AUTH_INFO + userId)
return;
}
await orm(c).update(user).set({ isDel: isDel.DELETE }).where(eq(user.userId, userId)).run();
await c.env.kv.delete(kvConst.AUTH_INFO + userId)
},
@@ -110,7 +102,6 @@ const userService = {
async physicsDelete(c, params) {
let { userIds } = params;
userIds = userIds.split(',').map(Number);
await starService.removeByUserIds(c, userIds);
await accountService.physicsDeleteByUserIds(c, userIds);
await oauthService.deleteByUserIds(c, userIds);
await orm(c).delete(user).where(inArray(user.userId, userIds)).run();
@@ -124,15 +115,6 @@ const userService = {
num = Number(num);
timeSort = Number(timeSort);
params.isDel = Number(params.isDel);
if (isNaN(size)) {
size = 50;
}
if (isNaN(num)) {
num = 1;
}
if (size > 50) {
size = 50;
}
@@ -148,7 +130,7 @@ const userService = {
if (email) {
conditions.push(sql`${user.email} COLLATE NOCASE LIKE ${email + '%'}`);
conditions.push(sql`${user.email} COLLATE NOCASE LIKE ${'%'+ email + '%'}`);
}
@@ -162,8 +144,7 @@ const userService = {
username: oauth.username,
trustLevel: oauth.trustLevel,
avatar: oauth.avatar,
name: oauth.name,
platform: oauth.platform
name: oauth.name
}).from(user).leftJoin(oauth, eq(oauth.userId, user.userId))
.where(and(...conditions));
@@ -323,7 +304,7 @@ const userService = {
async add(c, params) {
let { email, type, password } = params;
const { email, type, password } = params;
if (!c.env.domain.includes(emailUtils.getDomain(email))) {
throw new BizError(t('notEmailDomain'));
@@ -343,13 +324,7 @@ const userService = {
throw new BizError(t('isRegAccount'));
}
let role;
if (type === undefined) {
role = await roleService.selectDefaultRole(c);
type = role?.roleId;
} else {
role = await roleService.selectById(c, type);
}
const role = roleService.selectById(c, type);
if (!role) {
throw new BizError(t('roleNotExist'));
@@ -365,10 +340,6 @@ const userService = {
},
async resetDaySendCount(c) {
// 仅 UTC 0 点执行,便于配合每小时 cron
if (new Date().getUTCHours() !== 0) {
return;
}
const roleList = await roleService.selectByIdsAndSendType(c, 'email:send', roleConst.sendType.DAY);
const roleIds = roleList.map(action => action.roleId);
await orm(c).update(user).set({ sendCount: 0 }).where(inArray(user.type, roleIds)).run();
@@ -13,10 +13,6 @@ const verifyRecordService = {
},
async clearRecord(c) {
// 仅 UTC 0 点执行,便于配合每小时 cron
if (new Date().getUTCHours() !== 0) {
return;
}
await orm(c).delete(verifyRecord).run();
},
@@ -1,64 +0,0 @@
import domainUtils from '../utils/domain-uitls';
const webhookService = {
async sendEmail(c, emailRow, webhookUrl, retry = 0, webhookSecret) {
webhookUrl = domainUtils.toOssDomain(webhookUrl);
if (!webhookUrl) {
return;
}
retry = Number(retry);
if (isNaN(retry) || retry < 0) {
retry = 0;
}
const headers = {
'Content-Type': 'application/json'
};
if (webhookSecret) {
headers['Authorization'] = webhookSecret;
}
const body = JSON.stringify({
emailId: emailRow.emailId,
sendEmail: emailRow.sendEmail,
sendName: emailRow.name,
toEmail: emailRow.toEmail,
toName: emailRow.toName,
subject: emailRow.subject,
text: emailRow.text,
content: emailRow.content,
code: emailRow.code,
createTime: emailRow.createTime
});
let lastError = '';
for (let i = 0; i <= retry; i++) {
try {
const res = await fetch(webhookUrl, {
method: 'POST',
headers,
body
});
if (res.ok) {
return;
}
lastError = `status: ${res.status} response: ${await res.text()}`;
} catch (e) {
lastError = e.message;
}
}
console.error(`Webhook 推送失败: ${lastError}`);
}
};
export default webhookService;
-7
View File
@@ -14,13 +14,6 @@ const emailUtils = {
return parts.length === 2 ? parts[0] : '';
},
getBaseEmail(email) {
const parts = email.split('@');
if (parts.length !== 2) return '';
const localPart = parts[0].split('+')[0];
return localPart + '@' + parts[1];
},
formatText(text) {
if (!text) return ''
return text
+1
View File
@@ -47,6 +47,7 @@ const jwtUtils = {
},
async verifyToken(c, token) {
if (!token) return null;
try {
const [headerB64, payloadB64, signatureB64] = token.split('.');
+98 -15
View File
@@ -1,20 +1,103 @@
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import worker from '../src';
import { env, SELF } from 'cloudflare:test';
import { beforeAll, describe, expect, it } from 'vitest';
import passkeyService from '../src/service/passkey-service';
import settingService from '../src/service/setting-service';
describe('Hello World worker', () => {
it('responds with Hello World! (unit style)', async () => {
const request = new Request('http://example.com');
// Create an empty context to pass to `worker.fetch()`.
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
await waitOnExecutionContext(ctx);
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
describe('Passkey support', () => {
beforeAll(async () => {
const response = await SELF.fetch(`http://127.0.0.1/api/init/${env.jwt_secret}`);
expect(await response.text()).toBe('success');
});
it('responds with Hello World! (integration style)', async () => {
const response = await SELF.fetch('http://example.com');
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
it('creates disabled-by-default Passkey schema', async () => {
const settingColumn = await env.db.prepare(`SELECT name FROM pragma_table_info('setting') WHERE name = 'passkey'`).first();
const credentialTable = await env.db
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'passkey_credential'`).first();
const challengeTable = await env.db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'passkey_challenge'`).first();
const setting = await env.db.prepare('SELECT passkey FROM setting').first();
expect(settingColumn?.name).toBe('passkey');
expect(credentialTable?.name).toBe('passkey_credential');
expect(challengeTable?.name).toBe('passkey_challenge');
expect(setting?.passkey).toBe(0);
});
it('exposes the disabled flag and rejects authentication options', async () => {
const configResponse = await SELF.fetch('http://127.0.0.1/api/setting/websiteConfig');
const config = await configResponse.json();
expect(config.data.passkey).toBe(0);
const optionsResponse = await SELF.fetch('http://127.0.0.1/api/passkey/auth/options', {
method: 'POST',
headers: { Origin: 'http://127.0.0.1' },
});
const options = await optionsResponse.json();
expect(options.code).toBe(501);
});
it('keeps password login working', async () => {
const headers = { 'Content-Type': 'application/json' };
const registerResponse = await SELF.fetch('http://127.0.0.1/api/register', {
method: 'POST',
headers,
body: JSON.stringify({ email: '[email protected]', password: 'test-password' }),
});
expect((await registerResponse.json()).code).toBe(200);
const loginResponse = await SELF.fetch('http://127.0.0.1/api/login', {
method: 'POST',
headers,
body: JSON.stringify({ email: '[email protected]', password: 'test-password' }),
});
const login = await loginResponse.json();
expect(login.code).toBe(200);
expect(login.data.token).toBeTruthy();
});
it('accepts only exact production origins or loopback development origins', () => {
const context = (url, origin) => ({ req: { url, header: () => origin } });
expect(passkeyService.getRelyingParty(context('https://mail.example.com/api', 'https://mail.example.com'))).toEqual({
origin: 'https://mail.example.com',
rpID: 'mail.example.com',
});
expect(passkeyService.getRelyingParty(context('http://127.0.0.1:8787/api', 'http://localhost:3001'))).toEqual({
origin: 'http://localhost:3001',
rpID: 'localhost',
});
expect(() => passkeyService.getRelyingParty(context('https://mail.example.com/api', 'https://evil.example.com'))).toThrow();
expect(() => passkeyService.getRelyingParty(context('https://mail.example.com/api', 'http://mail.example.com'))).toThrow();
expect(() => passkeyService.getRelyingParty(context('https://mail.example.com/api', 'https://mail.example.com:8443'))).toThrow();
});
it('consumes each challenge only once', async () => {
const context = { env };
const transactionId = await passkeyService.saveChallenge(context, {
type: 'authentication',
challenge: 'test-challenge',
origin: 'http://127.0.0.1',
rpID: '127.0.0.1',
});
const first = await passkeyService.consumeChallenge(context, transactionId, 'authentication');
const second = await passkeyService.consumeChallenge(context, transactionId, 'authentication');
expect(first?.challenge).toBe('test-challenge');
expect(second).toBeNull();
});
it('generates discoverable authentication options when enabled', async () => {
await env.db.prepare('UPDATE setting SET passkey = 1').run();
await settingService.refresh({ env, set: () => {} });
const response = await SELF.fetch('http://127.0.0.1/api/passkey/auth/options', {
method: 'POST',
headers: { Origin: 'http://127.0.0.1' },
});
const body = await response.json();
const challenge = await env.db.prepare('SELECT challenge_id FROM passkey_challenge WHERE challenge_id = ?')
.bind(body.data.transactionId).first();
expect(body.code).toBe(200);
expect(body.data.options.rpId).toBe('127.0.0.1');
expect(body.data.options.userVerification).toBe('required');
expect(challenge?.challenge_id).toBe(body.data.transactionId);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
wrangler: { configPath: './wrangler-dev.toml' },
},
},
},
+7 -1
View File
@@ -1,6 +1,7 @@
name = "${NAME}"
main = "src/index.js"
compatibility_date = "2025-06-04"
compatibility_flags = ["nodejs_compat"]
[observability]
enabled = true
@@ -32,7 +33,7 @@ not_found_handling = "single-page-application"
run_worker_first = true
[triggers]
crons = ["0 * * * *"] #每小时执行;resetDaySendCount 仅在 UTC 0 点生效
crons = ["0 16 * * *"]
[vars]
@@ -44,5 +45,10 @@ admin = "${ADMIN}"
jwt_secret = "${JWT_SECRET}"
project_link = "${PROJECT_LINK}"
linuxdo_client_id = "${LINUXDO_CLIENT_ID}"
linuxdo_client_secret = "${LINUXDO_CLIENT_SECRET}"
linuxdo_callback_url = "${LINUXDO_CALLBACK_URL}"
linuxdo_switch = "${LINUXDO_SWITCH}"
[build]
command = "pnpm --prefix ../mail-vue install && pnpm --prefix ../mail-vue run build"
+2 -1
View File
@@ -1,6 +1,7 @@
name = "cloud-mail-dev"
main = "src/index.js"
compatibility_date = "2025-06-04"
compatibility_flags = ["nodejs_compat"]
keep_vars = true
[observability]
@@ -38,7 +39,7 @@ run_worker_first = true
[vars]
ai_model = ""
analysis_cache = false
orm_log = true
orm_log = false
domain = ["example.com", "example2.com", "example3.com", "example4.com"]
admin = "[email protected]"
jwt_secret = "b7f29a1d-18e2-4d3b-941f-f6b2c97c02fd"
+3 -2
View File
@@ -1,6 +1,7 @@
name = "test-mail"
main = "src/index.js"
compatibility_date = "2025-06-04"
compatibility_flags = ["nodejs_compat"]
keep_vars = true
[observability]
@@ -30,11 +31,11 @@ not_found_handling = "single-page-application"
run_worker_first = true
[triggers]
crons = ["0 * * * *"] #每小时执行;resetDaySendCount 仅在 UTC 0 点生效
crons = ["0 16 * * *"] #每天晚上12点执行每日任务,刷新分析缓存
[vars]
ai_model = "" #ai模型,不填默认使用@cf/meta/llama-3.1-8b-instruct-fast
ai_model = "" #ai模型,不填默认使用@cf/meta/llama-3.1-8b-instruct
analysis_cache = false #是否开启分析数据缓存
orm_log = false #是否sql日志
domain = ["example.com"] #邮件域名可可配置多个 示例: ["example1.com","example2.com"]
+4 -3
View File
@@ -1,6 +1,7 @@
name = "cloud-mail"
main = "src/index.js"
compatibility_date = "2025-06-04"
compatibility_flags = ["nodejs_compat"]
keep_vars = true
[observability]
@@ -32,15 +33,15 @@ not_found_handling = "single-page-application"
run_worker_first = true
[triggers]
crons = ["0 * * * *"] #每小时执行;resetDaySendCount 仅在 UTC 0 点生效
crons = ["0 16 * * *"] #每天晚上12点执行每日任务,刷新分析缓存
[vars]
#ai_model = "" #ai模型,不填默认使用@cf/meta/llama-3.1-8b-instruct-fast
#ai_model = "" #ai模型,不填默认使用@cf/meta/llama-3.1-8b-instruct
#analysis_cache = false #是否开启分析数据缓存
#orm_log = false #是否sql日志
#domain = [] #邮件域名可可配置多个 示例: ["example1.com","example2.com"]
#admin = "" #管理员的邮箱 示例: [email protected]
#admin = "" #管理员的邮箱 示例: [email protected]
#jwt_secret = "" #jwt令牌的密钥,随便填一串字符串
[build]