Compare commits

..

58 Commits

Author SHA1 Message Date
Dream Hunter
bf1243f4c4 release: v0.7.0 (#387) 2024-08-11 00:21:15 +08:00
Dream Hunter
15063b2e97 feat: add DISABLE_ADMIN_PASSWORD_CHECK (#386) 2024-08-11 00:10:16 +08:00
Dream Hunter
fc07f1cd87 feat: add passkey (#384) 2024-08-10 23:56:05 +08:00
Dream Hunter
9246550cc5 feat: add NO_LIMIT_SEND_ROLE (#373) 2024-08-04 21:02:11 +08:00
Dream Hunter
979b6eae1a feat: add SHOW_GITHUB config (#372) 2024-08-04 14:36:24 +08:00
Dream Hunter
10da337a9c feat: add SHOW_GITHUB config (#371) 2024-08-04 14:34:35 +08:00
Dream Hunter
9c5e8857af feat: add loading when process mails (#367) 2024-07-27 23:14:18 +08:00
Dream Hunter
84b4baa99e feat: add .github/workflows/pr_agent.yml (#366) 2024-07-27 23:06:54 +08:00
Dream Hunter
b57d46244a feat: add loading when process mails (#364) 2024-07-27 22:30:38 +08:00
Dream Hunter
5faae8796d feat: add ADMIN_USER_ROLE for user access admin panel (#363) 2024-07-27 22:04:18 +08:00
666-eth
a0805bc0ce Docs: Update new-address-api.md (#360) 2024-07-23 13:47:37 +08:00
Dream Hunter
d0ccc3ded1 v0.6.1 2024-07-22 13:09:42 +08:00
Dream Hunter
163d9451f7 feat: worker: newAddress if domain is not set, use the first domain (#358) 2024-07-22 13:05:50 +08:00
Dream Hunter
60dda7e3fe feat: add ANNOUNCEMENT (#357) 2024-07-22 13:01:38 +08:00
Dream Hunter
384eb9b041 fix: imap proxy do not support password && cleanup days translate (#356) 2024-07-19 22:40:53 +08:00
tqjason
38816cbf0f Add new workflow action and Fix cleanup bug (#355)
* Create frontend_pagefunction_deploy.yaml

* Update frontend_pagefunction_deploy.yaml

* Update cleanup_api.ts

* Update common.ts

* Update cleanup_api.ts

* Update common.ts
2024-07-19 22:34:01 +08:00
Dream Hunter
d7d1ba6b64 feat: wrangler d1 execute dev add --remote (#352) 2024-07-15 12:04:14 +08:00
Dream Hunter
14725e9e9f feat: add USER_DEFAULT_ROLE (#351) 2024-07-14 20:44:03 +08:00
Dream Hunter
2c1e63b8bc feat: add USER_DEFAULT_ROLE (#350) 2024-07-14 20:38:55 +08:00
Dream Hunter
f3a1d980c5 fix: roleDonotExist tip (#349) 2024-07-14 20:09:21 +08:00
Dream Hunter
75c48beb3b feat: add USER_ROLES && admin pages search when keybord enter && auto trim (#348)
* feat: add USER_ROLES

* feat: admin pages search when keybord enter && auto trim

* feat: update version to v0.6.0
2024-07-14 19:57:43 +08:00
Dream Hunter
26ccfdd6e0 feat: only allow address [a-z0-9] (#347) 2024-07-13 19:03:54 +08:00
刘志聪
aa8f3b4d46 fix: remove useless sql (#342) 2024-07-10 01:06:00 +08:00
Dream Hunter
a749c829d2 feat: update docs (#340) 2024-07-08 19:09:37 +08:00
Dream Hunter
4b2caf1a4b feat: update docs (#339) 2024-07-08 19:02:14 +08:00
Dream Hunter
80a8848ed8 feat: remove apiV1 and tables && update admin/statistics (#337) 2024-07-08 12:33:43 +08:00
Dream Hunter
dcfc1b3721 Update CHANGELOG.md 2024-07-07 12:55:29 +08:00
Dream Hunter
b0a0a6a1ef feat: updage dependencies (#336) 2024-07-06 20:26:54 +08:00
Dream Hunter
00c671cf14 feat: logo click 5 time to admin page && fix: 401 cannot show auth modal (#335) 2024-07-06 20:21:21 +08:00
Dream Hunter
0b78d1ff4a Update CHANGELOG.md 2024-07-04 13:38:00 +08:00
Dream Hunter
d152a7ce9f feat: allow admin and user delete mail, sendbox, send access(only admin) (#331) 2024-07-04 13:31:33 +08:00
Dream Hunter
21fed3fb00 feat: allow admin and user delete mail, sendbox, send access(only admin) (#329) 2024-07-04 13:25:14 +08:00
Dream Hunter
9448b3c754 fix: sendVerificationCode do not check cfToken when no TurnstileSiteKey (#325) 2024-06-29 01:01:46 +08:00
Dream Hunter
f1827f223a feat: docs: github actions add FRONTEND_BRANCH (#324) 2024-06-28 23:10:35 +08:00
Dream Hunter
2a0a34869e feat: github actions add FRONTEND_BRANCH env (#323) 2024-06-28 23:04:08 +08:00
Dream Hunter
881e66e484 feat: add DOMAIN_LABELS for chinese domain label (#322) 2024-06-28 22:25:06 +08:00
Dream Hunter
de7c3d5176 Update README.md 2024-06-12 14:52:16 +08:00
Dream Hunter
720d097ed7 Update README.md 2024-06-12 14:51:32 +08:00
Dream Hunter
53a03dc6a0 Update README.md 2024-06-12 14:35:22 +08:00
Dream Hunter
72b99e0c5e feat: upgeade npm packages (#311) 2024-06-12 13:57:26 +08:00
Dream Hunter
c4d9fe1fb9 feat: docs: add new-address-api (#309) 2024-06-12 13:53:40 +08:00
Dream Hunter
af9f46ba65 fix: smtp imap proxy sever: support senbox v2 (#306) 2024-06-09 13:35:26 +08:00
Dream Hunter
8bfd76bf71 Update README.md 2024-06-07 00:03:47 +08:00
Dream Hunter
dd477fe2c8 Update CHANGELOG.md 2024-06-06 21:45:57 +08:00
Dream Hunter
0db611bb3e feat: add MIN_ADDRESS_LEN && MAX_ADDRESS_LEN (#304) 2024-06-06 21:44:22 +08:00
Dream Hunter
6225f6521a fix: parseMail tg bot (#302) 2024-06-04 22:51:28 +08:00
Dream Hunter
da2e72e523 feat: add mail-parser-wasm-worker (#301) 2024-06-04 21:57:42 +08:00
Dream Hunter
c5d01e09e8 feat: change version (#294) 2024-06-01 21:31:30 +08:00
Dream Hunter
201c7658be feat: UI: admin mail page style add margin-top: 10px (#293) 2024-06-01 21:27:45 +08:00
Dream Hunter
77155299e0 feat: add mailbox multi delete and download (#292) 2024-06-01 21:23:17 +08:00
Dream Hunter
9725407c77 feat: add s3 attachment (#291) 2024-06-01 20:08:42 +08:00
Dream Hunter
e91bbe273a feat: UI i18n depends on router (#290) 2024-06-01 12:13:44 +08:00
Dream Hunter
b792c196c1 feat: UI i18n depends on router (#289) 2024-06-01 12:12:13 +08:00
Dream Hunter
7a368d7b23 feat: add global forward address list (#288) 2024-05-31 23:21:12 +08:00
Dream Hunter
f882e4cf97 feat: add Local Address Manage (#285) 2024-05-29 13:40:02 +08:00
Dream Hunter
00abf79417 fix: cannot delete addres when not bind KV (#284) 2024-05-29 12:08:56 +08:00
Dream Hunter
1f8edbc295 feat: add TITLE in worker for custom website title (#276) 2024-05-26 16:21:27 +08:00
Dream Hunter
268f3d6446 Update CHANGELOG.md 2024-05-26 15:35:18 +08:00
115 changed files with 7696 additions and 3541 deletions

View File

@@ -35,14 +35,27 @@ jobs:
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
export project_name=${{ secrets.FRONTEND_NAME }}
pnpm install --no-frozen-lockfile
pnpm run deploy --project-name=$project_name
export frontend_branch=${{ secrets.FRONTEND_BRANCH }}
if [ -n "$frontend_branch" ]; then
echo "Deploying branch $frontend_branch"
pnpm run deploy:actions --project-name=$project_name
else
echo "Deploying branch prodcution"
pnpm run deploy --project-name=$project_name
fi
echo "Deploying prodcution for ${{ github.ref_name }}"
echo "Deployed for tag ${{ github.ref_name }}"
export tg_mini_app_project_name=${{ secrets.TG_FRONTEND_NAME }}
if [ -n "$tg_mini_app_project_name" ]; then
echo "Deploying telegram mini app $tg_mini_app_project_name"
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
if [ -n "$frontend_branch" ]; then
echo "Deploying telegram mini app branch $frontend_branch"
pnpm run deploy:actions:telegram --project-name=$tg_mini_app_project_name
else
echo "Deploying telegram mini app branch prodcution"
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
fi
echo "Deployed telegram mini app for ${{ github.ref_name }}"
fi
env:

View File

@@ -0,0 +1,39 @@
name: Deploy Frontend with page function
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- uses: pnpm/action-setup@v3
name: Install pnpm
id: pnpm-install
with:
version: 8
run_install: false
- name: Deploy Frontend for ${{ github.ref_name }}
run: |
cd frontend/
pnpm install --no-frozen-lockfile
pnpm build:pages
cd ../pages/
echo '${{ secrets.PAGE_TOML }}' > wrangler.toml
pnpm install --no-frozen-lockfile
pnpm run deploy
echo "Deploying prodcution for ${{ github.ref_name }}"
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

26
.github/workflows/pr_agent.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Codium PR Agent
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
jobs:
pr_agent_job:
if: ${{ github.event.sender.type != 'Bot' }}
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
name: Run pr agent on every pull request, respond to user comments
steps:
- name: PR Agent action step
id: pragent
uses: Codium-ai/pr-agent@main
env:
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false"
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
CONFIG.MODEL: "gpt-4o"
CONFIG.MODEL_TURBO: "gpt-4o"
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

135
.gitignore vendored
View File

@@ -1,3 +1,138 @@
dist/
test/
.vscode/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.wrangler
wrangler.toml
.dev.vars
pnpm-lock.yaml

View File

@@ -1,12 +1,83 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG
## main branch
## v0.7.0
- UI lazy load
- telegram bot 添加用户全局推送功能
### Breaking Changes
DB changes: 增加用户 `passkey` 表, 需要执行 `db/2024-08-10-patch.sql` 更新 `D1` 数据库
### Changes
- Docs: Update new-address-api.md (#360)
- feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363)
- feat: worker 增加 `DISABLE_SHOW_GITHUB` 配置, 用于配置是否显示 github 链接
- feat: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 用于配置可以无限发送邮件的角色
- feat: 用户增加 `passkey` 登录方式, 用于用户登录, 无需输入密码
- feat: worker 增加 `DISABLE_ADMIN_PASSWORD_CHECK` 配置, 用于配置是否禁用 admin 控制台密码检查, 若你的网站只可私人访问,可通过此禁用检查
## v0.6.1
- pages github actions && 修复清理邮件天数为 0 不生效 by @tqjason (#355)
- fix: imap proxy server 不支持 密码 by @dreamhunter2333 (#356)
- worker 新增 `ANNOUNCEMENT` 配置, 用于配置公告信息 by @dreamhunter2333 (#357)
- fix: telegram bot 新建地址默认选择第一个域名 by @dreamhunter2333 (#358)
## v0.6.0
### Breaking Changes
DB changes: 增加用户角色表, 需要执行 `db/2024-07-14-patch.sql` 更新 `D1` 数据库
### Changes
worker 配置文件新增 `DEFAULT_DOMAINS`, `USER_ROLES`, `USER_DEFAULT_ROLE`, 具体查看文档 [worker配置](https://temp-mail-docs.awsl.uk/zh/guide/cli/worker.html#%E4%BF%AE%E6%94%B9-wrangler-toml-%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6)
- 移除 `apiV1` 相关代码和相关的数据库表
- 更新 `admin/statistics` api, 添加用户统计信息
- 更新地址的规则,只允许小写+数字,对于历史的地址在查询邮件时会进行 `lowercase` 处理
- 增加用户角色功能,`admin` 可以设置用户角色(目前可配置每个角色域名和前缀)
- admin 页面搜索优化, 回车自动搜索, 输入内容自动 trim
## v0.5.4
- 点击 logo 5 次进入 admin 页面
- 修复 401 时无法跳转登录页面(admin 和 网站认证)
## v0.5.3
- 修复 smtp imap proxy sever 的一些 bug
- 完善用户/admin 删除收件箱/发件箱的功能
- admin 可以删除 发件权限记录
- 添加中文邮件别名配置 `DOMAIN_LABELS` [文档](https://temp-mail-docs.awsl.uk/zh/guide/cli/worker.html)
- 移除 `mail channels` 相关代码
- github actions 增加 `FRONTEND_BRANCH` 变量用于指定部署的分支 (#324)
## v0.5.1
- 添加 `mail-parser-wasm-worker` 用于 worker 解析邮件, [文档](https://temp-mail-docs.awsl.uk/zh/guide/feature/mail_parser_wasm_worker.html)
- 添加校验用户邮箱长度配置 `MIN_ADDRESS_LEN``MAX_ADDRESS_LEN`
- 修复 `pages function` 未转发 `telegram` api 问题
## v0.5.0
- UI: 增加本地缓存进行地址管理
- worker: 增加 `FORWARD_ADDRESS_LIST` 全局邮件转发地址(等同于 `catch all`)
- UI: 多语言使用路由进行切换
- 添加保存附件到 S3 的功能
- UI: 增加收取邮件列表 `批量删除``批量下载`
## v0.4.6
- worker 配置文件添加 `TITLE = "Custom Title"`, 可自定义网站标题
- 修复 KV 未绑定无法删除地址的问题
## v0.4.5
- UI lazy load 懒加载
- telegram bot 添加用户全局推送功能(admin 用户)
- 增加对 cloudflare verified 用户发送邮件
- 增加使用 `resend` 发送邮件
- 增加使用 `resend` 发送邮件, `resend` 提供 http 和 smtp api, 使用更加方便, 文档: https://temp-mail-docs.awsl.uk/zh/guide/config-send-mail.html
## v0.4.4
@@ -264,7 +335,7 @@ The `mails` table will be discarded, and the `raw` text of the new `mail` will b
```bash
git checkout v0.2.0
cd worker
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql --remote
pnpm run deploy
cd ../frontend
pnpm run deploy

View File

@@ -1,17 +1,29 @@
# 使用 cloudflare 免费服务,搭建临时邮箱
<p align="center">
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE">
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="FeaturedHelloGitHub"/>
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors">
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
</p>
<p align="center">
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
<img alt="docs" src="https://img.shields.io/badge/docs-grey?style=for-the-badge&logo=vitepress">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
</a>
<a href="">
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
</a>
<a href="https://discord.gg/dQEwTWhA6Q">
<img alt="Join Discord Chat" src="https://img.shields.io/discord/1238705663623036939.svg?label=discord&logo=discord">
<a href="">
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
</a>
</p>

9
db/2024-07-14-patch.sql Normal file
View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS user_roles (
id INTEGER PRIMARY KEY,
user_id INTEGER UNIQUE NOT NULL,
role_text TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);

14
db/2024-08-10-patch.sql Normal file
View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS user_passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
passkey_name TEXT NOT NULL,
passkey_id TEXT NOT NULL,
passkey TEXT NOT NULL,
counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);

View File

@@ -1,15 +1,3 @@
CREATE TABLE IF NOT EXISTS mails (
id INTEGER PRIMARY KEY,
message_id TEXT,
source TEXT,
address TEXT,
subject TEXT,
message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mails_address ON mails(address);
CREATE TABLE IF NOT EXISTS raw_mails (
id INTEGER PRIMARY KEY,
message_id TEXT,
@@ -43,15 +31,6 @@ CREATE TABLE IF NOT EXISTS auto_reply_mails (
CREATE INDEX IF NOT EXISTS idx_auto_reply_mails_address ON auto_reply_mails(address);
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY,
source TEXT,
address TEXT,
message_id TEXT,
data TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS address_sender (
id INTEGER PRIMARY KEY,
address TEXT UNIQUE,
@@ -99,3 +78,28 @@ CREATE TABLE IF NOT EXISTS users_address (
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);
CREATE TABLE IF NOT EXISTS user_roles (
id INTEGER PRIMARY KEY,
user_id INTEGER UNIQUE NOT NULL,
role_text TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE TABLE IF NOT EXISTS user_passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
passkey_name TEXT NOT NULL,
passkey_id TEXT NOT NULL,
passkey TEXT NOT NULL,
counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "0.4.5",
"version": "0.7.0",
"private": true,
"type": "module",
"scripts": {
@@ -11,34 +11,39 @@
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
"preview": "vite preview",
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
"deploy:actions:telegram": "npm run build:telegram && wrangler pages deploy ./dist",
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
"deploy": "npm run build && wrangler pages deploy ./dist --branch production"
"deploy": "npm run build && wrangler pages deploy ./dist --branch production",
"deploy:actions": "npm run build && wrangler pages deploy ./dist"
},
"dependencies": {
"@simplewebauthn/browser": "^10.0.0",
"@unhead/vue": "^1.9.15",
"@vicons/material": "^0.12.0",
"@vueuse/core": "^10.9.0",
"@vueuse/core": "^10.11.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.2",
"mail-parser-wasm": "^0.1.6",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.1.8",
"naive-ui": "^2.38.2",
"postal-mime": "^2.2.5",
"vooks": "^0.2.12",
"vue": "^3.4.27",
"vue": "^3.4.31",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^9.13.1",
"vue-router": "^4.3.2"
"vue-router": "^4.4.0"
},
"devDependencies": {
"@vicons/fa": "^0.12.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue": "^5.0.5",
"unplugin-auto-import": "^0.17.6",
"unplugin-vue-components": "^0.27.0",
"vite": "^5.2.11",
"unplugin-vue-components": "^0.27.2",
"vite": "^5.3.3",
"vite-plugin-pwa": "^0.19.8",
"vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-wasm": "^3.3.0",
"workbox-window": "^7.1.0",
"wrangler": "^3.57.1"
"wrangler": "^3.63.1"
}
}

2561
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,18 +9,14 @@ import Footer from './views/Footer.vue';
const {
localeCache, isDark, loading, useSideMargin,
telegramApp, isTelegram
isDark, loading, useSideMargin, telegramApp, isTelegram
} = useGlobalState()
const { locale } = useI18n({});
const theme = computed(() => isDark.value ? darkTheme : null)
const localeConfig = computed(() => localeCache.value == 'zh' ? zhCN : null)
const localeConfig = computed(() => locale.value == 'zh' ? zhCN : null)
const isMobile = useIsMobile()
const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
const { locale } = useI18n({
useScope: 'global',
});
locale.value = localeCache.value;
onMounted(async () => {
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
@@ -58,7 +54,7 @@ onMounted(async () => {
<n-config-provider :locale="localeConfig" :theme="theme">
<n-global-style />
<n-spin description="loading..." :show="loading">
<n-message-provider>
<n-message-provider container-style="margin-top: 20px;">
<n-grid x-gap="12" :cols="12">
<n-gi v-if="showSideMargin" span="1"></n-gi>
<n-gi :span="!showSideMargin ? 12 : 10">

View File

@@ -4,13 +4,14 @@ import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const {
loading, auth, jwt, settings, openSettings,
userOpenSettings, userSettings,
userOpenSettings, userSettings, announcement,
showAuth, adminAuth, showAdminAuth, userJwt
} = useGlobalState();
const instance = axios.create({
baseURL: API_BASE,
timeout: 30000
timeout: 30000,
validateStatus: (status) => status >= 200 && status <= 500
});
const apiFetch = async (path, options = {}) => {
@@ -21,20 +22,21 @@ const apiFetch = async (path, options = {}) => {
data: options.body || null,
headers: {
'x-user-token': userJwt.value,
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401 && openSettings.value.auth) {
showAuth.value = true;
throw new Error("Unauthorized, you access password is wrong")
}
if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true;
throw new Error("Unauthorized, your admin password is wrong")
}
if (response.status === 401 && openSettings.value.auth) {
showAuth.value = true;
throw new Error("Unauthorized, you access password is wrong")
}
if (response.status >= 300) {
throw new Error(`${response.status} ${response.data}` || "error");
}
@@ -53,12 +55,18 @@ const apiFetch = async (path, options = {}) => {
const getOpenSettings = async (message) => {
try {
const res = await api.fetch("/open_api/settings");
const domainLabels = res["domainLabels"] || [];
Object.assign(openSettings.value, {
...res,
title: res["title"] || "",
prefix: res["prefix"] || "",
minAddressLen: res["minAddressLen"] || 1,
maxAddressLen: res["maxAddressLen"] || 30,
needAuth: res["needAuth"] || false,
domains: res["domains"].map((domain) => {
defaultDomains: res["defaultDomains"] || [],
domains: res["domains"].map((domain, index) => {
return {
label: domain,
label: domainLabels.length > index ? domainLabels[index] : domain,
value: domain
}
}),
@@ -70,10 +78,19 @@ const getOpenSettings = async (message) => {
copyright: res["copyright"] || openSettings.value.copyright,
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
enableWebhook: res["enableWebhook"] || false,
isS3Enabled: res["isS3Enabled"] || false,
});
if (openSettings.value.needAuth) {
showAuth.value = true;
}
if (openSettings.value.announcement && openSettings.value.announcement != announcement.value) {
announcement.value = openSettings.value.announcement;
message.info(announcement.value, {
showIcon: false,
duration: 0,
closable: true
});
}
} catch (error) {
message.error(error.message || "error");
}

View File

@@ -1,6 +1,5 @@
<script setup>
import { watch, onMounted, ref, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
@@ -10,7 +9,6 @@ import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
const message = useMessage()
const isMobile = useIsMobile()
const router = useRouter()
const props = defineProps({
enableUserDeleteEmail: {
@@ -37,11 +35,21 @@ const props = defineProps({
type: Boolean,
default: false,
requried: false
}
},
showSaveS3: {
type: Boolean,
default: false,
requried: false
},
saveToS3: {
type: Function,
default: (mail_id, filename, blob) => { },
requried: false
},
})
const {
localeCache, isDark, mailboxSplitSize, indexTab,
isDark, mailboxSplitSize, indexTab, loading,
useIframeShowMail, sendMailModel, preferShowTextMail
} = useGlobalState()
const autoRefresh = ref(false)
@@ -58,8 +66,13 @@ const curAttachments = ref([])
const curMail = ref(null);
const showTextMail = ref(preferShowTextMail.value)
const multiActionMode = ref(false)
const showMultiActionDownload = ref(false)
const showMultiActionDelete = ref(false)
const multiActionDownloadZip = ref({})
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'Success',
@@ -68,12 +81,17 @@ const { t } = useI18n({
refresh: 'Refresh',
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
pleaseSelectMail: "Please select a mail to view.",
pleaseSelectMail: "Please select mail",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete this mail?',
deleteMailTip: 'Are you sure you want to delete mail?',
reply: 'Reply',
showTextMail: 'Show Text Mail',
showHtmlMail: 'Show Html Mail'
showHtmlMail: 'Show Html Mail',
saveToS3: 'Save to S3',
multiAction: 'Multi Action',
cancelMultiAction: 'Cancel Multi Action',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
},
zh: {
success: '成功',
@@ -82,12 +100,17 @@ const { t } = useI18n({
refresh: '刷新',
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择一封邮件查看。",
pleaseSelectMail: "请选择邮件",
delete: '删除',
deleteMailTip: '确定要删除这封邮件吗?',
deleteMailTip: '确定要删除邮件吗?',
reply: '回复',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件'
showHtmlMail: '显示HTML邮件',
saveToS3: '保存到S3',
multiAction: '多选',
cancelMultiAction: '取消多选',
selectAll: '全选本页',
unselectAll: '取消全选',
}
}
});
@@ -124,22 +147,31 @@ const refresh = async () => {
const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value
);
loading.value = true;
data.value = await Promise.all(results.map(async (item) => {
item.checked = false;
return await processItem(item);
}));
if (totalCount > 0) {
count.value = totalCount;
}
if (!isMobile.value && !curMail.value && data.value.length > 0) {
curMail.value = null;
if (!isMobile.value && data.value.length > 0) {
curMail.value = data.value[0];
}
} catch (error) {
message.error(error.message || "error");
console.error(error);
} finally {
loading.value = false;
}
};
const clickRow = async (row) => {
if (multiActionMode.value) {
row.checked = !row.checked;
return;
}
curMail.value = row;
};
@@ -186,6 +218,92 @@ const onSpiltSizeChange = (size) => {
mailboxSplitSize.value = size;
}
const attachmentLoding = ref(false)
const saveToS3Proxy = async (filename, blob) => {
attachmentLoding.value = true
try {
await props.saveToS3(curMail.value.id, filename, blob);
} finally {
attachmentLoding.value = false
}
}
const multiActionModeClick = (enableMulti) => {
if (enableMulti) {
data.value.forEach((item) => {
item.checked = false;
});
multiActionMode.value = true;
} else {
multiActionMode.value = false;
data.value.forEach((item) => {
item.checked = false;
});
}
}
const multiActionSelectAll = (checked) => {
data.value.forEach((item) => {
item.checked = checked;
});
}
const multiActionDeleteMail = async () => {
try {
loading.value = true;
const selectedMails = data.value.filter((item) => item.checked);
if (selectedMails.length === 0) {
message.error(t('pleaseSelectMail'));
return;
}
multiActionDeleteProgress.value = {
percentage: 0,
tip: `0/${selectedMails.length}`
};
for (const [index, mail] of selectedMails.entries()) {
await props.deleteMail(mail.id);
showMultiActionDelete.value = true;
multiActionDeleteProgress.value = {
percentage: Math.floor((index + 1) / selectedMails.length * 100),
tip: `${index + 1}/${selectedMails.length}`
};
}
message.success(t("success"));
await refresh();
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
showMultiActionDelete.value = true;
}
}
const multiActionDownload = async () => {
try {
loading.value = true;
const selectedMails = data.value.filter((item) => item.checked);
if (selectedMails.length === 0) {
message.error(t('pleaseSelectMail'));
return;
}
const JSZipModlue = await import('jszip');
const JSZip = JSZipModlue.default;
const zip = new JSZip();
for (const mail of selectedMails) {
zip.file(`${mail.id}.eml`, mail.raw);
}
multiActionDownloadZip.value = {
url: URL.createObjectURL(await zip.generateAsync({ type: "blob" })),
filename: `mails-${new Date().toISOString().replace(/:/g, '-')}.zip`
}
showMultiActionDownload.value = true;
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
}
}
onMounted(async () => {
await refresh();
});
@@ -197,14 +315,38 @@ onBeforeUnmount(() => {
<template>
<div>
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25"
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
<template #1>
<div class="center">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small" :round="false">
<div v-if="!isMobile" class="left">
<div style="margin-bottom: 10px;">
<n-space v-if="multiActionMode">
<n-button @click="multiActionModeClick(false)" tertiary>
{{ t('cancelMultiAction') }}
</n-button>
<n-button @click="multiActionSelectAll(true)" tertiary>
{{ t('selectAll') }}
</n-button>
<n-button @click="multiActionSelectAll(false)" tertiary>
{{ t('unselectAll') }}
</n-button>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="multiActionDeleteMail">
<template #trigger>
<n-button tertiary type="error">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button @click="multiActionDownload" tertiary type="info">
<template #icon>
<n-icon :component="CloudDownloadRound" />
</template>
{{ t('downloadMail') }}
</n-button>
</n-space>
<n-space v-else>
<n-button @click="multiActionModeClick(true)" type="primary" tertiary>
{{ t('multiAction') }}
</n-button>
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
show-size-picker />
<n-switch v-model:value="autoRefresh" :round="false">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
</template>
@@ -212,90 +354,99 @@ onBeforeUnmount(() => {
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary" tertiary>
<n-button @click="refresh" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
</n-tag>
</n-space>
</div>
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<template #1>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail.raw)">
<template #icon>
<n-icon :component="CloudDownloadRound" />
</template>
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
</n-button>
</n-space>
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
style="margin-top: 10px;width: 100%; height: 100%;">
</iframe>
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
<n-card class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ `${row.created_at} UTC` }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card :bordered="false" embedded v-if="curMail" class="mail-item" :title="curMail.subject"
style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ `${curMail.created_at} UTC` }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail.raw)">
<template #icon>
<n-icon :component="CloudDownloadRound" />
</template>
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
</n-button>
</n-space>
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
style="margin-top: 10px;width: 100%; height: 100%;">
</iframe>
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
</div>
<div class="left" v-else>
<div class="center">
<n-space justify="center">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
@@ -307,10 +458,10 @@ onBeforeUnmount(() => {
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary">
<n-button @click="refresh" tertiary size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
</n-space>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
@@ -320,7 +471,7 @@ onBeforeUnmount(() => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
{{ `${row.created_at} UTC` }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
@@ -336,13 +487,13 @@ onBeforeUnmount(() => {
<n-drawer v-model:show="curMail" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 80vh;">
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
<n-card style="overflow: auto;">
<n-card :bordered="false" embedded style="overflow: auto;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
{{ `${curMail.created_at} UTC` }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
@@ -388,25 +539,51 @@ onBeforeUnmount(() => {
<template #header>
<div>{{ t("attachments") }}</div>
</template>
<n-list hoverable clickable>
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
<n-thing class="center" :title="row.filename">
<template #description>
<n-space>
<n-tag type="info">
Size: {{ row.size }}
</n-tag>
</n-space>
<n-spin v-model:show="attachmentLoding">
<n-list hoverable clickable>
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
<n-thing class="center" :title="row.filename">
<template #description>
<n-space>
<n-tag type="info">
Size: {{ row.size }}
</n-tag>
<n-button v-if="showSaveS3" @click="saveToS3Proxy(row.filename, row.blob)" ghost type="info"
size="small">
{{ t('saveToS3') }}
</n-button>
</n-space>
</template>
</n-thing>
<template #suffix>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
:href="row.url">
<n-icon :component="CloudDownloadRound" />
</n-button>
</template>
</n-thing>
<template #suffix>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
:href="row.url">
<n-icon :component="CloudDownloadRound" />
</n-button>
</template>
</n-list-item>
</n-list>
</n-list-item>
</n-list>
</n-spin>
</n-modal>
<n-modal v-model:show="showMultiActionDownload" preset="dialog" :title="t('downloadMail')">
<n-tag type="info">
{{ multiActionDownloadZip.filename }}
</n-tag>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="multiActionDownloadZip.filename"
:href="multiActionDownloadZip.url">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') + " zip" }}
</n-button>
</n-modal>
<n-modal v-model:show="showMultiActionDelete" preset="dialog" :title="t('delete') + t('success')"
negative-text="OK">
<n-space justify="center">
<n-progress type="circle" status="error" :percentage="multiActionDeleteProgress.percentage">
<span style="text-align: center">
{{ multiActionDeleteProgress.tip }}
</span>
</n-progress>
</n-space>
</n-modal>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup>
import { watch, onMounted, ref } from "vue";
import { watch, onMounted, ref, computed } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
@@ -9,6 +9,11 @@ const message = useMessage()
const isMobile = useIsMobile()
const props = defineProps({
enableUserDeleteEmail: {
type: Boolean,
default: false,
requried: false
},
showEMailFrom: {
type: Boolean,
default: false
@@ -18,9 +23,14 @@ const props = defineProps({
default: () => { },
requried: true
},
deleteMail: {
type: Function,
default: () => { },
requried: false
},
})
const { localeCache, isDark, mailboxSplitSize } = useGlobalState()
const { isDark, mailboxSplitSize, loading } = useGlobalState()
const data = ref([])
const count = ref(0)
@@ -30,20 +40,35 @@ const pageSize = ref(20)
const curMail = ref(null);
const showCode = ref(false)
const multiActionMode = ref(false)
const showMultiActionDelete = ref(false)
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'Success',
refresh: 'Refresh',
showCode: 'Change View Original Code',
pleaseSelectMail: "Please select a mail to view."
pleaseSelectMail: "Please select a mail to view.",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete mail?',
multiAction: 'Multi Action',
cancelMultiAction: 'Cancel Multi Action',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
},
zh: {
success: '成功',
refresh: '刷新',
showCode: '切换查看元数据',
pleaseSelectMail: "请选择一封邮件查看。",
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
multiAction: '多选',
cancelMultiAction: '取消多选',
selectAll: '全选本页',
unselectAll: '取消全选',
}
}
});
@@ -106,6 +131,71 @@ const onSpiltSizeChange = (size) => {
mailboxSplitSize.value = size;
}
const deleteMail = async () => {
try {
await props.deleteMail(curMail.value.id);
message.success(t("success"));
curMail.value = null;
await refresh();
} catch (error) {
message.error(error.message || "error");
}
};
const showMultiActionMode = computed(() => {
return props.enableUserDeleteEmail;
});
const multiActionModeClick = (enableMulti) => {
if (enableMulti) {
data.value.forEach((item) => {
item.checked = false;
});
multiActionMode.value = true;
} else {
multiActionMode.value = false;
data.value.forEach((item) => {
item.checked = false;
});
}
}
const multiActionSelectAll = (checked) => {
data.value.forEach((item) => {
item.checked = checked;
});
}
const multiActionDeleteMail = async () => {
try {
loading.value = true;
const selectedMails = data.value.filter((item) => item.checked);
if (selectedMails.length === 0) {
message.error(t('pleaseSelectMail'));
return;
}
multiActionDeleteProgress.value = {
percentage: 0,
tip: `0/${selectedMails.length}`
};
for (const [index, mail] of selectedMails.entries()) {
await props.deleteMail(mail.id);
showMultiActionDelete.value = true;
multiActionDeleteProgress.value = {
percentage: Math.floor((index + 1) / selectedMails.length * 100),
tip: `${index + 1}/${selectedMails.length}`
};
}
message.success(t("success"));
await refresh();
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
showMultiActionDelete.value = true;
}
}
onMounted(async () => {
await refresh();
});
@@ -113,70 +203,105 @@ onMounted(async () => {
<template>
<div>
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25"
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
<template #1>
<div class="center">
<div v-if="!isMobile" class="left">
<div style="margin-bottom: 10px;">
<n-space v-if="multiActionMode">
<n-button @click="multiActionModeClick(false)" tertiary>
{{ t('cancelMultiAction') }}
</n-button>
<n-button @click="multiActionSelectAll(true)" tertiary>
{{ t('selectAll') }}
</n-button>
<n-button @click="multiActionSelectAll(false)" tertiary>
{{ t('unselectAll') }}
</n-button>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="multiActionDeleteMail">
<template #trigger>
<n-button tertiary type="error">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
</n-space>
<n-space v-else>
<n-button v-if="showMultiActionMode" @click="multiActionModeClick(true)" type="primary" tertiary>
{{ t('multiAction') }}
</n-button>
<div style="display: inline-block; margin-right: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker />
</div>
<n-button @click="refresh" size="small" type="primary" tertiary>
<n-button @click="refresh" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
</n-tag>
<n-tag type="info">
TO: {{ row.to_mail }}
</n-tag>
</n-space>
</div>
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<template #1>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.address }}
</n-tag>
<n-tag type="info">
TO: {{ curMail.to_mail }}
</n-tag>
<n-button size="small" tertiary type="info" @click="showCode = !showCode">
{{ t('showCode') }}
</n-button>
</n-space>
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
</n-card>
<n-card class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ `${row.created_at} UTC` }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
</n-tag>
<n-tag type="info">
TO: {{ row.to_mail }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card :bordered="false" embedded v-if="curMail" class="mail-item" :title="curMail.subject"
style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ `${curMail.created_at} UTC` }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.address }}
</n-tag>
<n-tag type="info">
TO: {{ curMail.to_mail }}
</n-tag>
<n-button size="small" tertiary type="info" @click="showCode = !showCode">
{{ t('showCode') }}
</n-button>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
</n-space>
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
</div>
<div class="left" v-else>
<div class="center">
<div style="display: inline-block; margin-right: 10px;">
@@ -195,7 +320,7 @@ onMounted(async () => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
{{ `${row.created_at} UTC` }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
@@ -211,13 +336,13 @@ onMounted(async () => {
<n-drawer v-model:show="curMail" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 80vh;">
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
<n-card style="overflow: auto;">
<n-card :bordered="false" embedded style="overflow: auto;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
{{ `${curMail.created_at} UTC` }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.address }}
@@ -225,6 +350,15 @@ onMounted(async () => {
<n-tag type="info">
TO: {{ curMail.to_mail }}
</n-tag>
<n-button size="small" tertiary type="info" @click="showCode = !showCode">
{{ t('showCode') }}
</n-button>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
</n-space>
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>

View File

@@ -1,13 +1,12 @@
<script setup>
import { ref, watch, defineModel, onMounted } from "vue";
import { ref, watch, onMounted } from "vue";
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
const { localeCache, openSettings, isDark } = useGlobalState()
const { openSettings, isDark } = useGlobalState()
const cfToken = defineModel('value')
const { t } = useI18n({
locale: localeCache.value || 'zh',
const { locale, t } = useI18n({
messages: {
en: {
refresh: 'Refresh'
@@ -42,7 +41,7 @@ const checkCfTurnstile = async (remove) => {
"#cf-turnstile",
{
sitekey: openSettings.value.cfTurnstileSiteKey,
language: localeCache.value == 'zh' ? 'zh-CN' : 'en-US',
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
theme: isDark.value ? 'dark' : 'light',
callback: function (token) {
cfToken.value = token;

View File

@@ -3,6 +3,7 @@ import App from './App.vue'
import { createI18n } from 'vue-i18n'
import router from './router'
import { registerSW } from 'virtual:pwa-register'
import { createHead } from '@unhead/vue'
registerSW({ immediate: true })
const i18n = createI18n({
@@ -16,7 +17,19 @@ const i18n = createI18n({
messages: {}
}
})
router.beforeEach((to, from) => {
if (to.params.lang && ['en', 'zh'].includes(to.params.lang)) {
i18n.global.locale.value = to.params.lang
} else {
i18n.global.locale.value = 'zh'
}
});
const head = createHead()
const app = createApp(App)
app.use(i18n)
app.use(router)
app.use(head)
app.mount('#app')

View File

@@ -1,26 +1,36 @@
import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue'
import User from '../views/User.vue'
import { useGlobalState } from '../store'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
alias: "/:lang/",
component: Index
},
{
path: '/user',
alias: "/:lang/user",
component: User
},
{
path: '/admin',
alias: "/:lang/admin",
component: () => import('../views/Admin.vue')
},
{
path: '/telegram_mail',
alias: "/:lang/telegram_mail",
component: () => import('../views/telegram/Mail.vue')
},
{
name: 'not-found',
path: '/:pathMatch(.*)*',
redirect: '/'
}
]
})

View File

@@ -1,12 +1,15 @@
import { ref } from "vue";
import { createGlobalState, useStorage, useDark, useToggle } from '@vueuse/core'
import { computed, ref } from "vue";
import { createGlobalState, useStorage, useDark, useToggle, useLocalStorage } from '@vueuse/core'
export const useGlobalState = createGlobalState(
() => {
const isDark = useDark()
const toggleDark = useToggle(isDark)
const loading = ref(false);
const announcement = useLocalStorage('announcement', '');
const openSettings = ref({
title: '',
announcement: '',
prefix: '',
needAuth: false,
adminContact: '',
@@ -14,9 +17,15 @@ export const useGlobalState = createGlobalState(
enableUserDeleteEmail: false,
enableAutoReply: false,
enableIndexAbout: false,
/** @type {string[]} */
defaultDomains: [],
/** @type {Array<{label: string, value: string}>} */
domains: [],
copyright: 'Dream Hunter',
cfTurnstileSiteKey: '',
enableWebhook: false,
isS3Enabled: false,
showGithub: true,
})
const settings = ref({
fetched: false,
@@ -44,7 +53,6 @@ export const useGlobalState = createGlobalState(
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', '');
const localeCache = useStorage('locale', 'zh');
const adminTab = ref("account");
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
@@ -67,7 +75,14 @@ export const useGlobalState = createGlobalState(
user_email: '',
/** @type {number} */
user_id: 0,
/** @type {boolean} */
is_admin: false,
/** @type {string | null} */
access_token: null,
/** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
user_role: null,
});
const showAdminPage = computed(() => !!adminAuth.value || userSettings.value.is_admin);
const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
return {
@@ -76,12 +91,12 @@ export const useGlobalState = createGlobalState(
loading,
settings,
sendMailModel,
announcement,
openSettings,
showAuth,
showAddressCredential,
auth,
jwt,
localeCache,
adminAuth,
showAdminAuth,
adminTab,
@@ -99,6 +114,7 @@ export const useGlobalState = createGlobalState(
useSideMargin,
telegramApp,
isTelegram,
showAdminPage,
}
},
)

View File

@@ -16,11 +16,11 @@ export async function processItem(item) {
item.message = parsedEmail.body_html || parsedEmail.text || '';
item.text = parsedEmail.text || '';
item.attachments = parsedEmail.attachments?.map((a_item) => {
const blob_url = URL.createObjectURL(
new Blob(
[a_item.content],
{ type: a_item.content_type || 'application/octet-stream' }
))
const blob = new Blob(
[a_item.content],
{ type: a_item.content_type || 'application/octet-stream' }
);
const blob_url = URL.createObjectURL(blob);
if (a_item.content_id && a_item.content_id.length > 0) {
item.message = item.message.replace(`cid:${a_item.content_id}`, blob_url);
}
@@ -28,7 +28,8 @@ export async function processItem(item) {
id: a_item.content_id || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.content_id || "",
size: humanFileSize(a_item.content?.length || 0),
url: blob_url
url: blob_url,
blob: blob
}
}) || [];
} catch (error) {
@@ -49,11 +50,11 @@ export async function processItem(item) {
item.message = parsedEmail.html || parsedEmail.text || item.raw;
item.text = parsedEmail.text || '';
item.attachments = parsedEmail.attachments?.map((a_item) => {
const blob_url = URL.createObjectURL(
new Blob(
[a_item.content],
{ type: a_item.mimeType || 'application/octet-stream' }
))
const blob = new Blob(
[a_item.content],
{ type: a_item.mimeType || 'application/octet-stream' }
);
const blob_url = URL.createObjectURL(blob)
if (a_item.contentId && a_item.contentId.length > 0) {
item.message = item.message.replace(`cid:${a_item.contentId}`, blob_url);
}
@@ -61,7 +62,8 @@ export async function processItem(item) {
id: a_item.contentId || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.contentId || "",
size: humanFileSize(a_item.content?.length || 0),
url: blob_url
url: blob_url,
blob: blob
}
}) || [];
} catch (error) {

View File

@@ -1,6 +1,13 @@
export const hashPassword = async (password) => {
export const hashPassword = async (password: string) => {
// user crypto to hash password
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
const hashArray = Array.from(new Uint8Array(digest));
return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
}
export const getRouterPathWithLang = (path: string, lang: string) => {
if (!lang || lang === 'zh') {
return path;
}
return `/${lang}${path}`;
}

View File

@@ -21,7 +21,8 @@ import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
const {
localeCache, adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
adminAuth, showAdminAuth, adminTab, loading,
globalTabplacement, showAdminPage
} = useGlobalState()
const message = useMessage()
@@ -34,7 +35,6 @@ const authFunc = async () => {
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
accessHeader: 'Admin Password',
@@ -82,7 +82,7 @@ const { t } = useI18n({
});
onMounted(async () => {
if (!adminAuth.value) {
if (!showAdminPage.value) {
showAdminAuth.value = true;
return;
}
@@ -101,7 +101,7 @@ onMounted(async () => {
</n-button>
</template>
</n-modal>
<n-tabs type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="account" :tab="t('account')">
<n-tabs type="bar" animated>
<n-tab-pane name="account" :tab="t('account')">

View File

@@ -1,11 +1,10 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
const { localeCache, openSettings } = useGlobalState()
const { openSettings } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
copyright: "Copyright"

View File

@@ -1,6 +1,7 @@
<script setup>
import { ref, h, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useHead } from '@unhead/vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useIsMobile } from '../utils/composables'
import {
@@ -11,11 +12,13 @@ import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getRouterPathWithLang } from '../utils'
const message = useMessage()
const {
localeCache, toggleDark, isDark, isTelegram,
showAuth, adminAuth, auth, loading
toggleDark, isDark, isTelegram, showAdminPage,
showAuth, auth, loading, openSettings
} = useGlobalState()
const route = useRoute()
const router = useRouter()
@@ -36,13 +39,15 @@ const authFunc = async () => {
}
}
const changeLocale = (locale) => {
localeCache.value = locale;
location.reload()
const changeLocale = async (lang) => {
if (lang == 'zh') {
await router.push(route.fullPath.replace('/en', ''));
} else {
await router.push(`/${lang}${route.fullPath}`);
}
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
const { locale, t } = useI18n({
messages: {
en: {
title: 'Cloudflare Temp Email',
@@ -79,7 +84,10 @@ const menuOptions = computed(() => [
size: "small",
type: menuValue.value == "home" ? "primary" : "default",
style: "width: 100%",
onClick: async () => { await router.push('/'); showMobileMenu.value = false; }
onClick: async () => {
await router.push(getRouterPathWithLang('/', locale.value));
showMobileMenu.value = false;
}
},
{
default: () => t('home'),
@@ -95,7 +103,10 @@ const menuOptions = computed(() => [
size: "small",
type: menuValue.value == "user" ? "primary" : "default",
style: "width: 100%",
onClick: async () => { await router.push("/user"); showMobileMenu.value = false; }
onClick: async () => {
await router.push(getRouterPathWithLang("/user", locale.value));
showMobileMenu.value = false;
}
},
{
default: () => t('user'),
@@ -113,14 +124,17 @@ const menuOptions = computed(() => [
size: "small",
type: menuValue.value == "admin" ? "primary" : "default",
style: "width: 100%",
onClick: async () => { await router.push('/admin'); showMobileMenu.value = false; }
onClick: async () => {
await router.push(getRouterPathWithLang('/admin', locale.value));
showMobileMenu.value = false;
}
},
{
default: () => "Admin",
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
}
),
show: !!adminAuth.value,
show: showAdminPage.value,
key: "admin"
},
{
@@ -148,13 +162,13 @@ const menuOptions = computed(() => [
text: true,
size: "small",
style: "width: 100%",
onClick: () => {
localeCache.value == 'zh' ? changeLocale('en') : changeLocale('zh');
onClick: async () => {
locale.value == 'zh' ? await changeLocale('en') : await changeLocale('zh');
showMobileMenu.value = false;
}
},
{
default: () => localeCache.value == 'zh' ? "English" : "中文",
default: () => locale.value == 'zh' ? "English" : "中文",
icon: () => h(
NIcon, { component: Language }
)
@@ -178,12 +192,43 @@ const menuOptions = computed(() => [
icon: () => h(NIcon, { component: GithubAlt })
}
),
show: openSettings.value?.showGithub,
key: "github"
}
]);
useHead({
title: () => openSettings.value.title || t('title'),
meta: [
{ name: "description", content: openSettings.value.description || t('title') },
]
});
const logoClickCount = ref(0);
const logoClick = async () => {
if (route.path.includes("admin")) {
logoClickCount.value = 0;
return;
}
if (logoClickCount.value >= 5) {
logoClickCount.value = 0;
message.info("Change to admin Page");
await router.push(getRouterPathWithLang('/admin', locale.value));
} else {
logoClickCount.value++;
}
if (logoClickCount.value > 0) {
message.info(`Click ${5 - logoClickCount.value + 1} times to enter the admin page`);
}
}
onMounted(async () => {
await api.getOpenSettings(message);
try {
await api.getUserSettings(message);
} catch (error) {
console.error(error);
}
});
</script>
@@ -191,10 +236,12 @@ onMounted(async () => {
<div>
<n-page-header>
<template #title>
<h3>{{ t('title') }}</h3>
<h3>{{ openSettings.title || t('title') }}</h3>
</template>
<template #avatar>
<n-avatar style="margin-left: 10px;" src="/logo.png" />
<div @click="logoClick">
<n-avatar style="margin-left: 10px;" src="/logo.png" />
</div>
</template>
<template #extra>
<n-space>

View File

@@ -10,14 +10,15 @@ import MailBox from '../components/MailBox.vue';
import SendBox from '../components/SendBox.vue';
import AutoReply from './index/AutoReply.vue';
import AccountSettings from './index/AccountSettings.vue';
import WenHook from './index/Webhook.vue';
import Webhook from './index/Webhook.vue';
import Attachment from './index/Attachment.vue';
import About from './common/About.vue';
const SendMail = defineAsyncComponent(() => import('./index/SendMail.vue'));
const { localeCache, settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
const { settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
mailbox: 'Mail Box',
@@ -26,6 +27,8 @@ const { t } = useI18n({
auto_reply: 'Auto Reply',
accountSettings: 'Account Settings',
about: 'About',
s3Attachment: 'S3 Attachment',
saveToS3Success: 'save to s3 success',
},
zh: {
mailbox: '收件箱',
@@ -34,6 +37,8 @@ const { t } = useI18n({
auto_reply: '自动回复',
accountSettings: '账户设置',
about: '关于',
s3Attachment: 'S3附件',
saveToS3Success: '保存到s3成功',
}
}
});
@@ -46,9 +51,33 @@ const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
const deleteSenboxMail = async (curMailId) => {
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
};
const fetchSenboxData = async (limit, offset) => {
return await api.fetch(`/api/sendbox?limit=${limit}&offset=${offset}`);
};
const saveToS3 = async (mail_id, filename, blob) => {
try {
const { url } = await api.fetch(`/api/attachment/put_url`, {
method: 'POST',
body: JSON.stringify({ key: `${mail_id}/${filename}` })
});
// upload to s3 by formdata
const formData = new FormData();
formData.append(filename, blob);
await fetch(url, {
method: 'PUT',
body: formData
});
message.success(t('saveToS3Success'));
} catch (error) {
console.error(error);
message.error(error.message || "save to s3 error");
}
}
</script>
<template>
@@ -56,11 +85,13 @@ const fetchSenboxData = async (limit, offset) => {
<AddressBar />
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
<n-tab-pane name="mailbox" :tab="t('mailbox')">
<MailBox :showEMailTo="false" :showReply="true" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
<MailBox :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled" :saveToS3="saveToS3"
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
:deleteMail="deleteMail" />
</n-tab-pane>
<n-tab-pane name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" />
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:deleteMail="deleteSenboxMail" />
</n-tab-pane>
<n-tab-pane name="sendmail" :tab="t('sendmail')">
<SendMail />
@@ -72,7 +103,10 @@ const fetchSenboxData = async (limit, offset) => {
<AutoReply />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhook')">
<WenHook />
<Webhook />
</n-tab-pane>
<n-tab-pane v-if="openSettings.isS3Enabled" name="s3_attachment" :tab="t('s3Attachment')">
<Attachment />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableIndexAbout" name="about" :tab="t('about')">
<About />

View File

@@ -9,11 +9,10 @@ import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue';
const {
localeCache, userTab, globalTabplacement, userSettings
userTab, globalTabplacement, userSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address_management: 'Address Management',

View File

@@ -9,13 +9,12 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
localeCache, adminAuth, showAdminAuth, loading,
adminTab, adminMailTabAddress, adminSendBoxTabAddress
showAdminAuth, loading, adminTab,
adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
name: 'Name',
@@ -95,6 +94,7 @@ const deleteEmail = async () => {
const fetchData = async () => {
try {
addressQuery.value = addressQuery.value.trim()
const { results, count: addressCount } = await api.fetch(
`/admin/address`
+ `?limit=${pageSize.value}`
@@ -252,10 +252,6 @@ watch([page, pageSize], async () => {
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script>
@@ -269,7 +265,7 @@ onMounted(async () => {
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card>
<n-card :bordered="false" embedded>
<b>{{ curEmailCredential }}</b>
</n-card>
<template #action>
@@ -284,7 +280,8 @@ onMounted(async () => {
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
@keydown.enter="fetchData" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
@@ -297,7 +294,7 @@ onMounted(async () => {
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
</template>

View File

@@ -5,11 +5,10 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
save: 'Save',
@@ -69,7 +68,7 @@ onMounted(async () => {
<template>
<div class="center">
<n-card style="max-width: 600px;">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-form-item-row :label="t('address_block_list')">
<n-select v-model:value="addressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" />

View File

@@ -6,12 +6,11 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
const {
localeCache, loading, openSettings,
loading, openSettings,
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
@@ -72,11 +71,11 @@ onMounted(async () => {
<div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
<p>{{ t('addressCredential') }}</p>
<n-card>
<n-card :bordered="false" embedded>
<b>{{ result }}</b>
</n-card>
</n-modal>
<n-card style="max-width: 600px;">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
<n-checkbox v-model:checked="enablePrefix" />
</n-form-item-row>

View File

@@ -6,13 +6,9 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
import MailBox from '../../components/MailBox.vue';
const {
localeCache, adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const { adminMailTabAddress } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
addressQueryTip: 'Leave blank to query all addresses',
@@ -30,12 +26,9 @@ const { t } = useI18n({
const mailBoxKey = ref("")
const mailKeyword = ref("")
watch([adminMailTabAddress, mailKeyword], () => {
const queryMail = () => {
adminMailTabAddress.value = adminMailTabAddress.value.trim();
mailKeyword.value = mailKeyword.value.trim();
});
const queryMail = () => {
mailBoxKey.value = Date.now();
}
@@ -49,23 +42,23 @@ const fetchMailData = async (limit, offset) => {
);
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
const deleteMail = async (curMailId) => {
await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' });
};
</script>
<template>
<div style="margin-top: 10px;">
<n-input-group>
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" />
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" />
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')"
@keydown.enter="queryMail" />
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" @keydown.enter="queryMail" />
<n-button @click="queryMail" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="false" :fetchMailData="fetchMailData" />
<div style="margin-top: 10px;"></div>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="true" :fetchMailData="fetchMailData"
:deleteMail="deleteMail" />
</div>
</template>

View File

@@ -1,12 +1,7 @@
<script setup>
import { onMounted } from 'vue';
import { useGlobalState } from '../../store'
import { api } from '../../api'
import MailBox from '../../components/MailBox.vue';
const { adminAuth, showAdminAuth } = useGlobalState()
const fetchMailUnknowData = async (limit, offset) => {
return await api.fetch(
`/admin/mails_unknow`
@@ -15,16 +10,13 @@ const fetchMailUnknowData = async (limit, offset) => {
);
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
</script>
<template>
<div v-if="adminAuth" style="margin-top: 10px;">
<MailBox :enableUserDeleteEmail="false" :fetchMailData="fetchMailUnknowData" />
<div style="margin-top: 10px;">
<MailBox :enableUserDeleteEmail="true" :fetchMailData="fetchMailUnknowData" :deleteMail="deleteMail" />
</div>
</template>

View File

@@ -1,12 +1,10 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { CleaningServicesFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
const message = useMessage()
const cleanupModel = ref({
enableMailsAutoCleanup: false,
@@ -20,13 +18,12 @@ const cleanupModel = ref({
})
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
tip: 'Please input the cleanup days',
mailBoxLabel: 'Clean up days for mailbox',
mailUnknowLabel: "Clean up days for unknow receiver",
sendBoxLabel: "Clean up days for sendbox",
tip: 'Please input the days',
mailBoxLabel: 'Cleanup the inbox before n days',
mailUnknowLabel: "Cleanup the unknow mail before n days",
sendBoxLabel: "Cleanup the sendbox before n days",
cleanupNow: "Cleanup now",
autoCleanup: "Auto cleanup",
cleanupSuccess: "Cleanup success",
@@ -34,10 +31,10 @@ const { t } = useI18n({
cronTip: "Enable cron cleanup, need to configure [crons] in worker, please refer to the document",
},
zh: {
tip: '请输入清理天数',
mailBoxLabel: '收件箱清理天数',
mailUnknowLabel: "无收件人邮件清理天数",
sendBoxLabel: "发件箱清理天数",
tip: '请输入天数',
mailBoxLabel: '清理 n 天前的收件箱',
mailUnknowLabel: "清理 n 天前的无收件人邮件",
sendBoxLabel: "清理 n 天前的发件箱",
autoCleanup: "自动清理",
cleanupSuccess: "清理成功",
cleanupNow: "立即清理",
@@ -81,10 +78,6 @@ const save = async () => {
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData();
})
</script>
@@ -92,8 +85,8 @@ onMounted(async () => {
<template>
<div class="center">
<n-card>
<n-alert :show-icon="false">
<n-card :bordered="false" embedded>
<n-alert :show-icon="false" :bordered="false">
<span>{{ t('cronTip') }}</span>
</n-alert>
<n-form :model="cleanupModel">

View File

@@ -5,10 +5,9 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
import SendBox from '../../components/SendBox.vue';
const { localeCache, adminSendBoxTabAddress } = useGlobalState()
const { adminSendBoxTabAddress } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
query: 'Query',
@@ -22,22 +21,28 @@ const { t } = useI18n({
});
const fetchData = async (limit, offset) => {
adminSendBoxTabAddress.value = adminSendBoxTabAddress.value.trim();
return await api.fetch(
`/admin/sendbox?limit=${limit}&offset=${offset}`
+ (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
);
}
const deleteSenboxMail = async (curMailId) => {
await api.fetch(`/admin/sendbox/${curMailId}`, { method: 'DELETE' });
};
</script>
<template>
<div>
<n-input-group>
<n-input v-model:value="adminSendBoxTabAddress" :placeholder="t('queryTip')" />
<n-input v-model:value="adminSendBoxTabAddress" :placeholder="t('queryTip')" @keydown.enter="fetchData" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<SendBox style="margin-top: 10px;" :fetchMailData="fetchData" :showEMailFrom="true" />
<SendBox style="margin-top: 10px;" :enableUserDeleteEmail="true" :deleteMail="deleteSenboxMail"
:fetchMailData="fetchData" :showEMailFrom="true" />
</div>
</template>

View File

@@ -5,11 +5,10 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
@@ -18,6 +17,8 @@ const { t } = useI18n({
enable: 'Enable',
disable: 'Disable',
modify: 'Modify',
delete: 'Delete',
deleteTip: 'Are you sure to delete this?',
created_at: 'Created At',
action: 'Action',
itemCount: 'itemCount',
@@ -33,6 +34,8 @@ const { t } = useI18n({
enable: '启用',
disable: '禁用',
modify: '修改',
delete: '删除',
deleteTip: '确定删除吗?',
created_at: '创建时间',
action: '操作',
itemCount: '总数',
@@ -76,6 +79,7 @@ const updateData = async () => {
const fetchData = async () => {
try {
addressQuery.value = addressQuery.value.trim();
const { results, count: addressCount } = await api.fetch(
`/admin/address_sender`
+ `?limit=${pageSize.value}`
@@ -135,7 +139,25 @@ const columns = [
}
},
{ default: () => t('modify') }
)
),
h(NPopconfirm,
{
onPositiveClick: async () => {
await api.fetch(`/admin/address_sender/${row.id}`, { method: 'DELETE' });
await fetchData();
}
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "error",
},
{ default: () => t('delete') }
),
default: () => t('deleteTip')
}
),
])
}
}
@@ -171,7 +193,7 @@ onMounted(async () => {
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" />
<n-input v-model:value="addressQuery" @keydown.enter="fetchData" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
@@ -184,7 +206,7 @@ onMounted(async () => {
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
</template>

View File

@@ -4,24 +4,25 @@ import { useI18n } from 'vue-i18n'
import { User, UserCheck, MailBulk } from '@vicons/fa'
import { SendOutlined } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, adminAuth } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
userCount: 'Account Count',
activeUser: '7 days Active Mail Account',
userCount: 'User Count',
addressCount: 'Address Count',
activeAddressCount7days: '7 days Active Address Count',
activeAddressCount30days: '30 days Active Address Count',
mailCount: 'Mail Count',
sendMailCount: 'Send Mail Count'
},
zh: {
userCount: '地址总数',
activeUser: '周活跃邮箱地址',
userCount: '用户总数',
addressCount: '邮箱地址总数',
activeAddressCount7days: '7天活跃邮箱地址总数',
activeAddressCount30days: '30天活跃邮箱地址总数',
mailCount: '邮件总数',
sendMailCount: '发送邮件总数'
}
@@ -29,21 +30,27 @@ const { t } = useI18n({
});
const statistics = ref({
addressCount: 0,
userCount: 0,
mailCount: 0,
activeUserCount7days: 0,
activeAddressCount7days: 0,
activeAddressCount30days: 0,
sendMailCount: 0,
})
const fetchStatistics = async () => {
try {
const {
userCount, activeUserCount7days, mailCount, sendMailCount
userCount, mailCount, sendMailCount,
addressCount, activeAddressCount7days,
activeAddressCount30days,
} = await api.fetch(`/admin/statistics`);
statistics.value.mailCount = mailCount || 0;
statistics.value.userCount = userCount || 0;
statistics.value.activeUserCount7days = activeUserCount7days || 0;
statistics.value.sendMailCount = sendMailCount || 0;
statistics.value.userCount = userCount || 0;
statistics.value.addressCount = addressCount || 0;
statistics.value.activeAddressCount7days = activeAddressCount7days || 0;
statistics.value.activeAddressCount30days = activeAddressCount30days || 0;
} catch (error) {
console.log(error)
message.error(error.message || "error");
@@ -51,44 +58,68 @@ const fetchStatistics = async () => {
}
onMounted(async () => {
if (!adminAuth.value) {
return;
}
await fetchStatistics()
})
</script>
<template>
<n-card>
<n-row>
<n-col :span="6">
<n-statistic :label="t('userCount')" :value="statistics.userCount">
<template #prefix>
<n-icon :component="User" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('activeUser')" :value="statistics.activeUserCount7days">
<template #prefix>
<n-icon :component="UserCheck" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('mailCount')" :value="statistics.mailCount">
<template #prefix>
<n-icon :component="MailBulk" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('sendMailCount')" :value="statistics.sendMailCount">
<template #prefix>
<n-icon :component="SendOutlined" />
</template>
</n-statistic>
</n-col>
</n-row>
</n-card>
<div>
<n-card :bordered="false" embedded>
<n-row>
<n-col :span="8">
<n-statistic :label="t('addressCount')" :value="statistics.addressCount">
<template #prefix>
<n-icon :component="User" />
</template>
</n-statistic>
</n-col>
<n-col :span="8">
<n-statistic :label="t('activeAddressCount7days')" :value="statistics.activeAddressCount7days">
<template #prefix>
<n-icon :component="UserCheck" />
</template>
</n-statistic>
</n-col>
<n-col :span="8">
<n-statistic :label="t('activeAddressCount30days')" :value="statistics.activeAddressCount30days">
<template #prefix>
<n-icon :component="UserCheck" />
</template>
</n-statistic>
</n-col>
</n-row>
</n-card>
<n-card :bordered="false" embedded>
<n-row>
<n-col :span="8">
<n-statistic :label="t('userCount')" :value="statistics.userCount">
<template #prefix>
<n-icon :component="User" />
</template>
</n-statistic>
</n-col>
<n-col :span="8">
<n-statistic :label="t('mailCount')" :value="statistics.mailCount">
<template #prefix>
<n-icon :component="MailBulk" />
</template>
</n-statistic>
</n-col>
<n-col :span="8">
<n-statistic :label="t('sendMailCount')" :value="statistics.sendMailCount">
<template #prefix>
<n-icon :component="SendOutlined" />
</template>
</n-statistic>
</n-col>
</n-row>
</n-card>
</div>
</template>
<style scoped>
.n-card {
margin-bottom: 20px;
}
</style>

View File

@@ -6,13 +6,10 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
const { localeCache } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
init: 'Init',
@@ -115,8 +112,8 @@ onMounted(async () => {
<template>
<div class="center">
<n-card style="max-width: 800px; overflow: auto;">
<n-card>
<n-card :bordered="false" embedded style="max-width: 800px; overflow: auto;">
<n-card :bordered="false" embedded>
<n-form-item-row :label="t('enableTelegramAllowList')">
<n-input-group>
<n-checkbox v-model:checked="settings.enableAllowList" style="width: 20%;">

View File

@@ -1,22 +1,22 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { ref, h, onMounted, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n'
import { NMenu, NButton, NBadge } from 'naive-ui';
import { NMenu, NButton, NBadge, NTag } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { hashPassword } from '../../utils';
const { localeCache, loading } = useGlobalState()
const { loading, openSettings } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'Success',
user_email: 'User Email',
role: 'Role',
address_count: 'Address Count',
created_at: 'Created At',
actions: 'Actions',
@@ -30,10 +30,15 @@ const { t } = useI18n({
createUser: 'Create User',
email: 'Email',
password: 'Password',
changeRole: 'Change Role',
prefix: 'Prefix',
domains: 'Domains',
roleDonotExist: 'Current Role does not exist',
},
zh: {
success: '成功',
user_email: '用户邮箱',
role: '角色',
address_count: '地址数量',
created_at: '创建时间',
actions: '操作',
@@ -47,6 +52,10 @@ const { t } = useI18n({
createUser: '创建用户',
email: '邮箱',
password: '密码',
changeRole: '更改角色',
prefix: '前缀',
domains: '域名',
roleDonotExist: '当前角色不存在',
}
}
});
@@ -65,9 +74,31 @@ const user = ref({
email: "",
password: ""
})
const showChangeRole = ref(false)
const userRoles = ref([])
const curUserRole = ref('')
const userRolesOptions = computed(() => {
return userRoles.value.map(role => {
return {
label: role.role,
value: role.role
}
});
})
const fetchUserRoles = async () => {
try {
const results = await api.fetch(`/admin/user_roles`);
userRoles.value = results;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
userQuery.value = userQuery.value.trim()
const { results, count: userCount } = await api.fetch(
`/admin/users`
+ `?limit=${pageSize.value}`
@@ -139,6 +170,24 @@ const deleteUser = async () => {
}
}
const changeRole = async () => {
try {
await api.fetch(`/admin/user_roles`, {
method: "POST",
body: JSON.stringify({
user_id: curUserId.value,
role_text: curUserRole.value
})
});
message.success(t('success'));
showChangeRole.value = false;
await fetchData();
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
@@ -148,6 +197,19 @@ const columns = [
title: t('user_email'),
key: "user_email"
},
{
title: t('role'),
key: "role_text",
render(row) {
if (!row.role_text) return null;
return h(NTag, {
bordered: false,
type: "info"
}, {
default: () => row.role_text
})
}
},
{
title: t('address_count'),
key: "address_count",
@@ -177,6 +239,19 @@ const columns = [
icon: () => h(MenuFilled),
key: "action",
children: [
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curUserId.value = row.id;
curUserRole.value = row.role_text;
showChangeRole.value = true;
}
},
{ default: () => t('changeRole') }
),
},
{
label: () => h(NButton,
{
@@ -213,12 +288,29 @@ const columns = [
}
]
const getRolePrefix = (role) => {
const res = userRoles.value.find(r => r.role === role)?.prefix;
if (res === undefined || res === null) return openSettings.value.prefix;
return res;
}
const getRoleDomains = (role) => {
const res = userRoles.value.find(r => r.role === role)?.domains;
if (res === undefined || res === null || res.length == 0) return openSettings.value.defaultDomains;
return res;
}
const roleDonotExist = computed(() => {
return curUserRole.value && !userRoles.value.some(r => r.role === curUserRole.value);
})
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
await fetchData()
await fetchUserRoles();
await fetchData();
})
</script>
@@ -257,8 +349,21 @@ onMounted(async () => {
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showChangeRole" preset="dialog" :title="t('changeRole')">
<n-alert type="error" :bordered="false" v-if="roleDonotExist">
<span>{{ t('roleDonotExist') }}</span>
</n-alert>
<p>{{ t('prefix') + ": " + getRolePrefix(curUserRole) }}</p>
<p>{{ t('domains') + ": " + JSON.stringify(getRoleDomains(curUserRole)) }}</p>
<n-select clearable v-model:value="curUserRole" :options="userRolesOptions" />
<template #action>
<n-button :loading="loading" @click="changeRole" size="small" tertiary type="primary">
{{ t('changeRole') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="userQuery" />
<n-input v-model:value="userQuery" @keydown.enter="fetchData" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
@@ -277,7 +382,7 @@ onMounted(async () => {
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
</template>

View File

@@ -5,11 +5,10 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
save: 'Save',
@@ -83,7 +82,7 @@ onMounted(async () => {
<template>
<div class="center">
<n-card style="max-width: 600px;">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-form :model="userSettings">
<n-form-item-row :label="t('enableUserRegister')">
<n-checkbox v-model:checked="userSettings.enable" />

View File

@@ -6,13 +6,10 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
const { localeCache } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
successTip: 'Success',
@@ -65,7 +62,7 @@ onMounted(async () => {
<template>
<div class="center">
<n-card style="max-width: 800px; overflow: auto;">
<n-card :bordered="false" embedded style="max-width: 800px; overflow: auto;">
<n-form-item-row :label="t('webhookAllowList')">
<n-select v-model:value="webhookSettings.allowList" filterable multiple tag
:placeholder="t('webhookAllowList')" />

View File

@@ -4,7 +4,7 @@ import { GithubAlt, Discord, Telegram } from '@vicons/fa'
<template>
<div class="center">
<n-card>
<n-card :bordered="false" embedded>
<n-button tag="a" target="_blank" href="https://github.com/dreamhunter2333/cloudflare_temp_email">
<template #icon>
<n-icon :component="GithubAlt" />

View File

@@ -1,10 +1,9 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
const { localeCache, openSettings } = useGlobalState()
const { openSettings } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
adminContact: 'If you need help, please contact the administrator ({msg})',
@@ -17,7 +16,7 @@ const { t } = useI18n({
</script>
<template>
<n-alert v-if="openSettings.adminContact" :show-icon="false">
<n-alert v-if="openSettings.adminContact" :show-icon="false" :bordered="false">
<span>{{ t('adminContact', { msg: openSettings.adminContact }) }}</span>
</n-alert>
</template>

View File

@@ -5,13 +5,12 @@ import { useIsMobile } from '../../utils/composables'
import { useGlobalState } from '../../store'
const {
localeCache, mailboxSplitSize, useIframeShowMail, preferShowTextMail,
mailboxSplitSize, useIframeShowMail, preferShowTextMail,
globalTabplacement, useSideMargin
} = useGlobalState()
const isMobile = useIsMobile()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
mailboxSplitSize: 'Mailbox Split Size',
@@ -41,7 +40,7 @@ const { t } = useI18n({
<template>
<div class="center">
<n-card>
<n-card :bordered="false" embedded>
<n-form-item-row v-if="!isMobile" :label="t('mailboxSplitSize')">
<n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{
0.25: '0.25',

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
@@ -9,6 +9,7 @@ import Turnstile from '../../components/Turnstile.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
const props = defineProps({
bindUserAddress: {
@@ -36,7 +37,7 @@ const message = useMessage()
const router = useRouter()
const {
jwt, localeCache, loading, openSettings,
jwt, loading, openSettings,
showAddressCredential, userSettings
} = useGlobalState()
@@ -59,20 +60,19 @@ const login = async () => {
} catch (error) {
message.error(`${t('bindUserAddressError')}: ${error.message}`);
}
await router.push("/");
await router.push(getRouterPathWithLang("/", locale.value));
} catch (error) {
message.error(error.message || "error");
}
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
const { locale, t } = useI18n({
messages: {
en: {
login: 'Login',
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
getNewEmail: 'Create New Email',
getNewEmailTip1: 'Please input the email you want to use. only allow ., a-z, A-Z and 0-9',
getNewEmailTip1: 'Please input the email you want to use. only allow a-z and 0-9',
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
credential: 'Email Address Credential',
@@ -87,7 +87,7 @@ const { t } = useI18n({
login: '登录',
pleaseGetNewEmail: '请"登录"或点击 "注册新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '创建新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 a-z, 0-9',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
credential: '邮箱地址凭据',
@@ -110,7 +110,7 @@ const generateName = async () => {
.split('@')[0]
.replace(/\s+/g, '.')
.replace(/\.{2,}/g, '.')
.replace(/[^a-zA-Z0-9.]/g, '')
.replace(/[^a-z0-9]/g, '')
.toLowerCase();
} catch (error) {
message.error(error.message || "error");
@@ -128,7 +128,7 @@ const newEmail = async () => {
);
jwt.value = res["jwt"];
await api.getSettings();
await router.push("/");
await router.push(getRouterPathWithLang("/", locale.value));
showAddressCredential.value = true;
try {
await props.bindUserAddress();
@@ -140,17 +140,45 @@ const newEmail = async () => {
}
};
const addressPrefix = computed(() => {
// if user has role, return role prefix
if (userSettings.value?.user_role) {
return userSettings.value.user_role.prefix || "";
}
// if user has no role, return default prefix
return openSettings.value.prefix;
});
const domainsOptions = computed(() => {
// if user has role, return role domains
if (userSettings.value.user_role) {
const allDomains = userSettings.value.user_role.domains;
if (!allDomains) return openSettings.value.domains;
return openSettings.value.domains.filter((domain) => {
return allDomains.includes(domain.value);
});
}
// if user has no role, return default domains
if (!openSettings.value.defaultDomains) {
return openSettings.value.domains;
}
// if user has no role and no default domains, return all domains
return openSettings.value.domains.filter((domain) => {
return openSettings.value.defaultDomains.includes(domain.value);
});
});
onMounted(async () => {
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
await api.getOpenSettings();
}
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0]?.value : "";
emailDomain.value = domainsOptions.value ? domainsOptions.value[0]?.value : "";
});
</script>
<template>
<div>
<n-alert v-if="userSettings.user_email" :show-icon="false" closable>
<n-alert v-if="userSettings.user_email" :show-icon="false" :bordered="false" closable>
<span>{{ t('bindUserInfo') }}</span>
</n-alert>
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
@@ -186,13 +214,14 @@ onMounted(async () => {
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="openSettings.prefix">
{{ openSettings.prefix }}
<n-input-group-label v-if="addressPrefix">
{{ addressPrefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input v-model:value="emailName" show-count :minlength="openSettings.minAddressLen"
:maxlength="openSettings.maxAddressLen" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
:options="domainsOptions" />
</n-input-group>
<Turnstile v-model:value="cfToken" />
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
@@ -205,7 +234,7 @@ onMounted(async () => {
</n-spin>
</n-tab-pane>
<n-tab-pane name="help" :tab="t('help')">
<n-alert :show-icon="false">
<n-alert :show-icon="false" :bordered="false">
<span>{{ t('pleaseGetNewEmail') }}</span>
</n-alert>
<AdminContact />

View File

@@ -6,17 +6,17 @@ import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import Appearance from '../common/Appearance.vue'
import { getRouterPathWithLang } from '../../utils'
const {
jwt, localeCache, settings, showAddressCredential, loading
jwt, settings, showAddressCredential, loading
} = useGlobalState()
const router = useRouter()
const message = useMessage()
const showLogout = ref(false)
const showDelteAccount = ref(false)
const { t } = useI18n({
locale: localeCache.value || 'zh',
const { locale, t } = useI18n({
messages: {
en: {
logout: "Logout",
@@ -39,7 +39,7 @@ const { t } = useI18n({
const logout = async () => {
jwt.value = '';
await router.push('/')
await router.push(getRouterPathWithLang("/", locale.value))
location.reload()
}
@@ -49,7 +49,7 @@ const deleteAccount = async () => {
method: 'DELETE'
});
jwt.value = '';
await router.push('/')
await router.push(getRouterPathWithLang("/", locale.value))
location.reload()
} catch (error) {
message.error(error.message || "error");
@@ -59,7 +59,7 @@ const deleteAccount = async () => {
<template>
<div class="center" v-if="settings.address">
<n-card>
<n-card :bordered="false" embedded>
<Appearance />
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }}

View File

@@ -1,6 +1,6 @@
<script setup>
import useClipboard from 'vue-clipboard3'
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Copy, User, ExchangeAlt } from '@vicons/fa'
@@ -10,18 +10,19 @@ import { api } from '../../api'
import Login from '../common/Login.vue'
import AddressManagement from '../user/AddressManagement.vue'
import TelegramAddress from './TelegramAddress.vue'
import LocalAddress from './LocalAddress.vue'
import { getRouterPathWithLang } from '../../utils'
const { toClipboard } = useClipboard()
const message = useMessage()
const router = useRouter()
const {
jwt, localeCache, settings, showAddressCredential, userJwt,
isTelegram
jwt, settings, showAddressCredential, userJwt,
isTelegram, openSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
const { locale, t } = useI18n({
messages: {
en: {
addressManage: 'Address Manage',
@@ -50,6 +51,18 @@ const { t } = useI18n({
const showChangeAddress = ref(false)
const showTelegramChangeAddress = ref(false)
const showLocalAddress = ref(false)
const addressLabel = computed(() => {
if (settings.value.address) {
const domain = settings.value.address.split('@')[1]
const domainLabel = openSettings.value.domains.find(
d => d.value === domain
)?.label;
if (!domainLabel) return settings.value.address;
return settings.value.address.replace('@' + domain, `@${domainLabel}`);
}
return settings.value.address;
})
const copy = async () => {
try {
@@ -60,6 +73,10 @@ const copy = async () => {
}
}
const onUserLogin = async () => {
await router.push(getRouterPathWithLang("/user", locale.value))
}
onMounted(async () => {
await api.getSettings();
});
@@ -67,13 +84,13 @@ onMounted(async () => {
<template>
<div>
<n-card v-if="!settings.fetched">
<n-card :bordered="false" embedded v-if="!settings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<div v-else-if="settings.address">
<n-alert type="info" :show-icon="false">
<n-alert type="info" :show-icon="false" :bordered="false">
<span>
<b>{{ settings.address }}</b>
<b>{{ addressLabel }}</b>
<n-button v-if="isTelegram" style="margin-left: 10px" @click="showTelegramChangeAddress = true"
size="small" tertiary type="primary">
<n-icon :component="ExchangeAlt" /> {{ t('addressManage') }}
@@ -82,6 +99,10 @@ onMounted(async () => {
size="small" tertiary type="primary">
<n-icon :component="ExchangeAlt" /> {{ t('changeAddress') }}
</n-button>
<n-button v-else style="margin-left: 10px" @click="showLocalAddress = true" size="small" tertiary
type="primary">
<n-icon :component="ExchangeAlt" /> {{ t('addressManage') }}
</n-button>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
@@ -92,13 +113,13 @@ onMounted(async () => {
<TelegramAddress />
</div>
<div v-else class="center">
<n-card style="max-width: 600px;">
<n-alert v-if="jwt" type="warning" :show-icon="false" closable>
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert v-if="jwt" type="warning" :show-icon="false" :bordered="false" closable>
<span>{{ t('fetchAddressError') }}</span>
</n-alert>
<Login />
<n-divider />
<n-button @click="router.push('/user')" type="primary" block secondary strong>
<n-button @click="onUserLogin" type="primary" block secondary strong>
<template #icon>
<n-icon :component="User" />
</template>
@@ -112,11 +133,14 @@ onMounted(async () => {
<n-modal v-model:show="showChangeAddress" preset="card" :title="t('changeAddress')">
<AddressManagement />
</n-modal>
<n-modal v-model:show="showLocalAddress" preset="card" :title="t('changeAddress')">
<LocalAddress />
</n-modal>
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card>
<n-card :bordered="false" embedded>
<b>{{ jwt }}</b>
</n-card>
</n-modal>

View File

@@ -0,0 +1,91 @@
<script setup>
import { ref, h, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { api } from '../../api'
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
download: 'Download',
action: 'Action',
},
zh: {
download: '下载',
action: '操作',
}
}
});
const data = ref([])
const showDownload = ref(false)
const curRow = ref({})
const curDownloadUrl = ref('')
const fetchData = async () => {
try {
const { results } = await api.fetch(
`/api/attachment/list`
);
data.value = results;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "key",
key: "key"
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
tertiary: true,
onClick: async () => {
try {
const { url } = await api.fetch(`/api/attachment/get_url`, {
method: 'POST',
body: JSON.stringify({ key: row.key })
});
curDownloadUrl.value = url;
curRow.value = row;
showDownload.value = true;
}
catch (error) {
console.error(error);
message.error(error.message || "error");
}
}
},
{ default: () => t('download') }
)
])
}
}
]
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showDownload" preset="dialog" :title="t('download')">
<n-tag type="info">{{ curRow.key }}</n-tag>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curRow.key.replace('/', '_')"
:href="curDownloadUrl">
{{ t('download') }}
</n-button>
</n-modal>
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
</template>

View File

@@ -81,7 +81,7 @@ onMounted(async () => {
<template>
<div class="center">
<n-card v-if="settings.address" :title='t("settings")'>
<n-card :bordered="false" embedded v-if="settings.address" :title='t("settings")'>
<div class="right">
<n-button type="primary" @click="saveData">{{ t('save') }}</n-button>
</div>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { ref, h, computed } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import { useI18n } from 'vue-i18n'
import { NPopconfirm, NButton } from 'naive-ui'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import Login from '../common/Login.vue';
const { jwt } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
tip: 'These addresses are stored in your browser, maybe loss if you clear the browser cache.',
success: 'success',
address: 'Address',
actions: 'Actions',
changeMailAddress: 'Change Mail Address',
unbindMailAddress: 'Unbind Mail Address credential',
bind: 'Bind',
bindAddressSuccess: 'Bind Address Success',
},
zh: {
tip: '这些地址存储在您的浏览器中,如果您清除浏览器缓存,可能会丢失。',
success: '成功',
address: '地址',
actions: '操作',
changeMailAddress: '切换邮箱地址',
unbindMailAddress: '解绑邮箱地址',
bind: '绑定',
bindAddressSuccess: '绑定地址成功',
}
}
});
const tabValue = ref('address')
const localAddressCache = useLocalStorage("LocalAddressCache", []);
const data = computed(() => {
// @ts-ignore
if (!localAddressCache.value.includes(jwt.value)) {
// @ts-ignore
localAddressCache.value.push(jwt.value)
}
return localAddressCache.value.map((curJwt: string) => {
try {
var payload = JSON.parse(
decodeURIComponent(
atob(curJwt.split(".")[1]
.replace(/-/g, "+").replace(/_/g, "/")
)
)
);
return {
valid: true,
address: payload.address,
jwt: curJwt
}
} catch (e) {
return {
valid: false,
address: `invalid jwt [${curJwt}]`,
jwt: curJwt
}
}
})
})
const bindAddress = async () => {
try {
// @ts-ignore
if (!localAddressCache.value.includes(jwt.value)) {
// @ts-ignore
localAddressCache.value.push(jwt.value)
}
tabValue.value = 'address'
message.success(t('bindAddressSuccess'));
} catch (error) {
message.error((error as Error).message || "error");
}
}
const columns = [
{
title: t('address'),
key: "address"
},
{
title: t('actions'),
key: 'actions',
render(row: any) {
return h('div', [
h(NPopconfirm,
{
onPositiveClick: () => {
jwt.value = row.jwt
location.reload()
}
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "primary",
},
{ default: () => t('changeMailAddress') }
),
default: () => `${t('changeMailAddress')}?`
}
),
h(NPopconfirm,
{
onPositiveClick: () => {
if (jwt.value === row.jwt) {
return;
}
localAddressCache.value = localAddressCache.value.filter(
(curJwt: string) => curJwt !== row.jwt
);
}
},
{
trigger: () => h(NButton,
{
tertiary: true,
disabled: jwt.value === row.jwt,
type: "warning",
},
{ default: () => t('unbindMailAddress') }
),
default: () => `${t('unbindMailAddress')}?`
}
)
])
}
}
]
</script>
<template>
<div>
<n-alert type="warning" :show-icon="false" :bordered="false">
<span>{{ t('tip') }}</span>
</n-alert>
<n-tabs type="segment" v-model:value="tabValue">
<n-tab-pane name="address" :tab="t('address')">
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</n-tab-pane>
<n-tab-pane name="bind" :tab="t('bind')">
<Login :bindUserAddress="bindAddress" />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

@@ -142,9 +142,9 @@ onMounted(async () => {
<template>
<div class="center" v-if="settings.address">
<n-card>
<n-card :bordered="false" embedded>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<n-alert type="warning" :show-icon="false">
<n-alert type="warning" :show-icon="false" :bordered="false">
{{ t('requestAccessTip') }}
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
}}</n-button>
@@ -153,7 +153,7 @@ onMounted(async () => {
<AdminContact />
</div>
<div v-else>
<n-alert type="info" :show-icon="false">
<n-alert type="info" :show-icon="false" :bordered="false">
{{ t('send_balance') }}: {{ settings.send_balance }}
</n-alert>
<div class="right">
@@ -187,7 +187,7 @@ onMounted(async () => {
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<n-card v-if="isPreview">
<n-card :bordered="false" embedded v-if="isPreview">
<div v-html="sendMailModel.content" />
</n-card>
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">

View File

@@ -10,12 +10,11 @@ import { api } from '../../api'
// @ts-ignore
import Login from '../common/Login.vue';
const { localeCache, jwt, telegramApp } = useGlobalState()
const { jwt, telegramApp } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'success',
@@ -149,7 +148,7 @@ onMounted(async () => {
<div>
<n-tabs type="segment">
<n-tab-pane name="address" :tab="t('address')">
<n-data-table :columns="columns" :data="data" :bordered="false" />
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</n-tab-pane>
<n-tab-pane name="bind" :tab="t('bind')">
<Login :newAddressPath="newAddressPath" :bindUserAddress="bindAddress" />

View File

@@ -7,12 +7,11 @@ import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
const { localeCache, settings } = useGlobalState()
const { settings } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
successTip: 'Success',
@@ -90,7 +89,7 @@ onMounted(async () => {
<template>
<div class="center" v-if="settings.address">
<n-card v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
<n-form-item-row label="URL">
<n-input v-model:value="webhookSettings.url" />
</n-form-item-row>

View File

@@ -6,7 +6,7 @@ import { api } from '../../api'
import { onMounted, watch } from 'vue';
import { processItem } from '../../utils/email-parser'
const { telegramApp } = useGlobalState()
const { telegramApp, loading } = useGlobalState()
const route = useRoute()
const curMail = ref({});
@@ -26,12 +26,16 @@ const fetchMailData = async () => {
mailId: route.query.mail_id
})
});
loading.value = true;
return await processItem(res);
}
catch (error) {
console.error(error);
return {};
}
finally {
loading.value = false;
}
};
onMounted(async () => {
@@ -41,7 +45,7 @@ onMounted(async () => {
<template>
<div class="center">
<n-card v-if="curMail.message" style="max-width: 800px; overflow: auto;">
<n-card :bordered="false" embedded v-if="curMail.message" style="max-width: 800px; overflow: auto;">
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>

View File

@@ -6,13 +6,13 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
const { localeCache, jwt } = useGlobalState()
const { jwt } = useGlobalState()
const message = useMessage()
const router = useRouter()
const { t } = useI18n({
locale: localeCache.value || 'zh',
const { locale, t } = useI18n({
messages: {
en: {
success: 'success',
@@ -48,7 +48,7 @@ const changeMailAddress = async (address_id) => {
return;
}
jwt.value = res.jwt;
await router.push('/');
await router.push(getRouterPathWithLang("/", locale.value))
location.reload();
} catch (error) {
console.log(error)
@@ -165,6 +165,6 @@ onMounted(async () => {
<template>
<div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
</template>

View File

@@ -6,10 +6,9 @@ import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import Login from '../common/Login.vue'
const { userJwt, localeCache, userSettings, } = useGlobalState()
const { userJwt, userSettings, } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
logout: 'Logout',
@@ -30,7 +29,7 @@ onMounted(async () => {
<template>
<div class="center" v-if="userSettings.user_email">
<n-card style="max-width: 600px;">
<n-card :bordered="false" embedded style="max-width: 600px;">
<Login />
</n-card>
</div>

View File

@@ -11,11 +11,10 @@ const message = useMessage()
const router = useRouter()
const {
localeCache, userSettings, userJwt, userOpenSettings
userSettings, userJwt, userOpenSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
currentUser: 'Current Login User',
@@ -32,25 +31,26 @@ const { t } = useI18n({
onMounted(async () => {
await api.getUserOpenSettings(message);
await api.getUserSettings(message);
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
});
</script>
<template>
<div>
<n-card v-if="!userSettings.fetched">
<n-card :bordered="false" embedded v-if="!userSettings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<div v-else-if="userSettings.user_email">
<n-alert type="success" :show-icon="false">
<n-alert type="success" :show-icon="false" :bordered="false">
<span>
<b>{{ t('currentUser') }} <b>{{ userSettings.user_email }}</b></b>
</span>
</n-alert>
</div>
<div v-else class="center">
<n-card style="max-width: 600px;">
<n-alert v-if="userJwt" type="warning" :show-icon="false" closable>
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert v-if="userJwt" type="warning" :show-icon="false" :bordered="false" closable>
<span>{{ t('fetchUserSettingsError') }}</span>
</n-alert>
<UserLogin />

View File

@@ -1,21 +1,19 @@
<script setup>
import { useMessage } from 'naive-ui'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref } from "vue";
import { onMounted, ref } from "vue";
import { useI18n } from 'vue-i18n'
import { api } from '../../api';
import { useGlobalState } from '../../store'
import { hashPassword } from '../../utils';
import { startAuthentication } from '@simplewebauthn/browser';
import Turnstile from '../../components/Turnstile.vue';
const { userJwt, localeCache, userTab, userOpenSettings } = useGlobalState()
const { userJwt, userOpenSettings, openSettings } = useGlobalState()
const message = useMessage();
const router = useRouter();
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
login: 'Login',
@@ -34,6 +32,7 @@ const { t } = useI18n({
pleaseInputCode: 'Please input code',
pleaseCompleteTurnstile: 'Please complete turnstile',
pleaseLogin: 'Please login',
loginWithPasskey: 'Login with Passkey',
},
zh: {
login: '登录',
@@ -52,6 +51,7 @@ const { t } = useI18n({
pleaseInputCode: '请输入验证码',
pleaseCompleteTurnstile: '请完成人机验证',
pleaseLogin: '请登录',
loginWithPasskey: '使用 Passkey 登录',
}
}
});
@@ -99,7 +99,7 @@ const sendVerificationCode = async () => {
message.error(t('pleaseInputEmail'));
return;
}
if (!cfToken.value && userOpenSettings.value.enableMailVerify) {
if (openSettings.value.cfTurnstileSiteKey && !cfToken.value && userOpenSettings.value.enableMailVerify) {
message.error(t('pleaseCompleteTurnstile'));
return;
}
@@ -157,6 +157,33 @@ const emailSignup = async () => {
}
};
const passkeyLogin = async () => {
try {
const options = await api.fetch(`/user_api/passkey/authenticate_request`, {
method: 'POST',
body: JSON.stringify({
domain: location.hostname,
})
})
const credential = await startAuthentication(options)
// Send the result to the server and return the promise.
const res = await api.fetch(`/user_api/passkey/authenticate_response`, {
method: 'POST',
body: JSON.stringify({
origin: location.origin,
domain: location.hostname,
credential
})
})
userJwt.value = res.jwt;
location.reload();
} catch (e) {
console.error(e)
message.error(e.message)
}
};
onMounted(async () => {
});
@@ -179,6 +206,10 @@ onMounted(async () => {
<n-button @click="showModal = true" type="info" quaternary size="tiny">
{{ t('forgotPassword') }}
</n-button>
<n-divider />
<n-button @click="passkeyLogin" type="primary" block secondary strong>
{{ t('loginWithPasskey') }}
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="userOpenSettings.enable" name="signup" :tab="t('register')">
@@ -229,7 +260,7 @@ onMounted(async () => {
{{ t('resetPassword') }}
</n-button>
</n-form>
<n-alert v-else :show-icon="false">
<n-alert v-else :show-icon="false" :bordered="false">
<span>
{{ t('cannotForgotPassword') }}
</span>

View File

@@ -1,29 +1,58 @@
<script setup>
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { startRegistration } from '@simplewebauthn/browser';
import { NButton, NPopconfirm } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { userJwt, localeCache, userSettings, } = useGlobalState()
const router = useRouter()
const { userJwt, userSettings, } = useGlobalState()
const message = useMessage()
const showLogout = ref(false)
const showCreatePasskey = ref(false)
const passkeyName = ref('')
const showPasskeyList = ref(false)
const showRenamePasskey = ref(false)
const currentPasskeyId = ref(null)
const currentPasskeyName = ref('')
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
logout: 'Logout',
logoutConfirm: 'Are you sure you want to logout?',
passordTip: 'The server will only receive the hash value of the password, and will not receive the plaintext password, so it cannot view or retrieve your password. If the administrator enables email verification, you can reset the password in incognito mode',
createPasskey: 'Create Passkey',
showPasskeyList: 'Show Passkey List',
passkeyCreated: 'Passkey created successfully',
passkeyNamePlaceholder: 'Please enter the passkey name or leave it empty to generate a random one',
renamePasskey: 'Rename Passkey',
deletePasskey: 'Delete Passkey',
passkey_name: 'Passkey Name',
created_at: 'Created At',
updated_at: 'Updated At',
actions: 'Actions',
renamePasskey: 'Rename Passkey',
renamePasskeyNamePlaceholder: 'Please enter the new passkey name',
},
zh: {
logout: '退出登录',
logoutConfirm: '确定要退出登录吗?',
passordTip: '服务器只会接收到密码的哈希值,不会接收到明文密码,因此无法查看或者找回您的密码, 如果管理员启用了邮件验证您可以在无痕模式重置密码',
createPasskey: '创建 Passkey',
showPasskeyList: '查看 Passkey 列表',
passkeyCreated: 'Passkey 创建成功',
passkeyNamePlaceholder: '请输入 Passkey 名称或者留空自动生成',
renamePasskey: '重命名 Passkey',
deletePasskey: '删除 Passkey',
passkey_name: 'Passkey 名称',
created_at: '创建时间',
updated_at: '更新时间',
actions: '操作',
renamePasskey: '重命名 Passkey',
renamePasskeyNamePlaceholder: '请输入新的 Passkey 名称',
}
}
});
@@ -34,18 +63,145 @@ const logout = async () => {
location.reload()
}
const fetchData = async () => {
const createPasskey = async () => {
try {
const options = await api.fetch(`/user_api/passkey/register_request`, {
method: 'POST',
body: JSON.stringify({
domain: location.hostname,
})
})
const credential = await startRegistration(options)
// Send the result to the server and return the promise.
await api.fetch(`/user_api/passkey/register_response`, {
method: 'POST',
body: JSON.stringify({
origin: location.origin,
passkey_name: passkeyName.value || (
(window.navigator.userAgentData?.platform || "Unknown")
+ ": " + Math.random().toString(36).substring(7)
),
credential
})
})
message.success(t('passkeyCreated'));
} catch (e) {
console.error(e)
message.error(e.message)
} finally {
passkeyName.value = ''
showCreatePasskey.value = false
}
}
onMounted(async () => {
await fetchData()
})
const passkeyColumns = [
{
title: "Passkey ID",
key: "passkey_id"
},
{
title: t('passkey_name'),
key: "passkey_name"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('updated_at'),
key: "updated_at"
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
[
h(NButton,
{
tertiary: true,
type: "primary",
onClick: () => {
showRenamePasskey.value = true;
currentPasskeyId.value = row.passkey_id;
}
},
{ default: () => t('renamePasskey') }
),
h(NPopconfirm,
{
onPositiveClick: async () => {
try {
await api.fetch(`/user_api/passkey/${row.passkey_id}`, {
method: 'DELETE'
})
await fetchPasskeyList()
} catch (e) {
console.error(e)
message.error(e.message)
}
}
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "error",
},
{ default: () => t('deletePasskey') }
),
default: () => `${t('deletePasskey')}?`
}
),
]
])
}
}
]
const passkeyData = ref([])
const fetchPasskeyList = async () => {
try {
const data = await api.fetch(`/user_api/passkey`)
passkeyData.value = data
} catch (e) {
console.error(e)
message.error(e.message)
}
}
const renamePasskey = async () => {
try {
await api.fetch(`/user_api/passkey/rename`, {
method: 'POST',
body: JSON.stringify({
passkey_name: currentPasskeyName.value,
passkey_id: currentPasskeyId.value
})
})
await fetchPasskeyList()
} catch (e) {
console.error(e)
message.error(e.message)
} finally {
currentPasskeyName.value = ''
showRenamePasskey.value = false
}
}
</script>
<template>
<div class="center" v-if="userSettings.user_email">
<n-card>
<n-alert :show-icon="false">
<n-card :bordered="false" embedded>
<n-button @click="showPasskeyList = true; fetchPasskeyList();" secondary block strong>
{{ t('showPasskeyList') }}
</n-button>
<n-button @click="showCreatePasskey = true" type="primary" secondary block strong>
{{ t('createPasskey') }}
</n-button>
<n-alert :show-icon="false" :bordered="false">
<span>
{{ t('passordTip') }}
</span>
@@ -54,6 +210,25 @@ onMounted(async () => {
{{ t('logout') }}
</n-button>
</n-card>
<n-modal v-model:show="showCreatePasskey" preset="dialog" :title="t('createPasskey')">
<n-input v-model:value="passkeyName" :placeholder="t('passkeyNamePlaceholder')" />
<template #action>
<n-button :loading="loading" @click="createPasskey" size="small" tertiary type="primary">
{{ t('createPasskey') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showRenamePasskey" preset="dialog" :title="t('renamePasskey')">
<n-input v-model:value="currentPasskeyName" :placeholder="t('renamePasskeyNamePlaceholder')" />
<template #action>
<n-button :loading="loading" @click="renamePasskey" size="small" tertiary type="primary">
{{ t('renamePasskey') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showPasskeyList" preset="card" :title="t('showPasskeyList')">
<n-data-table :columns="passkeyColumns" :data="passkeyData" :bordered="false" embedded />
</n-modal>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
@@ -79,5 +254,6 @@ onMounted(async () => {
.n-button {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -12,3 +12,4 @@ Cargo.lock
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
web/

View File

@@ -1,6 +1,6 @@
[package]
name = "mail-parser-wasm"
version = "0.1.6"
version = "0.1.8"
edition = "2021"
description = "A simple mail parser for wasm"
license = "MIT"

View File

@@ -1,16 +1,45 @@
# mail-parser-wasm
# mail-parser-wasm web and cf worker
## usage
## [mail-parser-wasm](https://www.npmjs.com/package/mail-parser-wasm)
### mail-parser-wasm usage
```bash
pnpm add mail-parser-wasm
```
```js
import { parse_message } from 'mail-parser-wasm'
const parsedEmail = parse_message(item.raw);
const parsedEmail = parse_message(rawEmail);
```
## build
### mail-parser-wasm build
```bash
wasm-pack build --release
wasm-pack publish
```
## [mail-parser-wasm-worker](https://www.npmjs.com/package/mail-parser-wasm-worker)
### mail-parser-wasm-worker usage
```bash
pnpm add mail-parser-wasm-worker
```
```js
import { parse_message_wrapper } from 'mail-parser-wasm-worker'
const parsedEmail = parse_message_wrapper(rawEmail);
```
### mail-parser-wasm-worker build
```bash
wasm-pack build --out-dir web --target web --release
find web/ -type f ! -name '*.json' ! -name '.gitignore' -exec cp {} worker/ \;
# modify worker/package.json version or whatever
pnpm publish worker --no-git-checks
```

9
mail-parser-wasm/worker/index.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import initAsync, { MessageResult } from './mail_parser_wasm';
import MODULE from './mail_parser_wasm_bg.wasm';
export { initAsync, MODULE };
export * from './mail_parser_wasm';
/**
* @param {string} raw_message
* @returns {MessageResult}
*/
export function parse_message_wrapper(raw_message: string): MessageResult;

View File

@@ -0,0 +1,12 @@
import initAsync, { initSync, parse_message } from './mail_parser_wasm';
import MODULE from './mail_parser_wasm_bg.wasm';
initSync(MODULE);
export { initAsync, MODULE };
export * from './mail_parser_wasm';
export const parse_message_wrapper = (raw_message) => {
initSync(MODULE);
return parse_message(raw_message);
}

View File

@@ -0,0 +1,24 @@
{
"name": "mail-parser-wasm-worker",
"description": "A simple mail parser for worker",
"homepage": "https://github.com/dreamhunter2333/cloudflare_temp_email/tree/main/mail-parser-wasm",
"repository": {
"type": "git",
"url": "https://github.com/dreamhunter2333/cloudflare_temp_email",
"directory": "mail-parser-wasm"
},
"version": "0.1.8",
"license": "MIT",
"files": [
"mail_parser_wasm_bg.wasm",
"mail_parser_wasm.js",
"mail_parser_wasm.d.ts",
"index.js",
"index.d.ts"
],
"module": "index.js",
"types": "index.d.ts",
"sideEffects": [
"./snippets/*"
]
}

View File

@@ -1,4 +1,10 @@
const API_PATHS = ["/api/", "/open_api/", "/user_api/", "/admin/"]
const API_PATHS = [
"/api/",
"/open_api/",
"/user_api/",
"/admin/",
"/telegram/"
];
export async function onRequest(context) {
const reqPath = new URL(context.request.url).pathname;

View File

@@ -11,6 +11,6 @@
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^3.55.0"
"wrangler": "^3.62.0"
}
}

View File

@@ -13,6 +13,7 @@ class Settings(BaseSettings):
proxy_url: str = "http://localhost:8787"
port: int = 8025
imap_port: int = 11143
basic_password: str = ""
class Config:
env_file = ".env"

View File

@@ -121,6 +121,7 @@ class SimpleMailbox:
f"{settings.proxy_url}/api/mails?limit={limit}&offset={start - 1}",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)
@@ -147,6 +148,7 @@ class SimpleMailbox:
f"{settings.proxy_url}/api/sendbox?limit={limit}&offset={start - 1}",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)

View File

@@ -47,16 +47,26 @@ def parse_email(raw: str) -> EmailModel:
def generate_email_model(item: dict) -> EmailModel:
email_json = json.loads(item["raw"])
message = MIMEMultipart()
message['From'] = f"{email_json["from"]['name']} <{
email_json["from"]['email']}>"
message['To'] = ", ".join(
[f"{to['name']} <{to['email']}>" for to in email_json["personalizations"][0]["to"]])
if email_json.get("version") == "v2":
message['From'] = f"{email_json["from_name"]} <{item["address"]}>" if email_json.get(
"from_name") else item["address"]
message['To'] = f"{email_json["to_name"]} <{email_json["to_mail"]}>" if email_json.get(
"to_name") else email_json["to_mail"]
message.attach(MIMEText(
email_json["content"],
"html" if email_json.get("is_html") else "plain"
))
else:
message['From'] = f"{email_json["from"]['name']} <{
email_json["from"]['email']}>"
message['To'] = ", ".join(
[f"{to['name']} <{to['email']}>" for to in email_json["personalizations"][0]["to"]])
message.attach(MIMEText(
email_json["content"][0]["value"],
"html" if "html" in email_json["content"][0]["type"] else "plain"
))
message['Subject'] = email_json["subject"]
message["Date"] = datetime.datetime.strptime(
item["created_at"], "%Y-%m-%d %H:%M:%S"
).strftime("%a, %d %b %Y %H:%M:%S +0000")
message.attach(MIMEText(
email_json["content"][0]["value"],
"html" if "html" in email_json["content"][0]["type"] else "plain"
))
return parse_email(message.as_string())

View File

@@ -31,6 +31,16 @@ export default defineConfig({
logo: { src: '/logo.png', width: 24, height: 24 },
search: { provider: 'local' },
socialLinks: [
{
icon: 'discord',
link: 'https://discord.gg/dQEwTWhA6Q'
},
{
icon: {
svg: '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 448 512"><path d="M446.7 98.6l-67.6 318.8c-5.1 22.5-18.4 28.1-37.3 17.5l-103-75.9l-49.7 47.8c-5.5 5.5-10.1 10.1-20.7 10.1l7.4-104.9l190.9-172.5c8.3-7.4-1.8-11.5-12.9-4.1L117.8 284L16.2 252.2c-22.1-6.9-22.5-22.1 4.6-32.7L418.2 66.4c18.4-6.9 34.5 4.1 28.5 32.2z" fill="currentColor"></path></svg>'
},
link: 'https://t.me/cloudflare_temp_email'
},
{
icon: 'github',
link: 'https://github.com/dreamhunter2333/cloudflare_temp_email'

View File

@@ -96,11 +96,10 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
},
{
text: '通过命令行部署',
collapsed: false,
collapsed: true,
items: [
{ text: '命令行部署准备', link: 'cli/pre-requisite' },
{ text: 'D1 数据库', link: 'cli/d1' },
{ text: '配置 DKIM', link: 'dkim' },
{ text: 'Cloudflare workers 后端', link: 'cli/worker' },
{ text: '配置邮件转发', link: 'email-routing.md' },
{ text: 'Cloudflare Pages 前端', link: 'cli/pages' },
@@ -109,10 +108,9 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
},
{
text: '通过用户界面部署',
collapsed: false,
collapsed: true,
items: [
{ text: 'D1 数据库', link: 'ui/d1' },
{ text: '配置 DKIM', link: 'dkim' },
{ text: 'Cloudflare workers 后端', link: 'ui/worker' },
{ text: '配置邮件转发', link: 'email-routing.md' },
{ text: 'Cloudflare Pages 前端', link: 'ui/pages' },
@@ -121,25 +119,28 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
},
{
text: '通过 Github Actions 部署',
collapsed: false,
collapsed: true,
items: [
{ text: '通过 Github Actions 部署', link: 'github-action' },
]
},
{
text: '附加功能',
collapsed: false,
collapsed: true,
items: [
{ text: '配置 SMTP IMAP 代理服务', link: 'feature/config-smtp-proxy' },
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
{ text: '查看邮件 API', link: 'feature/mail-api' },
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
{ text: '配置 Telegram Bot', link: 'feature/telegram' },
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
]
},
{
text: '功能简介',
collapsed: false,
collapsed: true,
items: [
{ text: 'Admin 控制台', link: 'feature/admin' },
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },

View File

@@ -34,10 +34,10 @@ git clone https://github.com/dreamhunter2333/cloudflare_temp_email.git
```bash
# create a database, and copy the output to wrangler.toml in the next step
wrangler d1 create dev
wrangler d1 execute dev --file=db/schema.sql
wrangler d1 execute dev --file=db/schema.sql --remote
# schema update, if you have initialized the database before this date, you can execute this command to update
# wrangler d1 execute dev --file=db/2024-01-13-patch.sql
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql
# wrangler d1 execute dev --file=db/2024-01-13-patch.sql --remote
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql --remote
# create a namespace, and copy the output to wrangler.toml in the next step
wrangler kv:namespace create DEV
```
@@ -74,14 +74,31 @@ node_compat = true
# ]
[vars]
# TITLE = "Custom Title" # The title of the site
PREFIX = "tmp" # The mailbox name prefix to be processed
# (min, max) length of the adderss, if not set, the default is (1, 30)
# ANNOUNCEMENT = "Custom Announcement"
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# If you want your site to be private, uncomment below and change your password
# PASSWORDS = ["123", "456"]
# admin console password, if not configured, access to the console is not allowed
# ADMIN_PASSWORDS = ["123", "456"]
# warning: no password or user check for admin portal
# DISABLE_ADMIN_PASSWORD_CHECK = false
# admin contact information. If not configured, it will not be displayed. Any string can be configured.
# ADMIN_CONTACT = "xx@xx.xxx"
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # your domain name
DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all your domain name
# For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification)
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix
# USER_ROLES = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "admin", prefix = "" },
# ]
JWT_SECRET = "xxx" # Key used to generate jwt
BLACK_LIST = "" # Blacklist, used to filter senders, comma separated
# Allow users to create email addresses
@@ -94,16 +111,17 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true
# Footer text
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # Disable Show GitHub link
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # the role which can send emails without limit
# Turnstile verification configuration
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# dkim config
# DKIM_SELECTOR = "mailchannels" # Refer to the DKIM section mailchannels._domainkey for mailchannels
# DKIM_PRIVATE_KEY = "" # Refer to the contents of priv_key.txt in the DKIM section
# telegram bot
# TG_MAX_ACCOUNTS = 5
# TG_MAX_ADDRESS = 5
# global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
[[d1_databases]]
binding = "DB"
@@ -151,39 +169,3 @@ pnpm run deploy
```
![pages](/readme_assets/pages.png)
## Configure sending emails
Find the `SPF` record of `TXT` in the domain name `DNS` record, and add `include:relay.mailchannels.net`
```bash
v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all
```
Create a new `_mailchannels` record, the type is `TXT`, the content is `v=mc1 cfid=your worker domain name`
- The worker domain name here is the domain name of the back-end api. For example, if I deploy it at `https://temp-email-api.awsl.uk/`, fill in `v=mc1 cfid=awsl.uk`
- If your domain name is `https://temp-email-api.xxx.workers.dev`, fill in `v=mc1 cfid=xxx.workers.dev`
## Configure DKIM
Ref: [Adding-a-DKIM-Signature](https://support.mailchannels.com/hc/en-us/articles/7122849237389-Adding-a-DKIM-Signature)
Creating a DKIM private and public key:
Private key as PEM file and base64 encoded txt file:
```bash
openssl genrsa 2048 | tee priv_key.pem | openssl rsa -outform der | openssl base64 -A > priv_key.txt
```
Public key as DNS record:
```bash
echo -n "v=DKIM1;p=" > pub_key_record.txt && \
openssl rsa -in priv_key.pem -pubout -outform der | openssl base64 -A >> pub_key_record.txt
```
Add `TXT` record in `Cloudflare` all your mail domain `DNS`
- `_dmarc`: `v=DMARC1; p=none; adkim=r; aspf=r;`
- `mailchannels._domainkey`: `v=DKIM1; p=<content of the file pub_key_record.txt>`

View File

@@ -8,7 +8,7 @@ hero:
actions:
- theme: brand
text: Try it now
link: https://mail.awsl.uk/
link: https://mail.awsl.uk/en
- theme: alt
text: command line deployment
link: /en/cli

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -9,7 +9,7 @@ cd worker
cp wrangler.toml.template wrangler.toml
# 创建 D1 并执行 schema.sql
wrangler d1 create dev
wrangler d1 execute dev --file=../db/schema.sql
wrangler d1 execute dev --file=../db/schema.sql --remote
```
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
@@ -25,6 +25,6 @@ wrangler d1 execute dev --file=../db/schema.sql
```bash
cd worker
wrangler d1 execute dev --file=../db/2024-01-13-patch.sql
wrangler d1 execute dev --file=../db/2024-04-03-patch.sql
wrangler d1 execute dev --file=../db/2024-01-13-patch.sql --remote
wrangler d1 execute dev --file=../db/2024-04-03-patch.sql --remote
```

View File

@@ -42,14 +42,34 @@ node_compat = true
# ]
[vars]
# TITLE = "Custom Title" # 自定义网站标题
PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串
# (min, max) adderss的长度如果不设置默认为(1, 30)
# ANNOUNCEMENT = "Custom Announcement" # 自定义公告
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# 如果你想要你的网站私有,取消下面的注释,并修改密码
# PASSWORDS = ["123", "456"]
# admin 控制台密码, 不配置则不允许访问控制台
# ADMIN_PASSWORDS = ["123", "456"]
# 警告: 管理员控制台没有密码或用户检查
# DISABLE_ADMIN_PASSWORD_CHECK = false
# admin 联系方式,不配置则不显示,可配置任意字符串
# ADMIN_CONTACT = "xx@xx.xxx"
# DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 默认用户可用的域名(未登录或未分配角色的用户)
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名, 支持多个域名
# 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# 新用户默认角色, 仅在启用邮件验证时有效
# USER_DEFAULT_ROLE = "vip"
# admin 角色配置, 如果用户角色等于 ADMIN_USER_ROLE 则可以访问 admin 控制台
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# 用户角色配置, 如果 domains 为空将使用 default_domains
# 如果 prefix 为 null 将使用默认前缀, 如果 prefix 为空字符串将不使用前缀
# USER_ROLES = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "admin", prefix = "" },
# ]
JWT_SECRET = "xxx" # 用于生成 jwt 的密钥, jwt 用于给用户登录以及鉴权
BLACK_LIST = "" # 黑名单,用于过滤发件人,逗号分隔
# 是否允许用户创建邮件, 不配置则不允许
@@ -62,16 +82,17 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true
# 前端界面页脚文本
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # 是否显示 GitHub 链接
# 默认发送邮件余额,如果不设置,将为 0
# DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # 可以无限发送邮件的角色
# Turnstile 人机验证配置
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# dkim config
# DKIM_SELECTOR = "mailchannels" # 参考 DKIM 部分 mailchannels._domainkey 的 mailchannels
# DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容
# telegram bot 最多绑定邮箱数量
# TG_MAX_ACCOUNTS = 5
# TG_MAX_ADDRESS = 5
# 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
# D1 数据库的名称和 ID 可以在 cloudflare 控制台查看
[[d1_databases]]

View File

@@ -23,19 +23,3 @@ wrangler secret put RESEND_TOKEN
wrangler secret put RESEND_TOKEN_XXX_COM
wrangler secret put RESEND_TOKEN_DREAMHUNTER2333_XYZ
```
## 使用 mailchannels 发送邮件
::: warning
[Mail Channels 免费电子邮件发送 API 将于2024年6月30日结束](https://support.mailchannels.com/hc/en-us/articles/26814255454093-End-of-Life-Notice-Cloudflare-Workers)
:::
1. 找到域名 `DNS` 记录的 `TXT``SPF` 记录, 增加 `include:relay.mailchannels.net`
`v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all`
2. 新建 `_mailchannels` 记录, 类型为 `TXT`, 内容为 `v=mc1 cfid=你的worker域名`
- 此处 worker 域名为后端 api 的域名,比如我部署在 `https://temp-email-api.awsl.uk/`,则填写 `v=mc1 cfid=awsl.uk`
- 如果你的域名是 `https://temp-email-api.xxx.workers.dev`,则填写 `v=mc1 cfid=xxx.workers.dev`

View File

@@ -1,33 +0,0 @@
# 配置 DKIM
如果你不想配置 DKIM可以跳过这一节。
参考: [Adding-a-DKIM-Signature](https://support.mailchannels.com/hc/en-us/articles/7122849237389-Adding-a-DKIM-Signature)
Creating a DKIM private and public key:
Private key as PEM file and base64 encoded txt file:
```bash
openssl genrsa 2048 | tee priv_key.pem | openssl rsa -outform der | openssl base64 -A > priv_key.txt
```
Public key as DNS record:
```bash
echo -n "v=DKIM1;p=" > pub_key_record.txt && \
openssl rsa -in priv_key.pem -pubout -outform der | openssl base64 -A >> pub_key_record.txt
```
`Cloudflare``DNS` 记录中添加 `TXT` 记录
例如:
- `_dmarc`: `v=DMARC1; p=none; adkim=r; aspf=r;`
- `mailchannels._domainkey`: `v=DKIM1; p=<content of the file pub_key_record.txt>`
那我在 `wrangler.toml` 中的配置应该是这样的:
```toml
DKIM_SELECTOR = "mailchannels"
DKIM_PRIVATE_KEY = "<priv_key.txt 的内容>"
```

View File

@@ -1,7 +1,5 @@
# Admin 用户相关
默认不允许用户注册,可通过
## 用户管理页面
![admin-user-management](/feature/admin-user-management.png)

View File

@@ -1,7 +1,15 @@
# Admin 控制台
部署前端应用之后,访问 `/admin` 路径即可进入管理控制台。
> [!NOTE]
> 需要配置 `ADMIN_PASSWORDS` 或者 `ADMIN_USER_ROLE` 才可以访问 admin 控制台
> admin 角色配置, 如果用户角色等于 ADMIN_USER_ROLE 则可以访问 admin 控制台
需要在后端配置 `admin 控制台密码`, 不配置则不允许访问控制台。
部署前端应用之后,点击 左上角 logo 5 次 或者访问 `/admin` 路径即可进入管理控制台。
需要在后端配置 `ADMIN_PASSWORDS` 或者当前用户角色为 `ADMIN_USER_ROLE`, 则不允许访问控制台。
![admin](/feature/admin.png)
## 如果你的网站只可私人访问,可通过此禁用检查
`DISABLE_ADMIN_PASSWORD_CHECK = true`

View File

@@ -1,5 +1,9 @@
# 搭建 SMTP IMAP 代理服务
::: warning
如果你使用了 `resend`, 可直接使用 `resend``SMTP` 服务,不需要使用此服务
:::
## 为什么需要 SMTP IMAP 代理服务
`SMTP` `IMAP` 的应用场景更加广泛

View File

@@ -8,7 +8,7 @@
limit = 10
offset = 0
res = requests.get(
f"http://localhost:8787/api/mails?limit={limit}&offset={offset}`;",
f"http://localhost:8787/api/mails?limit={limit}&offset={offset}",
headers={
"Authorization": f"Bearer {你的JWT密码}",
# "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码

View File

@@ -0,0 +1,63 @@
# mail-parser-wasm-worker
> [!NOTE]
> 如果你使用了 webhook 转发,或者 telegram bot 接受邮件,但是邮件内容是乱码,或者无法解析,你对解析的需要更高的要求,可以使用这个功能。
## 修改代码
```bash
cd worker
pnpm add mail-parser-wasm-worker
```
编辑 `worker/src/common.ts`, 取消注释这段代码,使用 mail-parser-wasm-worker 来解析邮件
```ts
export const commonParseMail = async (raw_mail: string | undefined | null): Promise<{
sender: string,
subject: string,
text: string,
html: string
} | undefined> => {
if (!raw_mail) {
return undefined;
}
// 取消注释这段代码,使用 mail-parser-wasm-worker 来解析邮件 start
// TODO: WASM parse email
try {
const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
const parsedEmail = parse_message_wrapper(raw_mail);
return {
sender: parsedEmail.sender || "",
subject: parsedEmail.subject || "",
text: parsedEmail.text || "",
html: parsedEmail.body_html || "",
};
} catch (e) {
console.error("Failed use mail-parser-wasm-worker to parse email", e);
}
// 取消注释这段代码,使用 mail-parser-wasm-worker 来解析邮件 end
try {
const { default: PostalMime } = await import('postal-mime');
const parsedEmail = await PostalMime.parse(raw_mail);
return {
sender: parsedEmail.from ? `${parsedEmail.from.name} <${parsedEmail.from.address}>` : "",
subject: parsedEmail.subject || "",
text: parsedEmail.text || "",
html: parsedEmail.html || "",
};
}
catch (e) {
console.error("Failed use PostalMime to parse email", e);
}
return undefined;
}
```
## 部署
```bash
cd worker
pnpm run deploy
```

View File

@@ -0,0 +1,92 @@
# 新建邮箱地址 API
## 通过 admin API 新建邮箱地址
这是一个 `python` 的例子,使用 `requests` 库发送邮件。
```python
res = requests.post(
# 替换 xxxx.xxxx 为你的 worker 域名
"https://xxxx.xxxx/admin/new_address",
json={
# 是否启用前缀 (True/False)
"enablePrefix": True,
"name": "<邮箱名称>",
"domain": "<邮箱域名>",
},
headers={
'x-admin-auth': "<你的网站admin密码>",
"Content-Type": "application/json"
}
)
# 返回值 {"jwt": "<Jwt>"}
print(res.json())
```
# 批量创建随机用户名邮箱地址 API 示例
## 通过 admin API 批量新建邮箱地址
这是一个 `python` 的例子,使用 `requests` 库发送邮件。
```python
import requests
import random
import string
from concurrent.futures import ThreadPoolExecutor, as_completed
def generate_random_name():
# 生成5位英文字符
letters1 = ''.join(random.choices(string.ascii_lowercase, k=5))
# 生成1-3个数字
numbers = ''.join(random.choices(string.digits, k=random.randint(1, 3)))
# 生成1-3个英文字符
letters2 = ''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))
# 组合成最终名称
return letters1 + numbers + letters2
def fetch_email_data(name):
try:
res = requests.post(
"https://<worker 域名>",
json={
"enablePrefix": True,
"name": name,
"domain": "<邮箱域名>",
},
headers={
'x-admin-auth': "<你的网站admin密码>",
"Content-Type": "application/json"
}
)
if res.status_code == 200:
response_data = res.json()
email = response_data.get("address", "无地址")
jwt = response_data.get("jwt", "无jwt")
return f"{email}----{jwt}\n"
else:
print(f"请求失败,状态码: {res.status_code}")
return None
except requests.RequestException as e:
print(f"请求出现错误: {e}")
return None
def generate_and_save_emails(num_emails):
with ThreadPoolExecutor(max_workers=30) as executor, open('email.txt', 'a') as file:
futures = [executor.submit(fetch_email_data, generate_random_name()) for _ in range(num_emails)]
for future in as_completed(futures):
result = future.result()
if result:
file.write(result)
# 生成10个邮箱并追加到现有文件
generate_and_save_emails(10)
```

View File

@@ -0,0 +1,34 @@
# 配置 S3 附件
## 配置
> [!NOTE]
> 如果不需要 S3 附件, 可跳过此步骤
在 Cloudflare 创建一个 R2 bucket, 你也可以使用其他的 S3 服务(如有 bug 请提 issue)
参考: [配置 Cloudflare R2 的 cors](https://developers.cloudflare.com/r2/buckets/cors/#add-cors-policies-from-the-dashboard)
参考 [Cloudflare R2 s3 toke](https://developers.cloudflare.com/r2/api/s3/tokens/) 创建 token, 拿到 `ENDPOINT`, `Access Key ID``Secret Access Key`,然后执行下面的命令添加到 secrets 中
> [!NOTE]
> 你也可以在 Cloudflare worker 的 UI 界面中添加 `secrets`
```bash
cd worker
pnpm wrangler secret put S3_ENDPOINT
pnpm wrangler secret put S3_ACCESS_KEY_ID
pnpm wrangler secret put S3_SECRET_ACCESS_KEY
# 请注意这里的 bucket 是你的 bucket 名称
pnpm wrangler secret put S3_BUCKET
```
## 使用
保存附件
![S3 save](/feature/s3-save.png)
下载附件
![S3 download](/public/feature/s3-download.png)

View File

@@ -1,5 +1,9 @@
# 配置子域名邮箱
::: warning
子域名邮箱发送邮件可能无法发送邮件,建议使用主域名邮箱发送邮件,子域名邮箱仅用于接收邮件。
:::
参考
- [配置子域名邮箱](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/164#issuecomment-2082612710)

View File

@@ -1,5 +1,18 @@
# 配置 Telegram Bot
## Telegram Bot 配置
> [!NOTE]
> 如果不需要 Telegram Bot, 可跳过此步骤
请先创建一个 Telegram Bot然后获取 `token`,然后执行下面的命令,将 `token` 添加到 secrets 中
你也可以在 Cloudflare 的 UI 界面中添加 `secrets`
```bash
pnpm wrangler secret put TELEGRAM_BOT_TOKEN
```
## Bot
- 可设置白名单用户

View File

@@ -17,6 +17,7 @@
- `BACKEND_TOML`: 后端配置文件,[参考此处](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件)
- `FRONTEND_ENV`: 前端配置文件,请复制 `frontend/.env.example` 的内容,[并参考此处修改](/zh/guide/cli/pages.html)
- `FRONTEND_NAME`: 你在 Cloudflare Pages 创建的项目名称,可通过 [用户界面](https://temp-mail-docs.awsl.uk/zh/guide/ui/pages.html) 或者 [命令行](https://temp-mail-docs.awsl.uk/zh/guide/cli/pages.html) 创建
- `FRONTEND_BRANCH`: (可选) pages 部署的分支,可不配置,默认 `production`
- `TG_FRONTEND_NAME`: (可选) 你在 Cloudflare Pages 创建的项目名称,同 `FRONTEND_NAME`,如果需要 Telegram Mini App 功能,请填写
1. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production``Deploy Frontend`,点击 `Run workflow` 选择分支手动部署

View File

@@ -8,6 +8,10 @@
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
::: warning 注意
下面输入的是 `db/schema.sql` 的内容
:::
打开 `Console` 标签页,输入 `db/schema.sql` 的内容,点击 `Execute` 执行
![d1](/ui_install/d1-exec.png)

View File

@@ -4,9 +4,9 @@
"version": "0.2.6",
"type": "module",
"devDependencies": {
"@types/node": "^20.12.7",
"vitepress": "^1.1.0",
"wrangler": "^3.50.0"
"@types/node": "^20.14.10",
"vitepress": "^1.2.3",
"wrangler": "^3.63.1"
},
"scripts": {
"dev": "vitepress dev docs",

File diff suppressed because it is too large Load Diff

View File

@@ -11,18 +11,22 @@
"build": "wrangler deploy --dry-run --outdir dist --minify"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240512.0",
"@cloudflare/workers-types": "^4.20240620.0",
"@eslint/js": "8.56.0",
"@simplewebauthn/types": "^10.0.0",
"eslint": "8.56.0",
"globals": "^15.3.0",
"typescript-eslint": "^7.10.0",
"wrangler": "^3.57.1"
"globals": "^15.8.0",
"typescript-eslint": "^7.15.0",
"wrangler": "^3.63.1"
},
"dependencies": {
"hono": "^4.3.9",
"@aws-sdk/client-s3": "^3.609.0",
"@aws-sdk/s3-request-presigner": "^3.609.0",
"@simplewebauthn/server": "^10.0.1",
"hono": "^4.4.12",
"mimetext": "^3.0.24",
"postal-mime": "^2.2.5",
"resend": "^3.2.0",
"resend": "^3.4.0",
"telegraf": "4.16.3"
},
"pnpm": {

1843
worker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { Context } from 'hono';
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting, checkUserPassword, getDomains } from '../utils';
import { getJsonSetting, saveSetting, checkUserPassword, getDomains, getUserRoles } from '../utils';
import { UserSettings, GeoData, UserInfo } from "../models";
import { handleListQuery } from '../common'
import { HonoCustomType } from '../types';
@@ -38,18 +38,22 @@ export default {
const { limit, offset, query } = c.req.query();
if (query) {
return await handleListQuery(c,
`SELECT u.id, u.user_email, u.created_at, u.updated_at,`
`SELECT u.id as id, u.user_email, u.created_at, u.updated_at,`
+ ` ur.role_text as role_text,`
+ ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count`
+ ` FROM users u`
+ ` LEFT JOIN user_roles ur ON u.id = ur.user_id`
+ ` where u.user_email like ?`,
`SELECT count(*) as count FROM users where user_email like ?`,
[`%${query}%`], limit, offset
);
}
return await handleListQuery(c,
`SELECT u.id, u.user_email, u.created_at, u.updated_at,`
`SELECT u.id as id, u.user_email, u.created_at, u.updated_at,`
+ ` ur.role_text as role_text,`
+ ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count`
+ ` FROM users u`,
+ ` FROM users u`
+ ` LEFT JOIN user_roles ur ON u.id = ur.user_id`,
`SELECT count(*) as count FROM users`,
[], limit, offset
);
@@ -114,4 +118,30 @@ export default {
}
return c.json({ success: true });
},
updateUserRoles: async (c: Context<HonoCustomType>) => {
const { user_id, role_text } = await c.req.json();
if (!user_id) return c.text("Invalid user_id", 400);
if (!role_text) {
const { success } = await c.env.DB.prepare(
`DELETE FROM user_roles WHERE user_id = ?`
).bind(user_id).run();
if (!success) {
return c.text("Failed to update user roles", 500)
}
return c.json({ success: true })
}
const user_roles = getUserRoles(c);
if (!user_roles.find((r) => r.role === role_text)) {
return c.text("Invalid role_text", 400)
}
const { success } = await c.env.DB.prepare(
`INSERT INTO user_roles (user_id, role_text)`
+ ` VALUES (?, ?)`
+ ` ON CONFLICT(user_id) DO UPDATE SET role_text = ?, updated_at = datetime('now')`
).bind(user_id, role_text, role_text).run();
if (!success) {
return c.text("Failed to update user roles", 500)
}
return c.json({ success: true })
}
}

View File

@@ -2,7 +2,7 @@ import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { HonoCustomType } from '../types'
import { sendAdminInternalMail, getJsonSetting, saveSetting } from '../utils'
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles } from '../utils'
import { newAddress, handleListQuery } from '../common'
import { CONSTANTS } from '../constants'
import cleanup_api from './cleanup_api'
@@ -40,7 +40,7 @@ api.post('/admin/new_address', async (c) => {
return c.text("Please provide a name", 400)
}
try {
const res = await newAddress(c, name, domain, enablePrefix);
const res = await newAddress(c, name, domain, enablePrefix, false, null, false);
return c.json(res);
} catch (e) {
return c.text(`Failed create address: ${(e as Error).message}`, 400)
@@ -127,6 +127,16 @@ api.get('/admin/mails_unknow', async (c) => {
);
});
api.delete('/admin/mails/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE id = ? `
).bind(id).run();
return c.json({
success: success
})
})
api.get('/admin/address_sender', async (c) => {
const { address, limit, offset } = c.req.query();
if (address) {
@@ -166,6 +176,16 @@ api.post('/admin/address_sender', async (c) => {
})
})
api.delete('/admin/address_sender/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE id = ? `
).bind(id).run();
return c.json({
success: success
})
})
api.get('/admin/sendbox', async (c) => {
const { address, limit, offset } = c.req.query();
if (address) {
@@ -182,6 +202,16 @@ api.get('/admin/sendbox', async (c) => {
);
})
api.delete('/admin/sendbox/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE id = ? `
).bind(id).run();
return c.json({
success: success
})
})
api.get('/admin/statistics', async (c) => {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM raw_mails`
@@ -189,16 +219,24 @@ api.get('/admin/statistics', async (c) => {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address`
).first<{ count: number }>() || {};
const { count: activeUserCount7days } = await c.env.DB.prepare(
const { count: activeAddressCount7days } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first<{ count: number }>() || {};
const { count: activeAddressCount30days } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-30 day')`
).first<{ count: number }>() || {};
const { count: sendMailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox`
).first<{ count: number }>() || {};
const { count: userCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM users`
).first<{ count: number }>() || {};
return c.json({
mailCount: mailCount,
userCount: addressCount,
activeUserCount7days: activeUserCount7days,
addressCount: addressCount,
activeAddressCount7days: activeAddressCount7days,
activeAddressCount30days: activeAddressCount30days,
userCount: userCount,
sendMailCount: sendMailCount
})
});
@@ -254,5 +292,7 @@ api.get('/admin/users', admin_user_api.getUsers)
api.delete('/admin/users/:user_id', admin_user_api.deleteUser)
api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
api.get('/admin/user_roles', async (c) => c.json(getUserRoles(c)))
api.post('/admin/user_roles', admin_user_api.updateUserRoles)
api.get("/admin/webhook/settings", webhook_settings.getWebhookSettings);
api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings);

View File

@@ -1,8 +1,9 @@
import { Hono } from 'hono'
import { getDomains, getPasswords, getBooleanValue } from './utils';
import { getDomains, getPasswords, getBooleanValue, getIntValue, getStringArray, getDefaultDomains, getStringValue } from './utils';
import { CONSTANTS } from './constants';
import { HonoCustomType } from './types';
import { isS3Enabled } from './mails_api/s3_attachment';
const api = new Hono<HonoCustomType>
@@ -15,8 +16,14 @@ api.get('/open_api/settings', async (c) => {
needAuth = !auth || !passwords.includes(auth);
}
return c.json({
"title": c.env.TITLE,
"announcement": getStringValue(c.env.ANNOUNCEMENT),
"prefix": c.env.PREFIX,
"minAddressLen": getIntValue(c.env.MIN_ADDRESS_LEN, 1),
"maxAddressLen": getIntValue(c.env.MAX_ADDRESS_LEN, 30),
"defaultDomains": getDefaultDomains(c),
"domains": getDomains(c),
"domainLabels": getStringArray(c.env.DOMAIN_LABELS),
"needAuth": needAuth,
"adminContact": c.env.ADMIN_CONTACT,
"enableUserCreateEmail": getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
@@ -26,7 +33,9 @@ api.get('/open_api/settings', async (c) => {
"copyright": c.env.COPYRIGHT,
"cfTurnstileSiteKey": c.env.CF_TURNSTILE_SITE_KEY,
"enableWebhook": getBooleanValue(c.env.ENABLE_WEBHOOK),
"isS3Enabled": isS3Enabled(c),
"version": CONSTANTS.VERSION,
"showGithub": !getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
});
})

View File

@@ -1,32 +1,52 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { getBooleanValue, getDomains, getStringValue } from './utils';
import { HonoCustomType } from './types';
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains } from './utils';
import { HonoCustomType, UserRole } from './types';
import { unbindTelegramByAddress } from './telegram_api/common';
export const newAddress = async (
c: Context<HonoCustomType>,
name: string, domain: string | undefined | null,
enablePrefix: boolean
enablePrefix: boolean,
checkLengthByConfig: boolean = true,
addressPrefix: string | undefined | null = null,
checkAllowDomains: boolean = true
): Promise<{ address: string, jwt: string }> => {
// remove special characters
name = name.replace(/[^a-zA-Z0-9.]/g, '')
name = name.replace(/[^a-z0-9]/g, '')
// name min length min 1
const minAddressLength = Math.max(
checkLengthByConfig ? getIntValue(c.env.MIN_ADDRESS_LEN, 1) : 1,
1
);
// name max length min 1
const maxAddressLength = Math.max(
checkLengthByConfig ? getIntValue(c.env.MAX_ADDRESS_LEN, 30) : 30,
1
);
// check name length
if (name.length <= 0) {
throw new Error("Name too short")
if (name.length < minAddressLength) {
throw new Error(`Name too short (min ${minAddressLength})`);
}
// create address
if (enablePrefix) {
if (name.length > maxAddressLength) {
throw new Error(`Name too long (max ${maxAddressLength})`);
}
// create address with prefix
if (typeof addressPrefix === "string") {
name = addressPrefix + name;
} else if (enablePrefix) {
name = getStringValue(c.env.PREFIX) + name;
}
if (name.length >= 30) {
throw new Error("Name too long (max 30)")
// check domain
const allowDomains = checkAllowDomains ? await getAllowDomains(c) : getDomains(c);
// if domain is not set, use the first domain
if (!domain && allowDomains.length > 0) {
domain = allowDomains[0];
}
// check domain, generate random domain
const domains = getDomains(c);
if (!domain || !domains.includes(domain)) {
domain = domains[Math.floor(Math.random() * domains.length)];
// check domain is valid
if (!domain || !allowDomains.includes(domain)) {
throw new Error("Invalid domain")
}
// create address
name = name + "@" + domain;
@@ -63,7 +83,7 @@ export const cleanup = async (
cleanType: string | undefined | null,
cleanDays: number | undefined | null
): Promise<boolean> => {
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
if (!cleanType || typeof cleanDays !== 'number' || cleanDays < 0 || cleanDays > 30) {
throw new Error("Invalid cleanType or cleanDays")
}
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
@@ -166,3 +186,74 @@ export const handleListQuery = async (
).bind(...params).first("count") : 0;
return c.json({ results, count });
}
export const commonParseMail = async (raw_mail: string | undefined | null): Promise<{
sender: string,
subject: string,
text: string,
html: string
} | undefined> => {
if (!raw_mail) {
return undefined;
}
// TODO: WASM parse email
// try {
// const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
// const parsedEmail = parse_message_wrapper(raw_mail);
// return {
// sender: parsedEmail.sender || "",
// subject: parsedEmail.subject || "",
// text: parsedEmail.text || "",
// html: parsedEmail.body_html || "",
// };
// } catch (e) {
// console.error("Failed use mail-parser-wasm-worker to parse email", e);
// }
try {
const { default: PostalMime } = await import('postal-mime');
const parsedEmail = await PostalMime.parse(raw_mail);
return {
sender: parsedEmail.from ? `${parsedEmail.from.name} <${parsedEmail.from.address}>` : "",
subject: parsedEmail.subject || "",
text: parsedEmail.text || "",
html: parsedEmail.html || "",
};
}
catch (e) {
console.error("Failed use PostalMime to parse email", e);
}
return undefined;
}
export const commonGetUserRole = async (
c: Context<HonoCustomType>, user_id: number
): Promise<UserRole | undefined | null> => {
const user_roles = getUserRoles(c);
const role_text = await c.env.DB.prepare(
`SELECT role_text FROM user_roles where user_id = ?`
).bind(user_id).first<string | undefined | null>("role_text");
return role_text ? user_roles.find((r) => r.role === role_text) : null;
}
export const getAddressPrefix = async (c: Context<HonoCustomType>): Promise<string | undefined> => {
const user = c.get("userPayload");
if (!user) {
return c.env.PREFIX;
}
const user_role = await commonGetUserRole(c, user.user_id);
if (typeof user_role?.prefix === "string") {
return user_role.prefix;
}
return c.env.PREFIX;
}
export const getAllowDomains = async (c: Context<HonoCustomType>): Promise<string[]> => {
const user = c.get("userPayload");
if (!user) {
return getDefaultDomains(c);
}
const user_role = await commonGetUserRole(c, user.user_id);
return user_role?.domains || getDefaultDomains(c);;
}

View File

@@ -1,5 +1,5 @@
export const CONSTANTS = {
VERSION: 'v0.4.5',
VERSION: 'v0.7.0',
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',

View File

@@ -1,113 +0,0 @@
import { Hono } from 'hono'
// api v1 is deprecated
const api = new Hono()
api.get('/admin/v1/mails', async (c) => {
const { address, limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT id, source, subject, message FROM mails where address = ? order by id desc limit ? offset ? `
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM mails where address = ? `
).bind(address).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
});
api.get('/admin/v1/mails_unknow', async (c) => {
const { limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(`
SELECT id, source, subject, message FROM mails
where address NOT IN(select name from address)
order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM mails
where address NOT IN (select name from address)`
).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
});
api.get('/api/v1/mails', async (c) => {
const { address } = c.get("jwtPayload")
if (!address) {
return c.json({ "error": "No address" }, 400)
}
const { limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT id, source, subject, message, message_id, created_at FROM mails where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM mails where address = ?`
).bind(address).first();
count = mailCount;
}
// add attachments
let attachmentResults = [];
const message_ids = results.map((r) => r.message_id).filter((r) => r);
if (message_ids && message_ids.length > 0) {
const { results: innerAttachmentResults } = await c.env.DB.prepare(
`SELECT id, message_id FROM attachments where message_id in (${message_ids.map((id) => `'${id}'`).join(",")})`
).all();
attachmentResults = innerAttachmentResults || [];
}
results.forEach((r) => {
const attachment_id = attachmentResults.filter((ar) => ar.message_id == r.message_id).map((ar) => ar.id);
if (attachment_id && attachment_id.length > 0) {
r.attachment_id = attachment_id[0];
}
delete r.message_id;
})
return c.json({
results: results,
count: count
})
})
// attachments
api.get("/api/v1/attachment/:attachment_id", async (c) => {
const { attachment_id } = c.req.param();
const { data } = await c.env.DB.prepare(
`SELECT data FROM attachments where id = ? `
).bind(attachment_id).first();
if (!data) {
return c.text("Not found", 404)
}
return c.json(JSON.parse(data))
})
export { api }

View File

@@ -1,5 +1,6 @@
import { Context } from "hono";
import { getEnvStringList } from "../utils";
import { sendMailToTelegram } from "../telegram_api";
import { Bindings, HonoCustomType } from "../types";
import { auto_reply } from "./auto_reply";
@@ -25,6 +26,16 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
console.log(`Failed save message from ${message.from} to ${message.to}`);
}
// forward email
try {
const forwardAddressList = getEnvStringList(env.FORWARD_ADDRESS_LIST)
for (const forwardAddress of forwardAddressList) {
await message.forward(forwardAddress);
}
} catch (error) {
console.log("forward email error", error);
}
// send email to telegram
try {
await sendMailToTelegram(

Some files were not shown because too many files have changed in this diff Show More