Compare commits

..
147 Commits
Author SHA1 Message Date
eoao ad9ced5b5f 新增更多部署错误提示 2026-01-23 00:22:00 +08:00
eoao 5ae3fe277a 新增邮件和用户列表右键菜单 2026-01-22 23:50:00 +08:00
eoao aaf4a29013 新增批量删除用户 2026-01-22 22:22:00 +08:00
eoao ef002f9863 新增邮箱列表置顶排序 2026-01-20 21:46:00 +08:00
ifsheldon ed16cc476e fix: Handle HTTP redirects in workflow curl requests (#240)
* update workflow

* fix workflow
2026-01-22 15:41:07 +08:00
eoao 86c408dd27 优化降低所需配置简化部署 2026-01-19 19:22:00 +08:00
eoao a5995947ac 优化邮件列表改为虚拟滚动列表 2026-01-15 19:46:00 +08:00
eoao 1cdca9107a 新增全部邮件自动刷新 2026-01-14 00:00:37 +08:00
eoao 454cde670c 优化自动刷新邮件请求次数 2026-01-12 22:33:00 +08:00
eoao 2960dd2d11 优化部署流程和变量错误提示 2026-01-11 21:06:00 +08:00
eoao 5ae8800ed7 修复账号ID导致的BUG 2026-01-07 22:02:00 +08:00
eoao dc78582509 修改README 2026-01-02 23:09:00 +08:00
eoao 877f03a8cf 修复附件和背景保存异常 2026-01-03 00:01:47 +08:00
eoao a0553203ce 修复邮件接收异常 2026-01-01 19:16:43 +08:00
eoao a860fd7116 KV存储改为默认开启
保存

保存
2026-01-01 15:11:41 +08:00
eoao bf9529ef2f 修改自动刷新邮件失效 2025-12-10 23:09:00 +08:00
eoao a4cbd6448e 修改README.md 2025-12-07 22:09:00 +08:00
eoao 13abc11f2c 新增显示所有邮箱邮件 2025-12-07 22:08:00 +08:00
eoao 9ec5345309 修复切换邮箱自动刷新错乱 2025-11-23 13:29:11 +08:00
eoao 7de9b44f79 修复其他列表显示已读图标问题 2025-11-18 23:22:00 +08:00
eoao aa5d3647c1 新增自动刷新邮件弹窗通知 2025-11-18 22:44:00 +08:00
eoao 0a6dbec531 新增邮件显示已读未读功能 2025-11-18 22:08:00 +08:00
eoao aa86b2dc38 新增注册邮箱名自定义字符过滤 2025-11-16 12:56:38 +08:00
eoao fd90625651 设置登录背景加载超时时间 2025-11-09 08:08:00 +08:00
eoao 28f7814190 新增设置注册邮箱前缀最小位数 2025-11-08 22:25:00 +08:00
eoao d0c04cf2e9 修复 Delete workflow 限不足时报错 2025-11-08 21:44:00 +08:00
eoao d611436115 新增 LinuxDo Oauth2 登录 2025-11-08 21:33:00 +08:00
eoao c774009f82 修改README.md 2025-11-06 22:59:00 +08:00
eoao 5c4e2a3284 发送邮件正文图片链接改为内置附件 2025-11-06 22:55:00 +08:00
eoao c83b86c392 修复<>字符导致TG消息发送失败 2025-10-25 08:35:00 +08:00
eoao ff42f406a5 修复一些附件文件名无后缀导致报错 2025-10-25 08:22:00 +08:00
eoao 4c5fef2d91 新增TG自定义是否显示文本消息 2025-10-23 23:55:00 +08:00
eoao fcf15dd3cb 修复TG一些邮件和图片不显示 2025-10-23 23:11:00 +08:00
eoao 287d94c3cd 修改README文档 2025-10-23 17:58:27 +08:00
eoao b41c9f4725 新增自定义显示TG邮件消息内容 2025-10-23 00:22:45 +08:00
eoao b8b1bee015 新增TG邮件查看HTML 2025-10-22 21:33:45 +08:00
eoao 140d451472 新增KV数据库存储附件 2025-10-21 20:45:20 +08:00
eoao a42e8fce8e 修复发送成功无法记录联系人 2025-10-18 22:32:00 +08:00
eoao 7042302e2f 修复无法接收无人邮件 2025-10-13 00:22:00 +08:00
eoao 7fd576aca7 新增最近联系人功能 2025-10-10 22:22:00 +08:00
eoao fe6356ff2b 邮件内容样式优化 2025-09-27 00:22:00 +08:00
eoao 3573f705c8 补充修复一些邮件接收异常 2025-09-26 17:55:38 +08:00
eoao 2908eb90af 修复一些邮件接收异常 2025-09-26 00:10:17 +08:00
eoao e37f93d3e2 Github Action 支持无R2, 自动获取初始化地址 2025-09-25 22:23:00 +08:00
eoao b4c8be9946 图标改成项目内置 2025-09-24 00:22:00 +08:00
eoao 207d334f83 修复邮件数量统计错误 2025-09-22 21:32:23 +08:00
eoao 9777f9b8cf 新增全部用户查看删除邮箱 2025-09-21 19:20:00 +08:00
eoao 45c152577b 修复请求影响延迟 2025-09-14 11:49:37 +08:00
eoao dc6224c920 修复删除S3文件没有MD5异常 2025-09-06 21:33:30 +08:00
eoao 066e646a0a 修复浏览器无法初始化地址 2025-09-06 19:24:14 +08:00
eoao c2fba5d20e 修复Turnstile加载失败 2025-09-06 12:01:40 +08:00
eoao 56070121d4 PWA注册改为异步避免影响加载速度 2025-09-05 17:26:08 +08:00
eoao 7eee7041ea 修复批量删除邮件时漏掉附件 2025-09-05 16:24:54 +08:00
eoao 19846f3084 修复使用S3有时无法接收邮件 2025-09-05 16:23:46 +08:00
梦念逍遥 e8eaa7d45b 修复npm缓存错误 (#112) 2025-09-04 12:35:21 +08:00
eoao bd26e34097 修改版本和图片域名转换 2025-09-04 01:22:00 +08:00
eoao 5c60ab2977 邮件拒收退回 2025-09-04 01:01:00 +08:00
eoao e6dc9a05cc 修改报错提示 2025-09-04 00:40:00 +08:00
eoao 22d3e6a204 禁用Workbox 2025-09-04 00:29:34 +08:00
eoao 3264f0d25a 优化多个地方 2025-09-03 08:31:44 +08:00
eoao 0c3f0dae3a 修改图标和权限*拦截邮件 2025-09-01 08:22:22 +08:00
eoao 180f1d5fac 删除静态资源转为部署时自动打包 2025-08-31 12:07:21 +08:00
eoao 0010c7a2b6 新增支持s3协议对象存储 2025-08-31 12:04:19 +08:00
eoao c6a7c6b220 新增pwa 2025-08-30 07:36:38 +08:00
eoao 99eca7893d 添加静态资源 2025-08-28 22:28:00 +08:00
eoao 859bcf0d1c 饼状图不在统计noreply 2025-08-28 22:23:00 +08:00
eoao 4c74e00493 部署报错详细提示 2025-08-28 22:22:00 +08:00
eoao 57b22f300e 新增批量删除邮件 2025-08-28 22:11:00 +08:00
eoao 224890b2f5 腾讯EdgeOne部署配置 2025-08-27 17:35:11 +08:00
eoao c7e9480363 添加配置浏览器缓存所有静态资源 2025-08-26 23:44:22 +08:00
eoao 5097ff4bf0 修改登录背景图片加载显示 2025-08-26 23:34:22 +08:00
eoao 91e90f475d 邮件列表加载动画优化和打包拆分 2025-08-26 23:10:55 +08:00
eoao 49d338e81c 修复登录界面首次进入加载背景异常 2025-08-22 22:02:00 +08:00
eoao 00a08d35e4 修复浏览器刷新时有黑影闪过 2025-08-22 19:03:53 +08:00
eoao 6b88e45743 修复写邮件按钮位置靠右 2025-08-22 17:22:40 +08:00
eoao ffcb5fed53 修复全部邮件权限无效问题 2025-08-22 01:05:00 +08:00
eoao 200913069c 新增深色模式 2025-08-22 00:13:56 +08:00
eoao eb16ef913b Update issue templates 2025-08-19 16:01:41 +08:00
eoao 655c4fc11c 修复切换语言时间格式没有变化 2025-09-21 19:26:50 +08:00
LLLLLLiuleiandliulei 6953827d23 对字符串domain尝试转换为json (#79)
Co-authored-by: liulei <[email protected]>
2025-08-13 01:53:10 +08:00
eoao ddc43896aa API Token 有效时间改成无限 2025-08-11 10:44:33 +08:00
eoao d0f42b50f2 修复其他语言下英语样式仍是中文的 2025-08-10 20:32:57 +08:00
eoao 398eb2ca59 修复login_domain字段可能初始化失败 2025-08-10 19:27:41 +08:00
eoao ba6b02635b 新增支持API添加用户,查询邮件 2025-08-10 11:37:46 +08:00
eoao 021c3b23f5 修复超级管理员账号会被禁用发件 2025-08-04 17:58:06 +08:00
eoao 39da16d2fd 新增公告弹窗和无人收件开关 2025-08-03 21:03:54 +08:00
eoao cbefc1d6f2 新增规则触发人机验证和加载失败提示 2025-07-29 20:00:00 +08:00
eoao 72bcc7367b 修复无法自适应浏览器语言 2025-07-29 14:09:00 +08:00
eoao b771c1f65a 新增权限域名限制和链接背景上传 2025-07-26 23:11:00 +08:00
Abner 6af3643704 修复 wrangler 配置文件占位符替换时序问题 (#57)
- 移除 telemetry 命令的配置文件参数避免在替换前读取配置
- 重新组织工作流步骤,确保配置文件占位符替换在所有 wrangler 命令之前执行
- 添加调试信息显示 R2_BUCKET_NAME 值和配置文件内容
- 修复 R2 bucket name 验证错误
- 调整数据库初始化等待时间从 10 秒增加到 15 秒
2025-07-27 10:23:21 +08:00
eoao bf70e7dbd3 修改文档和版本号 2025-07-24 18:05:00 +08:00
eoao 1d8dcc94e9 修复注册码权限无效bug 2025-07-22 23:33:46 +08:00
eoao 674eb934ea 新增中英文切换 2025-07-22 22:02:23 +08:00
eoao 0aa65a598e 修改注册密码长度限制 2025-07-18 21:18:12 +08:00
eoao 655d93d228 修改版本号 2025-07-18 01:16:44 +08:00
eoao 5506664e0b 修复请求响应过慢 2025-07-16 20:07:39 +08:00
eoao 63c6bcc76f 修复可选注册码报错
(cherry picked from commit b40c62f94d6e33814db1498534d585b5b8d4303f)
2025-07-16 20:38:00 +08:00
eoao a6f6417a7d 修复注册密码提示问题 2025-07-16 20:30:00 +08:00
eoao a66c9cfa31 新增注册码、草稿箱、权限拦截邮件、发件菜单隐藏 2025-07-15 20:09:56 +08:00
eoao 54477dde22 修复gmail附件和回复编辑器图片不显示 2025-07-10 09:00:14 +08:00
Abner f6d4653a47 fix 部署描述及action部署在未配置变量时输出成功提示 2025-07-09 19:26:36 +08:00
eoaoandAbner db890fba0d 新增 GitHub Actions 自动化部署
Co-authored-by: Abner <[email protected]>
2025-07-09 16:35:01 +08:00
eoao f7b5510a17 修复邮件图片移动端无法自适应 2025-07-07 22:43:57 +08:00
eoao e223df29f0 修复收件内嵌图片只能显示一张 2025-07-07 22:38:56 +08:00
eoao c00a50936b 优化移动端404图片大小 2025-07-05 23:28:17 +08:00
eoao b54ed15114 修复邮件列表截取文字加载图片导致大量爆红 2025-07-04 21:11:41 +08:00
eoao 6893ec6299 删除配置 2025-07-04 20:22:59 +08:00
eoao 09e5ef70e6 修改版本号 2025-07-04 20:16:33 +08:00
MengNianxiaoyao 75239c3b6e 修正错误文本
(cherry picked from commit 773f0cdcbb05eb27eeeb73e86d18a4f8e44cdb57)
2025-07-04 18:55:01 +08:00
eoao 4367cf948d 优化登录身份缓存机制 2025-07-04 20:02:00 +08:00
eoao 1f7b9381e6 截取邮箱名做发件名防止出现空名 2025-06-30 22:44:30 +08:00
eoao f213782398 修复星标邮件列表不同步和图表空数据不为0 2025-06-30 15:27:59 +08:00
eoao c0c5d153db 调整移动动端空状态大小、加载时标题改成空 2025-06-29 22:01:39 +08:00
eoao 8812e6a14b 修复邮件列表发件无法搜索和不显示邮箱 2025-06-29 14:25:39 +08:00
eoao 7d2ebc657d 邮件详情样式优化和查询优化 2025-06-29 11:57:53 +08:00
eoao b92fd05836 修复纯文本邮件文字不会自动行 2025-06-28 22:01:42 +08:00
eoao 947eabb715 修改版本号 2025-06-28 21:43:24 +08:00
eoao 1d83c6543c 修复某些情况取消星标不同步 2025-06-28 21:35:43 +08:00
eoao eb25f831d3 新增点击复制邮箱功能 2025-06-28 20:22:52 +08:00
eoao 7820d7eef5 新增tg和第三方邮件转发 2025-06-28 13:35:37 +08:00
eoao c9271a17dc 优化编辑器表情底部空白和table不显示线 2025-06-25 19:30:07 +08:00
eoao ef24a26c37 饼状图不再统计空名 2025-06-25 11:46:34 +08:00
eoao dcae28d368 新增无人收件和支持一些字符邮箱 2025-06-25 10:39:21 +08:00
eoao 46a6f29c01 修复富文本居中图片后内容页还是靠左 2025-06-21 08:36:41 +08:00
eoao 2a1bf8ecec 修复数据可视化最后一天为0 2025-06-20 00:52:17 +08:00
eoao b23f0fabd1 新增打包文件 2025-06-17 09:23:00 +08:00
eoao a94e84fd35 设置柱状图默认值 2025-06-17 09:23:00 +08:00
eoao 75cfd33b8f 修改版本号 2025-06-17 09:21:00 +08:00
eoao dca2a97510 新增邮件群发和数据可视化 2025-06-17 09:20:00 +08:00
eoao da1995f8d3 修改图片压缩规则,优化样式 2025-06-06 19:20:59 +08:00
eoao 6446e1aa34 修复透明度覆盖影响设置问题,优化样式 2025-06-06 00:23:31 +08:00
eoao a787ee6404 修复星标不能查看,附件等多个bug,优化样式 2025-06-05 23:09:05 +08:00
eoao 174d722f40 修复邮件正文不是utc+8时间 2025-06-05 15:02:57 +08:00
eoao 47c486dce6 修改文档和修改项目错字 2025-06-05 08:53:24 +08:00
eoao e0abb4ac00 修复无法接收邮件问题 2025-06-04 22:12:54 +08:00
eoao 771499c34d 修改登录页面 2025-06-04 00:20:53 +08:00
eoao c54e93051e 修复自动刷新问题 2025-06-03 13:29:52 +08:00
eoao 124525959f 修复人机验证不显示问题 2025-06-03 12:58:33 +08:00
eoao 4660cf89e0 修复内容显示问题 2025-06-03 07:53:22 +08:00
eoao 82905173ef 新增邮件发送和管理员功能 2025-05-29 17:38:50 +08:00
eoao 4cb850602c 添加教程,更新打包文件
删除描述
2025-04-29 04:49:33 +08:00
eoao 55ad9ea71a 修复a标签变成黑色问题 2025-04-27 17:19:37 +08:00
eoao 71bb036d16 修改错字 2025-04-27 11:47:53 +08:00
eoao b100efa2ab 修改配置 2025-04-27 09:32:25 +08:00
eoao 15b91f6c4e 添加说明和MIT 2025-04-27 09:09:43 +08:00
eoao 0d73fad289 添加打包文件 2025-04-26 17:33:50 +08:00
eoao e1d96a5852 保存 2025-04-26 17:24:39 +08:00
79 changed files with 1028 additions and 3160 deletions
+36 -56
View File
@@ -24,16 +24,11 @@ jobs:
D1_DATABASE_ID: ${{ secrets.D1_DATABASE_ID || vars.D1_DATABASE_ID }}
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' }}
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:
worker_url: ${{ steps.deploy.outputs.worker_url }}
@@ -45,12 +40,12 @@ jobs:
- name: ⚙ 设置 pnpm / Set up pnpm
uses: pnpm/[email protected]
with:
version: 9
version: latest
- name: ⚙ 设置 Node.js / Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "20"
cache: "pnpm"
cache-dependency-path: "./mail-worker/pnpm-lock.yaml"
@@ -94,7 +89,7 @@ jobs:
fi
if [ -z "$CUSTOM_DOMAIN" ]; then
echo "::warning:: CUSTOM_DOMAIN variable is not set."
echo "::error:: CUSTOM_DOMAIN variable is not set."
fi
CONFIG_FILE="wrangler-action.toml"
@@ -103,10 +98,6 @@ jobs:
sed -i '/\[\[r2_buckets\]\]/,/^$/d' "$CONFIG_FILE"
fi
if [ -z "$PROJECT_LINK" ]; then
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
@@ -114,14 +105,6 @@ jobs:
if [ -z "$CUSTOM_DOMAIN" ]; then
sed -i '/\[\[routes\]\]/,/^$/d' "$CONFIG_FILE"
fi
if [ "$(printf '%s' "$CLOUDFLARE_EMAIL" | tr '[:upper:]' '[:lower:]')" = "true" ]; then
cat >> "$CONFIG_FILE" <<'EOF'
[[send_email]]
name = "email"
EOF
fi
sed -i "s|\${NAME}|${NAME}|g" "$CONFIG_FILE"
sed -i "s|\${CUSTOM_DOMAIN}|${CUSTOM_DOMAIN}|g" "$CONFIG_FILE"
@@ -129,9 +112,6 @@ jobs:
sed -i "s|\${ADMIN}|${ADMIN}|g" "$CONFIG_FILE"
sed -i "s|\${JWT_SECRET}|${JWT_SECRET}|g" "$CONFIG_FILE"
sed -i "s|\${R2_BUCKET_NAME}|${R2_BUCKET_NAME}|g" "$CONFIG_FILE"
sed -i "s|\${PROJECT_LINK}|${PROJECT_LINK}|g" "$CONFIG_FILE"
sed -i "s|\${ANALYSIS_CACHE}|${ANALYSIS_CACHE}|g" "$CONFIG_FILE"
sed -i "s|\${AI_MODEL}|${AI_MODEL}|g" "$CONFIG_FILE"
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"
@@ -152,18 +132,9 @@ jobs:
fi
echo "🔍 Checking if the database exists..."
KV_LIST=$(pnpm wrangler kv namespace list)
set +e
KV_LIST=$(pnpm wrangler kv namespace list 2>&1)
STATUS=$?
set -e
if [ $STATUS -ne 0 ]; then
echo "$KV_LIST"
exit 1
fi
if echo "$KV_LIST" | jq -e ".[] | select(.title == \"$NAME\")" >/dev/null; then
if echo "$KV_LIST" | jq -e ".[] | select(.title == \"$NAME\")" > /dev/null; then
echo "✅ Database $NAME already exists."
KV_ID=$(echo "$KV_LIST" | jq -r ".[] | select(.title == \"$NAME\") | .id")
echo "KV_NAMESPACE_ID: $KV_ID"
@@ -190,18 +161,9 @@ jobs:
fi
echo "🔍 Checking if the database exists..."
DB_LIST=$(pnpm wrangler d1 list --json)
set +e
DB_LIST=$(pnpm wrangler d1 list --json 2>&1)
STATUS=$?
set -e
if [ $STATUS -ne 0 ]; then
echo "$DB_LIST"
exit 1
fi
if echo "$DB_LIST" | jq -e ".[] | select(.name == \"$NAME\")" >/dev/null; then
if echo "$DB_LIST" | jq -e ".[] | select(.name == \"$NAME\")" > /dev/null; then
echo "✅ Database $NAME already exists."
D1_ID=$(echo "$DB_LIST" | jq -r ".[] | select(.name == \"$NAME\") | .uuid")
echo "D1_DATABASE_ID: $D1_ID"
@@ -220,17 +182,7 @@ jobs:
working-directory: ./mail-worker
run: |
echo "🚀 Starting deployment..."
pnpm wrangler deploy -c wrangler-action.toml 2>&1 \
| tee deploy.log \
| grep -v "https://.*\.workers\.dev" \
| sed -E 's/env\.domain .*/env.domain (***)/' \
|| true
DEPLOY_EXIT_CODE=${PIPESTATUS[0]}
if [ $DEPLOY_EXIT_CODE -ne 0 ]; then
exit 1
fi
pnpm wrangler deploy -c wrangler-action.toml | tee deploy.log | grep -v "https://.*\.workers\.dev" || true
WORKER_URL=$(grep -o "https://.*\.workers\.dev" deploy.log || echo "")
if [ -n "$WORKER_URL" ]; then
@@ -263,6 +215,34 @@ jobs:
exit 1
fi
- name: 📮 设置邮件接收 / Set up email receiving
run: |
echo "🛠️ Starting email receiving setup..."
WORKER_URL="${CUSTOM_DOMAIN:+https://$CUSTOM_DOMAIN}"
WORKER_URL="${WORKER_URL:-${{ steps.deploy.outputs.worker_url }}}"
HTTP_CODE=$(curl -sL --post301 --post302 --post303 -w "%{http_code}" -o response.txt \
-X POST \
-H "Content-Type: application/json" \
-d "{\"domainList\": $DOMAIN,\"accountId\":\"$CLOUDFLARE_ACCOUNT_ID\",\"token\":\"$CLOUDFLARE_API_TOKEN\",\"workerName\":\"$NAME\"}" \
"$WORKER_URL/api/initForward")
RESPONSE_BODY=$(cat response.txt)
if [ "$RESPONSE_BODY" = "success" ]; then
echo "✅ Setup completed."
exit 0
fi
if [ "$HTTP_CODE" = "200" ] && [ "$RESPONSE_BODY" != "success" ]; then
echo "::error:: Email receiving setup failed: $RESPONSE_BODY"
exit 0
fi
echo "::error:: Email receiving setup failed: $HTTP_CODE $RESPONSE_BODY"
- name: 🗑️ 删除运行记录 / Delete workflow runs
uses: GitRML/delete-workflow-runs@main
continue-on-error: true
+3 -5
View File
@@ -23,8 +23,8 @@
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/20459" target="_blank" >
<img src="https://trendshift.io/api/badge/repositories/20459" alt="trendshift" >
<a href="https://trendshift.io/repositories/14418" target="_blank" >
<img src="https://trendshift.io/api/badge/repositories/14418" alt="trendshift" >
</a>
</p>
</p>
@@ -57,8 +57,6 @@ With only one domain, you can create multiple different email addresses, similar
- **📡 Open API**: Supports batch user creation via API and multi-condition email queries
- **🔢 Verification Code Recognition**: Auto-detect codes via Workers AI
- **📈 Data Visualization**: Use ECharts to visualize system data, including user email growth.
- **🎨 Personalization**: Customize website title, login background, and transparency.
@@ -133,7 +131,7 @@ cloud-mail
```
## Sponsor
## Support
<a href="https://doc.skymail.ink/support.html">
<img width="170px" src="./doc/images/support.png" alt="">
+2 -4
View File
@@ -23,8 +23,8 @@
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/20459" target="_blank" >
<img src="https://trendshift.io/api/badge/repositories/20459" alt="trendshift" >
<a href="https://trendshift.io/repositories/14418" target="_blank" >
<img src="https://trendshift.io/api/badge/repositories/14418" alt="trendshift" >
</a>
</p>
</p>
@@ -62,8 +62,6 @@
- **📡 开放API**:支持使用API批量生成用户,多条件查询邮件
- **🔢 验证码识别**:使用Workers AI,自动识别邮件验证码
- **📈 数据可视化**:使用ECharts对系统数据详情,用户邮件增长可视化显示
- **🎨 个性化设置**:可以自定义网站标题,登录背景,透明度
+4
View File
@@ -0,0 +1,4 @@
NODE_ENV = 'eo'
VITE_APP_TITLE = 'eo环境'
VITE_BASE_URL = ''
VITE_PWA_NAME = 'Cloud Mail'
+1 -1
View File
@@ -1,4 +1,4 @@
NODE_ENV = 'remote'
VITE_APP_TITLE = '远程环境'
VITE_BASE_URL = 'https://skymail.ink/api'
VITE_BASE_URL = ''
VITE_PWA_NAME = 'Cloud Mail'
+43 -60
View File
@@ -43,94 +43,77 @@
.loading-hide {
pointer-events: none;
transition: all 200ms;
opacity: 0;
}
.loading-icon {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
font-size: 30px;
}
.loading-image {
width: 70px;
height: 70px;
object-fit: contain;
.circular {
height: 30px;
width: 30px;
animation: loading-rotate 1s linear infinite;
}
.loading-progress {
position: relative;
width: 64px;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: rgba(64, 158, 255, 0.18);
.loading-path .dot1 {
transform: translate(3.75px, 3.75px);
fill: #409EFF;
animation: custom-spin-move 1s infinite linear alternate;
opacity: 0.3;
}
.loading-progress::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: inherit;
background: #409EFF;
transform: scaleX(0);
transform-origin: left center;
animation: loading-progress-grow 2.4s linear forwards;
.loading-path .dot2 {
transform: translate(calc(100% - 3.75px), 3.75px);
fill: #409EFF;
animation: custom-spin-move 1s infinite linear alternate;
opacity: 0.3;
animation-delay: 0.4s;
}
.loading-progress::after {
content: "";
position: absolute;
top: 0;
width: 24px;
height: 100%;
border-radius: inherit;
background: rgba(64, 158, 255, 0.45);
filter: blur(4px);
transform: translateX(-24px);
animation: loading-progress-peg 2.4s linear forwards;
.loading-path .dot3 {
transform: translate(3.75px, calc(100% - 3.75px));
fill: #409EFF;
animation: custom-spin-move 1s infinite linear alternate;
opacity: 0.3;
animation-delay: 1.2s;
}
.loading-complete .loading-progress::before {
animation: none;
transform: scaleX(1);
transition: transform 200ms linear;
.loading-path .dot4 {
transform: translate(calc(100% - 3.75px), calc(100% - 3.75px));
fill: #409EFF;
animation: custom-spin-move 1s infinite linear alternate;
opacity: 0.3;
animation-delay: 0.8s;
}
.loading-complete .loading-progress::after {
animation: none;
transform: translateX(60px);
transition: transform 200ms linear;
}
@keyframes loading-progress-grow {
0% {
transform: scaleX(0);
}
100% {
transform: scaleX(1);
@keyframes loading-rotate {
to {
transform: rotate(360deg);
}
}
@keyframes loading-progress-peg {
0% {
transform: translateX(-24px);
}
100% {
transform: translateX(60px);
@keyframes custom-spin-move {
to {
opacity: 1;
}
}
</style>
<body>
<div id="loading-first" >
<div class="loading-icon">
<img class="loading-image" src="/mail-pwa.png" alt="Cloud Mail">
<div class="loading-progress" aria-hidden="true"></div>
<svg class="circular" viewBox="0 0 20 20">
<g class="loading-path">
<circle r="3.375" class="dot1" rx="0" ry="0"/>
<circle r="3.375" class="dot2" rx="0" ry="0"/>
<circle r="3.375" class="dot4" rx="0" ry="0"/>
<circle r="3.375" class="dot3" rx="0" ry="0"/>
</g>
</svg>
</div>
</div>
<div id="app"></div>
+1 -2
View File
@@ -12,10 +12,9 @@
},
"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",
"axios": "^1.7.8",
"compressorjs": "^1.2.1",
"date-time-format-timezone": "^1.0.22",
"dayjs": "^1.11.13",
+16 -28
View File
@@ -11,9 +11,6 @@ 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])
@@ -21,8 +18,8 @@ importers:
specifier: ^12.0.0
version: 12.8.2
axios:
specifier: 1.15.2
version: 1.15.2([email protected])
specifier: ^1.7.8
version: 1.11.0
compressorjs:
specifier: ^1.2.1
version: 1.2.1
@@ -1064,9 +1061,6 @@ 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==}
@@ -1222,8 +1216,8 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
[email protected]5.2:
resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==}
[email protected]1.0:
resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==}
[email protected]:
resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==}
@@ -1519,8 +1513,8 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
[email protected].5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
[email protected].4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
[email protected]:
@@ -1570,7 +1564,7 @@ packages:
[email protected]:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting [email protected]
deprecated: Glob versions prior to v9 are no longer supported
[email protected]:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
@@ -2021,9 +2015,8 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
[email protected]:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
@@ -2429,7 +2422,6 @@ 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
@@ -3507,8 +3499,6 @@ snapshots:
'@rollup/[email protected]':
optional: true
'@simplewebauthn/[email protected]': {}
'@surma/[email protected]':
dependencies:
ejs: 3.1.10
@@ -3713,11 +3703,11 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
[email protected]5.2([email protected]):
[email protected]1.0:
dependencies:
follow-redirects: 1.15.11([email protected])
form-data: 4.0.5
proxy-from-env: 2.1.0
follow-redirects: 1.15.11
form-data: 4.0.4
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
@@ -4087,15 +4077,13 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
[email protected]([email protected]):
optionalDependencies:
debug: 4.4.1
[email protected]: {}
[email protected]:
dependencies:
is-callable: 1.2.7
[email protected].5:
[email protected].4:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
@@ -4577,7 +4565,7 @@ snapshots:
[email protected]: {}
proxy-from-env@2.1.0: {}
proxy-from-env@1.1.0: {}
[email protected]:
optional: true
+11 -84
View File
@@ -82,12 +82,9 @@
<div class="email-text">
<span class="email-subject" :style="(item.unread === EmailUnreadEnum.UNREAD && showUnread) ? 'font-weight: bold' : ''">
<div class="unread" v-if="!isMobile && (item.unread === EmailUnreadEnum.UNREAD && showUnread) "/>
<span v-if="item.code" class="code-tag" @click.stop="copyCode(item.code)">[{{ t('codeLabel') }}{{ item.code }}]</span>
<span class="subject-text">
<slot name="subject" :email="item" >
{{ item.subject || '\u200B' }}
</slot>
</span>
<slot name="subject" :email="item" >
{{ item.subject || '\u200B' }}
</slot>
</span>
<span class="email-content">{{ item.formatText || '\u200B' }}</span>
</div>
@@ -155,15 +152,7 @@
>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="rightClickEmail.code" @click="copyCode(rightClickEmail.code)" >
<template #default>
<div class="right-dropdown-item">
<Icon icon="fluent-color:clipboard-24" width="20" height="20" />
<span>{{t('copyCode')}}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item v-if="['email'].includes(props.type)" @click="emailRead(rightClickEmail.emailId)" >
<el-dropdown-item v-if="props.type === 'email'" @click="emailRead()" >
<template #default>
<div class="right-dropdown-item">
<Icon icon="fluent:mail-read-20-regular" width="20" height="20" />
@@ -171,7 +160,7 @@
</div>
</template>
</el-dropdown-item>
<el-dropdown-item v-if="['email','star'].includes(props.type)" @click="openReply(rightClickEmail)">
<el-dropdown-item v-if="props.type === 'email'" @click="openReply(rightClickEmail)">
<template #default>
<div class="right-dropdown-item">
<Icon icon="la:reply" width="20" height="20" />
@@ -179,14 +168,6 @@
</div>
</template>
</el-dropdown-item>
<el-dropdown-item v-if="['email','send', 'star'].includes(props.type)" @click="openForward(rightClickEmail)">
<template #default>
<div class="right-dropdown-item">
<Icon icon="iconoir:arrow-up-right" width="19" height="19" />
<span>{{t('forward')}}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item v-if="['email','send', 'star'].includes(props.type)" @click="starChange(rightClickEmail)">
<template #default>
<div class="right-dropdown-item">
@@ -490,10 +471,6 @@ function openReply(email) {
uiStore.writerRef.openReply(email)
}
function openForward(email) {
uiStore.writerRef.openForward(email)
}
function visibleChange(e) {
dropdownShow.value = e;
dropdownCloseLock.value = true;
@@ -616,15 +593,6 @@ function changeAccountShow() {
const handleRead = () => {
const emailIds = getSelectedMailsIds();
props.emailRead(emailIds);
localRead(emailIds);
}
function emailRead(emailId) {
props.emailRead([emailId])
localRead([emailId]);
}
function localRead(emailIds) {
emailIds.forEach(emailId => {
const index = emailList.findIndex(email => email.emailId === emailId);
if (index > -1) {
@@ -634,6 +602,11 @@ function localRead(emailIds) {
})
}
function emailRead() {
const emailIds = getSelectedMailsIds();
props.emailRead(emailIds)
}
function rightDelete(emailId) {
if (props.type === 'all-email') {
@@ -667,24 +640,6 @@ function handleSearch(type, value) {
emit('right-search', type, value);
}
async function copyCode(code) {
try {
await navigator.clipboard.writeText(code);
ElMessage({
message: t('copySuccessMsg'),
type: 'success',
plain: true
})
} catch (err) {
console.error(`${t('copyFailMsg')}:`, err);
ElMessage({
message: t('copyFailMsg'),
type: 'error',
plain: true
})
}
}
function handleDelete() {
ElMessageBox.confirm(t('delEmailsConfirm'), {
confirmButtonText: t('confirm'),
@@ -994,11 +949,6 @@ function loadData() {
@media (max-width: 1366px) {
height: 83px;
}
@media (pointer: coarse) {
/* 触屏 */
user-select: none;
}
&.all-email {
height: 65px;
@media (max-width: 1366px) {
@@ -1168,37 +1118,14 @@ function loadData() {
}
.email-subject {
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
white-space: nowrap;
min-width: 0;
text-overflow: ellipsis;
@media (min-width: 1367px) {
padding-left: 5px;
}
}
.code-tag {
flex: 0 0 auto;
max-width: 170px;
height: 20px;
line-height: 20px;
font-size: 14px;
color: var(--el-text-color-primary);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
}
.subject-text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.email-content {
overflow: hidden;
white-space: nowrap;
@@ -8,6 +8,7 @@
<script setup>
import {ref, onMounted, onBeforeUnmount, watch, nextTick, shallowRef, defineEmits, computed} from 'vue';
import loading from "@/components/loading/index.vue";
import {compressImage} from "@/utils/file-utils.js";
import {useI18n} from 'vue-i18n'
import {useUiStore} from '@/store/ui.js'
import {useSettingStore} from '@/store/setting.js'
@@ -139,6 +140,7 @@ function initEditor() {
input.addEventListener('change', async (e) => {
let file = e.target.files[0];
file = await compressImage(file);
const reader = new FileReader();
reader.onload = () => {
const id = 'blobid' + (new Date()).getTime();
+6 -31
View File
@@ -73,7 +73,7 @@ const en = {
unauthorized: 'Unauthorized',
unlimited: 'Unlimited',
sendCount: 'Send email : ',
accountCount: 'Add address : ',
accountCount: 'Add Address : ',
action: 'Action',
chgPwd: 'Password',
perm: 'Role',
@@ -94,18 +94,17 @@ const en = {
order: 'Order',
default: 'Default',
description: 'Description',
removeContent: 'Remove content',
removeAll: 'Remove all',
expand: 'Expand',
collapse: 'Collapse',
daily: 'Daily',
internal: 'Internal',
searchRegKeyDesc: 'Enter invite code to search',
remainingUses: 'Remaining Uses',
exhausted: 'Exhausted',
validUntil: 'Valid Until',
expired: 'Expired',
copy: 'Copy',
copyCode: 'Copy Code',
codeLabel: 'Code: ',
history: 'History',
addRegKey: 'Add Invite Code',
regKey: 'Invite Code',
@@ -136,21 +135,7 @@ const en = {
loginSwitch: 'Sign in',
websiteSetting: 'Website',
websiteReg: 'Sign Up',
loginDomain: 'Hide Sign-In Domain',
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?',
loginDomain: 'Sign-In Box Domain',
multipleEmail: 'Multiple Email Address',
multipleEmailDesc: 'Enable this feature to allow users to add multiple email',
customization: 'Customization',
@@ -163,11 +148,6 @@ const en = {
autoRefreshDesc: 'Automatically fetch the latest emails from the server',
sendEmail: 'Send Email',
resendToken: 'Resend Token',
cloudflareEmailSending: 'Cloudflare Email Sending',
blackList: 'Blocklist',
codeRecognition: 'Verification Code Recognition',
codeRecognitionRules: 'Recognition Rules',
codeRecognitionRulesDesc: 'Only recognize emails containing the following info. Empty means all.',
oss: 'Object Storage',
osDomain: 'Domain',
ossDomainDesc: 'Leave empty if using KV storage.',
@@ -203,9 +183,9 @@ const en = {
optional: 'Optional',
subjectInputDesc: 'Please enter the email subject',
changeUserName: 'Change Username',
sendSeparately: 'Separately',
send: 'Send',
reply: 'Reply',
forward: 'Forward',
confirm: 'Confirm',
cancel: 'Cancel',
delEmailConfirm: 'Confirm deleting this email?',
@@ -250,6 +230,7 @@ const en = {
banRestore: 'Confirm banning {msg}?',
logOut: 'Sign out',
clearContentConfirm: 'Are you sure to clear all content?',
attLimitMsg: 'Attachment size limit: 28MB',
emptyRecipientMsg: 'Recipient email cannot be empty',
emptySubjectMsg: 'Subject cannot be empty',
emptyContentMsg: 'Content cannot be empty',
@@ -269,7 +250,6 @@ const en = {
language: 'Language',
totalUserAccount: '{msg}',
sendBanned: 'Banned',
sendInternal: 'Only internal',
wrote: 'wrote',
support: 'Support',
supportDesc: 'Buy me tea',
@@ -332,11 +312,6 @@ const en = {
character: '',
mustNotContain: 'Must Not Contain',
mustNotContainDesc: 'Separate with commas',
blackSubjectDesc: 'Subject',
blackContentDesc: 'Content',
blackFromDesc: 'Sender or domain',
senderRules: 'Sender address or domain',
blackListDesc: 'Reject emails containing the following information',
setSuccess: 'Settings saved successfully',
details: 'Details',
userDetails: 'User Details',
+6 -31
View File
@@ -94,18 +94,17 @@ const zh = {
order: '排序',
default: '默认',
description: '描述',
removeContent: '移除正文',
removeAll: '丢弃邮件',
expand: '展开',
collapse: '收起',
daily: '每天',
internal: '站内',
searchRegKeyDesc: '输入注册码搜索',
remainingUses: '剩余次数',
exhausted: '已用尽',
validUntil: '有效至期',
expired: '已过期',
copy: '复制',
copyCode: '复制验证码',
codeLabel: '验证码:',
history: '记录',
addRegKey: '添加注册码',
regKey: '注册码',
@@ -136,21 +135,7 @@ const zh = {
loginSwitch: '去登录',
websiteSetting: '网站设置',
websiteReg: '用户注册',
loginDomain: '隐藏登录域名',
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',
loginDomain: '登录域名',
multipleEmail: '多号模式',
multipleEmailDesc: '开启后账号栏出现一个用户可以添加多个邮箱',
customization: '个性化设置',
@@ -163,11 +148,6 @@ const zh = {
autoRefreshDesc: '轮询请求服务器获取最新邮件',
sendEmail: '邮件发送',
resendToken: 'Resend Token',
cloudflareEmailSending: 'Cloudflare 邮件发送',
blackList: '邮件黑名单',
codeRecognition: '识别验证码',
codeRecognitionRules: '识别规则',
codeRecognitionRulesDesc: '只识别包含以下信息的邮件,为空识别全部',
oss: '对象存储',
osDomain: '访问域名',
ossDomainDesc: '如果是KV存储不要填',
@@ -203,9 +183,9 @@ const zh = {
optional: '可选',
subjectInputDesc: '请输入邮件主题',
changeUserName: '修改用户名',
sendSeparately: '分别发送',
send: '发送',
reply: '回复',
forward: '转发',
confirm: '确定',
cancel: '取消',
delEmailConfirm: '确认删除该邮件吗?',
@@ -250,6 +230,7 @@ const zh = {
banRestore: '确认禁用 {msg} 吗?',
logOut: '退出',
clearContentConfirm: '确定要清空所有内容吗?',
attLimitMsg: '附件不能超过28MB',
emptyRecipientMsg: '收件人邮箱地址不能为空',
emptySubjectMsg: '主题不能为空',
emptyContentMsg: '邮件正文不能为空',
@@ -266,10 +247,9 @@ const zh = {
serverBusyErrorMsg: '服务器繁忙,请稍后重试',
reqFailErrorMsg: '请求失败,请稍后再试',
message: '邮件详情',
language: '系统语言',
language: '网站语言',
totalUserAccount: '{msg} 个',
sendBanned: '已禁用',
sendInternal: '仅站内',
wrote: '来信',
support: '捐助',
supportDesc: '请我喝杯奶茶',
@@ -332,11 +312,6 @@ const zh = {
character: '位',
mustNotContain: '禁止包含',
mustNotContainDesc: '输入多个值用,分开',
blackSubjectDesc: '邮件标题',
blackContentDesc: '邮件内容',
blackFromDesc: '发件人邮箱或域名',
senderRules: '发件人邮箱或域名',
blackListDesc: '拒收包含以下信息的邮件',
setSuccess: '设置成功',
details: '详情',
userDetails: '用户详情',
+14
View File
@@ -54,4 +54,18 @@ export async function init() {
settingStore.domainList = setting.domainList;
document.title = setting.title;
}
removeLoading();
}
function removeLoading() {
if (window.innerWidth < 1025) {
document.documentElement.style.setProperty('--loading-hide-transition', 'none')
}
const doc = document.getElementById('loading-first');
doc.classList.add('loading-hide')
setTimeout(() => {
doc.remove()
},1000)
}
+3 -10
View File
@@ -127,7 +127,7 @@
</template>
<script setup>
import {Icon} from "@iconify/vue";
import {computed, nextTick, reactive, ref, watch} from "vue";
import {nextTick, reactive, ref, watch} from "vue";
import {
accountList,
accountAdd,
@@ -153,7 +153,7 @@ const settingStore = useSettingStore();
const emailStore = useEmailStore();
const showAdd = ref(false)
const addLoading = ref(false);
const domainList = computed(() => settingStore.domainList)
const domainList = settingStore.domainList
const accounts = reactive([])
const noLoading = ref(false)
const loading = ref(false)
@@ -176,7 +176,7 @@ const addForm = reactive({
})
let skeletonRows = 10
const queryParams = {
size: 30
size: 10
}
const mySelect = ref()
@@ -189,12 +189,6 @@ watch(() => accountStore.changeUserAccountName, () => {
accounts[0].name = accountStore.changeUserAccountName
})
watch(() => settingStore.domainList, (list) => {
if (!addForm.suffix && list.length > 0) {
addForm.suffix = list[0]
}
}, {immediate: true})
const openSelect = () => {
mySelect.value.toggleMenu()
@@ -344,7 +338,6 @@ function changeAccount(account) {
}
function add() {
addForm.suffix = addForm.suffix || settingStore.domainList[0]
showAdd.value = true
setTimeout(() => {
addRef.value.focus()
-8
View File
@@ -113,10 +113,6 @@ const sendType = computed(() => {
return t('sendBanned')
}
if (userStore.user.role.sendType === 'internal') {
return t('sendInternal')
}
if (!userStore.user.role.sendCount) {
return t('unlimited')
}
@@ -141,10 +137,6 @@ const sendCount = computed(() => {
return null
}
if (userStore.user.role.sendType === 'internal') {
return null
}
if (!userStore.user.role.sendCount) {
return null
}
+52 -80
View File
@@ -15,13 +15,13 @@
</div>
</div>
<div class="container">
<el-input-tag @add-tag="addTagChange" tag-type="primary" @input="inputChange" size="default" v-model="form.receiveEmail" >
<el-input-tag @add-tag="addTagChange" tag-type="primary" @input="inputChange" size="default" v-model="form.receiveEmail"
:placeholder="ruleEmailsInputDesc">
<template #prefix>
<div class="item-title" >{{ $t('recipient') }}</div>
<el-select
ref="mySelect"
class="write-select"
popper-class="write-select"
class="select"
:show-arrow="false"
:no-match-text="' '"
:no-data-text="' '"
@@ -33,17 +33,22 @@
:key="item"
:label="item"
:value="item"
style="color: #999896;"
/>
</el-select>
</template>
<template #suffix>
<div style="display: flex;margin-right: 3px;">
<div style="display: flex;">
<Icon icon="fa7-solid:user-plus" width="20" height="20" class="add-contact" @click.stop="openContacts" />
</div>
<span class="distribute" :class="form.manyType ? 'checked' : ''"
@click.stop="checkDistribute">{{ $t('sendSeparately') }}</span>
</template>
</el-input-tag>
<el-input v-model="form.subject" :placeholder="t('subject')" />
<el-input v-model="form.subject" :placeholder="$t('subjectInputDesc')">
<template #prefix>
<div class="item-title">{{ $t('subject') }}</div>
</template>
</el-input>
<tinyEditor :def-value="defValue" ref="editor" @change="change" @focus="focusChange" />
<div class="button-item">
<div class="att-add" @click="chooseFile">
@@ -63,7 +68,6 @@
</div>
<div>
<el-button type="primary" @click="sendEmail" v-if="form.sendType === 'reply'">{{ $t('reply') }}</el-button>
<el-button type="primary" @click="sendEmail" v-else-if="form.sendType === 'forward'">{{ $t('forward') }}</el-button>
<el-button type="primary" @click="sendEmail" v-else>{{ $t('send') }}</el-button>
</div>
</div>
@@ -113,12 +117,10 @@ import db from "@/db/db.js";
import dayjs from "dayjs";
import {useI18n} from "vue-i18n";
import router from "@/router/index.js";
import {ElMessageBox} from "element-plus";
defineExpose({
open,
openReply,
openForward,
openDraft
})
@@ -139,6 +141,7 @@ const contactsTabRef = ref({})
const showContacts = ref(false)
const mySelect = ref()
let selectStatus = false
const ruleEmailsInputDesc = ref(t('ruleEmailsInputDesc'))
const backReply = reactive({
receiveEmail: [],
subject: '',
@@ -149,6 +152,7 @@ const form = reactive({
sendEmail: '',
receiveEmail: [],
accountId: -1,
manyType: null,
name: '',
subject: '',
content: '',
@@ -212,6 +216,7 @@ function selectChange(value) {
function selectStatusChange(status) {
selectStatus = status
ruleEmailsInputDesc.value = status ? '' : ruleEmailsInputDesc.value = t('ruleEmailsInputDesc')
}
const openSelect = () => {
@@ -250,6 +255,10 @@ function addTagChange(val) {
if (selectStatus && has) openSelect()
}
function checkDistribute() {
form.manyType = form.manyType ? null : 'divide'
}
function clearContent() {
ElMessageBox.confirm(t('clearContentConfirm'), {
confirmButtonText: t('confirm'),
@@ -268,23 +277,25 @@ function delAtt(index) {
function chooseFile() {
const doc = document.createElement("input")
doc.setAttribute("type", "file")
doc.multiple = true;
doc.click()
doc.onchange = async (e) => {
const fileList = e.target.files;
for (const file of fileList) {
const size = file.size
const filename = file.name
const contentType = file.type
const content = await fileToBase64(file)
form.attachments.push({content, filename, size, contentType})
const file = e.target.files[0]
const size = file.size
const filename = file.name
const contentType = file.type
const TotalSize = form.attachments.reduce((acc, item) => acc + item.size, 0);
if ((TotalSize + size) > 29360128) {
ElMessage({
message: t('attLimitMsg'),
type: 'error',
plain: true,
})
return
}
const content = await fileToBase64(file)
form.attachments.push({content, filename, size, contentType})
}
}
@@ -308,10 +319,6 @@ async function sendEmail() {
return
}
if (!form.content) {
form.content = editor.value.getContent();
}
if (!form.content) {
ElMessage({
message: t('emptyContentMsg'),
@@ -433,32 +440,6 @@ function focusChange() {
if (selectStatus) openSelect()
}
function openForward(email) {
resetForm();
email.subject = email.subject || ''
form.subject = email.subject
form.sendType = 'forward'
defValue.value = ''
setTimeout(() => {
defValue.value = `
${formatImage(email.content) || `<pre style="font-family: inherit;word-break: break-word;white-space: pre-wrap;margin: 0">${email.text}</pre>`}
`
open()
nextTick(() => {
backReply.content = editor.value.getContent()
backReply.subject = form.subject
backReply.receiveEmail = form.receiveEmail
backReply.sendType = form.sendType
})
});
}
function openReply(email) {
resetForm();
@@ -466,11 +447,7 @@ function openReply(email) {
email.subject = email.subject || ''
form.receiveEmail.push(email.sendEmail)
form.subject = (
email.subject.startsWith('Re:') ||
email.subject.startsWith('Re') ||
email.subject.startsWith('回复:') ||
email.subject.startsWith('回复:')) ? email.subject : 'Re: ' + email.subject
form.subject = (email.subject.startsWith('Re:') || email.subject.startsWith('回复:')) ? email.subject : 'Re: ' + email.subject
form.sendType = 'reply'
form.emailId = email.emailId
@@ -546,10 +523,6 @@ function close() {
if (selectStatus) openSelect();
if (!form.content) {
form.content = editor.value.getContent();
}
if (form.draftId) {
draftStore.setDraft = {...toRaw(form)}
show.value = false
@@ -563,13 +536,10 @@ function close() {
return;
}
if (backReply.sendType === 'reply' || backReply.sendType === 'forward') {
if (backReply.sendType === 'reply') {
let subjectFlag = form.subject === backReply.subject
let contentFlag = editor.value.getContent() === backReply.content
let receiveFlag = form.receiveEmail.length === 1 && form.receiveEmail[0] === backReply.receiveEmail[0]
if (backReply.sendType === 'forward' && form.receiveEmail.length === 0) {
receiveFlag = true;
}
if (subjectFlag && contentFlag && receiveFlag) {
resetForm();
close()
@@ -583,7 +553,7 @@ function close() {
type: 'warning',
distinguishCancelAndClose: true
}).then(async () => {
const formData = {...toRaw(form)};
const formData = {...toRaw(form)}
delete formData.draftId
delete formData.attachments
formData.createTime = dayjs().utc().format('YYYY-MM-DD HH:mm:ss');
@@ -591,9 +561,7 @@ function close() {
db.value.att.add({draftId, attachments: toRaw(form.attachments)})
draftStore.refreshList++
show.value = false
await nextTick(() => {
resetForm()
})
resetForm()
}).catch((action) => {
if (action === 'cancel') {
show.value = false
@@ -604,18 +572,7 @@ function close() {
}
</script>
<style>
.write-select .el-select-dropdown__list {
padding: 4px 4px !important;
}
.write-select .el-select-dropdown__item {
padding: 0 10px 0 10px;
}
.write-select .el-select-dropdown {
min-width: 0 !important;
}
</style>
<style scoped lang="scss">
.send {
position: fixed;
@@ -694,6 +651,21 @@ function close() {
grid-template-rows: auto auto 1fr auto;
gap: 15px;
.distribute {
color: var(--el-color-info);
background: var(--el-color-info-light-9);
border: var(--el-color-info-light-8);
border-radius: 4px;
font-size: 12px;
padding: 0 5px;
}
.distribute.checked {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary) !important;
border-radius: 4px;
}
.item-title {
}
@@ -769,7 +741,7 @@ function close() {
color: var(--regular-text-color)
}
.write-select {
.select {
position: absolute;
width: 300px;
left: 60px;
-25
View File
@@ -1,25 +0,0 @@
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}})
}
+1 -1
View File
@@ -5,7 +5,7 @@ export function roleAdd(params) {
}
export function rolePermTree() {
return http.get('/role/tree')
return http.get('/role/permTree')
}
export function roleRoleList() {
+1 -5
View File
@@ -1,7 +1,7 @@
import http from '@/axios/index.js';
export function settingSet(setting) {
return http.put('/setting/set', setting)
return http.put('/setting/set',setting)
}
export function settingQuery() {
@@ -19,7 +19,3 @@ export function setBackground(background) {
export function deleteBackground() {
return http.delete('/setting/deleteBackground')
}
export function setBlackList(params) {
return http.put('/setting/setBlacklist', params)
}
+4 -19
View File
@@ -92,11 +92,9 @@ router.beforeEach((to, from, next) => {
clearTimeout(timer)
}
if (!first) {
timer = setTimeout(() => {
NProgress.start()
}, 100)
}
timer = setTimeout(() => {
NProgress.start()
}, first ? 200 : 100)
const token = localStorage.getItem('token')
@@ -151,11 +149,7 @@ function loadBackground(next) {
router.afterEach((to) => {
clearTimeout(timer)
if (first) {
removeLoading()
} else {
NProgress.done();
}
NProgress.done();
const uiStore = useUiStore()
if (to.meta.menu) {
@@ -173,13 +167,4 @@ router.afterEach((to) => {
first = false
})
function removeLoading() {
const doc = document.getElementById('loading-first');
if (!doc) {
return;
}
doc.remove()
}
export default router
+1 -4
View File
@@ -11,8 +11,5 @@ export const useSettingStore = defineStore('setting', {
}),
actions: {
},
persist: {
pick: ['lang'],
},
}
})
+4 -5
View File
@@ -102,6 +102,7 @@ import {Icon} from "@iconify/vue";
import router from "@/router/index.js";
import {useI18n} from 'vue-i18n';
import {toUtc} from "@/utils/day.js";
import {AutoRefreshEnum} from "@/enums/setting-enum.js";
import {sleep} from "@/utils/time-utils.js";
import {useSettingStore} from "@/store/setting.js";
import { useRoute } from 'vue-router'
@@ -296,13 +297,11 @@ async function latest() {
while (true) {
let autoRefresh = settingStore.settings.autoRefresh;
await sleep(autoRefresh > 1 ? autoRefresh * 1000 : 3000);
await sleep(1000)
const latestId = sysEmailScroll.value.latestEmail?.emailId
if (autoRefresh < 2) {
if (settingStore.settings.autoRefresh === AutoRefreshEnum.DISABLED) {
continue
}
@@ -346,7 +345,7 @@ async function latest() {
} catch (e) {
if (e.code === 401 || e.code === 403) {
settingStore.settings.autoRefresh = 0;
settingStore.settings.autoRefresh = AutoRefreshEnum.DISABLED;
}
console.error(e)
}
+3 -8
View File
@@ -7,8 +7,7 @@
<Icon class="icon" @click="changeStar" v-if="email.isStar" icon="fluent-color:star-16" width="20" height="20"/>
<Icon class="icon" @click="changeStar" v-else icon="solar:star-line-duotone" width="18" height="18"/>
</span>
<Icon class="icon" v-if="emailStore.contentData.showReply" v-perm="'email:send'" @click="openReply" icon="la:reply" width="21" height="21" />
<Icon class="icon" v-if="emailStore.contentData.showReply" v-perm="'email:send'" @click="openForward" icon="iconoir:arrow-up-right" width="20" height="20" />
<Icon class="icon" v-if="emailStore.contentData.showReply" v-perm="'email:send'" @click="openReply" icon="la:reply" width="20" height="20" />
</div>
<div></div>
<el-scrollbar class="scrollbar">
@@ -30,7 +29,7 @@
<div>{{ formatDetailDate(email.createTime) }}</div>
</div>
</div>
<el-alert v-if="email.status === 3" :closable="false" :title="toMessage(email.message)" class="email-msg" type="error" show-icon />
<el-alert v-if="email.status === 3" :closable="false" :title="`${$t('bounced')} ` + toMessage(email.message)" class="email-msg" type="error" show-icon />
<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>
@@ -122,10 +121,6 @@ function openReply() {
uiStore.writerRef.openReply(email)
}
function openForward() {
uiStore.writerRef.openForward(email)
}
function toMessage(message) {
return message ? JSON.parse(message).message : '';
}
@@ -221,7 +216,7 @@ const handleDelete = () => {
}
.header-actions {
padding: 9px 15px 8px;
padding: 9px 15px;
display: flex;
align-items: center;
gap: 20px;
+17 -5
View File
@@ -34,6 +34,7 @@ import {sleep} from "@/utils/time-utils.js";
import router from "@/router/index.js";
import {Icon} from "@iconify/vue";
import { useRoute } from 'vue-router'
import {AutoRefreshEnum} from "@/enums/setting-enum.js";
defineOptions({
name: 'email'
@@ -77,8 +78,7 @@ const existIds = new Set();
async function latest() {
while (true) {
let autoRefresh = settingStore.settings.autoRefresh;
await sleep(autoRefresh > 1 ? autoRefresh * 1000 : 3000);
await sleep(1000)
if (route.name !== 'email') {
continue;
@@ -86,7 +86,7 @@ async function latest() {
const latestId = scroll.value.latestEmail?.emailId
if (!scroll.value.firstLoad && autoRefresh > 1) {
if (!scroll.value.firstLoad && settingStore.settings.autoRefresh === AutoRefreshEnum.ENABLED) {
try {
const accountId = accountStore.currentAccountId
const allReceive = scroll.value.latestEmail?.allReceive
@@ -110,7 +110,19 @@ async function latest() {
if (!existIds.has(email.emailId)) {
existIds.add(email.emailId)
scroll.value.addItem(email)
const flag = scroll.value.addItem(email)
if (innerWidth > 1367 && flag) {
ElNotification({
type: 'primary',
message: `<div style="cursor: pointer;"><div style="overflow: hidden;white-space: nowrap;text-overflow: ellipsis; font-weight: bold;font-size: 16px;margin-bottom: 5px;">${email.name}</div><div style="color: teal;">${email.subject}</div></div>`,
position: 'bottom-right',
dangerouslyUseHTMLString: true,
onClick: () => {
jumpContent(email);
}
})
}
await sleep(50)
}
@@ -122,7 +134,7 @@ async function latest() {
}
} catch (e) {
if (e.code === 401 || e.code === 403) {
settingStore.settings.autoRefresh = 0;
settingStore.settings.autoRefresh = AutoRefreshEnum.DISABLED;
}
console.error(e)
}
+19 -108
View File
@@ -14,9 +14,9 @@
<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 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">
<el-input :class="settingStore.settings.loginDomain === 0 ? 'email-input' : ''" v-model="form.email"
type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append v-if="settingStore.settings.loginDomain === 0">
<div @click.stop="openSelect">
<el-select
v-if="show === 'login'"
@@ -39,26 +39,19 @@
</div>
</template>
</el-input>
<el-input ref="loginPasswordInput" v-model="form.password" :placeholder="$t('password')" type="password"
autocomplete="off" @keydown.tab="handlePasswordTab" @keyup.enter="submit">
<el-input v-model="form.password" :placeholder="$t('password')" type="password" autocomplete="off">
</el-input>
<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" type="primary" @click="submit" :loading="loginLoading"
>{{ $t('loginBtn') }}
</el-button>
<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')"
<el-input class="email-input" v-model="registerForm.email" type="text" :placeholder="$t('emailAccount')"
autocomplete="off">
<template #append v-if="!hideLoginDomain">
<template #append>
<div @click.stop="openSelect">
<el-select
v-if="show !== 'login'"
@@ -115,8 +108,8 @@
</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">
<template #append v-if="!hideLoginDomain">
<el-input v-model="bindForm.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append>
<div @click.stop="openSelect">
<el-select
ref="mySelect"
@@ -147,7 +140,7 @@
</el-button>
</div>
</el-dialog>
<a v-show="settingStore.settings.projectLink" class="github" href="https://github.com/maillab/cloud-mail">
<a class="github" href="https://github.com/maillab/cloud-mail">
<Icon icon="mingcute:github-line" color="#1890ff" width="20" height="20" />
</a>
</div>
@@ -158,7 +151,6 @@ import router from "@/router";
import {computed, nextTick, reactive, ref} from "vue";
import {login} from "@/request/login.js";
import {register} from "@/request/login.js";
import {websiteConfig} from "@/request/setting.js";
import {isEmail} from "@/utils/verify-utils.js";
import {useSettingStore} from "@/store/setting.js";
import {useAccountStore} from "@/store/account.js";
@@ -170,8 +162,6 @@ import {loginUserInfo} from "@/request/my.js";
import {permsToRouter} from "@/perm/perm.js";
import {useI18n} from "vue-i18n";
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();
@@ -179,13 +169,10 @@ const userStore = useUserStore();
const uiStore = useUiStore();
const settingStore = useSettingStore();
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 loginEmailInput = ref()
const loginPasswordInput = ref()
const bindForm = reactive({
email: '',
@@ -211,18 +198,6 @@ 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
@@ -261,8 +236,6 @@ const loginOpacity = computed(() => {
return uiStore.dark ? `rgba(0, 0, 0, ${opacity})` : `rgba(255, 255, 255, ${opacity})`
})
const hideLoginDomain = computed(() => settingStore.settings.loginDomain === 1)
const background = computed(() => {
return settingStore.settings.background ? {
@@ -277,14 +250,6 @@ const openSelect = () => {
mySelect.value.toggleMenu()
}
const getFullEmail = (email) => {
return hideLoginDomain.value ? email : email + suffix.value
}
const getEmailName = (email) => {
return email.split('@')[0]
}
function linuxDoLogin() {
const clientId = settingStore.settings.linuxdoClientId
const redirectUri = encodeURIComponent(settingStore.settings.linuxdoCallbackUrl)
@@ -340,7 +305,7 @@ function bind() {
}
if (getEmailName(bindForm.email).length < settingStore.settings.minEmailPrefix) {
if (bindForm.email.length < settingStore.settings.minEmailPrefix) {
ElMessage({
message: t('minEmailPrefix', {msg: settingStore.settings.minEmailPrefix}),
type: 'error',
@@ -349,7 +314,7 @@ function bind() {
return
}
let email = getFullEmail(bindForm.email);
let email = bindForm.email + suffix.value;
if (!isEmail(email)) {
@@ -375,7 +340,7 @@ function bind() {
}
const form = {email, oauthUserId: bindForm.oauthUserId, code: bindForm.code}
const form = {email: bindForm.email + suffix.value, oauthUserId: bindForm.oauthUserId, code: bindForm.code}
bindLoading.value = true
oauthBindUser(form).then(data => {
@@ -396,7 +361,7 @@ const submit = () => {
return
}
let email = getFullEmail(form.email);
let email = form.email + (settingStore.settings.loginDomain === 0 ? suffix.value : '');
if (!isEmail(email)) {
ElMessage({
@@ -424,38 +389,8 @@ 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()
const user = await loginUserInfo();
accountStore.currentAccountId = user.account.accountId;
accountStore.currentAccount = user.account;
@@ -470,19 +405,6 @@ async function saveToken(token) {
bindLoading.value = false;
}
function refreshWebsiteConfig() {
websiteConfig().then(setting => {
settingStore.settings = setting
settingStore.domainList = setting.domainList
if (!suffix.value && setting.domainList.length > 0) {
suffix.value = setting.domainList[0]
}
document.title = setting.title
}).catch(e => {
console.error(e)
})
}
function submitRegister() {
@@ -497,7 +419,7 @@ function submitRegister() {
console.log(registerForm.email)
if (getEmailName(registerForm.email).length < settingStore.settings.minEmailPrefix) {
if (registerForm.email.length < settingStore.settings.minEmailPrefix) {
ElMessage({
message: t('minEmailPrefix', {msg: settingStore.settings.minEmailPrefix}),
type: 'error',
@@ -506,9 +428,7 @@ function submitRegister() {
return
}
const email = getFullEmail(registerForm.email);
if (!isEmail(email)) {
if (!isEmail(registerForm.email + suffix.value)) {
ElMessage({
message: t('notEmailMsg'),
type: 'error',
@@ -587,7 +507,7 @@ function submitRegister() {
registerLoading.value = true
const form = {
email,
email: registerForm.email + suffix.value,
password: registerForm.password,
token: verifyToken,
code: registerForm.code
@@ -690,15 +610,6 @@ function submitRegister() {
border-radius: 6px;
}
.login-actions {
display: flex;
gap: 10px;
.btn {
flex: 1;
}
}
.form-desc {
margin-top: 5px;
margin-bottom: 18px;
+1 -1
View File
@@ -57,7 +57,7 @@
</div>
</div>
<div class="empty" v-if="regKeyData.length === 0">
<el-empty v-if="!regKeyFirst" :image-size="isMobile ? 120 : null" :description="$t('noCodeFound')"/>
<el-empty v-if="!regKeyFirst" :image-size="isMobile ? 120 : 0" :description="$t('noCodeFound')"/>
</div>
</el-scrollbar>
<el-dialog v-model="showAdd" :title="$t('addRegKey')">
+19 -4
View File
@@ -71,8 +71,13 @@
autocomplete="off"/>
<el-input class="dialog-input" v-model="form.description" :maxlength="30" type="text"
:placeholder="$t('description')" autocomplete="off"/>
<el-input-tag class="dialog-input" tag-type="warning" v-model="form.banEmail"
<el-input-tag class="dialog-input-tag" tag-type="warning"
:class="form.banEmail.length === 0 ? 'dialog-input' : '' " v-model="form.banEmail"
@add-tag="banEmailAddTag" type="text" :placeholder="$t('emailInterception')" autocomplete="off"/>
<el-radio-group class="dialog-radio" v-model="form.banEmailType" v-if="form.banEmail.length > 0">
<el-radio :label="$t('removeAll')" :value="0"/>
<el-radio :label="$t('removeContent')" :value="1"/>
</el-radio-group>
<el-select
class="dialog-input"
v-model="form.availDomain"
@@ -116,14 +121,13 @@
<div>
<span>{{ node.label }}</span>
<span class="send-num" v-if="data.permKey === 'email:send'" @click.stop>
<el-input-number v-if="form.sendType === 'day' || form.sendType === 'count'" v-model="form.sendCount" controls-position="right" :min="0" :max="99999" size="small"
<el-input-number v-model="form.sendCount" controls-position="right" :min="0" :max="99999" size="small"
:placeholder="$t('total')">
</el-input-number>
<el-select v-model="form.sendType" placeholder="Select" size="small"
:style="`width: ${ locale === 'zh' ? 65 : 85 }px;margin-left: 5px;`">
style="width: 65px;margin-left: 5px;">
<el-option :label="$t('total')" value="count"/>
<el-option :label="$t('daily')" value="day"/>
<el-option :label="$t('internal')" value="internal"/>
<el-option :label="$t('btnBan')" value="ban"/>
</el-select>
</span>
@@ -182,6 +186,7 @@ const form = reactive({
name: null,
description: null,
banEmail: [],
banEmailType: 0,
sendType: 'count',
sendCount: 0,
accountCount: 0,
@@ -330,6 +335,7 @@ function resetForm() {
form.sendCount = 0
form.accountCount = 0
form.banEmail = []
form.banEmailType = 0
form.availDomain = []
tree.value.setCheckedKeys([])
}
@@ -346,6 +352,7 @@ function openRoleSet(role) {
form.sendCount = role.sendCount
form.accountCount = role.accountCount
form.banEmail = role.banEmail
form.banEmailType = role.banEmailType
form.availDomain = role.availDomain
nextTick(() => {
tree.value.setCheckedKeys(role.permIds)
@@ -519,6 +526,14 @@ window.onresize = () => {
.dialog-input {
margin-bottom: 15px !important;
}
.dialog-radio {
margin-top: 5px;
margin-bottom: 5px;
}
.dialog-input-tag {
}
}
.perm-expand {
+1 -170
View File
@@ -30,44 +30,13 @@
</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
:model-value="langSelect"
class="language-select"
placeholder="Select"
@change="changeLang"
>
<el-option label="中文" value="zh" @pointerdown.prevent.stop="changeLang('zh')"/>
<el-option label="English" value="en" @pointerdown.prevent.stop="changeLang('en')"/>
</el-select>
</div>
<div class="del-email" v-perm="'my:delete'">
<div class="title">{{$t('deleteUser')}}</div>
<div style="color: var(--regular-text-color);">
{{$t('delAccountMsg')}}
</div>
<div>
<el-button type="danger" @click="deleteConfirm">{{$t('deleteUserBtn')}}</el-button>
<el-button type="primary" @click="deleteConfirm">{{$t('deleteUserBtn')}}</el-button>
</div>
</div>
<el-dialog v-model="pwdShow" :title="$t('changePassword')" width="340">
@@ -77,15 +46,6 @@
<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>
@@ -96,97 +56,18 @@ import router from "@/router/index.js";
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()
const settingStore = useSettingStore()
const userStore = useUserStore();
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
@@ -226,17 +107,6 @@ function setName() {
})
}
function changeLang(lang) {
let setting = {}
try {
setting = JSON.parse(localStorage.getItem('setting') || '{}')
} catch (e) {
setting = {}
}
localStorage.setItem('setting', JSON.stringify({...setting, lang}))
window.location.reload()
}
const pwdShow = ref(false)
const form = reactive({
password: '',
@@ -379,45 +249,6 @@ function submitPwd() {
}
}
.language {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 40px;
.language-select {
width: 100px;
}
}
.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;
+14 -197
View File
@@ -20,17 +20,10 @@
<div class="setting-item">
<div><span>{{ $t('loginDomain') }}</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="1" :inactive-value="0"
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
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>
@@ -184,11 +177,8 @@
</div>
</div>
<div class="setting-item">
<div><span>{{ setting.hasCfEmail ? $t('cloudflareEmailSending') : $t('resendToken') }}</span></div>
<div v-if="setting.hasCfEmail">
<span>{{ $t('enabled') }}</span>
</div>
<div v-else>
<div><span>{{ $t('resendToken') }}</span></div>
<div>
<el-button class="opt-button" style="margin-top: 0" @click="openResendList" size="small"
type="primary">
<Icon icon="ic:round-list" width="18" height="18"/>
@@ -199,15 +189,6 @@
</el-button>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('blackList') }}</span></div>
<div>
<el-button class="opt-button" style="margin-top: 0" @click="openBlackListForm" size="small"
type="primary">
<Icon icon="fluent:settings-48-regular" width="16" height="16"/>
</el-button>
</div>
</div>
</div>
</div>
@@ -371,27 +352,6 @@
</div>
</div>
<div class="settings-card">
<div class="card-title">Workers AI</div>
<div class="card-content">
<div class="setting-item">
<div><span>{{ $t('codeRecognition') }}</span></div>
<div>
<el-switch @change="changeField('aiCode', $event)" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.aiCode"/>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('codeRecognitionRules') }}</span></div>
<div class="forward">
<el-button class="opt-button" size="small" type="primary" @click="openAiCodeFilter">
<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">
@@ -531,7 +491,7 @@
</div>
</template>
<div class="forward-set-body">
<el-input :placeholder="setting.tgBotToken || $t('tgBotToken')" v-model="tgBotToken"></el-input>
<el-input :placeholder="$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" ></el-input>
@@ -764,55 +724,17 @@
</div>
<div class="prefix-filter">
<div style="margin-bottom: 10px;">{{ t('mustNotContain') }}</div>
<el-input-tag style="margin-bottom: 10px;" v-model="emailPrefixFilter" />
<el-input-tag style="margin-bottom: 10px;" v-model="emailPrefixFilter" :placeholder="t('mustNotContainDesc')" />
</div>
<el-button type="primary" style="width: 100%;" :loading="settingLoading" @click="saveEmailPrefix">{{ $t('save') }}</el-button>
</el-dialog>
<el-dialog v-model="blackFormShow" class="forward-dialog" @closed="resetBlackList">
<template #header>
<div class="forward-head">
<span class="forward-set-title">{{ $t('blackList') }}</span>
<el-tooltip effect="dark" :content="$t('blackListDesc')">
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</div>
</template>
<el-form>
<el-form-item :label="t('blackFromDesc')" label-position="top">
<el-input-tag v-model="blackListForm.blackFrom" @add-tag="banEmailAddTag" />
</el-form-item>
<el-form-item :label="t('blackSubjectDesc')" label-position="top">
<el-input-tag v-model="blackListForm.blackSubject"/>
</el-form-item>
<el-form-item :label="t('blackContentDesc')" label-position="top">
<el-input-tag v-model="blackListForm.blackContent"/>
</el-form-item>
</el-form>
<el-button type="primary" style="width: 100%;" :loading="settingLoading" @click="saveBlackList">{{ $t('save') }}</el-button>
</el-dialog>
<el-dialog v-model="aiCodeFilterShow" class="forward-dialog" @closed="resetAiCodeFilter">
<template #header>
<div class="forward-head">
<span class="forward-set-title">{{ $t('codeRecognitionRules') }}</span>
<el-tooltip effect="dark" :content="$t('codeRecognitionRulesDesc')">
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</div>
</template>
<el-form>
<el-form-item :label="t('senderRules')" label-position="top">
<el-input-tag v-model="aiCodeFilter" @add-tag="aiCodeFilterAddTag"/>
</el-form-item>
</el-form>
<el-button type="primary" style="width: 100%;" :loading="settingLoading" @click="saveAiCodeFilter">{{ $t('save') }}</el-button>
</el-dialog>
</el-scrollbar>
</div>
</template>
<script setup>
import {computed, defineOptions, nextTick, reactive, ref} from "vue";
import {deleteBackground, setBackground, setBlackList, settingQuery, settingSet} from "@/request/setting.js";
import {computed, defineOptions, reactive, ref} from "vue";
import {deleteBackground, setBackground, settingQuery, settingSet} from "@/request/setting.js";
import {useSettingStore} from "@/store/setting.js";
import {useUiStore} from "@/store/ui.js";
import {useUserStore} from "@/store/user.js";
@@ -821,7 +743,7 @@ import {Icon} from "@iconify/vue";
import {cvtR2Url} from "@/utils/convert.js";
import {storeToRefs} from "pinia";
import {debounce} from 'lodash-es'
import {isDomain, isEmail} from "@/utils/verify-utils.js";
import {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"
@@ -832,20 +754,17 @@ defineOptions({
name: 'sys-setting'
})
const currentVersion = 'v3.0.0'
const currentVersion = 'v2.8.0'
const hasUpdate = ref(false)
let getUpdateErrorCount = 1;
const {t, locale} = useI18n();
const firstLoading = ref(true)
const settingReady = ref(false)
const backgroundImage = ref('')
const localUpShow = ref(false)
const accountStore = useAccountStore();
const userStore = useUserStore();
const editTitleShow = ref(false)
const resendTokenFormShow = ref(false)
const blackFormShow = ref(false)
const aiCodeFilterShow = ref(false)
const r2DomainShow = ref(false)
const turnstileShow = ref(false)
const tgSettingShow = ref(false)
@@ -908,20 +827,9 @@ const regKeyOptions = computed(() => [
{label: t('optional'), value: 2},
])
const blackListForm = ref({
blackSubject: [],
blackContent: [],
blackFrom: []
})
const aiCodeFilter = ref([])
const authRefreshOptions = computed(() => [
{label: t('disable'), value: 0},
{label: '3s', value: 3},
{label: '5s', value: 5},
{label: '10s', value: 10},
{label: '15s', value: 15},
{label: '20s', value: 20},
{label: t('enable'), value: 1},
])
const tgChatId = ref([])
@@ -947,10 +855,8 @@ getSettings()
getUpdate()
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
@@ -964,11 +870,6 @@ function getSettings() {
resetNoticeForm()
resetAddS3Form()
resetEmailPrefix()
resetBlackList()
resetAiCodeFilter()
nextTick(() => {
settingReady.value = true
})
})
}
@@ -1063,7 +964,7 @@ function closedSetBackground() {
function openTgSetting() {
tgBotStatus.value = setting.value.tgBotStatus
tgBotToken.value = ''
tgBotToken.value = setting.value.tgBotToken
customDomain.value = setting.value.customDomain
tgMsgFrom.value = setting.value.tgMsgFrom
tgMsgText.value = setting.value.tgMsgText
@@ -1204,6 +1105,7 @@ function saveS3() {
function tgBotSave() {
const form = {
tgBotToken: tgBotToken.value,
customDomain: customDomain.value,
tgBotStatus: tgBotStatus.value,
tgChatId: tgChatId.value + '',
@@ -1211,7 +1113,6 @@ function tgBotSave() {
tgMsgText: tgMsgText.value,
tgMsgTo: tgMsgTo.value
}
if (tgBotToken.value) form.tgBotToken = tgBotToken.value
editSetting(form)
}
@@ -1233,7 +1134,6 @@ function ruleEmailSave() {
}
function doOpacityChange() {
if (!settingReady.value) return
const form = {}
form.loginOpacity = loginOpacity.value
editSetting(form, true)
@@ -1244,16 +1144,6 @@ function resetEmailPrefix() {
emailPrefixFilter.value = setting.value.emailPrefixFilter
}
function resetBlackList() {
blackListForm.value.blackFrom = setting.value.blackFrom ? setting.value.blackFrom.split(',') : []
blackListForm.value.blackContent = setting.value.blackContent ? setting.value.blackContent.split(',') : []
blackListForm.value.blackSubject = setting.value.blackSubject ? setting.value.blackSubject.split(',') : []
}
function resetAiCodeFilter() {
aiCodeFilter.value = setting.value.aiCodeFilter ? setting.value.aiCodeFilter.split(',') : []
}
function saveEmailPrefix() {
const form = {}
form.minEmailPrefix = minEmailPrefix.value
@@ -1261,67 +1151,11 @@ function saveEmailPrefix() {
editSetting(form, true)
}
function saveAiCodeFilter() {
editSetting({aiCodeFilter: aiCodeFilter.value + ''})
}
const opacityChange = debounce(doOpacityChange, 1000, {
leading: false,
trailing: true
})
function saveBlackList() {
let form = {
blackContent: blackListForm.value.blackContent + '',
blackSubject: blackListForm.value.blackSubject + '',
blackFrom: blackListForm.value.blackFrom + ''
}
settingLoading.value = true
setBlackList(form).then(() => {
getSettings()
ElMessage({
message: t('setSuccess'),
type: "success",
plain: true
})
blackFormShow.value = false;
}).finally(() => {
settingLoading.value = false;
})
}
function banEmailAddTag(val) {
const emails = Array.from(new Set(
val.split(/[,]/).map(item => item.trim()).filter(item => item)
));
blackListForm.value.blackFrom.splice(blackListForm.value.blackFrom.length - 1, 1)
emails.forEach(email => {
if ((isEmail(email) || isDomain(email)) && !blackListForm.value.blackFrom.includes(email)) {
blackListForm.value.blackFrom.push(email)
}
})
}
function aiCodeFilterAddTag(val) {
const emails = Array.from(new Set(
val.split(/[,]/).map(item => item.trim()).filter(item => item)
));
aiCodeFilter.value.splice(aiCodeFilter.value.length - 1, 1)
emails.forEach(email => {
if ((isEmail(email) || isDomain(email)) && !aiCodeFilter.value.includes(email)) {
aiCodeFilter.value.push(email)
}
})
}
function delBackground() {
ElMessageBox.confirm(t('delBackgroundConfirm'), {
confirmButtonText: t('confirm'),
@@ -1407,14 +1241,6 @@ function openResendForm() {
resendTokenFormShow.value = true
}
function openBlackListForm() {
blackFormShow.value = true
}
function openAiCodeFilter() {
aiCodeFilterShow.value = true
}
function saveResendToken() {
const settingForm = {
resendTokens: {}
@@ -1437,29 +1263,21 @@ function cleanResendTokenForm() {
}
function beforeChange() {
if (!settingReady.value || settingLoading.value) return false
if (settingLoading.value) return false
backupSetting()
return true
}
function change(e) {
if (!settingReady.value) return
const settingForm = {...setting.value}
delete settingForm.siteKey
delete settingForm.secretKey
delete settingForm.s3AccessKey
delete settingForm.s3SecretKey
delete settingForm.tgBotToken
delete settingForm.resendTokens
editSetting(settingForm, false)
}
function changeField(key, value) {
if (!settingReady.value) return
setting.value[key] = value
editSetting({[key]: value}, false)
}
function saveTitle() {
editSetting({title: editTitle.value})
}
@@ -1500,7 +1318,6 @@ function editSetting(settingForm, refreshStatus = true) {
noticePopupShow.value = false
addS3Show.value = false
emailPrefixShow.value = false
aiCodeFilterShow.value = false
}).catch((e) => {
loginOpacity.value = setting.value.loginOpacity
setting.value = {...setting.value, ...JSON.parse(backup)}
@@ -1561,7 +1378,7 @@ function editSetting(settingForm, refreshStatus = true) {
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
padding: 20px;
gap: 20px;
@media (max-width: 500px) {
+3 -10
View File
@@ -153,7 +153,7 @@
</el-button>
</div>
</el-dialog>
<el-dialog v-model="showAdd" :title="$t('addUser')" @closed="resetAddForm">
<el-dialog v-model="showAdd" :title="$t('addUser')">
<div class="container">
<el-input v-model="addForm.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append>
@@ -743,12 +743,13 @@ function submit() {
form.email = form.email + form.suffix
userAdd(form).then(() => {
addLoading.value = false
addForm.email = ''
showAdd.value = false
ElMessage({
message: t('addSuccessMsg'),
type: "success",
plain: true
})
resetAddForm()
getUserList(false)
}).finally(res => {
addLoading.value = false
@@ -760,7 +761,6 @@ function formatSendType(user) {
if (user.sendAction.sendType === 'day') return t('daily')
if (user.sendAction.sendType === 'count') return t('total')
if (user.sendAction.sendType === 'ban') return t('sendBanned')
if (user.sendAction.sendType === 'internal') return t('sendInternal')
}
function formatSendCount(user) {
@@ -1273,13 +1273,6 @@ function adjustWidth() {
white-space: nowrap;
}
:deep(.el-table) {
@media (pointer: coarse) {
/* 触屏 */
user-select: none;
}
}
:deep(.el-table th.el-table__cell>.cell.highlight) {
color: #909399;
}
+2 -3
View File
@@ -11,15 +11,14 @@
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.7.5",
"vitest": "~3.0.7",
"wrangler": "^4.90.0"
"wrangler": "^4.7.0"
},
"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",
"hono": "^4.7.5",
"i18next": "^25.3.2",
"linkedom": "^0.18.10",
"postal-mime": "^2.4.3",
+286 -802
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1,6 +1,11 @@
import app from '../hono/hono';
import { dbInit } from '../init/init';
import { initForward } from "../init/forward";
app.get('/init/:secret', (c) => {
return dbInit.init(c);
})
app.post('/initForward', async (c) => {
return initForward(c, await c.req.json());
})
-32
View File
@@ -1,32 +0,0 @@
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());
});
+1 -1
View File
@@ -19,7 +19,7 @@ app.put('/role/set', async (c) => {
return c.json(result.ok());
});
app.get('/role/tree', async (c) => {
app.get('/role/permTree', async (c) => {
const tree = await permService.tree(c);
return c.json(result.ok(tree));
});
-6
View File
@@ -1,7 +1,6 @@
import app from '../hono/hono';
import result from '../model/result';
import settingService from '../service/setting-service';
import userContext from "../security/user-context";
app.put('/setting/set', async (c) => {
await settingService.set(c, await c.req.json());
@@ -28,8 +27,3 @@ app.delete('/setting/deleteBackground', async (c) => {
return c.json(result.ok());
});
app.put('/setting/setBlacklist', async (c) => {
const setting = await settingService.setBlacklist(c, await c.req.json());
return c.json(result.ok(setting));
})
-4
View File
@@ -128,10 +128,6 @@ export const settingConst = {
OPEN: 0,
CLOSE: 1
},
aiCode: {
OPEN: 0,
CLOSE: 1
},
authRefresh: {
OPEN: 1,
CLOSE: 0
-1
View File
@@ -2,7 +2,6 @@ const KvConst = {
AUTH_INFO: 'auth-uid:',
SETTING: 'setting:',
SEND_DAY_COUNT: 'send_day_count:',
ANALYSIS_ECHARTS: 'analysis_echarts:',
PUBLIC_KEY: "public_key:"
}
+49 -44
View File
@@ -5,12 +5,13 @@ import settingService from '../service/setting-service';
import attService from '../service/att-service';
import constant from '../const/constant';
import fileUtils from '../utils/file-utils';
import { emailConst, isDel, settingConst } from '../const/entity-const';
import { emailConst, isDel, roleConst, settingConst } from '../const/entity-const';
import emailUtils from '../utils/email-utils';
import roleService from '../service/role-service';
import verifyUtils from '../utils/verify-utils';
import r2Service from '../service/r2-service';
import userService from '../service/user-service';
import telegramService from '../service/telegram-service';
import aiService from '../service/ai-service';
export async function email(message, env, ctx) {
@@ -25,12 +26,7 @@ export async function email(message, env, ctx) {
ruleEmail,
ruleType,
r2Domain,
noRecipient,
blackSubject,
blackContent,
blackFrom,
aiCode,
aiCodeFilter
noRecipient
} = await settingService.query({ env });
if (receive === settingConst.receive.CLOSE) {
@@ -38,6 +34,7 @@ export async function email(message, env, ctx) {
return;
}
const reader = message.raw.getReader();
let content = '';
@@ -49,14 +46,6 @@ export async function email(message, env, ctx) {
const email = await PostalMime.parse(content);
const blockFlag = checkBlock(blackSubject, blackContent, blackFrom, email);
if (blockFlag) {
message.setReject('Message rejected');
return;
}
const account = await accountService.selectByEmailIncludeDel({ env: env }, message.to);
if (!account && noRecipient === settingConst.noRecipient.CLOSE) {
@@ -67,21 +56,50 @@ export async function email(message, env, ctx) {
let userRow = {}
if (account) {
userRow = await userService.selectByIdIncludeDel({ env: env }, account.userId);
userRow = await userService.selectById({ env: env }, account.userId);
}
if (account && userRow.email !== env.admin) {
let { banEmail, availDomain } = await roleService.selectByUserId({ env: env }, account.userId);
let { banEmail, banEmailType, availDomain } = await roleService.selectByUserId({ env: env }, account.userId);
if (!roleService.hasAvailDomainPerm(availDomain, message.to)) {
message.setReject('The recipient is not authorized to use this domain.');
message.setReject('Mailbox disabled');
return;
}
if(roleService.isBanEmail(banEmail, email.from.address)) {
message.setReject('The recipient is disabled from receiving emails.');
return;
banEmail = banEmail.split(',').filter(item => item !== '');
if (banEmail.includes('*')) {
if (!banEmailHandler(banEmailType, message, email)) return;
}
for (const item of banEmail) {
if (verifyUtils.isDomain(item)) {
const banDomain = item.toLowerCase();
const receiveDomain = emailUtils.getDomain(email.from.address.toLowerCase());
if (banDomain === receiveDomain) {
if (!banEmailHandler(banEmailType, message, email)) return;
}
} else {
if (item.toLowerCase() === email.from.address.toLowerCase()) {
if (!banEmailHandler(banEmailType, message, email)) return;
}
}
}
}
@@ -92,7 +110,6 @@ export async function email(message, env, ctx) {
}
const toName = email.to.find(item => item.address === message.to)?.name || '';
const code = await aiService.extractCode({ env }, email, { aiCode, aiCodeFilter });
const params = {
toEmail: message.to,
@@ -100,7 +117,6 @@ export async function email(message, env, ctx) {
sendEmail: email.from.address,
name: email.from.name || emailUtils.getName(email.from.address),
subject: email.subject,
code,
content: email.html,
text: email.text,
cc: email.cc ? JSON.stringify(email.cc) : '[]',
@@ -185,30 +201,19 @@ export async function email(message, env, ctx) {
}
}
function checkBlock(blackSubjectStr, blackContentStr, blackFromStr, email) {
function banEmailHandler(banEmailType, message, email) {
const blackFromList = blackFromStr ? blackFromStr.split(',') : []
const blackContentList = blackContentStr ? blackContentStr.split(',') : []
const blackSubjectList = blackSubjectStr ? blackSubjectStr.split(',') : []
for (const blackSubject of blackSubjectList) {
if (email.subject?.includes(blackSubject)) {
return true
}
if (banEmailType === roleConst.banEmailType.ALL) {
message.setReject('Mailbox disabled');
return false;
}
for (const blackContent of blackContentList) {
if (email.html?.includes(blackContent) || email.text?.includes(blackContent)) {
return true
}
if (banEmailType === roleConst.banEmailType.CONTENT) {
email.html = 'The content has been deleted';
email.text = 'The content has been deleted';
email.attachments = [];
}
for (const blackFrom of blackFromList) {
if (email.from.address === blackFrom || emailUtils.getDomain(email.from.address) === blackFrom) {
return true
}
}
return false
return true;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const att = sqliteTable('attachments', {
export const att = sqliteTable('attachments', {
attId: integer('att_id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull(),
emailId: integer('email_id').notNull(),
-1
View File
@@ -7,7 +7,6 @@ export const email = sqliteTable('email', {
accountId: integer('account_id').notNull(),
userId: integer('user_id').notNull(),
subject: text('subject'),
code: text('code').default('').notNull(),
text: text('text'),
content: text('content'),
cc: text('cc').default('[]'),
@@ -1,15 +0,0 @@
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;
+1 -7
View File
@@ -35,7 +35,6 @@ 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(),
@@ -47,11 +46,6 @@ export const setting = sqliteTable('setting', {
tgMsgTo: text('tg_msg_to').default('show').notNull(),
tgMsgText: text('tg_msg_text').default('hide').notNull(),
minEmailPrefix: integer('min_email_prefix').default(0).notNull(),
emailPrefixFilter: text('email_prefix_filter').default('').notNull(),
blackSubject: text('black_subject').default('').notNull(),
blackContent: text('black_content').default('').notNull(),
blackFrom: text('black_from').default('').notNull(),
aiCode: integer('ai_code').default(1).notNull(),
aiCodeFilter: text('ai_code_filter').default('').notNull()
emailPrefixFilter: text('email_prefix_filter').default('').notNull()
});
export default setting
-1
View File
@@ -20,5 +20,4 @@ import '../api/reg-key-api'
import '../api/public-api'
import '../api/telegram-api'
import '../api/oauth-api'
import '../api/passkey-api'
export default app;
+2 -10
View File
@@ -1,9 +1,4 @@
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',
@@ -20,17 +15,15 @@ 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',
noSeparateSend: 'Attachments are not supported in separate sending',
daySendLimit: 'Daily send limit reached',
totalSendLimit: 'Total send limit reached',
daySendLack: 'Not enough remaining sends today',
totalSendLack: 'Not enough total remaining sends',
senderAccountNotExist: 'Sender email does not exist',
noResendToken: 'Resend API token not configured',
noSendProvider: 'Email sending service is not configured',
sendEmailNotCurUser: 'Sender email does not belong to current user',
notExistEmailReply: 'Mail does not exist and cannot be replied to',
imageAttLimit: 'The maximum number of image attachments is 10',
attLimit: 'The maximum number of attachments is 10.',
pwdLengthLimit: 'Password length exceeds the limit',
emailLengthLimit: 'Email length exceeds the limit',
minEmailPrefix: 'Email must be at least {{msg}} characters',
@@ -61,8 +54,7 @@ const en = {
botVerifyFail: 'Bot verification failed, please try again',
authExpired: 'Authentication has expired. Please sign in again',
unauthorized: 'Unauthorized',
bannedSend: 'You can only send emails to internal mailboxes',
onlyInternalSend: 'Your current role allows sending internal emails only',
bannedSend: 'You do not have permission to send emails',
noDomainPermAdd: "No permission to add this domain email",
noDomainPermReg: "No permission to register this domain email",
noDomainPermRegKey: "Registration code not valid for this domain",
+3 -11
View File
@@ -1,9 +1,4 @@
const zh = {
passkeyDisabled: 'Passkey 登录未启用',
passkeyChallengeInvalid: 'Passkey 请求已过期,请重试',
passkeyVerifyFailed: 'Passkey 验证失败',
passkeyNotFound: '未找到可用的 Passkey',
passkeyOriginInvalid: '当前站点无法使用 Passkey',
IncorrectPwd: '密码输入错误',
addAccountDisabled: '添加邮箱功能已关闭',
regDisabled: '注册功能已关闭',
@@ -20,17 +15,15 @@ const zh = {
noOsDomainSendAtt: '对象存储域名未配置不能发送附件',
noOsSendAtt: '对象存储未配置不能发送附件',
disabledSend: '邮件发送功能已停用',
noSeparateSend: '分别发送暂时不支持附件',
daySendLimit: '发送次数已到达每日限制',
totalSendLimit: '发送次数已到达限制',
daySendLack: '当日剩余发送次数不足',
totalSendLack: '剩余发送次数不足',
senderAccountNotExist: '发件人邮箱不存在',
noResendToken: 'Resend未配置,只能给站内邮箱发件',
noSendProvider: '发信服务未配置,只能给站内邮箱发件',
noResendToken: 'resend密钥未配置',
sendEmailNotCurUser: '发件人邮箱非当前用户所有',
notExistEmailReply: '邮件不存在无法回复',
imageAttLimit: '图片不能超过10个',
attLimit: '附件不能超过10个',
pwdLengthLimit: '密码长度超出限制',
emailLengthLimit: '邮箱长度超出限制',
minEmailPrefix: '邮箱名至少{{msg}}位',
@@ -61,8 +54,7 @@ const zh = {
botVerifyFail: '人机验证失败,请重试',
authExpired: '身份认证失效,请重新登录',
unauthorized: '权限不足',
bannedSend: '你没有邮件发送权限',
onlyInternalSend: '权限不足,只能给站内邮箱发件',
bannedSend: '你没有发送邮件权限',
noDomainPermAdd: '你没有权限添加该域名邮箱',
noDomainPermReg: '你没有权限注册该域名邮箱',
noDomainPermRegKey: '你的注册码没有权限注册该域名邮箱',
-7
View File
@@ -5,7 +5,6 @@ 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 analysisService from './service/analysis-service';
export default {
async fetch(req, env, ctx) {
@@ -25,15 +24,9 @@ export default {
},
email: email,
async scheduled(c, env, ctx) {
if (c.cron === '*/30 * * * *') {
await analysisService.refreshEchartsCache({ env })
return;
}
await verifyRecordService.clearRecord({ env })
await userService.resetDaySendCount({ env })
await emailService.completeReceiveAll({ env })
await oauthService.clearNoBindOathUser({ env })
await analysisService.refreshEchartsCache({ env })
},
};
+124
View File
@@ -0,0 +1,124 @@
export async function initForward(c, params) {
const { workerName, domainList, token } = params;
let headers = {
Authorization: `Bearer ${token}`
};
let mainList = [];
const childList = [];
//查询DOMAIN变量对应域名
for (let domain of domainList) {
// 提取一级域名(主域名 + 顶级域名)
const parts = domain.split('.');
let paramDomain = domain
if (parts.length > 2) {
paramDomain = parts.slice(-2).join('.');
}
//结尾匹配查询域名
const res = await fetch(`https://api.cloudflare.com/client/v4/zones?name=ends_with:${paramDomain}`, {
method: 'GET',
headers
});
const body = await res.json();
if(!res.ok) {
return c.json(body);
}
const { result } = body;
result.forEach(item => {
if (domain === item.name) {
mainList.push({ domain: item.name, domainId: item.id });
} else if (domain.includes(item.name)) {
mainList.push({ domain: item.name, domainId: item.id });
childList.push({ domain, domainId: item.id });
}
})
}
mainList = [...new Set(mainList)];
if (mainList.length === 0) {
return c.text('DOMAIN does not exist in Cloudflare.');
}
//开启主域名电子邮件路由
for (const { domainId } of mainList) {
const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${domainId}/email/routing/enable`, {
method: 'POST',
headers
});
const body = await res.json();
const error = body.errors[0];
if(!res.ok) {
return c.text(`${error.code} ${error.message}`);
}
}
//开启catch_all转发到worker
for (const { domainId } of mainList) {
const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${domainId}/email/routing/rules/catch_all`, {
method: 'PUT',
headers,
body: JSON.stringify({
actions: [
{
type: "worker",
value: [workerName]
}
],
matchers: [
{
type: "all"
}
],
enabled: true
})
});
const body = await res.json();
const error = body.errors[0];
if(!res.ok) {
return c.text(`${error.code} ${error.message}`);
}
}
//开启子域名电子邮件路由
for (const { domain, domainId } of childList) {
const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${domainId}/email/routing/enable`, {
method: 'POST',
headers,
body: JSON.stringify({
name: domain
})
});
const body = await res.json();
const error = body.errors[0];
if(!res.ok) {
return c.text(`${error.code} ${error.message}`);
}
}
return c.text('success');
}
+2 -71
View File
@@ -27,80 +27,10 @@ const dbInit = {
await this.v2_6DB(c);
await this.v2_7DB(c);
await this.v2_8DB(c);
await this.v2_9DB(c);
await this.v3_0DB(c);
await this.v3_1DB(c);
await settingService.refresh(c);
return c.text('success');
},
async v3_1DB(c) {
try {
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 {
await c.env.db.batch([
await c.env.db.prepare(`ALTER TABLE email ADD COLUMN code TEXT NOT NULL DEFAULT '';`),
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN ai_code INTEGER NOT NULL DEFAULT 1;`),
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN ai_code_filter TEXT NOT NULL DEFAULT '';`)
]);
} catch (e) {
console.warn(`跳过字段:${e.message}`);
}
try {
await c.env.db.batch([
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN black_subject TEXT NOT NULL DEFAULT '';`),
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN black_content TEXT NOT NULL DEFAULT '';`),
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN black_from TEXT NOT NULL DEFAULT '';`)
]);
} catch (e) {
console.warn(`跳过字段:${e.message}`);
}
},
async v2_9DB(c) {
try {
await c.env.db.prepare(`UPDATE setting SET auto_refresh = 5 WHERE auto_refresh = 1;`).run();
} catch (e) {
console.warn(`跳过字段:${e.message}`);
}
},
async v2_8DB(c) {
try {
await c.env.db.batch([
@@ -114,7 +44,8 @@ const dbInit = {
async v2_7DB(c) {
try {
await c.env.db.batch([
c.env.db.prepare(`ALTER TABLE setting RENAME COLUMN auto_refresh_time TO auto_refresh;`)
c.env.db.prepare(`ALTER TABLE setting RENAME COLUMN auto_refresh_time TO auto_refresh;`),
c.env.db.prepare(`UPDATE setting SET auto_refresh = 1 WHERE auto_refresh != 0;`)
]);
} catch (e) {
console.warn(`跳过字段:${e.message}`);
+3 -6
View File
@@ -18,8 +18,7 @@ const exclude = [
'/public/genToken',
'/telegram',
'/test',
'/oauth',
'/passkey/auth/'
'/oauth'
];
const requirePerms = [
@@ -44,13 +43,11 @@ const requirePerms = [
'/setting/deleteBackground',
'/setting/set',
'/setting/query',
'/setting/setBlacklist',
'/user/delete',
'/user/setPwd',
'/user/setStatus',
'/user/setType',
'/user/list',
'/user/restore',
'/user/resetSendCount',
'/user/add',
'/user/deleteAccount',
@@ -77,13 +74,13 @@ const premKey = {
'user:add': ['/user/add'],
'user:reset-send': ['/user/resetSendCount'],
'user:set-pwd': ['/user/setPwd'],
'user:set-status': ['/user/setStatus', '/user/restore'],
'user:set-status': ['/user/setStatus'],
'user:set-type': ['/user/setType'],
'user:delete': ['/user/delete','/user/deleteAccount'],
'all-email:query': ['/allEmail/list','/allEmail/latest'],
'all-email:delete': ['/allEmail/delete','/allEmail/batchDelete'],
'setting:query': ['/setting/query'],
'setting:set': ['/setting/set', '/setting/setBackground','/setting/deleteBackground','/setting/setBlacklist'],
'setting:set': ['/setting/set', '/setting/setBackground','/setting/deleteBackground'],
'analysis:query': ['/analysis/echarts'],
'reg-key:add': ['/regKey/add'],
'reg-key:query': ['/regKey/list','/regKey/history'],
+2 -2
View File
@@ -12,8 +12,8 @@ const userContext = {
async getToken(c) {
const jwt = c.req.header(constant.TOKEN_HEADER);
const result = await JwtUtils.verifyToken(c,jwt);
return result?.token;
const { token } = JwtUtils.verifyToken(c,jwt);
return token;
},
};
export default userContext;
@@ -259,6 +259,7 @@ const accountService = {
async setAsTop(c, params, userId) {
const { accountId } = params;
console.log(accountId);
const userRow = await userService.selectById(c, userId);
const mainAccountRow = await accountService.selectByEmailIncludeDel(c, userRow.email);
let mainSort = mainAccountRow.sort === 0 ? 2 : mainAccountRow.sort + 1;
-72
View File
@@ -1,72 +0,0 @@
import emailUtils from '../utils/email-utils';
import { settingConst } from '../const/entity-const';
const aiService = {
async extractCode(c, email, options = {}) {
if (!this.shouldExtractCode(options.aiCode, options.aiCodeFilter, email)) {
return '';
}
const ai = c.env.ai;
try {
const subject = email.subject || '';
const text = emailUtils.formatText(email.text || '');
const htmlText = emailUtils.htmlToText(email.html || '');
const body = (htmlText || text).slice(0, 6000);
if (!subject && !body) {
return '';
}
const result = await ai.run(c.env.ai_model || '@cf/meta/llama-3.1-8b-instruct', {
messages: [
{
role: 'system',
content: 'You extract verification codes from emails. Return only JSON like {"code":"12345678"} or {"code":""}. The code must be 8 characters or fewer and must not contain spaces. If the code is longer than 8 characters or contains spaces, return {"code":""}. Do not explain.'
},
{
role: 'user',
content: `Subject: ${subject}\n\n${body}`
}
],
temperature: 0,
max_tokens: 32
});
const content = typeof result === 'string' ? result : result?.response || '';
const json = JSON.parse(content);
if (typeof json.code !== 'string') {
return '';
}
if (json.code.length > 8 || /\s/.test(json.code)) {
return '';
}
return json.code;
} catch (e) {
console.error('验证码提取失败: ', e);
return '';
}
},
shouldExtractCode(aiCode, aiCodeFilterStr, email) {
if (aiCode !== settingConst.aiCode.OPEN) {
return false;
}
const filterList = aiCodeFilterStr ? aiCodeFilterStr.split(',').map(item => item.trim().toLowerCase()).filter(Boolean) : [];
if (filterList.length === 0) {
return true;
}
const fromEmail = (email.from?.address || '').trim().toLowerCase();
const fromDomain = emailUtils.getDomain(fromEmail).toLowerCase();
return filterList.some(item => item === fromEmail || item === fromDomain);
}
};
export default aiService;
@@ -9,38 +9,7 @@ import { toUtc } from '../utils/date-uitil';
const analysisService = {
async echarts(c, params) {
if (!this.analysisCacheEnabled(c)) {
return await this.queryEcharts(c, params);
}
const cacheKey = this.echartsCacheKey(params);
const cache = await c.env.kv.get(cacheKey, { type: 'json' });
if (cache) {
return cache;
}
return await this.refreshEchartsCacheByKey(c, cacheKey);
},
async refreshEchartsCacheByKey(c, cacheKey) {
const params = this.echartsParamsByCacheKey(cacheKey);
const data = await this.queryEcharts(c, params);
await c.env.kv.put(cacheKey, JSON.stringify(data));
return data;
},
async refreshEchartsCache(c) {
if (!this.analysisCacheEnabled(c)) {
return;
}
const { keys } = await c.env.kv.list({ prefix: kvConst.ANALYSIS_ECHARTS });
await Promise.all(keys.map(key => this.refreshEchartsCacheByKey(c, key.name)));
},
async queryEcharts(c, params) {
const { timeZone } = params;
@@ -115,20 +84,6 @@ const analysisService = {
return {date: day,total}
})
},
echartsCacheKey(params = {}) {
return kvConst.ANALYSIS_ECHARTS + encodeURIComponent(params.timeZone || 'UTC');
},
echartsParamsByCacheKey(cacheKey) {
return {
timeZone: decodeURIComponent(cacheKey.replace(kvConst.ANALYSIS_ECHARTS, ''))
};
},
analysisCacheEnabled(c) {
return c.env.analysis_cache === true || c.env.analysis_cache === 'true';
}
}
+15 -28
View File
@@ -91,10 +91,13 @@ const attService = {
if (src.startsWith(domainUtils.toOssDomain(r2Domain))) {
attData.key = src.replace(domainUtils.toOssDomain(r2Domain) + '/','');
attData.path = src;
}
if (src.startsWith('attachments/')) {
const origin = new URL(c.req.url).origin;
attData.key = src;
attData.path = origin + '/' + src;
}
attData.contentId = cid;
@@ -114,34 +117,22 @@ const attService = {
}
//查询已有内嵌url图片信息
const keys = [...new Set(imageDataList.filter(item => !item.content).map(item => item.key))];
const keys = [...new Set(imageDataList.filter(item => item.path).map(item => item.key))];
const dbImageList = await this.selectOneByKeys(c, keys);
//设置给当前附件
await Promise.all(imageDataList.map(async image => {
if (image.content) {
return;
}
imageDataList.forEach(image => {
dbImageList.forEach(dbImage => {
if (image.path && (image.key === dbImage.key)) {
image.size = dbImage.size;
image.filename = dbImage.filename;
image.mimeType = dbImage.mimeType;
image.contentType = dbImage.mimeType;
}
})
})
const dbImage = dbImageList.find(dbImage => image.key === dbImage.key);
if (!dbImage) {
return;
}
image.size = dbImage.size;
image.filename = dbImage.filename;
image.mimeType = dbImage.mimeType;
image.contentType = dbImage.mimeType;
const obj = await r2Service.getObj(c, image.key);
if (!obj) {
return;
}
image.content = obj instanceof ArrayBuffer ? obj : await obj.arrayBuffer();
}))
imageDataList = imageDataList.filter(image => image.content);
imageDataList = imageDataList.filter(image => !image.path || image.size);
return { imageDataList, html: document.toString() };
},
@@ -180,15 +171,11 @@ const attService = {
attData.emailId = emailId;
attData.accountId = accountId;
attData.type = attConst.type.EMBED;
if (!attData.buff) {
continue;
}
await r2Service.putObj(c, attData.key, attData.buff, {
contentType: attData.mimeType,
cacheControl: `max-age=259200`,
contentDisposition: `inline;filename=${attData.filename}`
});
delete attData.buff;
}
await orm(c).insert(att).values(attDataList).run();
+165 -412
View File
@@ -7,7 +7,6 @@ import settingService from './setting-service';
import accountService from './account-service';
import BizError from '../error/biz-error';
import emailUtils from '../utils/email-utils';
import fileUtils from '../utils/file-utils';
import { Resend } from 'resend';
import attService from './att-service';
import { parseHTML } from 'linkedom';
@@ -20,8 +19,7 @@ import kvConst from '../const/kv-const';
import { t } from '../i18n/i18n'
import domainUtils from '../utils/domain-uitls';
import account from "../entity/account";
import { att } from '../entity/att';
import telegramService from './telegram-service';
import {sleep} from "../utils/time-utils";
const emailService = {
@@ -149,26 +147,25 @@ const emailService = {
return orm(c).insert(email).values({ ...params }).returning().get();
},
//邮件发送
async send(c, params, userId) {
let {
accountId, //发送账号id
name, //发件人名字
sendType, //发件类型
emailId, //邮件id,如果是回复邮件会带
receiveEmail, //收件人邮箱
text, //邮件纯文本
content, //邮件内容
subject, //邮件标题
attachments = [] //附件
accountId,
name,
sendType,
emailId,
receiveEmail,
manyType,
text,
content,
subject,
attachments
} = params;
const { resendTokens, r2Domain, send, domainList } = await settingService.query(c);
const { resendTokens, r2Domain, send } = await settingService.query(c);
let { imageDataList, html } = await attService.toImageUrlHtml(c, content);
//判断是否关闭发件功能
if (send === settingConst.send.CLOSE) {
throw new BizError(t('disabledSend'), 403);
}
@@ -176,27 +173,10 @@ const emailService = {
const userRow = await userService.selectById(c, userId);
const roleRow = await roleService.selectById(c, userRow.type);
//判断接收方是不是全部为站内邮箱
const allInternal = receiveEmail.every(email => {
const domain = '@' + emailUtils.getDomain(email);
return domainList.includes(domain);
});
if (c.env.admin !== userRow.email) {
//发件被禁用
if (roleRow.sendType === 'ban') {
throw new BizError(t('bannedSend'), 403);
}
//发件被禁用
if (roleRow.sendType === 'internal' && !allInternal) {
throw new BizError(t('onlyInternalSend'), 403);
}
if (c.env.admin !== userRow.email && roleRow.sendType === 'ban') {
throw new BizError(t('bannedSend'), 403);
}
//如果不是管理员,权限设置了发送次数
if (c.env.admin !== userRow.email && roleRow.sendCount) {
if (userRow.sendCount >= roleRow.sendCount) {
@@ -211,6 +191,11 @@ const emailService = {
}
if (attachments.length > 0 && manyType === 'divide') {
throw new BizError(t('noSeparateSend'));
}
const accountRow = await accountService.selectById(c, accountId);
if (!accountRow) {
@@ -222,7 +207,7 @@ const emailService = {
}
if (c.env.admin !== userRow.email) {
//用户没有这个域名的使用权限
if(!roleService.hasAvailDomainPerm(roleRow.availDomain, accountRow.email)) {
throw new BizError(t('noDomainPermSend'),403)
}
@@ -231,14 +216,12 @@ const emailService = {
const domain = emailUtils.getDomain(accountRow.email);
const resendToken = resendTokens[domain];
const useCloudflareEmail = !!c.env.email;
//如果接收方存在站外邮箱,又没有发信服务
if (!useCloudflareEmail && !resendToken && !allInternal) {
throw new BizError(t('noSendProvider'));
if (!resendToken) {
throw new BizError(t('noResendToken'));
}
//没有发件人名字自动截取
if (!name) {
name = emailUtils.getName(accountRow.email);
}
@@ -247,7 +230,6 @@ const emailService = {
messageId: null
};
//如果是回复邮件
if (sendType === 'reply') {
emailRow = await this.selectById(c, emailId);
@@ -258,40 +240,59 @@ const emailService = {
}
let sendResult = {};
let resendResult = null;
//存在站外邮箱时,如果配置了 Cloudflare Email Service 就优先使用,否则使用 Resend
if (!allInternal) {
const resend = new Resend(resendToken);
if (useCloudflareEmail) {
sendResult = await this.sendByCloudflareEmail(c, {
name,
accountEmail: accountRow.email,
receiveEmail,
subject,
text,
html,
attachments: [...imageDataList, ...attachments],
sendType,
messageId: emailRow.messageId
});
} else {
sendResult = await this.sendByResend(resendToken, {
name,
accountEmail: accountRow.email,
receiveEmail,
subject,
text,
html,
attachments: [...imageDataList, ...attachments],
sendType,
messageId: emailRow.messageId
});
//如果是分开发送
if (manyType === 'divide') {
let sendFormList = [];
receiveEmail.forEach(email => {
const sendForm = {
from: `${name} <${accountRow.email}>`,
to: [email],
subject: subject,
text: text,
html: html
};
if (sendType === 'reply') {
sendForm.headers = {
'in-reply-to': emailRow.messageId,
'references': emailRow.messageId
};
}
sendFormList.push(sendForm);
});
resendResult = await resend.batch.send(sendFormList);
} else {
const sendForm = {
from: `${name} <${accountRow.email}>`,
to: [...receiveEmail],
subject: subject,
text: text,
html: html,
attachments: [...imageDataList, ...attachments]
};
if (sendType === 'reply') {
sendForm.headers = {
'in-reply-to': emailRow.messageId,
'references': emailRow.messageId
};
}
resendResult = await resend.emails.send(sendForm);
}
const { data, error } = sendResult;
const { data, error } = resendResult;
if (error) {
@@ -303,7 +304,6 @@ const emailService = {
//把图片标签cid标签切换会通用url
html = this.imgReplace(html, imageDataList, r2Domain);
//封装数据保存到数据库
const emailData = {};
emailData.sendEmail = accountRow.email;
emailData.name = name;
@@ -311,60 +311,72 @@ const emailService = {
emailData.content = html;
emailData.text = text;
emailData.accountId = accountId;
emailData.status = useCloudflareEmail ? emailConst.status.DELIVERED : emailConst.status.SENT;
emailData.type = emailConst.type.SEND;
emailData.userId = userId;
emailData.resendEmailId = data?.id;
emailData.status = emailConst.status.SENT;
const recipient = [];
const emailDataList = [];
receiveEmail.forEach(item => {
recipient.push({ address: item, name: '' });
});
if (manyType === 'divide') {
emailData.recipient = JSON.stringify(recipient);
receiveEmail.forEach((item, index) => {
const emailDataItem = { ...emailData };
emailDataItem.resendEmailId = data.data[index].id;
emailDataItem.recipient = JSON.stringify([{ address: item, name: '' }]);
emailDataList.push(emailDataItem);
});
if (sendType === 'reply') {
emailData.inReplyTo = emailRow.messageId;
emailData.relation = emailRow.messageId;
} else {
emailData.resendEmailId = data.id;
const recipient = [];
receiveEmail.forEach(item => {
recipient.push({ address: item, name: '' });
});
emailData.recipient = JSON.stringify(recipient);
emailDataList.push(emailData);
}
//如果权限有发送次数增加用户发送次数
if (roleRow.sendCount && roleRow.sendType !== 'internal') {
if (sendType === 'reply') {
emailDataList.forEach(emailData => {
emailData.inReplyTo = emailRow.messageId;
emailData.relation = emailRow.messageId;
});
}
if (roleRow.sendCount) {
await userService.incrUserSendCount(c, receiveEmail.length, userId);
}
//保存到数据库并返回结果
const emailResult = await orm(c).insert(email).values(emailData).returning().get();
const emailRowList = await Promise.all(
//保存内嵌附件
if (imageDataList.length > 0) {
if (imageDataList.length > 10) {
throw new BizError(t('imageAttLimit'));
}
await attService.saveArticleAtt(c, imageDataList, userId, accountId, emailResult.emailId);
}
emailDataList.map(async (emailData) => {
const emailRow = await orm(c).insert(email).values(emailData).returning().get();
//保存普通附件
if (attachments?.length > 0) {
if (attachments.length > 10) {
throw new BizError(t('attLimit'));
}
await attService.saveSendAtt(c, attachments, userId, accountId, emailResult.emailId);
}
if (imageDataList.length > 0) {
await attService.saveArticleAtt(c, imageDataList, userId, accountId, emailRow.emailId);
}
const attList = await attService.selectByEmailIds(c, [emailResult.emailId]);
emailResult.attList = attList;
if (attachments?.length > 0) {
await attService.saveSendAtt(c, attachments, userId, accountId, emailRow.emailId);
}
//如果全是站内接收方,直接写入数据库
if (allInternal) {
await this.HandleOnSiteEmail(c, receiveEmail, emailResult, attList);
}
const attsList = await attService.selectByEmailIds(c, [emailRow.emailId]);
emailRow.attList = attsList;
return emailRow;
})
);
const dateStr = dayjs().format('YYYY-MM-DD');
let daySendTotal = await c.env.kv.get(kvConst.SEND_DAY_COUNT + dateStr);
//记录每天发件次数统计
if (!daySendTotal) {
await c.env.kv.put(kvConst.SEND_DAY_COUNT + dateStr, JSON.stringify(receiveEmail.length), { expirationTtl: 60 * 60 * 24 });
} else {
@@ -372,282 +384,7 @@ const emailService = {
await c.env.kv.put(kvConst.SEND_DAY_COUNT + dateStr, JSON.stringify(daySendTotal), { expirationTtl: 60 * 60 * 24 });
}
return [ emailResult ];
},
async sendByCloudflareEmail(c, params) {
const sendForm = {
from: { email: params.accountEmail, name: params.name },
to: [...params.receiveEmail],
subject: params.subject
};
if (params.text) {
sendForm.text = params.text;
}
if (params.html) {
sendForm.html = params.html;
}
const attachments = await this.toCloudflareAttachments(params.attachments);
if (attachments.length > 0) {
sendForm.attachments = attachments;
}
if (params.sendType === 'reply' && params.messageId) {
sendForm.headers = {
'in-reply-to': params.messageId,
'references': params.messageId
};
}
const result = await c.env.email.send(sendForm);
return {
data: {
id: result.messageId
}
};
},
async sendByResend(resendToken, params) {
const resend = new Resend(resendToken);
const sendForm = {
from: `${params.name} <${params.accountEmail}>`,
to: [...params.receiveEmail],
subject: params.subject,
text: params.text,
html: params.html,
attachments: await this.toResendAttachments(params.attachments)
};
if (params.sendType === 'reply') {
sendForm.headers = {
'in-reply-to': params.messageId,
'references': params.messageId
};
}
return await resend.emails.send(sendForm);
},
async toCloudflareAttachments(attachments) {
const arrayBufferAttachments = await this.toArrayBufferAttachments(attachments);
return arrayBufferAttachments.map(attachment => {
const item = {
content: attachment.content,
filename: attachment.filename,
type: attachment.mimeType || attachment.contentType || attachment.type || 'application/octet-stream',
disposition: attachment.contentId ? 'inline' : 'attachment'
};
if (attachment.contentId) {
item.contentId = attachment.contentId.replace(/^<|>$/g, '');
}
return item;
});
},
async toResendAttachments(attachments = []) {
const result = [];
for (const attachment of attachments) {
const content = await this.toAttachmentBase64(attachment);
if (!content) {
continue;
}
result.push({
...attachment,
content,
contentType: attachment.contentType || attachment.mimeType || attachment.type || 'application/octet-stream'
});
}
return result;
},
async toArrayBufferAttachments(attachments = []) {
const result = [];
for (const attachment of attachments) {
const content = await this.toAttachmentArrayBuffer(attachment);
if (!content) {
continue;
}
result.push({ ...attachment, content });
}
return result;
},
async toAttachmentBase64(attachment) {
let content = attachment.content;
if (!content) {
return null;
}
if (typeof content === 'string') {
if (content.startsWith('data:')) {
content = content.split(',')[1] || content;
}
return content.replace(/\s+/g, '');
}
const arrayBuffer = await this.toAttachmentArrayBuffer(attachment);
if (!arrayBuffer) {
return null;
}
const bytes = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
},
async toAttachmentArrayBuffer(attachment) {
let content = attachment.content;
if (!content) {
return null;
}
if (content instanceof ArrayBuffer) {
return content;
}
if (content instanceof Uint8Array) {
return content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength);
}
if (typeof content === 'string') {
if (content.startsWith('data:')) {
content = content.split(',')[1] || content;
}
return fileUtils.base64ToUint8Array(content.replace(/\s+/g, '')).buffer;
}
return content;
},
//处理站内邮件发送
async HandleOnSiteEmail(c, receiveEmail, sendEmailData, attList) {
const { noRecipient } = await settingService.query(c);
//查询所有收件人账号信息
let accountList = await orm(c).select().from(account).where(inArray(account.email, receiveEmail)).all();
//查询所有收件人权限身份
const userIds = accountList.map(accountRow => accountRow.userId);
let roleList = await roleService.selectByUserIds(c, userIds);
//封装数据库准备保存到数据库
const emailDataList = [];
for (const email of receiveEmail) {
//把发件人邮件改成收件
const emailValues = {...sendEmailData}
emailValues.status = emailConst.status.RECEIVE;
emailValues.type = emailConst.type.RECEIVE;
emailValues.toEmail = email;
emailValues.toName = emailUtils.getName(email);
emailValues.emailId = null;
const accountRow = accountList.find(accountRow => accountRow.email === email);
//如果收件人存在就把邮件信息改成收件人的
if (accountRow) {
//设置给收件人保存
emailValues.userId = accountRow.userId;
emailValues.accountId = accountRow.accountId;
emailValues.type = emailConst.type.RECEIVE;
emailValues.status = emailConst.status.RECEIVE;
const roleRow = roleList.find(roleRow => roleRow.userId === accountRow.userId);
let { banEmail, availDomain } = roleRow;
//如果收件人没有这个域名的使用权限和有邮件拦截,就把邮件改为拒收状态
if (email !== c.env.admin) {
if (!roleService.hasAvailDomainPerm(availDomain, email)) {
emailValues.status = emailConst.status.BOUNCED;
emailValues.message = `The recipient <${email}> is not authorized to use this domain.`;
} else if(roleService.isBanEmail(banEmail, sendEmailData.sendEmail)) {
emailValues.status = emailConst.status.BOUNCED;
emailValues.message = `The recipient <${email}> is disabled from receiving emails.`;
}
}
emailDataList.push(emailValues);
} else {
//设置无收件人邮件信息
emailValues.userId = 0;
emailValues.accountId = 0;
emailValues.type = emailConst.type.RECEIVE;
emailValues.status = emailConst.status.NOONE;
//如果无人收件关闭改为拒收
if (noRecipient === settingConst.noRecipient.CLOSE) {
emailValues.status = emailConst.status.BOUNCED;
emailValues.message = `Recipient not found: <${email}>`;
}
emailDataList.push(emailValues);
}
}
//保存邮件
const receiveEmailList = emailDataList.filter(emailRow => emailRow.status === emailConst.status.RECEIVE || emailRow.status === emailConst.status.NOONE);
for (const emailData of receiveEmailList) {
const emailRow = await orm(c).insert(email).values(emailData).returning().get();
//设置附件保存
for (const attRow of attList) {
const attValues = {...attRow};
attValues.emailId = emailRow.emailId;
attValues.accountId = emailRow.accountId;
attValues.userId = emailRow.userId;
attValues.attId = null;
await orm(c).insert(att).values(attValues).run();
}
}
const bouncedEmail = emailDataList.find(emailRow => emailRow.status === emailConst.status.BOUNCED);
let status = emailConst.status.DELIVERED;
let message = ''
//如果有拒收邮件,就把发件人的邮件改成拒收
if (bouncedEmail) {
const messageJson = { message: bouncedEmail.message };
message = JSON.stringify(messageJson);
status = emailConst.status.BOUNCED;
}
await orm(c).update(email).set({ status, message: message }).where(eq(email.emailId, sendEmailData.emailId)).run();
return emailRowList;
},
imgReplace(content, cidAttList, r2domain) {
@@ -709,22 +446,30 @@ const emailService = {
allReceive = accountRow.allReceive;
}
let list = await orm(c).select({...email}).from(email)
.leftJoin(
account,
eq(account.accountId, email.accountId)
)
.where(
and(
gt(email.emailId, emailId),
eq(email.userId, userId),
eq(email.isDel, isDel.NORMAL),
eq(account.isDel, isDel.NORMAL),
allReceive ? eq(1,1) : eq(email.accountId, accountId),
eq(email.type, emailConst.type.RECEIVE)
))
.orderBy(desc(email.emailId))
.limit(20);
let count = 0
let list = []
while ((count < 6) && list.length === 0) {
list = await orm(c).select({...email}).from(email)
.leftJoin(
account,
eq(account.accountId, email.accountId)
)
.where(
and(
gt(email.emailId, emailId),
eq(email.userId, userId),
eq(email.isDel, isDel.NORMAL),
eq(account.isDel, isDel.NORMAL),
allReceive ? eq(1,1) : eq(email.accountId, accountId),
eq(email.type, emailConst.type.RECEIVE)
))
.orderBy(desc(email.emailId))
.limit(20);
await sleep(5000);
count++
}
await this.emailAddAtt(c, list);
@@ -857,9 +602,9 @@ const emailService = {
query.orderBy(desc(email.emailId));
}
const listQuery = query.limit(size).all();
const totalQuery = queryCount.get();
const latestEmailQuery = orm(c).select().from(email)
const listQuery = await query.limit(size).all();
const totalQuery = await queryCount.get();
const latestEmailQuery = await orm(c).select().from(email)
.where(and(
eq(email.type, emailConst.type.RECEIVE),
ne(email.status, emailConst.status.SAVING)
@@ -885,16 +630,24 @@ const emailService = {
const { emailId } = params;
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),
ne(email.status, emailConst.status.SAVING)
))
.orderBy(desc(email.emailId))
.limit(20);
let count = 0
let list = []
while ((count < 6) && list.length === 0) {
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),
ne(email.status, emailConst.status.SAVING)
))
.orderBy(desc(email.emailId))
.limit(20);
await sleep(5000);
count++
}
await this.emailAddAtt(c, list);
@@ -907,10 +660,10 @@ const emailService = {
if (emailIds.length > 0) {
const attList = await attService.selectByEmailIds(c, emailIds);
const attsList = await attService.selectByEmailIds(c, emailIds);
list.forEach(emailRow => {
const atts = attList.filter(attRow => attRow.emailId === emailRow.emailId);
const atts = attsList.filter(attsRow => attsRow.emailId === emailRow.emailId);
emailRow.attList = atts;
});
}
+2 -9
View File
@@ -17,11 +17,9 @@ const kvObjService = {
await Promise.all(keys.map( key => c.env.kv.delete(key)));
},
async getObj(c, key) {
async toObjResp(c, key) {
const obj = await c.env.kv.getWithMetadata(key, { type: "arrayBuffer"});
if (!obj.value) {
return null;
}
return new Response(obj.value, {
headers: {
@@ -30,11 +28,6 @@ const kvObjService = {
'Cache-Control': obj.metadata?.cacheControl || null
}
});
},
async toObjResp(c, key) {
return await this.getObj(c, key);
}
+4 -12
View File
@@ -213,18 +213,6 @@ 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'));
}
@@ -233,6 +221,10 @@ 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 });
+1 -1
View File
@@ -70,7 +70,7 @@ const oauthService = {
userInfo.oauthUserId = String(userInfo.id);
userInfo.active = userInfo.active ? 0 : 1;
userInfo.silenced = userInfo.silenced ? 0 : 1;
userInfo.silenced = userInfo.active ? 0 : 1;
userInfo.trustLevel = userInfo.trust_level;
userInfo.avatar = userInfo.avatar_url;
-257
View File
@@ -1,257 +0,0 @@
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;
+1 -13
View File
@@ -41,19 +41,7 @@ const r2Service = {
},
async getObj(c, key) {
const storageType = await this.storageType(c);
if (storageType === 'KV') {
return await kvObjService.getObj(c, key);
}
if (storageType === 'R2') {
return await c.env.r2.get(key);
}
if (storageType === 'S3') {
return await s3Service.getObj(c, key);
}
return await c.env.r2.get(key);
},
async delete(c, key) {
+7 -5
View File
@@ -6,18 +6,17 @@ const resendService = {
async webhooks(c, body) {
const params = {
resendEmailId: body.data.email_id,
status: emailConst.status.SENT
}
const params = {}
console.error(body)
if (body.type === 'email.delivered') {
params.status = emailConst.status.DELIVERED
params.resendEmailId = body.data.email_id
params.message = null
}
if (body.type === 'email.complained') {
params.status = emailConst.status.COMPLAINED
params.resendEmailId = body.data.email_id
params.message = null
}
@@ -25,16 +24,19 @@ const resendService = {
let bounce = body.data.bounce
bounce = JSON.stringify(bounce);
params.status = emailConst.status.BOUNCED
params.resendEmailId = body.data.email_id
params.message = bounce
}
if (body.type === 'email.delivery_delayed') {
params.status = emailConst.status.DELAYED
params.resendEmailId = body.data.email_id
params.message = null
}
if (body.type === 'email.failed') {
params.status = emailConst.status.FAILED
params.resendEmailId = body.data.email_id
params.message = body.data.failed.reason
}
+1 -44
View File
@@ -165,6 +165,7 @@ const roleService = {
const availIndex = availDomain.findIndex(item => {
const domain = emailUtils.getDomain(email.toLowerCase());
const availDomainItem = item.toLowerCase();
console.log(domain,availDomainItem)
return domain === availDomainItem
})
@@ -173,50 +174,6 @@ const roleService = {
selectByName(c, roleName) {
return orm(c).select().from(role).where(eq(role.name, roleName)).get();
},
selectByUserIds(c, userIds) {
if (!userIds || userIds.length === 0) {
return [];
}
return orm(c).select({ ...role, userId: user.userId }).from(user).leftJoin(role, eq(role.roleId, user.type)).where(inArray(user.userId, userIds)).all();
},
isBanEmail(banEmail, fromEmail) {
banEmail = banEmail.split(',').filter(item => item !== '');
if (banEmail.includes('*')) {
return true;
}
for (const item of banEmail) {
if (verifyUtils.isDomain(item)) {
const banDomain = item.toLowerCase();
const receiveDomain = emailUtils.getDomain(fromEmail.toLowerCase());
if (banDomain === receiveDomain) {
return true;
}
} else {
if (item.toLowerCase() === fromEmail.toLowerCase()) {
return true;
}
}
}
return false;
}
};
+1 -18
View File
@@ -1,4 +1,4 @@
import { S3Client, PutObjectCommand, DeleteObjectsCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { S3Client, PutObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
import settingService from './setting-service';
import domainUtils from '../utils/domain-uitls';
import { settingConst } from '../const/entity-const';
@@ -75,23 +75,6 @@ const s3Service = {
);
},
async getObj(c, key) {
const client = await this.client(c);
const { bucket } = await settingService.query(c);
const result = await client.send(new GetObjectCommand({
Bucket: bucket,
Key: key
}));
return new Response(result.Body, {
headers: {
'Content-Type': result.ContentType || 'application/octet-stream',
'Content-Disposition': result.ContentDisposition || null,
'Cache-Control': result.CacheControl || null
}
});
},
async client(c) {
const { region, endpoint, s3AccessKey, s3SecretKey, forcePathStyle } = await settingService.query(c);
+5 -35
View File
@@ -8,7 +8,6 @@ import constant from '../const/constant';
import BizError from '../error/biz-error';
import {t} from '../i18n/i18n'
import verifyRecordService from './verify-record-service';
import userContext from '../security/user-context';
const settingService = {
@@ -50,7 +49,6 @@ const settingService = {
let linuxdoSwitch = c.env.linuxdo_switch;
let projectLink = c.env.project_link;
if (typeof linuxdoSwitch === 'string' && linuxdoSwitch === 'true') {
linuxdoSwitch = true
@@ -60,16 +58,6 @@ const settingService = {
linuxdoSwitch = false
}
if (typeof projectLink === 'string' && projectLink === 'false') {
projectLink = false
} else if (projectLink === false) {
projectLink = false
} else {
projectLink = true
}
setting.projectLink = projectLink;
setting.linuxdoClientId = c.env.linuxdo_client_id;
setting.linuxdoCallbackUrl = c.env.linuxdo_callback_url;
setting.linuxdoSwitch = linuxdoSwitch;
@@ -89,10 +77,10 @@ const settingService = {
if (!showSiteKey) {
settingRow.siteKey = settingRow.siteKey ? `${settingRow.siteKey.slice(0, 6)}******` : null;
settingRow.siteKey = settingRow.siteKey ? `${settingRow.siteKey.slice(0, 12)}******` : null;
}
settingRow.secretKey = settingRow.secretKey ? `${settingRow.secretKey.slice(0, 6)}******` : null;
settingRow.secretKey = settingRow.secretKey ? `${settingRow.secretKey.slice(0, 12)}******` : null;
Object.keys(settingRow.resendTokens).forEach(key => {
settingRow.resendTokens[key] = `${settingRow.resendTokens[key].slice(0, 12)}******`;
@@ -100,9 +88,7 @@ const settingService = {
settingRow.s3AccessKey = settingRow.s3AccessKey ? `${settingRow.s3AccessKey.slice(0, 12)}******` : null;
settingRow.s3SecretKey = settingRow.s3SecretKey ? `${settingRow.s3SecretKey.slice(0, 12)}******` : null;
settingRow.tgBotToken = settingRow.tgBotToken ? `${settingRow.tgBotToken.slice(0, 20)}******` : null;
settingRow.hasR2 = !!c.env.r2
settingRow.hasCfEmail = !!c.env.email
let regVerifyOpen = false
let addVerifyOpen = false
@@ -135,10 +121,6 @@ const settingService = {
params.emailPrefixFilter = params.emailPrefixFilter + '';
}
if (Array.isArray(params.aiCodeFilter)) {
params.aiCodeFilter = params.aiCodeFilter + '';
}
params.resendTokens = JSON.stringify(resendTokens);
await orm(c).update(setting).set({ ...params }).returning().get();
await this.refresh(c);
@@ -189,18 +171,9 @@ const settingService = {
return background;
},
async setBlacklist(c, params) {
const { blackSubject, blackContent, blackFrom } = params
await orm(c).update(setting).set({ blackSubject, blackContent, blackFrom }).run();
await this.refresh(c);
return this.get(c);
},
async websiteConfig(c) {
const settingRow = await this.get(c, true);
const token = await userContext.getToken(c);
return {
register: settingRow.register,
@@ -215,7 +188,7 @@ const settingService = {
siteKey: settingRow.siteKey,
background: settingRow.background,
loginOpacity: settingRow.loginOpacity,
domainList: settingRow.loginDomain === 1 && !token ? [] : settingRow.domainList,
domainList: settingRow.domainList,
regKey: settingRow.regKey,
regVerifyOpen: settingRow.regVerifyOpen,
addVerifyOpen: settingRow.addVerifyOpen,
@@ -228,15 +201,12 @@ const settingService = {
noticeOffset: settingRow.noticeOffset,
notice: settingRow.notice,
loginDomain: settingRow.loginDomain,
passkey: settingRow.passkey,
linuxdoClientId: settingRow.linuxdoClientId,
linuxdoCallbackUrl: settingRow.linuxdoCallbackUrl,
linuxdoSwitch: settingRow.linuxdoSwitch,
minEmailPrefix: settingRow.minEmailPrefix,
projectLink: settingRow.projectLink
minEmailPrefix: settingRow.minEmailPrefix
};
},
}
};
export default settingService;
+1 -1
View File
@@ -15,7 +15,7 @@ const starService = {
if (!email) {
throw new BizError(t('starNotExistEmail'));
}
if (email.userId !== userId) {
if (!email.userId === userId) {
throw new BizError(t('starNotExistEmail'));
}
const exist = await orm(c).select().from(star).where(
+10 -20
View File
@@ -52,23 +52,6 @@ const telegramService = {
const jwtToken = await jwtUtils.generateToken(c, { emailId: email.emailId })
const webAppUrl = customDomain ? `${domainUtils.toOssDomain(customDomain)}/api/telegram/getEmail/${jwtToken}` : 'https://www.cloudflare.com/404'
const inlineKeyboard = [
[
{
text: 'View',
web_app: { url: webAppUrl }
}
]
];
if (email.code) {
inlineKeyboard.push([
{
text: email.code,
copy_text: { text: email.code }
}
]);
}
await Promise.all(tgChatIds.map(async chatId => {
try {
@@ -82,15 +65,22 @@ const telegramService = {
parse_mode: 'HTML',
text: emailMsgTemplate(email, tgMsgTo, tgMsgFrom, tgMsgText),
reply_markup: {
inline_keyboard: inlineKeyboard
inline_keyboard: [
[
{
text: '查看',
web_app: { url: webAppUrl }
}
]
]
}
})
});
if (!res.ok) {
console.error(`转发 Telegram 失败 status: ${res.status} response: ${await res.text()}`);
console.error(`转发 Telegram 失败: chatId=${chatId}, 状态码=${res.status}`);
}
} catch (e) {
console.error(`转发 Telegram 失败:`, e.message);
console.error(`转发 Telegram 失败: chatId=${chatId}`, e.message);
}
}));
+1 -1
View File
@@ -58,7 +58,7 @@ const userService = {
const { password } = params;
if (password.length < 6) {
if (password < 6) {
throw new BizError(t('pwdMinLength'));
}
const { salt, hash } = await cryptoUtils.hashPassword(password);
+1 -2
View File
@@ -7,7 +7,6 @@ export default function emailHtmlTemplate(html, domain) {
document.querySelectorAll('script').forEach(script => script.remove());
html = document.toString();
html = html.replace(/{{domain}}/g, domainUtils.toOssDomain(domain) + '/');
const safeHtmlJson = JSON.stringify(html).replace(/</g, '\\u003C');
return `<!DOCTYPE html>
<html lang='en' >
@@ -128,7 +127,7 @@ export default function emailHtmlTemplate(html, domain) {
}
// 使用示例
const exampleHtml = ${safeHtmlJson};
const exampleHtml = \`${html}\`;
// 渲染HTML
renderHTML(exampleHtml);
+9 -32
View File
@@ -1,62 +1,39 @@
import emailUtils from '../utils/email-utils';
const TELEGRAM_MESSAGE_LIMIT = 3500;
const TRUNCATED_SUFFIX = '...';
function escapeHtml(text = '') {
return text
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function truncateText(text, maxLength) {
if (!text || text.length <= maxLength) {
return text || '';
}
if (maxLength <= TRUNCATED_SUFFIX.length) {
return TRUNCATED_SUFFIX.slice(0, maxLength);
}
return text.slice(0, maxLength - TRUNCATED_SUFFIX.length) + TRUNCATED_SUFFIX;
}
export default function emailMsgTemplate(email, tgMsgTo, tgMsgFrom, tgMsgText) {
let template = `<b>${escapeHtml(email.subject || '')}</b>`
let template = `<b>${email.subject}</b>`
if (tgMsgFrom === 'only-name') {
template += `
From\u200B${escapeHtml(email.name || '')}`
发件人${email.name}`
}
if (tgMsgFrom === 'show') {
template += `
From\u200B${escapeHtml(email.name || '')} &lt;${escapeHtml(email.sendEmail || '')}&gt;`
发件人${email.name} &lt;${email.sendEmail}&gt;`
}
if(tgMsgTo === 'show' && tgMsgFrom === 'hide') {
template += `
To\u200B${escapeHtml(email.toEmail || '')}`
收件人\u200B${email.toEmail}`
} else if(tgMsgTo === 'show') {
template += `
To\u200B${escapeHtml(email.toEmail || '')}`
收件人\u200B${email.toEmail}`
}
const text = escapeHtml(emailUtils.formatText(email.text) || emailUtils.htmlToText(email.content));
const text = (email.text || emailUtils.htmlToText(email.content))
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if(tgMsgText === 'show') {
const prefix = `${template}
`;
const maxTextLength = Math.max(0, TELEGRAM_MESSAGE_LIMIT - prefix.length);
template += `
${truncateText(text, maxTextLength)}`
${text}`
}
return template;
+2 -19
View File
@@ -14,30 +14,13 @@ const emailUtils = {
return parts.length === 2 ? parts[0] : '';
},
formatText(text) {
if (!text) return ''
return text
.split('\n')
.map(line => {
return line.replace(/[\u200B-\u200F\uFEFF\u034F\u200B-\u200F\u00A0\u3000\u00AD]/g, '')
.replace(/\s+/g, ' ')
.trim();
})
.join('\n')
.replace(/\n{3,}/g, '\n')
.trim();
},
htmlToText(content) {
if (!content) return ''
try {
const wrappedContent = content.includes('<body')
? content
: `<!DOCTYPE html><html><body>${content}</body></html>`;
const { document } = parseHTML(wrappedContent);
const { document } = parseHTML(content);
document.querySelectorAll('style, script, title').forEach(el => el.remove());
let text = document.body.innerText;
return this.formatText(text);
return text.trim();
} catch (e) {
console.error(e)
return ''
+1 -1
View File
@@ -11,7 +11,7 @@ const fileUtils = {
async getBuffHash(buff) {
const hashBuffer = await crypto.subtle.digest('SHA-256', buff);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.slice(0, 16).map(b => b.toString(16).padStart(2, '0')).join('');
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
},
base64ToDataStr(base64) {
-1
View File
@@ -47,7 +47,6 @@ const jwtUtils = {
},
async verifyToken(c, token) {
if (!token) return null;
try {
const [headerB64, payloadB64, signatureB64] = token.split('.');
+15 -98
View File
@@ -1,103 +1,20 @@
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';
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import worker from '../src';
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');
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!"`);
});
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);
it('responds with Hello World! (integration style)', async () => {
const response = await SELF.fetch('http://example.com');
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler-dev.toml' },
wrangler: { configPath: './wrangler.jsonc' },
},
},
},
+10 -17
View File
@@ -1,7 +1,6 @@
name = "${NAME}"
main = "src/index.js"
compatibility_date = "2025-06-04"
compatibility_flags = ["nodejs_compat"]
[observability]
enabled = true
@@ -12,38 +11,32 @@ custom_domain = true
[[d1_databases]]
binding = "db"
database_name = "cloud-mail"
database_id = "${D1_DATABASE_ID}"
database_name = "cloud-mail" # 数据库的名称
database_id = "${D1_DATABASE_ID}" # 使用占位符引用环境变量
[[kv_namespaces]]
binding = "kv"
id = "${KV_NAMESPACE_ID}"
id = "${KV_NAMESPACE_ID}" # 使用占位符引用环境变量
[[r2_buckets]]
binding = "r2"
bucket_name = "${R2_BUCKET_NAME}"
[ai]
binding = "ai"
bucket_name = "${R2_BUCKET_NAME}" # 使用占位符引用环境变量
[assets]
binding = "assets"
directory = "./dist"
binding = "assets" #静态资源绑定名默认不可修改
directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist
not_found_handling = "single-page-application"
run_worker_first = true
[triggers]
crons = ["0 16 * * *"]
crons = ["0 16 * * *"] #定时任务每天晚上12点执行
[vars]
ai_model = "${AI_MODEL}"
analysis_cache = "${ANALYSIS_CACHE}"
#orm_log = false
domain = "${DOMAIN}"
admin = "${ADMIN}"
jwt_secret = "${JWT_SECRET}"
project_link = "${PROJECT_LINK}"
domain = "${DOMAIN}" #邮件域名可可配置多个 示例: ["example1.com","example2.com"]
admin = "${ADMIN}" #管理员的邮箱 示例: [email protected]
jwt_secret = "${JWT_SECRET}" #jwt令牌的密钥,随便填一串字符串
linuxdo_client_id = "${LINUXDO_CLIENT_ID}"
linuxdo_client_secret = "${LINUXDO_CLIENT_SECRET}"
-9
View File
@@ -1,7 +1,6 @@
name = "cloud-mail-dev"
main = "src/index.js"
compatibility_date = "2025-06-04"
compatibility_flags = ["nodejs_compat"]
keep_vars = true
[observability]
@@ -23,22 +22,14 @@ id = "2io01d4b299e481b9de060ece9e7785c"
#binding = "r2"
#bucket_name = "email"
#[ai]
#binding = "ai"
[assets]
binding = "assets"
directory = "./dist"
not_found_handling = "single-page-application"
run_worker_first = true
#[[send_email]]
#name = "email"
[vars]
ai_model = ""
analysis_cache = false
orm_log = false
domain = ["example.com", "example2.com", "example3.com", "example4.com"]
admin = "[email protected]"
+2 -8
View File
@@ -1,7 +1,6 @@
name = "test-mail"
main = "src/index.js"
compatibility_date = "2025-06-04"
compatibility_flags = ["nodejs_compat"]
keep_vars = true
[observability]
@@ -21,9 +20,6 @@ id = "9be10cd058e04c55b526f8c0c116f50c" #kv数据库id
#binding = "r2" #r2对象存储绑定名默认不可修改
#bucket_name = "test-email" #r2对象存储桶的名字
[ai]
binding = "ai"
[assets]
binding = "assets" #静态资源绑定名默认不可修改
directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist
@@ -31,13 +27,11 @@ not_found_handling = "single-page-application"
run_worker_first = true
[triggers]
crons = ["0 16 * * *"] #每天晚上12点执行每日任务,刷新分析缓存
crons = ["0 16 * * *"] #定时任务每天晚上12点执行
[vars]
ai_model = "" #ai模型,不填默认使用@cf/meta/llama-3.1-8b-instruct
analysis_cache = false #是否开启分析数据缓存
orm_log = false #是否sql日志
orm_log = false
domain = ["example.com"] #邮件域名可可配置多个 示例: ["example1.com","example2.com"]
admin = "[email protected]" #管理员的邮箱 示例: [email protected]
jwt_secret = "b7f29a1d-18e2-4d3b-941f-f6b2c97c02fd" #jwt令牌的密钥,随便填一串字符串
+3 -12
View File
@@ -1,7 +1,6 @@
name = "cloud-mail"
main = "src/index.js"
compatibility_date = "2025-06-04"
compatibility_flags = ["nodejs_compat"]
keep_vars = true
[observability]
@@ -20,12 +19,6 @@ enabled = true
#binding = "r2" #r2对象存储绑定名默认不可修改
#bucket_name = "" #r2对象存储桶的名字
#[[send_email]]
#name = "email" #CF发件, 配置后优先使用
[ai]
binding = "ai"
[assets]
binding = "assets" #静态资源绑定名默认不可修改
directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist
@@ -33,13 +26,11 @@ not_found_handling = "single-page-application"
run_worker_first = true
[triggers]
crons = ["0 16 * * *"] #每天晚上12点执行每日任务,刷新分析缓存
crons = ["0 16 * * *"] #定时任务每天晚上12点执行
[vars]
#ai_model = "" #ai模型,不填默认使用@cf/meta/llama-3.1-8b-instruct
#analysis_cache = false #是否开启分析数据缓存
#orm_log = false #是否sql日志
#[vars]
#orm_log = false
#domain = [] #邮件域名可可配置多个 示例: ["example1.com","example2.com"]
#admin = "" #管理员的邮箱 示例: [email protected]
#jwt_secret = "" #jwt令牌的密钥,随便填一串字符串