Compare commits

..

1 Commits

Author SHA1 Message Date
dreamhunter2333
09e0d0b7d7 feat: backup v1 old data 2024-04-09 15:13:37 +08:00
143 changed files with 4537 additions and 15132 deletions

View File

@@ -1,23 +0,0 @@
---
name: Bug 反馈
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ''
---
## 复现步骤
## 预期行为
## 部署方式
- [ ] cli 部署
- [ ] 用户界面部署
## 浏览器环境

View File

@@ -1,16 +0,0 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "[Feature]"
labels: enhancement, good first issue
assignees: ''
---
## 请描述您的 Feature
## 描述您想要的解决方案
## 描述您考虑过的替代方案
## 附加上下文

View File

@@ -1,44 +0,0 @@
name: Deploy Backend Production
on:
push:
tags:
- "*"
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 Backend for ${{ github.ref_name }}
run: |
cd worker/
echo '${{ secrets.BACKEND_TOML }}' > wrangler.toml
pnpm install --no-frozen-lockfile
output=$(pnpm run deploy 2>&1)
if [ $? -ne 0 ]; then
code=$?
echo "Command failed with exit code $code"
exit $code
fi
echo "Deployed for tag ${{ github.ref_name }}"
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@@ -1,48 +0,0 @@
name: Deploy Docs
on:
push:
paths:
- "vitepress-docs/**"
tags:
- "*"
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- 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 Docs for ${{github.ref_name}}
run: |
cd vitepress-docs/
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip
pnpm install --no-frozen-lockfile
if [[ ${{github.ref}} == refs/tags/* ]]; then
export TAG_NAME=${{github.ref_name}}
else
export TAG_NAME=$(git describe --tags --abbrev=0)
fi
echo "Deploying docs for tag $TAG_NAME"
pnpm run deploy
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@@ -1,43 +0,0 @@
name: Deploy Frontend
on:
push:
paths:
- "frontend/**"
tags:
- "*"
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/
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
export project_name=${{ secrets.FRONTEND_NAME }}
pnpm install --no-frozen-lockfile
pnpm run deploy --project-name=$project_name
echo "Deploying prodcution for ${{ github.ref_name }}"
echo "Deployed for tag ${{ github.ref_name }}"
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@@ -1,48 +0,0 @@
name: SMTP Proxy Server Docker Image CI
on:
push:
paths:
- "smtp_proxy_server/**"
tags:
- "*"
branches:
- main
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: smtp_proxy_server
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: ./smtp_proxy_server
file: ./smtp_proxy_server/dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:latest

View File

@@ -1,46 +0,0 @@
name: Tag Build CI
on:
push:
tags:
- "*"
jobs:
build:
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: Build Frontend
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:release
- name: Zip Frontend dist
run: cd frontend/dist/ && zip -r frontend.zip *
- name: cp wrangler.toml
run: cd worker && cp wrangler.toml.template wrangler.toml
- name: Build Backend
run: cd worker && pnpm install --no-frozen-lockfile && pnpm build
- name: Upload to Release
uses: softprops/action-gh-release@v2
with:
files: |
frontend/dist/frontend.zip
worker/dist/worker.js

1
.gitignore vendored
View File

@@ -1,3 +1,2 @@
dist/ dist/
test/ test/
.vscode/

34
CHANGELOG Normal file
View File

@@ -0,0 +1,34 @@
# CHANGE LOG
## 2024-04-10 v0.0.1
Breaking changes:
- remove `ENABLE_ATTACHMENT` config
- use rust wasm to parse email in frontend
- deprecated api moved to `/api/v1`
DB changes
- `db/2024-04-09-patch.sql`
## 2024-04-09 v0.0.0
release v0.0.0
## 2024-04-03
DB changes
- `db/2024-04-03-patch.sql`
Changes:
- add delete account
- add admin panel search
## 2024-01-13
DB changes
- `db/2024-01-13-patch.sql`

View File

@@ -1,222 +0,0 @@
# CHANGE LOG
## main branch
### DB Changes
新增 user 相关表,用于存储用户信息
- `db/2024-05-08-patch.sql`
### config changs
```toml
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
```
### function changs
- 增加用户注册功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证
- 增加默认以文本显示邮件文本和HTML邮箱显示方式切换按钮
- 修复 `BUG` 随机生成的邮箱名字不合法 #211
- `admin` 邮件页面支持邮件内容搜索 #210
- 修复删除地址时邮件未删除的BUG #213
- UI 增加全局标签页位置配置, 侧边距配置
## v0.3.3
- 修复 Admin 删除邮件报错
- UI: 回复邮件按钮, 引用原始邮件文本 #186
- 添加发送邮件地址黑名单
- 添加 `CF Turnstile` 人机验证配置
- 添加 `/external/api/send_mail` 发送邮件 api, 使用 body 验证 #194
## v0.3.2
## What's Changed
- UI: 添加回复邮件按钮
- 添加定时清理功能,可在 admin 页面配置(需要在配置文件启用定时任务)
- 修复删除账户无反应的问题
* feat: UI: MailBox add reply button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/187
* feat: add cron auto clean up by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/189
* fix: delete account by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/190
## v0.3.1
### DB Changes
新增 `settings` 表,用于存储通用配置信息
- `db/2024-05-01-patch.sql`
### Changes
- `ENABLE_USER_CREATE_EMAIL` 是否允许用户创建邮件
- 允许 admin 创建无前缀的邮件
- 添加 `SMTP proxy server`,支持 SMTP 发送邮件
- 修复某些情况浏览器无法加载 `wasm` 时使用 js 解析邮件
- 页脚添加 `COPYRIGHT`
- UI 允许用户切换邮件展示模式 `v-html` / `iframe`
- 添加 `admin` 账户配置页面,支持配置用户注册名称黑名单
* feat: support admin create address && add ENABLE_USER_CREATE_EMAIL co… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/175
* feat: add SMTP proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/177
* fix: cf ui var is string by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/178
* fix: UI mailbox 100vh to 80vh by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/179
* fix: smtp_proxy_server hostname && add docker image for linux/arm64 by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/180
* fix: some browser do not support wasm by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/182
* feat: add COPYRIGHT by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/183
* feat: UI: add user page: useIframeShowMail && mailboxSplitSize by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/184
* feat: add address_block_list for new address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/185
## v0.3.0
### Breaking Changes
`address` 表的前缀将从代码中迁移到 db 中,请将下面 sql 中的 `tmp` 替换为你的前缀,然后执行。
如果你的数据很重要,请先备份数据库。
**注意替换前缀**
```sql
update
address
set
name = 'tmp' || name;
```
### Changes
- `address` 表的前缀将从代码中迁移到 db 中
- `admin` 账户页面添加收发邮件数量
- `admin` 发件页面默认显示全部
- `admin` 发件权限页面支持搜索地址
- `admin` 邮件页面使用左右分栏 UI
* feat: remove PREFIX logic in db by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/171
* feat: admin page add account mail count && sendbox default all && sen… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/172
* feat: all mail use MailBox Component by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/173
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/0.2.10...v0.3.0
## v0.2.10
- `ENABLE_USER_DELETE_EMAIL` 是否允许用户删除账户和邮件
- `ENABLE_AUTO_REPLY` 是否启用自动回复
- fetchAddressError 提示改进
- 自动刷新显示倒计时
* feat: docs update by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/165
* feat: add ENABLE_USER_DELETE_EMAIL && ENABLE_AUTO_REPLY && modify fetchAddressError i18n && UI: show autoRefreshInterval by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/169
## v0.2.9
- 添加富文本编辑器
- admin 联系方式,不配置则不显示,可配置任意字符串 `ADMIN_CONTACT = "xx@xx.xxx"`
- 默认发送邮件余额,如果不设置,将为 0 `DEFAULT_SEND_BALANCE = 1`
## v0.2.8
- 允许用户删除邮件
- admin 修改发件权限时邮件通知用户
- 发件权限默认 1 条
- 添加 RATE_LIMITER 限流 发送邮件 和 新建地址
- 一些 bug 修复
---
- feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132
- feat: requset_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146
- fix: delete_address not delete address_sender by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/153
- fix: send_balance not update when click sendmail by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/155
## v0.2.7
- Added user interface installation documentation
- Support email DKIM
- Rate limiting configuration for `/api/new_address`
## v0.2.6
- Added admin query outbox page
- Add admin data cleaning page
## 2024-04-12 v0.2.5
- support send email
DB changes:
- `db/2024-04-12-patch.sql`
## 2024-04-10 v0.2.0
### Breaking Changes
- remove `ENABLE_ATTACHMENT` config
- use rust wasm to parse email in frontend
- deprecated api moved to `/api/v1`
### Rust Mail Parser
由于 nodejs 解析 email 的库有些问题,此版本切换到使用 rust wasm 调用 rust 的mail 解析库
- 速度更快,附件支持好,可以显示邮件的附件图片
- 解析支持更多 rfc 规范
Due to some problems with nodejs' email parsing library, this version switches to using rust wasm to call rust's mail parsing library.
- Faster speed, good attachment support, can display attachment images of emails
- Parsing supports more rfc specifications
### DB changs
`mails` 表废弃,新的 `mail``raw` 文本将直接存入 `raw_mails` 表.
The `mails` table will be discarded, and the `raw` text of the new `mail` will be directly stored in the `raw_mails` table
## Upgrade Step
```bash
git checkout v0.2.0
cd worker
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql
pnpm run deploy
cd ../frontend
pnpm run deploy
```
注意:对于历史邮件,请使用部署新网页查看旧数据。
Note: For historical messages, use the Deploy New web page to view old data.
```bash
git checkout feature/backup
cd frontend
# 创建一个新的 pages, 用于访问旧数据
pnpm run deploy --project-name temp-email-v1
```
## 2024-04-09 v0.0.0
release v0.0.0
## 2024-04-03
DB changes
- `db/2024-04-03-patch.sql`
Changes:
- add delete account
- add admin panel search
## 2024-01-13
DB changes
- `db/2024-01-13-patch.sql`

209
README.md
View File

@@ -1,23 +1,24 @@
# 使用 cloudflare 免费服务,搭建临时邮箱 # 使用 cloudflare 免费服务,搭建临时邮箱
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。 ## [English](README_EN.md)
## [查看部署文档](https://temp-mail-docs.awsl.uk) [CHANGELOG](CHANGELOG)
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/dreamhunter2333/cloudflare_temp_email) [Backend](https://temp-email-api.dreamhunter2333.xyz/)
![](https://uptime.aks.awsl.icu/api/badge/10/status)
![](https://uptime.aks.awsl.icu/api/badge/10/uptime)
![](https://uptime.aks.awsl.icu/api/badge/10/ping)
![](https://uptime.aks.awsl.icu/api/badge/10/avg-response)
![](https://uptime.aks.awsl.icu/api/badge/10/cert-exp)
![](https://uptime.aks.awsl.icu/api/badge/10/response)
[Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/github-action.html) [Frontend](https://temp-email.dreamhunter2333.xyz/)
![](https://uptime.aks.awsl.icu/api/badge/12/status)
[English Docs](https://temp-mail-docs.awsl.uk/en/) ![](https://uptime.aks.awsl.icu/api/badge/12/uptime)
![](https://uptime.aks.awsl.icu/api/badge/12/ping)
## [CHANGELOG](CHANGELOG.md) ![](https://uptime.aks.awsl.icu/api/badge/12/avg-response)
![](https://uptime.aks.awsl.icu/api/badge/12/cert-exp)
## [在线演示](https://mail.awsl.uk/) ![](https://uptime.aks.awsl.icu/api/badge/12/response)
| | |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Backend](https://temp-email-api.awsl.uk/) | [![Deploy Backend Production](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/10/status) ![](https://uptime.aks.awsl.icu/api/badge/10/uptime) ![](https://uptime.aks.awsl.icu/api/badge/10/ping) ![](https://uptime.aks.awsl.icu/api/badge/10/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/10/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/10/response) |
| [Frontend](https://mail.awsl.uk/) | [![Deploy Frontend](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/12/status) ![](https://uptime.aks.awsl.icu/api/badge/12/uptime) ![](https://uptime.aks.awsl.icu/api/badge/12/ping) ![](https://uptime.aks.awsl.icu/api/badge/12/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/12/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/12/response) |
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
@@ -26,30 +27,172 @@
</picture> </picture>
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱) - [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
- [查看部署文档](#查看部署文档) - [English](#english)
- [CHANGELOG](#changelog)
- [在线演示](#在线演示) - [在线演示](#在线演示)
- [功能/TODO](#功能todo) - [功能/TODO](#功能todo)
- [Reference](#reference) - [什么是临时邮箱](#什么是临时邮箱)
- [Cloudflare 服务](#cloudflare-服务)
- [wrangler 的安装](#wrangler-的安装)
- [D1 数据库](#d1-数据库)
- [Cloudflare workers 后端](#cloudflare-workers-后端)
- [Cloudflare Workers 后端](#cloudflare-workers-后端-1)
- [Cloudflare Email Routing](#cloudflare-email-routing)
- [Cloudflare Pages 前端](#cloudflare-pages-前端)
- [参考资料](#参考资料)
## [在线演示](https://temp-email.dreamhunter2333.xyz/)
## 功能/TODO ## 功能/TODO
- [x] 使用 `password` 重新登录之前的邮箱 - [x] Cloudflare D1 作为数据库
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单 - [x] 使用 Cloudflare Pages 部署前端
- [x] 使用 Cloudflare Workers 部署后端
- [x] email 转发使用 Cloudflare Email Routing
- [x] 使用 password 重新登录之前的邮箱
- [x] 获取自定义名字的邮箱
- [x] 支持多语言 - [x] 支持多语言
- [x] 增加访问密码,可作为私人站点 - [x] 增加访问授权,可作为私人站点
- [x] 增加自动回复功能 - [x] 增加自动回复功能
- [x] 增加查看 `附件` 功能 - [x] 增加查看附件功能
- [x] 使用 `rust wasm` 解析邮件 - [x] 使用 rust wasm 解析邮件
- [x] 支持发送邮件
- [x] 支持 `DKIM`
- [x] `admin` 后台创建无前缀邮箱
- [x] 添加 `SMTP proxy server`,支持 SMTP 发送邮件
- [x] 添加完整的用户注册登录功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证切换不同邮箱
## Reference ---
- Cloudflare D1 作为数据库 ## 什么是临时邮箱
- 使用 Cloudflare Pages 部署前端
- 使用 Cloudflare Workers 部署后端 临时邮箱,也被称为一次性邮箱或临时邮件地址,是一种用于临时接收邮件的虚拟邮箱。与常规邮箱不同,临时邮箱旨在提供一种匿名且临时的邮件接收解决方案。
- email 转发使用 Cloudflare Email Routing
临时邮箱往往由网站或在线服务提供商提供,用户可以在需要注册或接收验证邮件时使用临时邮箱地址,而无需暴露自己的真实邮箱地址。这样做的好处是可以保护个人隐私
---
## Cloudflare 服务
- `D1``Cloudflare` 的原生无服务器数据库。
- `Pages``Cloudflare` 的静态网站托管服务, 速度超快,始终保持最新状态。
- `Workers``Cloudflare``serverless` 应用服务,可以在全球 300 个数据中心运行代码, 而无需配置或维护基础架构。
- `Cloudflare Email Routing` 可以处理域名的所有电子邮件流量,而无需管理电子邮件服务器。
---
## wrangler 的安装
安装 wrangler
```bash
npm install wrangler -g
```
克隆项目
```bash
git clone https://github.com/dreamhunter2333/cloudflare_temp_email.git
```
---
## D1 数据库
第一次执行登录 wrangler 命令时,会提示登录, 按提示操作即可
```bash
# 创建 D1 并执行 schema.sql
wrangler d1 create dev
wrangler d1 execute dev --file=db/schema.sql
# schema 更新,如果你在此日期之前初始化过数据库,可以执行此命令更新
# wrangler d1 execute dev --file=db/2024-01-13-patch.sql
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql
```
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
![D1](readme_assets/d1.png)
---
## Cloudflare workers 后端
初始化项目
```bash
cd worker
pnpm install
cp wrangler.toml.template wrangler.toml
```
修改 `wrangler.toml` 文件
```bash
name = "cloudflare_temp_email"
main = "src/worker.js"
compatibility_date = "2023-08-14"
node_compat = true
[vars]
PREFIX = "tmp" # 要处理的邮箱名称前缀
# 如果你想要你的网站私有,取消下面的注释,并修改密码
# PASSWORDS = ["123", "456"]
# admin 控制台密码, 不配置则不允许访问控制台
# ADMIN_PASSWORDS = ["123", "456"]
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名
JWT_SECRET = "xxx" # 用于生成 jwt 的密钥
BLACK_LIST = "" # 黑名单,用于过滤发件人,逗号分隔
[[d1_databases]]
binding = "DB"
database_name = "xxx" # D1 数据库名称
database_id = "xxx" # D1 数据库 ID
```
---
## Cloudflare Workers 后端
部署
```bash
pnpm run deploy
```
部署成功之后再路由中可以看到 `worker``url`,控制台也会输出 `worker``url`
![worker](readme_assets/worker.png)
---
## Cloudflare Email Routing
配置对应域名的 `电子邮件 DNS 记录`
配置 `Cloudflare Email Routing` catch-all 发送到 `worker`
![email](readme_assets/email.png)
---
## Cloudflare Pages 前端
```bash
cd frontend
pnpm install
cp .env.example .env.local
```
修改 `.env.local` 文件, 将 `VITE_API_BASE` 修改为 `worker``url`, 不要在末尾加 `/`
例如: `VITE_API_BASE=https://xxx.xxx.workers.dev`
```bash
pnpm build --emptyOutDir
# 根据提示创建 pages
pnpm run deploy
```
![pages](readme_assets/pages.png)
## 参考资料
- https://developers.cloudflare.com/d1/
- https://developers.cloudflare.com/pages/
- https://developers.cloudflare.com/workers/
- https://developers.cloudflare.com/email-routing/

84
README_EN.md Normal file
View File

@@ -0,0 +1,84 @@
# cloudflare temp email
## [中文](README.md)
[CHANGELOG](CHANGELOG)
## [Live Demo](https://temp-email.dreamhunter2333.xyz/)
This is a temporary email service that uses Cloudflare Workers to create a temporary email address and view the received email in web browser.
## Features
- [x] Cloudflare D1 as a database
- [x] Deploy the front end with Cloudflare Pages
- [x] Deploy the backend with Cloudflare Workers
- [x] Email forwarding using Cloudflare Email Routing
- [x] Use password to login to the previous mailbox again.
- [x] Get Custom Name Email
- [x] Support multiple languages
- [x] Add access authorization, which can be used as a private site
- [x] Add auto reply feature
- [x] Add attachment viewing function
- [x] use rust wasm to parse email
![demo](readme_assets/demo.png)
## Deploy
[Install/Update Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/)
## DB - Cloudflare D1
```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
# 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
```
![d1](readme_assets/d1.png)
### Backend - Cloudflare workers
```bash
cd worker
pnpm install
# copy wrangler.toml.template to wrangler.toml
# and add your d1 config and these config
# PREFIX = "tmp" - the email create will be like tmp<xxxxx>@DOMAIN
# IF YOU WANT TO MAKE YOUR SITE PRIVATE, UNCOMMENT THE FOLLOWING LINES
# PASSWORDS = ["123", "456"]
# For admin panel, if not set will no allow to access the admin panel
# ADMIN_PASSWORDS = ["123", "456"]
# DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] you domain name
# JWT_SECRET = "xxx"
# BLACK_LIST = ""
cp wrangler.toml.template wrangler.toml
# deploy
pnpm run deploy
```
you can find and test the worker's url in the workers dashboard
![worker](readme_assets/worker.png)
enable email route and config email forward catch-all to the worker
![email](readme_assets/email.png)
### Frontend - Cloudflare pages
```bash
cd frontend
pnpm install
# add .env.local and modify VITE_API_BASE to your worker's url
# VITE_API_BASE=https://xxx.xxx.workers.dev - don't put / in the end
cp .env.example .env.local
pnpm build --emptyOutDir
pnpm run deploy
```
![pages](readme_assets/pages.png)

View File

@@ -1,14 +0,0 @@
CREATE TABLE IF NOT EXISTS address_sender (
id INTEGER PRIMARY KEY,
address TEXT UNIQUE,
balance INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sendbox (
id INTEGER PRIMARY KEY,
address TEXT,
raw TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,6 +0,0 @@
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,21 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
user_email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
user_info TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
CREATE TABLE IF NOT EXISTS users_address (
id INTEGER PRIMARY KEY,
user_id INTEGER,
address_id INTEGER UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
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);

View File

@@ -8,8 +8,6 @@ CREATE TABLE IF NOT EXISTS mails (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_mails_address ON mails(address);
CREATE TABLE IF NOT EXISTS raw_mails ( CREATE TABLE IF NOT EXISTS raw_mails (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
message_id TEXT, message_id TEXT,
@@ -19,8 +17,6 @@ CREATE TABLE IF NOT EXISTS raw_mails (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE TABLE IF NOT EXISTS address ( CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT UNIQUE, name TEXT UNIQUE,
@@ -28,8 +24,6 @@ CREATE TABLE IF NOT EXISTS address (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
CREATE TABLE IF NOT EXISTS auto_reply_mails ( CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
source_prefix TEXT, source_prefix TEXT,
@@ -41,8 +35,6 @@ CREATE TABLE IF NOT EXISTS auto_reply_mails (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_auto_reply_mails_address ON auto_reply_mails(address);
CREATE TABLE IF NOT EXISTS attachments ( CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
source TEXT, source TEXT,
@@ -51,51 +43,3 @@ CREATE TABLE IF NOT EXISTS attachments (
data TEXT, data TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS address_sender (
id INTEGER PRIMARY KEY,
address TEXT UNIQUE,
balance INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_address_sender_address ON address_sender(address);
CREATE TABLE IF NOT EXISTS sendbox (
id INTEGER PRIMARY KEY,
address TEXT,
raw TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
user_email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
user_info TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
CREATE TABLE IF NOT EXISTS users_address (
id INTEGER PRIMARY KEY,
user_id INTEGER,
address_id INTEGER UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
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);

1
frontend/.gitignore vendored
View File

@@ -29,4 +29,3 @@ coverage
.env.* .env.*
*-dist/ *-dist/
components.d.ts

View File

@@ -14,7 +14,6 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/logo.png" sizes="any"> <link rel="icon" href="/logo.png" sizes="any">
<link rel="apple-touch-icon" href="/logo.png"> <link rel="apple-touch-icon" href="/logo.png">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
</head> </head>
<body> <body>

View File

@@ -6,36 +6,27 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build -m prod --emptyOutDir", "build": "vite build -m prod --emptyOutDir",
"build:release": "vite build -m example --emptyOutDir",
"preview": "vite preview", "preview": "vite preview",
"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"
}, },
"dependencies": { "dependencies": {
"@vicons/material": "^0.12.0", "@vicons/material": "^0.12.0",
"@vueuse/core": "^10.9.0", "@vueuse/core": "^10.9.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.6.8", "axios": "^1.6.8",
"mail-parser-wasm": "^0.1.6", "naive-ui": "^2.38.1",
"naive-ui": "^2.38.2",
"postal-mime": "^2.2.5",
"vooks": "^0.2.12", "vooks": "^0.2.12",
"vue": "^3.4.26", "vue": "^3.4.21",
"vue-clipboard3": "^2.0.0", "vue-clipboard3": "^2.0.0",
"vue-i18n": "^9.13.1", "vue-i18n": "^9.10.2",
"vue-router": "^4.3.2" "vue-router": "^4.3.0"
}, },
"devDependencies": { "devDependencies": {
"@vicons/fa": "^0.12.0", "@vicons/fa": "^0.12.0",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^4.6.2",
"unplugin-auto-import": "^0.17.5", "unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.27.0", "unplugin-vue-components": "^0.26.0",
"vite": "^5.2.11", "vite": "^5.2.6",
"vite-plugin-pwa": "^0.19.8", "vite-plugin-pwa": "^0.19.7",
"vite-plugin-top-level-await": "^1.4.1", "workbox-window": "^7.0.0"
"vite-plugin-wasm": "^3.3.0",
"workbox-window": "^7.1.0",
"wrangler": "^3.53.1"
} }
} }

6567
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -1,18 +1,16 @@
<script setup> <script setup>
import { darkTheme, NGlobalStyle, zhCN } from 'naive-ui' import { darkTheme, NGlobalStyle } from 'naive-ui'
import { zhCN } from 'naive-ui'
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalState } from './store' import { useGlobalState } from './store'
import { useIsMobile } from './utils/composables' import { useIsMobile } from './utils/composables'
import Header from './views/Header.vue'; import Header from './views/Header.vue';
import Footer from './views/Footer.vue';
const { localeCache, themeSwitch, loading } = useGlobalState()
const { localeCache, isDark, loading, useSideMargin } = useGlobalState() const theme = computed(() => themeSwitch.value ? darkTheme : null)
const theme = computed(() => isDark.value ? darkTheme : null)
const localeConfig = computed(() => localeCache.value == 'zh' ? zhCN : null) const localeConfig = computed(() => localeCache.value == 'zh' ? zhCN : null)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const showSideMargin = computed(() => !isMobile.value && !useSideMargin.value);
const { locale } = useI18n({ const { locale } = useI18n({
useScope: 'global', useScope: 'global',
@@ -40,19 +38,16 @@ onMounted(async () => {
<n-spin description="loading..." :show="loading"> <n-spin description="loading..." :show="loading">
<n-message-provider> <n-message-provider>
<n-grid x-gap="12" :cols="12"> <n-grid x-gap="12" :cols="12">
<n-gi v-if="!showSideMargin" span="1"></n-gi> <n-gi v-if="!isMobile" span="1"></n-gi>
<n-gi :span="showSideMargin ? 12 : 10"> <n-gi :span="isMobile ? 12 : 10">
<div class="main"> <div class="main">
<n-space vertical> <n-space vertical>
<n-layout style="min-height: 80vh;"> <Header />
<Header /> <router-view></router-view>
<router-view></router-view>
</n-layout>
<Footer />
</n-space> </n-space>
</div> </div>
</n-gi> </n-gi>
<n-gi v-if="!showSideMargin" span="1"></n-gi> <n-gi v-if="!isMobile" span="1"></n-gi>
</n-grid> </n-grid>
<n-back-top /> <n-back-top />
</n-message-provider> </n-message-provider>

View File

@@ -2,15 +2,12 @@ import { useGlobalState } from '../store'
import axios from 'axios' import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_BASE || ""; const API_BASE = import.meta.env.VITE_API_BASE || "";
const { const { loading, auth, jwt, settings, openSettings } = useGlobalState();
loading, auth, jwt, settings, openSettings, const { showAuth, adminAuth, showAdminAuth } = useGlobalState();
userOpenSettings, userSettings,
showAuth, adminAuth, showAdminAuth, userJwt
} = useGlobalState();
const instance = axios.create({ const instance = axios.create({
baseURL: API_BASE, baseURL: API_BASE,
timeout: 30000 timeout: 10000
}); });
const apiFetch = async (path, options = {}) => { const apiFetch = async (path, options = {}) => {
@@ -20,7 +17,6 @@ const apiFetch = async (path, options = {}) => {
method: options.method || 'GET', method: options.method || 'GET',
data: options.body || null, data: options.body || null,
headers: { headers: {
'x-user-token': userJwt.value,
'x-custom-auth': auth.value, 'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value, 'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`, 'Authorization': `Bearer ${jwt.value}`,
@@ -29,7 +25,7 @@ const apiFetch = async (path, options = {}) => {
}); });
if (response.status === 401 && openSettings.value.auth) { if (response.status === 401 && openSettings.value.auth) {
showAuth.value = true; showAuth.value = true;
throw new Error("Unauthorized, you access password is wrong") throw new Error("Unauthorized, you password is wrong")
} }
if (response.status === 401 && path.startsWith("/admin")) { if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true; showAdminAuth.value = true;
@@ -53,7 +49,7 @@ const apiFetch = async (path, options = {}) => {
const getOpenSettings = async (message) => { const getOpenSettings = async (message) => {
try { try {
const res = await api.fetch("/open_api/settings"); const res = await api.fetch("/open_api/settings");
Object.assign(openSettings.value, { openSettings.value = {
prefix: res["prefix"] || "", prefix: res["prefix"] || "",
needAuth: res["needAuth"] || false, needAuth: res["needAuth"] || false,
domains: res["domains"].map((domain) => { domains: res["domains"].map((domain) => {
@@ -61,14 +57,8 @@ const getOpenSettings = async (message) => {
label: domain, label: domain,
value: domain value: domain
} }
}), })
adminContact: res["adminContact"] || "", };
enableUserCreateEmail: res["enableUserCreateEmail"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableAutoReply: res["enableAutoReply"] || false,
copyright: res["copyright"] || openSettings.value.copyright,
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
});
if (openSettings.value.needAuth) { if (openSettings.value.needAuth) {
showAuth.value = true; showAuth.value = true;
} }
@@ -85,41 +75,17 @@ const getSettings = async () => {
const res = await apiFetch("/api/settings");; const res = await apiFetch("/api/settings");;
settings.value = { settings.value = {
address: res["address"], address: res["address"],
auto_reply: res["auto_reply"], auto_reply: res["auto_reply"]
has_v1_mails: res["has_v1_mails"],
send_balance: res["send_balance"],
}; };
} finally { } finally {
settings.value.fetched = true; settings.value.fetched = true;
} }
} }
const adminShowPassword = async (id) => {
const getUserOpenSettings = async (message) => {
try { try {
const res = await api.fetch(`/user_api/open_settings`); const { password } = await apiFetch(`/admin/show_password/${id}`);
Object.assign(userOpenSettings.value, res); return password;
} catch (error) {
message.error(error.message || "fetch settings failed");
}
}
const getUserSettings = async (message) => {
try {
if (!userJwt.value) return;
const res = await api.fetch("/user_api/settings")
Object.assign(userSettings.value, res)
} catch (error) {
message.error(error.message || "error");
} finally {
userSettings.value.fetched = true;
}
}
const adminShowAddressCredential = async (id) => {
try {
const { jwt: addressCredential } = await apiFetch(`/admin/show_password/${id}`);
return addressCredential;
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -135,24 +101,10 @@ const adminDeleteAddress = async (id) => {
} }
} }
const bindUserAddress = async () => {
if (!userJwt.value) return;
try {
await apiFetch(`/user_api/bind_address`, {
method: 'POST',
});
} catch (error) {
throw error;
}
}
export const api = { export const api = {
fetch: apiFetch, fetch: apiFetch,
getSettings, getSettings: getSettings,
getOpenSettings, getOpenSettings: getOpenSettings,
getUserOpenSettings, adminShowPassword: adminShowPassword,
getUserSettings, adminDeleteAddress: adminDeleteAddress,
adminShowAddressCredential,
adminDeleteAddress,
bindUserAddress,
} }

View File

@@ -1,438 +0,0 @@
<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'
import { CloudDownloadRound, ReplyFilled } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
const message = useMessage()
const isMobile = useIsMobile()
const router = useRouter()
const props = defineProps({
enableUserDeleteEmail: {
type: Boolean,
default: false,
requried: false
},
showEMailTo: {
type: Boolean,
default: true,
requried: false
},
fetchMailData: {
type: Function,
default: () => { },
requried: true
},
deleteMail: {
type: Function,
default: () => { },
requried: false
},
showReply: {
type: Boolean,
default: false,
requried: false
}
})
const {
localeCache, isDark, mailboxSplitSize, indexTab,
useIframeShowMail, sendMailModel, preferShowTextMail
} = useGlobalState()
const autoRefresh = ref(false)
const autoRefreshInterval = ref(30)
const data = ref([])
const timer = ref(null)
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
const curMail = ref(null);
const showTextMail = ref(preferShowTextMail.value)
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'Success',
autoRefresh: 'Auto Refresh',
refreshAfter: 'Refresh After {msg} Seconds',
refresh: 'Refresh',
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
pleaseSelectMail: "Please select a mail to view.",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete this mail?',
reply: 'Reply',
showTextMail: 'Show Text Mail',
showHtmlMail: 'Show Html Mail'
},
zh: {
success: '成功',
autoRefresh: '自动刷新',
refreshAfter: '{msg}秒后刷新',
refresh: '刷新',
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择一封邮件查看。",
delete: '删除',
deleteMailTip: '确定要删除这封邮件吗?',
reply: '回复',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件'
}
}
});
const setupAutoRefresh = async (autoRefresh) => {
// auto refresh every 30 seconds
autoRefreshInterval.value = 30;
if (autoRefresh) {
timer.value = setInterval(async () => {
autoRefreshInterval.value--;
if (autoRefreshInterval.value <= 0) {
autoRefreshInterval.value = 30;
await refresh();
}
}, 1000)
} else {
clearInterval(timer.value)
timer.value = null
}
}
watch(autoRefresh, async (autoRefresh, old) => {
setupAutoRefresh(autoRefresh)
})
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
await refresh();
}
})
const refresh = async () => {
try {
const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value
);
data.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (totalCount > 0) {
count.value = totalCount;
}
if (!isMobile.value && !curMail.value && data.value.length > 0) {
curMail.value = data.value[0];
}
} catch (error) {
message.error(error.message || "error");
console.error(error);
}
};
const clickRow = async (row) => {
curMail.value = row;
};
const getAttachments = (attachments) => {
curAttachments.value = attachments;
showAttachments.value = true;
};
const mailItemClass = (row) => {
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
};
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 replyMail = async () => {
const emailRegex = /(.+?) <(.+?)>/;
let toMail = curMail.value.originalSource;
let toName = ""
const match = emailRegex.exec(curMail.value.source);
if (match) {
toName = match[1];
toMail = match[2];
}
Object.assign(sendMailModel.value, {
toName: toName,
toMail: toMail,
subject: `${t('reply')}: ${curMail.value.subject}`,
contentType: 'rich',
content: curMail.value.text ? `<p><br></p><blockquote>${curMail.value.text}</blockquote><p><br></p>` : '',
});
indexTab.value = 'sendmail';
};
const onSpiltSizeChange = (size) => {
mailboxSplitSize.value = size;
}
onMounted(async () => {
await refresh();
});
onBeforeUnmount(() => {
clearInterval(timer.value)
})
</script>
<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; margin-top: 10px; margin-bottom: 10px;">
<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">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" 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>
</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>
<div class="left" v-else>
<div class="center">
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<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">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary">
{{ 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)">
<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>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
<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-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)">
<n-icon :component="CloudDownloadRound" />
{{ 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-space>
<div v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
</n-drawer-content>
</n-drawer>
</div>
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<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>
</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-modal>
</div>
</template>
<style scoped>
.left {
text-align: left;
}
.center {
text-align: center;
}
.overlay {
width: 100%;
height: 100%;
z-index: 1000;
}
.overlay-dark-backgroud {
background-color: rgba(255, 255, 255, 0.1);
}
.overlay-light-backgroud {
background-color: rgba(0, 0, 0, 0.1);
}
.mail-item {
height: 100%;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

View File

@@ -1,88 +0,0 @@
<script setup>
import { ref, watch, defineModel, onMounted } from "vue";
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
const { localeCache, openSettings, isDark } = useGlobalState()
const cfToken = defineModel('value')
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
refresh: 'Refresh'
},
zh: {
refresh: '刷新'
}
}
});
const cfTurnstileId = ref("")
const turnstileLoading = ref(false)
const checkCfTurnstile = async (remove) => {
if (!openSettings.value.cfTurnstileSiteKey) return;
turnstileLoading.value = true;
try {
let container = document.getElementById("cf-turnstile");
let count = 100;
while (!container && count-- > 0) {
container = document.getElementById("cf-turnstile");
await new Promise(r => setTimeout(r, 10));
}
count = 100;
while (!window.turnstile && count-- > 0) {
await new Promise(r => setTimeout(r, 10));
}
if (remove && cfTurnstileId.value) {
window.turnstile.remove(cfTurnstileId.value);
}
cfTurnstileId.value = window.turnstile.render(
"#cf-turnstile",
{
sitekey: openSettings.value.cfTurnstileSiteKey,
language: localeCache.value == 'zh' ? 'zh-CN' : 'en-US',
theme: isDark.value ? 'dark' : 'light',
callback: function (token) {
cfToken.value = token;
},
}
);
} finally {
turnstileLoading.value = false;
}
}
watch(isDark, async (isDark) => {
checkCfTurnstile(true)
}, { immediate: true })
onMounted(() => {
cfToken.value = "";
checkCfTurnstile(true);
})
</script>
<template>
<div v-if="openSettings.cfTurnstileSiteKey" class="center">
<n-spin description="loading..." :show="turnstileLoading">
<n-form-item-row>
<div id="cf-turnstile"></div>
<n-button text @click="checkCfTurnstile(true)">
{{ t('refresh') }}
</n-button>
</n-form-item-row>
</n-spin>
</div>
</template>
<style scoped>
.center {
display: flex;
}
.n-button {
margin-left: 10px;
}
</style>

View File

@@ -12,7 +12,7 @@ const i18n = createI18n({
'en': { 'en': {
messages: {} messages: {}
}, },
'zh': { 'zhCN': {
messages: {} messages: {}
} }
}) })

View File

@@ -1,8 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue' import Index from '../views/Index.vue'
import UserLogin from '../views/user/UserLogin.vue' import Settings from '../views/Settings.vue'
import User from '../views/User.vue'
import SendMail from '../views/index/SendMail.vue'
import Admin from '../views/Admin.vue' import Admin from '../views/Admin.vue'
const router = createRouter({ const router = createRouter({
@@ -13,8 +11,8 @@ const router = createRouter({
component: Index component: Index
}, },
{ {
path: '/user', path: '/settings',
component: User component: Settings
}, },
{ {
path: '/admin', path: '/admin',

View File

@@ -1,26 +1,19 @@
import { ref } from "vue"; import { ref } from "vue";
import { createGlobalState, useStorage, useDark, useToggle } from '@vueuse/core' import { createGlobalState, useStorage } from '@vueuse/core'
export const useGlobalState = createGlobalState( export const useGlobalState = createGlobalState(
() => { () => {
const isDark = useDark()
const toggleDark = useToggle(isDark)
const loading = ref(false); const loading = ref(false);
const openSettings = ref({ const openSettings = ref({
prefix: '', prefix: '',
needAuth: false, needAuth: false,
adminContact: '', domains: [{
enableUserCreateEmail: false, label: 'test.com',
enableUserDeleteEmail: false, value: 'test.com'
enableAutoReply: false, }]
domains: [],
copyright: 'Dream Hunter',
cfTurnstileSiteKey: '',
}) })
const settings = ref({ const settings = ref({
fetched: false, fetched: false,
has_v1_mails: false,
send_balance: 0,
address: '', address: '',
auto_reply: { auto_reply: {
subject: '', subject: '',
@@ -29,72 +22,27 @@ export const useGlobalState = createGlobalState(
source_prefix: '', source_prefix: '',
name: '', name: '',
} }
}); })
const sendMailModel = useStorage('sendMailModel', {
fromName: "",
toName: "",
toMail: "",
subject: "",
contentType: 'text',
content: "",
});
const showAuth = ref(false); const showAuth = ref(false);
const showAddressCredential = ref(false);
const showAdminAuth = ref(false); const showAdminAuth = ref(false);
const auth = useStorage('auth', ''); const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', ''); const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', ''); const jwt = useStorage('jwt', '');
const localeCache = useStorage('locale', 'zh'); const localeCache = useStorage('locale', 'zhCN');
const adminTab = ref("account"); const themeSwitch = useStorage('themeSwitch', false);
const adminMailTabAddress = ref(""); const showLogin = ref(false);
const adminSendBoxTabAddress = ref("");
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
const useIframeShowMail = useStorage('useIframeShowMail', false);
const preferShowTextMail = useStorage('preferShowTextMail', false);
const userJwt = useStorage('userJwt', '');
const userTab = useStorage('userTab', 'user_settings');
const indexTab = useStorage('indexTab', 'mailbox');
const globalTabplacement = useStorage('globalTabplacement', 'top');
const useSideMargin = useStorage('useSideMargin', true);
const userOpenSettings = ref({
enable: false,
enableMailVerify: false,
});
const userSettings = ref({
/** @type {boolean} */
fetched: false,
/** @type {string} */
user_email: '',
/** @type {number} */
user_id: 0,
});
return { return {
isDark,
toggleDark,
loading, loading,
settings, settings,
sendMailModel,
openSettings, openSettings,
showAuth, showAuth,
showAddressCredential,
auth, auth,
jwt, jwt,
localeCache, localeCache,
themeSwitch,
adminAuth, adminAuth,
showAdminAuth, showAdminAuth,
adminTab, showLogin,
adminMailTabAddress,
adminSendBoxTabAddress,
mailboxSplitSize,
useIframeShowMail,
preferShowTextMail,
userJwt,
userTab,
indexTab,
userOpenSettings,
userSettings,
globalTabplacement,
useSideMargin,
} }
}, },
) )

View File

@@ -1,20 +1,13 @@
import PostalMime from 'postal-mime'; import PostalMime from 'postal-mime';
import { parse_message } from 'mail-parser-wasm'
function humanFileSize(size) {
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return parseFloat((size / Math.pow(1024, i)).toFixed(2)) + ' ' + ['B', 'KB', 'MB', 'GB', 'TB'][i];
}
export async function processItem(item) { export async function processItem(item) {
// Try to parse the email using mail-parser-wasm // Try to parse the email using mail-parser-wasm
item.originalSource = item.source;
try { try {
const { parse_message } = await import('mail-parser-wasm');
const parsedEmail = parse_message(item.raw); const parsedEmail = parse_message(item.raw);
item.source = parsedEmail.sender || item.source; item.source = parsedEmail.sender || item.source;
item.subject = parsedEmail.subject || ''; item.subject = parsedEmail.subject || '';
item.message = parsedEmail.body_html || parsedEmail.text || ''; item.message = parsedEmail.body_html || parsedEmail.text || '';
item.text = parsedEmail.text || '';
item.attachments = parsedEmail.attachments?.map((a_item) => { item.attachments = parsedEmail.attachments?.map((a_item) => {
const blob_url = URL.createObjectURL( const blob_url = URL.createObjectURL(
new Blob( new Blob(
@@ -27,7 +20,7 @@ export async function processItem(item) {
return { return {
id: a_item.content_id || Math.random().toString(36).substring(2, 15), id: a_item.content_id || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.content_id || "", filename: a_item.filename || a_item.content_id || "",
size: humanFileSize(a_item.content?.length || 0), size: a_item.content?.length || 0,
url: blob_url url: blob_url
} }
}) || []; }) || [];
@@ -47,7 +40,6 @@ export async function processItem(item) {
} }
item.subject = parsedEmail.subject || 'No Subject'; item.subject = parsedEmail.subject || 'No Subject';
item.message = parsedEmail.html || parsedEmail.text || item.raw; item.message = parsedEmail.html || parsedEmail.text || item.raw;
item.text = parsedEmail.text || '';
item.attachments = parsedEmail.attachments?.map((a_item) => { item.attachments = parsedEmail.attachments?.map((a_item) => {
const blob_url = URL.createObjectURL( const blob_url = URL.createObjectURL(
new Blob( new Blob(
@@ -60,7 +52,7 @@ export async function processItem(item) {
return { return {
id: a_item.contentId || Math.random().toString(36).substring(2, 15), id: a_item.contentId || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.contentId || "", filename: a_item.filename || a_item.contentId || "",
size: humanFileSize(a_item.content?.length || 0), size: a_item.content?.length || 0,
url: blob_url url: blob_url
} }
}) || []; }) || [];
@@ -70,7 +62,6 @@ export async function processItem(item) {
item.subject = 'No Subject'; item.subject = 'No Subject';
item.message = item.raw; item.message = item.raw;
} }
return item;
} }
export function getDownloadEmlUrl(raw) { export function getDownloadEmlUrl(raw) {

View File

@@ -1,6 +0,0 @@
export const hashPassword = async (password) => {
// 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('');
}

View File

@@ -1,27 +1,20 @@
<script setup> <script setup>
import { onMounted } from 'vue'; import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { User, UserCheck, MailBulk } from '@vicons/fa'
import { useGlobalState } from '../store' import { useGlobalState } from '../store'
import { api } from '../api'
import SenderAccess from './admin/SenderAccess.vue' const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
import Statistics from "./admin/Statistics.vue" const router = useRouter()
import SendBox from './admin/SendBox.vue';
import Account from './admin/Account.vue';
import CreateAccount from './admin/CreateAccount.vue';
import AccountSettings from './admin/AccountSettings.vue';
import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import Maintenance from './admin/Maintenance.vue';
import Appearance from './common/Appearance.vue';
const {
localeCache, adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
} = useGlobalState()
const message = useMessage() const message = useMessage()
const showEmailPassword = ref(false)
const curEmailPassword = ref("")
const addressQuery = ref("")
const authFunc = async () => { const authFunc = async () => {
try { try {
location.reload() location.reload()
@@ -34,98 +27,371 @@ const { t } = useI18n({
locale: localeCache.value || 'zh', locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
accessHeader: 'Admin Password', title: 'Temp Email Admin',
accessTip: 'Please enter the admin password', auth: 'Admin Auth',
home: 'Home',
authTip: 'Please enter the correct auth code',
name: 'Name',
created_at: 'Created At',
showPass: 'Show Passwrod',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
delete: 'Delete',
deleteTip: 'Are you sure to delete this email?',
refresh: 'Refresh',
mails: 'Emails', mails: 'Emails',
itemCount: 'itemCount',
query: 'Query',
userCount: 'User Count',
activeUser: '7 days Active User',
mailCount: 'Mail Count',
account: 'Account', account: 'Account',
account_create: 'Create Account', unknow: 'Unknow',
account_settings: 'Account Settings', addressQueryTip: 'Leave blank to query all addresses',
user_management: 'User Management',
user_settings: 'User Settings',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
statistics: 'Statistics',
maintenance: 'Maintenance',
appearance: 'Appearance',
ok: 'OK',
}, },
zh: { zh: {
accessHeader: 'Admin 密码', title: '临时邮件 Admin',
accessTip: '请输入 Admin 密码', auth: 'Admin 授权',
home: '首页',
authTip: '请输入正确的授权码',
name: '名称',
created_at: '创建时间',
showPass: '显示密码',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
refresh: '刷新',
mails: '邮件', mails: '邮件',
itemCount: '总数',
query: '查询',
userCount: '用户总数',
activeUser: '周活跃用户',
mailCount: '邮件总数',
account: '账号', account: '账号',
account_create: '创建账号', unknow: '未知',
account_settings: '账号设置', addressQueryTip: '留空查询所有地址',
user_management: '用户管理',
user_settings: '用户设置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
statistics: '统计',
maintenance: '维护',
appearance: '外观',
ok: '确定',
} }
} }
}); });
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showPassword = async (id) => {
try {
curEmailPassword.value = await api.adminShowPassword(id)
showEmailPassword.value = true
} catch (error) {
message.error(error.message || "error");
showEmailPassword.value = false
curEmailPassword.value = ""
}
}
const deleteEmail = async (id) => {
try {
await api.adminDeleteAddress(id)
message.success("success");
await fetchData()
} catch (error) {
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/admin/address`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('name'),
key: "name"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: 'Action',
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
ghost: true,
onClick: () => showPassword(row.id)
},
{ default: () => t('showPass') }
),
h(NButton,
{
type: 'success',
ghost: true,
onClick: () => {
mailAddress.value = row.name
tab.value = "mails"
}
},
{ default: () => t('mails') }
),
h(NPopconfirm,
{
onPositiveClick: () => deleteEmail(row.id)
},
{
trigger: () => h(NButton, { type: "error" }, () => t('delete')),
default: () => t('deleteTip')
}
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
const statistics = ref({
userCount: 0,
mailCount: 0,
activeUserCount7days: 0,
})
const fetchStatistics = async () => {
try {
const { userCount, activeUserCount7days, mailCount } = await api.fetch(`/admin/statistics`);
statistics.value.mailCount = mailCount || 0;
statistics.value.userCount = userCount || 0;
statistics.value.activeUserCount7days = activeUserCount7days || 0;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
onMounted(async () => { onMounted(async () => {
if (!adminAuth.value) { if (!adminAuth.value) {
showAdminAuth.value = true; showAdminAuth.value = true
return; } else {
await fetchData()
await fetchStatistics()
} }
}) })
const tab = ref("account")
const mailAddress = ref("")
const mailData = ref([])
const mailCount = ref(0)
const mailPage = ref(1)
const mailPageSize = ref(20)
watch([mailPage, mailPageSize, mailAddress], async () => {
await fetchMailData()
})
const fetchMailData = async () => {
if (!mailAddress.value) {
return
}
try {
const { results, count } = await api.fetch(
`/admin/v1/mails`
+ `?address=${mailAddress.value}`
+ `&limit=${mailPageSize.value}`
+ `&offset=${(mailPage.value - 1) * mailPageSize.value}`
);
mailData.value = results;
if (count > 0) {
mailCount.value = count;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const mailUnknowData = ref([])
const mailUnknowCount = ref(0)
const mailUnknowPage = ref(1)
const mailUnknowPageSize = ref(20)
watch([mailUnknowPage, mailUnknowPageSize], async () => {
await fetchMailUnknowData()
})
const fetchMailUnknowData = async () => {
try {
const { results, count } = await api.fetch(
`/admin/v1/mails_unknow`
+ `?limit=${mailPageSize.value}`
+ `&offset=${(mailPage.value - 1) * mailPageSize.value}`
);
mailUnknowData.value = results;
if (count > 0) {
mailUnknowCount.value = count;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
</script> </script>
<template> <template>
<div> <div>
<n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog" <n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
:title="t('accessHeader')"> title="Dialog">
<p>{{ t('accessTip') }}</p> <template #header>
<n-input v-model:value="adminAuth" type="textarea" :autosize="{ minRows: 3 }" /> <div>{{ t('auth') }}</div>
</template>
<p>{{ t('authTip') }}</p>
<n-input v-model:value="adminAuth" type="textarea" :autosize="{
minRows: 3
}" />
<template #action> <template #action>
<n-button @click="authFunc" type="primary" :loading="loading"> <n-button @click="authFunc" size="small" tertiary round type="primary">
{{ t('ok') }} {{ t('auth') }}
</n-button> </n-button>
</template> </template>
</n-modal> </n-modal>
<n-tabs type="card" v-model:value="adminTab" :placement="globalTabplacement"> <n-modal v-model:show="showEmailPassword" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("password") }}</div>
</template>
<span>
<p>{{ t("passwordTip") }}</p>
</span>
<n-card>
<b>{{ curEmailPassword }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<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('activeUser')" :value="statistics.activeUserCount7days">
<template #prefix>
<n-icon :component="UserCheck" />
</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-row>
<n-tabs type="segment" v-model:value="tab">
<n-tab-pane name="account" :tab="t('account')"> <n-tab-pane name="account" :tab="t('account')">
<Account /> <n-input-group>
</n-tab-pane> <n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
<n-tab-pane name="account_create" :tab="t('account_create')"> <n-button @click="fetchData" type="primary" ghost>
<CreateAccount /> {{ t('query') }}
</n-tab-pane> </n-button>
<n-tab-pane name="account_settings" :tab="t('account_settings')"> </n-input-group>
<AccountSettings /> <div style="display: inline-block;">
</n-tab-pane> <n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
<n-tab-pane name="user_management" :tab="t('user_management')"> show-size-picker>
<UserManagement /> <template #prefix="{ itemCount }">
</n-tab-pane> {{ t('itemCount') }}: {{ itemCount }}
<n-tab-pane name="user_settings" :tab="t('user_settings')"> </template>
<UserSettings /> </n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')"> <n-tab-pane name="mails" :tab="t('mails')">
<Mails /> <n-input-group>
<n-input v-model:value="mailAddress" />
<n-button @click="fetchMailData" type="primary" ghost>
{{ t('query') }}
</n-button>
</n-input-group>
<n-list hoverable clickable>
<div style="display: inline-block; margin-bottom: 10px;">
<n-pagination v-model:page="mailPage" v-model:page-size="mailPageSize" :item-count="mailCount" simple>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-list-item v-for="row in mailData" v-bind:key="row.id">
<n-thing class="center" :title="row.subject">
<template #description>
<n-space>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
</n-space>
</template>
<div v-html="row.message"></div>
</n-thing>
</n-list-item>
</n-list>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="unknow" :tab="t('unknow')"> <n-tab-pane name="unknow" :tab="t('unknow')">
<MailsUnknow /> <n-button @click="fetchMailUnknowData" type="primary" ghost>
</n-tab-pane> {{ t('query') }}
<n-tab-pane name="senderAccess" :tab="t('senderAccess')"> </n-button>
<SenderAccess /> <n-list hoverable clickable>
</n-tab-pane> <div style="display: inline-block; margin-bottom: 10px;">
<n-tab-pane name="sendBox" :tab="t('sendBox')"> <n-pagination v-model:page="mailUnknowPage" v-model:page-size="mailUnknowPageSize"
<SendBox /> :item-count="mailUnknowCount" simple>
</n-tab-pane> <template #prefix="{ itemCount }">
<n-tab-pane name="statistics" :tab="t('statistics')"> {{ t('itemCount') }}: {{ itemCount }}
<Statistics /> </template>
</n-tab-pane> </n-pagination>
<n-tab-pane name="maintenance" :tab="t('maintenance')"> </div>
<Maintenance /> <n-list-item v-for="row in mailUnknowData" v-bind:key="row.id">
</n-tab-pane> <n-thing class="center" :title="row.subject">
<n-tab-pane name="appearance" :tab="t('appearance')"> <template #description>
<Appearance /> <n-space>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
</n-space>
</template>
<div v-html="row.message"></div>
</n-thing>
</n-list-item>
</n-list>
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
</div> </div>

View File

@@ -1,38 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
const { localeCache, openSettings } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
copyright: "Copyright"
},
zh: {
copyright: "版权所有"
}
}
});
</script>
<template>
<div>
<n-divider class="footer-divider" />
<div style="text-align: center; padding: 20px">
<n-text depth="3">
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }} {{ openSettings.copyright }}
</n-text>
</div>
</div>
</template>
<style scoped>
.footer-divider {
margin: 0;
padding: 0 var(--x-padding);
}
</style>

View File

@@ -1,32 +1,46 @@
<script setup> <script setup>
import useClipboard from 'vue-clipboard3'
import { ref, h, computed, onMounted } from 'vue' import { ref, h, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute, useRouter, RouterLink } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useIsMobile } from '../utils/composables' import { useIsMobile } from '../utils/composables'
import { import { DarkModeFilled, LightModeFilled, MenuFilled, AdminPanelSettingsFilled } from '@vicons/material'
DarkModeFilled, LightModeFilled, MenuFilled, import { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
AdminPanelSettingsFilled
} from '@vicons/material'
import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store' import { useGlobalState } from '../store'
import { api } from '../api' import { api } from '../api'
const { toClipboard } = useClipboard()
const message = useMessage() const message = useMessage()
const { const { jwt, localeCache, themeSwitch, showAuth, adminAuth, auth } = useGlobalState()
localeCache, toggleDark, isDark, openSettings, const { showLogin, openSettings, settings } = useGlobalState()
showAuth, adminAuth, auth, loading
} = useGlobalState()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const isAdminRoute = computed(() => route.path.includes('admin'))
const showMobileMenu = ref(false) const showNewEmail = ref(false)
const menuValue = computed(() => { const showLogout = ref(false)
if (route.path.includes("user")) return "user"; const showDelteAccount = ref(false)
if (route.path.includes("admin")) return "admin"; const password = ref('')
return "home"; const showPassword = ref(false)
}); const emailName = ref("")
const emailDomain = ref("")
const login = async () => {
try {
jwt.value = password.value;
await api.getSettings()
location.reload()
} catch (error) {
message.error(error.message || "error");
}
}
const logout = () => {
jwt.value = '';
location.reload()
}
const authFunc = async () => { const authFunc = async () => {
try { try {
@@ -48,69 +62,89 @@ const { t } = useI18n({
title: 'Cloudflare Temp Email', title: 'Cloudflare Temp Email',
dark: 'Dark', dark: 'Dark',
light: 'Light', light: 'Light',
accessHeader: 'Access Password', login: 'Login',
accessTip: 'Please enter the correct access password', logout: 'Logout',
logoutConfirm: 'Are you sure to logout?',
delteAccount: "Delete Account",
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
auth: 'Auth',
authTip: 'Please enter the correct auth code',
settings: 'Settings',
home: 'Home', home: 'Home',
menu: 'Menu', menu: 'Menu',
user: 'User', user: 'User',
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
getNewEmail: 'Get New Email',
getNewEmailTip1: 'Please input the email you want to use.',
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
yourAddress: 'Your email address is',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.', cancel: 'Cancel',
ok: 'OK', ok: 'OK',
copy: 'Copy',
copied: 'Copied',
showPassword: 'Show Password',
fetchAddressError: 'Fetch address error, maybe your jwt is invalid or network error.',
}, },
zh: { zh: {
title: 'Cloudflare 临时邮件', title: 'Cloudflare 临时邮件',
dark: '暗色', dark: '暗色',
light: '亮色', light: '亮色',
accessHeader: '访问密码', login: '登录',
accessTip: '请输入站点访问密码', logout: '登出',
logoutConfirm: '确定要登出吗?',
delteAccount: "删除账户",
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
auth: '授权',
authTip: '请输入正确的授权码',
settings: '设置',
home: '主页', home: '主页',
menu: '菜单', menu: '菜单',
user: '用户', user: '用户',
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '获取新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址。',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
yourAddress: '你的邮箱地址是',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
cancel: '取消',
ok: '确定', ok: '确定',
copy: '复制',
copied: '已复制',
showPassword: '查看密码',
fetchAddressError: '获取地址失败, 请检查你的 jwt 是否有效 或 网络是否正常。',
} }
} }
}); });
const showUserMenu = computed(() => !!settings.value.address)
const menuOptions = computed(() => [ const menuOptions = computed(() => [
{ {
label: () => h(NButton, label: () => h(
NButton,
{ {
text: true, bordered: false,
ghost: true,
size: "small", size: "small",
type: menuValue.value == "home" ? "primary" : "default", onClick: () => router.push('/')
style: "width: 100%",
onClick: async () => { await router.push('/'); showMobileMenu.value = false; }
}, },
{ {
default: () => t('home'), default: () => t('home'),
icon: () => h(NIcon, { component: Home }) icon: () => h(NIcon, { component: Home })
}), }
),
key: "home" key: "home"
}, },
{ {
label: () => h( label: () => h(
NButton, NButton,
{ {
text: true, bordered: false,
ghost: true,
size: "small", size: "small",
type: menuValue.value == "user" ? "primary" : "default", onClick: () => router.push('/admin')
style: "width: 100%",
onClick: async () => { await router.push("/user"); showMobileMenu.value = false; }
},
{
default: () => t('user'),
icon: () => h(NIcon, { component: User }),
}
),
key: "user",
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
type: menuValue.value == "admin" ? "primary" : "default",
style: "width: 100%",
onClick: async () => { await router.push('/admin'); showMobileMenu.value = false; }
}, },
{ {
default: () => "Admin", default: () => "Admin",
@@ -124,15 +158,85 @@ const menuOptions = computed(() => [
label: () => h( label: () => h(
NButton, NButton,
{ {
text: true, bordered: false,
ghost: true,
size: "small", size: "small",
style: "width: 100%",
onClick: () => { toggleDark(); showMobileMenu.value = false; }
}, },
{ {
default: () => isDark.value ? t('light') : t('dark'), default: () => t('user'),
icon: () => h(NIcon, { component: User }),
}
),
show: showUserMenu.value,
key: "user",
children: [
{
label: () => h(
NButton,
{
tertiary: true,
ghost: true,
size: "small",
onClick: () => { showPassword.value = true }
},
{ default: () => t('showPassword') }
),
key: "showPassword"
},
{
label: () => h(
NButton,
{
tertiary: true,
ghost: true,
size: "small",
onClick: () => { router.push('/settings') }
},
{ default: () => t('settings') }
),
key: "settings"
},
{
label: () => h(
NButton,
{
tertiary: true,
ghost: true,
size: "small",
onClick: () => { showLogout.value = true }
},
{ default: () => t('logout') }
),
key: "logout"
},
{
label: () => h(
NButton,
{
tertiary: true,
ghost: true,
size: "small",
onClick: () => { showDelteAccount.value = true }
},
{ default: () => t('delteAccount') }
),
key: "delte_account"
}
]
},
{
label: () => h(
NButton,
{
bordered: false,
ghost: true,
size: "small",
onClick: () => { themeSwitch.value = !themeSwitch.value }
},
{
default: () => themeSwitch.value ? t('light') : t('dark'),
icon: () => h( icon: () => h(
NIcon, { component: isDark.value ? LightModeFilled : DarkModeFilled } NIcon, { component: themeSwitch.value ? LightModeFilled : DarkModeFilled }
) )
} }
), ),
@@ -142,13 +246,10 @@ const menuOptions = computed(() => [
label: () => h( label: () => h(
NButton, NButton,
{ {
text: true, bordered: false,
ghost: true,
size: "small", size: "small",
style: "width: 100%", onClick: () => localeCache.value == 'zh' ? changeLocale('en') : changeLocale('zh')
onClick: () => {
localeCache.value == 'zh' ? changeLocale('en') : changeLocale('zh');
showMobileMenu.value = false;
}
}, },
{ {
default: () => localeCache.value == 'zh' ? "English" : "中文", default: () => localeCache.value == 'zh' ? "English" : "中文",
@@ -163,9 +264,9 @@ const menuOptions = computed(() => [
label: () => h( label: () => h(
NButton, NButton,
{ {
text: true, bordered: !isMobile.value,
ghost: true,
size: "small", size: "small",
style: "width: 100%",
tag: "a", tag: "a",
target: "_blank", target: "_blank",
href: "https://github.com/dreamhunter2333/cloudflare_temp_email", href: "https://github.com/dreamhunter2333/cloudflare_temp_email",
@@ -179,47 +280,189 @@ const menuOptions = computed(() => [
} }
]); ]);
const menuOptionsMobile = computed(() => [
{
label: t('menu'),
icon: () => h(
NIcon,
{
component: MenuFilled
}
),
key: "menu",
children: menuOptions.value
},
]);
const copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
}
}
const newEmail = async () => {
try {
const res = await api.fetch(
`/api/new_address`
+ `?name=${emailName.value || ''}`
+ `&domain=${emailDomain.value || ''}`
);
jwt.value = res["jwt"];
await api.getSettings();
showNewEmail.value = false;
showPassword.value = true;
} catch (error) {
message.error(error.message || "error");
}
};
const deleteAccount = async () => {
try {
await api.fetch(`/api/delete_address`, {
method: 'DELETE'
});
jwt.value = '';
location.reload()
} catch (error) {
message.error(error.message || "error");
}
};
onMounted(async () => { onMounted(async () => {
await api.getOpenSettings(message); await api.getOpenSettings(message);
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
}); });
</script> </script>
<template> <template>
<div> <div>
<n-page-header> <n-layout-header>
<template #title> <h2 style="display: inline-block; margin-left: 10px;">{{ t('title') }}</h2>
<h3>{{ t('title') }}</h3> <div>
</template> <n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" />
<template #avatar> <n-menu v-else mode="horizontal" :options="menuOptionsMobile" />
<n-avatar style="margin-left: 10px;" src="/logo.png" /> </div>
</template> </n-layout-header>
<template #extra> <div v-if="!isAdminRoute">
<n-space> <n-card v-if="!settings.fetched">
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" responsive /> <n-skeleton style="height: 50vh" />
<n-button v-else :text="true" @click="showMobileMenu = !showMobileMenu" style="margin-right: 10px;"> </n-card>
<template #icon> <n-alert v-else-if="settings.address" type="info" show-icon>
<n-icon :component="MenuFilled" /> <span>
</template> <b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
{{ t('menu') }} <n-button style="margin-left: 10px" @click="copy" size="small" tertiary round type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button> </n-button>
</n-space> </span>
</n-alert>
<n-card v-else>
<n-result status="info" :description="t('pleaseGetNewEmail')">
<template #footer>
<n-alert v-if="jwt" type="warning" show-icon>
<span>{{ t('fetchAddressError') }}</span>
</n-alert>
<n-button @click="showLogin = true" tertiary round type="primary">
{{ t('login') }}
</n-button>
<n-button @click="showNewEmail = true" tertiary round type="primary">
{{ t('getNewEmail') }}
</n-button>
</template>
</n-result>
</n-card>
</div>
<n-modal v-model:show="showNewEmail" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('getNewEmail') }}</div>
</template> </template>
</n-page-header> <span>
<n-drawer v-model:show="showMobileMenu" placement="top" style="height: 100vh;"> <p>{{ t("getNewEmailTip1") }}</p>
<n-drawer-content :title="t('menu')" closable> <p>{{ t("getNewEmailTip2") }}</p>
<n-menu :options="menuOptions" /> </span>
</n-drawer-content> <n-input-group>
</n-drawer> <n-input-group-label v-if="openSettings.prefix">
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog" {{ openSettings.prefix }}
:title="t('accessHeader')"> </n-input-group-label>
<p>{{ t('accessTip') }}</p> <n-input v-model:value="emailName" />
<n-input v-model:value="auth" type="textarea" :autosize="{ minRows: 3 }" /> <n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false" :options="openSettings.domains" />
</n-input-group>
<template #action> <template #action>
<n-button :loading="loading" @click="authFunc" type="primary"> <n-button @click="showNewEmail = false">
{{ t('cancel') }}
</n-button>
<n-button @click="newEmail" type="primary">
{{ t('ok') }} {{ t('ok') }}
</n-button> </n-button>
</template> </template>
</n-modal> </n-modal>
<n-modal v-model:show="showPassword" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("password") }}</div>
</template>
<span>
<p>{{ t("passwordTip") }}</p>
</span>
<n-card>
<b>{{ jwt }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showLogin" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('login') }}</div>
</template>
<n-input v-model:value="password" type="textarea" :autosize="{
minRows: 3
}" />
<template #action>
<n-button @click="login" size="small" tertiary round type="primary">
{{ t('login') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showLogout" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('logout') }}</div>
</template>
<p>{{ t('logoutConfirm') }}</p>
<template #action>
<n-button @click="logout" size="small" tertiary round type="primary">
{{ t('logout') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('delteAccount') }}</div>
</template>
<p>{{ t('delteAccountConfirm') }}</p>
<template #action>
<n-button @click="deleteAccount" size="small" tertiary round type="error">
{{ t('delteAccount') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
title="Dialog">
<template #header>
<div>{{ t('auth') }}</div>
</template>
<p>{{ t('authTip') }}</p>
<n-input v-model:value="auth" type="textarea" :autosize="{
minRows: 3
}" />
<template #action>
<n-button @click="authFunc" size="small" tertiary round type="primary">
{{ t('auth') }}
</n-button>
</template>
</n-modal>
</div> </div>
</template> </template>
@@ -239,16 +482,4 @@ onMounted(async () => {
.n-card { .n-card {
margin-top: 10px; margin-top: 10px;
} }
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
.n-form .n-button {
margin-top: 10px;
}
</style> </style>

View File

@@ -1,67 +1,304 @@
<script setup> <script setup>
import { watch, onMounted, ref } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store' import { useGlobalState } from '../store'
import { api } from '../api' import { api } from '../api'
import { CloudDownloadRound } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import AddressBar from './index/AddressBar.vue'; const message = useMessage()
import MailBox from '../components/MailBox.vue'; const isMobile = useIsMobile()
import AutoReply from './index/AutoReply.vue';
import SendBox from './index/SendBox.vue';
import SendMail from './index/SendMail.vue';
import AccountSettings from './index/AccountSettings.vue';
const { localeCache, settings, openSettings, indexTab, globalTabplacement } = useGlobalState() const { settings, themeSwitch } = useGlobalState()
const autoRefresh = ref(false)
const data = ref([])
const timer = ref(null)
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
const curMail = ref(null);
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh', locale: 'zh',
messages: { messages: {
en: { en: {
mailbox: 'Mail Box', autoRefresh: 'Auto Refresh',
sendbox: 'Send Box', refresh: 'Refresh',
sendmail: 'Send Mail', attachments: 'Show Attachments',
auto_reply: 'Auto Reply', pleaseSelectMail: "Please select a mail to view."
accountSettings: 'Account Settings',
}, },
zh: { zh: {
mailbox: '收件箱', autoRefresh: '自动刷新',
sendbox: '发件箱', refresh: '刷新',
sendmail: '发送邮件', attachments: '查看附件',
auto_reply: '自动回复', pleaseSelectMail: "请选择一封邮件查看。"
accountSettings: '账户设置',
} }
} }
}); });
const fetchMailData = async (limit, offset) => { const setupAutoRefresh = async (autoRefresh) => {
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`); if (autoRefresh) {
timer.value = setInterval(async () => {
await refresh();
}, 30000)
} else {
clearInterval(timer.value)
timer.value = null
}
}
watch(autoRefresh, async (autoRefresh, old) => {
setupAutoRefresh(autoRefresh)
})
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
await refresh();
}
})
const refresh = async () => {
if (typeof settings.value.address != 'string' || settings.value.address.trim() === '') {
return;
}
try {
const { results, count: totalCount } = await api.fetch(
`/api/v1/mails`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
);
data.value = results;
if (totalCount > 0) {
count.value = totalCount;
}
if (!isMobile.value && !curMail.value && data.value.length > 0) {
curMail.value = results[0];
}
} catch (error) {
message.error(error.message || "error");
console.error(error);
}
}; };
const deleteMail = async (curMailId) => { const clickRow = async (row) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' }); curMail.value = row;
}; };
const getAttachments = async (attachment_id) => {
try {
const res = await api.fetch(
`/api/v1/attachment/${attachment_id}`
);
curAttachments.value = res
.filter((item) => item?.content?.data)
.map((item) => {
return {
id: item.contentId || Math.random().toString(36).substring(2, 15),
filename: item.filename || "",
size: item.size,
url: URL.createObjectURL(
new Blob(
[new Uint8Array(item.content.data)],
{ type: item.contentType || 'application/octet-stream' }
))
}
});
showAttachments.value = true;
} catch (error) {
message.error(error.message || "error");
}
};
const mailItemClass = (row) => {
return curMail.value && row.id == curMail.value.id ? (themeSwitch.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
};
onMounted(async () => {
await api.getSettings();
await refresh();
});
</script> </script>
<template> <template>
<div> <div>
<AddressBar /> <n-layout v-if="settings.address">
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement"> <n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25" :default-size="0.25">
<n-tab-pane name="mailbox" :tab="t('mailbox')"> <template #1>
<MailBox :showEMailTo="false" :showReply="true" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" <div>
:fetchMailData="fetchMailData" :deleteMail="deleteMail" /> <div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
</n-tab-pane> <n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
<n-tab-pane name="sendbox" :tab="t('sendbox')"> </div>
<SendBox /> <n-switch v-model:value="autoRefresh" size="small">
</n-tab-pane> <template #checked>
<n-tab-pane name="sendmail" :tab="t('sendmail')"> {{ t('autoRefresh') }}
<SendMail /> </template>
</n-tab-pane> <template #unchecked>
<n-tab-pane name="accountSettings" :tab="t('accountSettings')"> {{ t('autoRefresh') }}
<AccountSettings /> </template></n-switch>
</n-tab-pane> <n-button class="center" @click="refresh" size="small" type="primary">
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')"> {{ t('refresh') }}
<AutoReply /> </n-button>
</n-tab-pane> </div>
</n-tabs> <div style="overflow: scroll; 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 class="center" :title="row.subject" style="overflow: scroll">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<div style="word-break: break-all; font-size: small;">
FROM: {{ row.source }}
</div>
</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: scroll;">
<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-button v-if="curMail.attachment_id" size="small" tertiary type="info"
@click="getAttachments(curMail.attachment_id)">
{{ t('attachments') }}
</n-button>
</n-space>
<div v-html="curMail.message" style="max-height: 100vh;"></div>
</n-card>
<n-card class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
<div class="left" v-else>
<div>
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<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">
<template #checked>
{{ t('autoRefresh') }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template></n-switch>
<n-button class="center" @click="refresh" size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
<div id="drawer-target" style="overflow: scroll; max-height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-thing class="center" :title="row.subject" style="overflow: scroll">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<div style="word-break: break-all; font-size: small;">
FROM: {{ row.source }}
</div>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
<n-drawer v-model:show="curMail" width="100%" :trap-focus="false" :block-scroll="false" to="#drawer-target">
<n-drawer-content :title="curMail.subject" closable>
<n-card style="overflow: scroll;">
<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-button v-if="curMail.attachment_id" size="small" tertiary type="info"
@click="getAttachments(curMail.attachment_id)">
{{ t('attachments') }}
</n-button>
</n-space>
<div v-html="curMail.message" style="max-height: 100vh;"></div>
</n-card>
</n-drawer-content>
</n-drawer>
</div>
</n-layout>
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<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>
</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>
<template #action>
</template>
</n-modal>
</div> </div>
</template> </template>
<style scoped>
.left {
overflow: scroll;
text-align: left;
}
.overlay {
width: 100%;
height: 100%;
z-index: 1000;
}
.overlay-dark-backgroud {
background-color: rgba(255, 255, 255, 0.1);
}
.overlay-light-backgroud {
background-color: rgba(0, 0, 0, 0.1);
}
.mail-item {
height: 100%;
}
</style>

View File

@@ -2,8 +2,9 @@
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useGlobalState } from '../../store' import Header from './Header.vue'
import { api } from '../../api' import { useGlobalState } from '../store'
import { api } from '../api'
const message = useMessage() const message = useMessage()
const sourcePrefix = ref("") const sourcePrefix = ref("")
@@ -41,22 +42,18 @@ const { t } = useI18n({
} }
}); });
const fetchData = async () => { const getSettings = async () => {
try { await api.getSettings()
const res = await api.fetch("/api/auto_reply") sourcePrefix.value = settings.value.auto_reply.source_prefix || ""
sourcePrefix.value = res.source_prefix || "" enableAutoReply.value = settings.value.auto_reply.enabled || false
enableAutoReply.value = res.enabled || false name.value = settings.value.auto_reply.name || ""
name.value = res.name || "" autoReplyMessage.value = settings.value.auto_reply.message || ""
autoReplyMessage.value = res.message || "" subject.value = settings.value.auto_reply.subject || ""
subject.value = res.subject || ""
} catch (error) {
message.error(error.message || "error");
}
} }
const saveData = async () => { const saveSettings = async () => {
try { try {
await api.fetch("/api/auto_reply", { await api.fetch("/api/settings", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
auto_reply: { auto_reply: {
@@ -75,7 +72,7 @@ const saveData = async () => {
} }
onMounted(async () => { onMounted(async () => {
await fetchData() await getSettings()
}) })
</script> </script>
@@ -83,7 +80,7 @@ onMounted(async () => {
<div class="center"> <div class="center">
<n-card v-if="settings.address" :title='t("settings")'> <n-card v-if="settings.address" :title='t("settings")'>
<div class="right"> <div class="right">
<n-button type="primary" @click="saveData">{{ t('save') }}</n-button> <n-button type="primary" @click="saveSettings">{{ t('save') }}</n-button>
</div> </div>
<div class="left"> <div class="left">
<n-form-item :label="t('enableAutoReply')" label-placement="left"> <n-form-item :label="t('enableAutoReply')" label-placement="left">

View File

@@ -1,48 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import AddressMangement from './user/AddressManagement.vue';
import UserSettingsPage from './user/UserSettings.vue';
import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue';
const {
localeCache, userTab, globalTabplacement, userSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address_management: 'Address Management',
user_settings: 'User Settings',
bind_address: 'Bind Mail Address',
},
zh: {
address_management: '地址管理',
user_settings: '用户设置',
bind_address: '绑定邮箱地址',
}
}
});
</script>
<template>
<div>
<UserBar />
<n-tabs v-if="userSettings.user_email" type="card" v-model:value="userTab" :placement="globalTabplacement">
<n-tab-pane name="address_management" :tab="t('address_management')">
<AddressMangement />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettingsPage />
</n-tab-pane>
<n-tab-pane name="bind_address" :tab="t('bind_address')">
<BindAddress />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

@@ -1,309 +0,0 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { NBadge } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
localeCache, adminAuth, showAdminAuth, loading,
adminTab, adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
name: 'Name',
created_at: 'Created At',
updated_at: 'Update At',
mail_count: 'Mail Count',
send_count: 'Send Count',
showCredential: 'Show Mail Address Credential',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
delete: 'Delete',
deleteTip: 'Are you sure to delete this email?',
delteAccount: 'Delete Account',
viewMails: 'View Mails',
viewSendBox: 'View SendBox',
itemCount: 'itemCount',
query: 'Query',
addressQueryTip: 'Leave blank to query all addresses',
actions: 'Actions'
},
zh: {
name: '名称',
created_at: '创建时间',
updated_at: '更新时间',
mail_count: '邮件数量',
send_count: '发送数量',
showCredential: '查看邮箱地址凭证',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
delteAccount: '删除邮箱',
viewMails: '查看邮件',
viewSendBox: '查看发件箱',
itemCount: '总数',
query: '查询',
addressQueryTip: '留空查询所有地址',
actions: '操作',
}
}
});
const showEmailCredential = ref(false)
const curEmailCredential = ref("")
const curDeleteAddressId = ref(0);
const addressQuery = ref("")
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showDeleteAccount = ref(false)
const showCredential = async (id) => {
try {
curEmailCredential.value = await api.adminShowAddressCredential(id)
showEmailCredential.value = true
} catch (error) {
message.error(error.message || "error");
showEmailCredential.value = false
curEmailCredential.value = ""
}
}
const deleteEmail = async () => {
try {
await api.adminDeleteAddress(curDeleteAddressId.value)
message.success("success");
await fetchData()
} catch (error) {
message.error(error.message || "error");
} finally {
showDeleteAccount.value = false
}
}
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/admin/address`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('name'),
key: "name"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('updated_at'),
key: "updated_at"
},
{
title: t('mail_count'),
key: "mail_count",
render(row) {
return h(NButton,
{
text: true,
onClick: () => {
if (row.mail_count > 0) {
adminMailTabAddress.value = row.name;
adminTab.value = "mails";
}
}
},
{
icon: () => h(NBadge, {
value: row.mail_count,
'show-zero': true,
max: 99,
type: "success"
}),
default: () => row.mail_count > 0 ? t('viewMails') : ""
}
)
}
},
{
title: t('send_count'),
key: "send_count",
render(row) {
return h(NButton,
{
text: true,
onClick: () => {
if (row.send_count > 0) {
adminSendBoxTabAddress.value = row.name;
adminTab.value = "sendBox";
}
}
},
{
icon: () => h(NBadge, {
value: row.send_count,
'show-zero': true,
max: 99,
type: "success"
}),
default: () => row.send_count > 0 ? t('viewSendBox') : ""
}
)
}
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
h(NMenu, {
mode: "horizontal",
options: [
{
label: t('actions'),
icon: () => h(MenuFilled),
key: "action",
children: [
{
label: () => h(NButton,
{
text: true,
onClick: () => showCredential(row.id)
},
{ default: () => t('showCredential') }
),
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
adminMailTabAddress.value = row.name;
adminTab.value = "mails";
}
},
{ default: () => t('viewMails') }
)
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
adminSendBoxTabAddress.value = row.name;
adminTab.value = "sendBox";
}
},
{ default: () => t('viewSendBox') }
)
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curDeleteAddressId.value = row.id;
showDeleteAccount.value = true;
}
},
{ default: () => t('delete') }
)
}
]
}
]
})
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("addressCredential") }}</div>
</template>
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card>
<b>{{ curEmailCredential }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('delteAccount')">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
{{ t('delteAccount') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -1,91 +0,0 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
save: 'Save',
successTip: 'Save Success',
address_block_list: 'Address Block Keywords for Users(Admin can skip)',
address_block_list_placeholder: 'Please enter the keywords you want to block',
send_address_block_list: 'Address Block Keywords for send email',
},
zh: {
save: '保存',
successTip: '保存成功',
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
send_address_block_list: '发送邮件地址屏蔽关键词',
}
}
});
const addressBlockList = ref([])
const sendAddressBlockList = ref([])
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/account_settings`)
addressBlockList.value = res.blockList || []
sendAddressBlockList.value = res.sendBlockList || []
} catch (error) {
message.error(error.message || "error");
}
}
const save = async () => {
try {
await api.fetch(`/admin/account_settings`, {
method: 'POST',
body: JSON.stringify({
blockList: addressBlockList.value || [],
sendBlockList: sendAddressBlockList.value || []
})
})
message.success(t('successTip'))
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card 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')" />
</n-form-item-row>
<n-form-item-row :label="t('send_address_block_list')">
<n-select v-model:value="sendAddressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" />
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

View File

@@ -1,109 +0,0 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const {
localeCache, loading, openSettings,
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
enablePrefix: 'If enable Prefix',
creatNewEmail: 'Get New Email',
fillInAllFields: 'Please fill in all fields',
successTip: 'Success Created',
addressCredential: 'Mail Address Credential',
},
zh: {
address: '地址',
enablePrefix: '是否启用前缀',
creatNewEmail: '创建新邮箱',
fillInAllFields: '请填写完整信息',
successTip: '创建成功',
addressCredential: '邮箱地址凭证',
}
}
});
const enablePrefix = ref(true)
const emailName = ref("")
const emailDomain = ref("")
const showReultModal = ref(false)
const result = ref("")
const newEmail = async () => {
if (!emailName.value || !emailDomain.value) {
message.error(t('fillInAllFields'))
return
}
try {
const res = await api.fetch(`/admin/new_address`, {
method: 'POST',
body: JSON.stringify({
enablePrefix: enablePrefix.value,
name: emailName.value,
domain: emailDomain.value,
})
})
result.value = res["jwt"];
message.success(t('successTip'))
showReultModal.value = true
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
if (openSettings.prefix) {
enablePrefix.value = true
}
emailDomain.value = openSettings.value.domains?.[0]?.value || ""
})
</script>
<template>
<div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
<p>{{ t('addressCredential') }}</p>
<n-card>
<b>{{ result }}</b>
</n-card>
</n-modal>
<n-card 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>
<n-form-item-row :label="t('address')">
<n-input-group>
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
</n-form-item-row>
<n-button @click="newEmail" type="primary" block :loading="loading">
{{ t('creatNewEmail') }}
</n-button>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

View File

@@ -1,71 +0,0 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import MailBox from '../../components/MailBox.vue';
const {
localeCache, adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
addressQueryTip: 'Leave blank to query all addresses',
keywordQueryTip: 'Leave blank to not query by keyword',
query: 'Query',
},
zh: {
addressQueryTip: '留空查询所有地址',
keywordQueryTip: '留空不按关键字查询',
query: '查询',
}
}
});
const mailBoxKey = ref("")
const mailKeyword = ref("")
watch([adminMailTabAddress, mailKeyword], () => {
adminMailTabAddress.value = adminMailTabAddress.value.trim();
mailKeyword.value = mailKeyword.value.trim();
});
const queryMail = () => {
mailBoxKey.value = Date.now();
}
const fetchMailData = async (limit, offset) => {
return await api.fetch(
`/admin/mails`
+ `?limit=${limit}`
+ `&offset=${offset}`
+ (adminMailTabAddress.value ? `&address=${adminMailTabAddress.value}` : '')
+ (mailKeyword.value ? `&keyword=${mailKeyword.value}` : '')
);
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script>
<template>
<div>
<n-input-group>
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" />
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" />
<n-button @click="queryMail" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="false" :fetchMailData="fetchMailData" />
</div>
</template>

View File

@@ -1,30 +0,0 @@
<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`
+ `?limit=${limit}`
+ `&offset=${offset}`
);
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script>
<template>
<div v-if="adminAuth">
<MailBox :enableUserDeleteEmail="false" :fetchMailData="fetchMailUnknowData" />
</div>
</template>

View File

@@ -1,178 +0,0 @@
<script setup>
import { ref, h, onMounted, watch } 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,
cleanMailsDays: 30,
enableUnknowMailsAutoCleanup: false,
cleanUnknowMailsDays: 30,
enableAddressAutoCleanup: false,
cleanAddressDays: 30,
enableSendBoxAutoCleanup: false,
cleanSendBoxDays: 30,
})
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",
addressUnActiveLabel: "Clean up days for unactive address",
sendBoxLabel: "Clean up days for sendbox",
cleanupNow: "Cleanup now",
autoCleanup: "Auto cleanup",
cleanupSuccess: "Cleanup success",
save: "Save",
cronTip: "Enable cron cleanup, need to configure [crons] in worker, please refer to the document",
},
zh: {
tip: '请输入清理天数',
mailBoxLabel: '收件箱清理天数',
mailUnknowLabel: "无收件人邮件清理天数",
addressUnActiveLabel: "未活跃地址清理天数",
sendBoxLabel: "发件箱清理天数",
autoCleanup: "自动清理",
cleanupSuccess: "清理成功",
cleanupNow: "立即清理",
save: "保存",
cronTip: "启用定时清理, 需在 worker 配置 [crons] 参数, 请参考文档",
}
}
});
const cleanup = async (cleanType, cleanDays) => {
try {
await api.fetch('/admin/cleanup', {
method: 'POST',
body: JSON.stringify({ cleanType, cleanDays })
});
message.success(t('cleanupSuccess'));
} catch (error) {
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
const res = await api.fetch('/admin/auto_cleanup');
if (res) Object.assign(cleanupModel.value, res);
} catch (error) {
message.error(error.message || "error");
}
}
const save = async () => {
try {
await api.fetch('/admin/auto_cleanup', {
method: 'POST',
body: JSON.stringify(cleanupModel.value)
});
message.success(t('cleanupSuccess'));
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData();
})
</script>
<template>
<div class="center">
<n-card>
<n-alert :show-icon="false">
<span>{{ t('cronTip') }}</span>
</n-alert>
<n-form :model="cleanupModel">
<n-form-item-row :label="t('mailBoxLabel')">
<n-checkbox v-model:checked="cleanupModel.enableMailsAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails', cleanupModel.cleanMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('mailUnknowLabel')">
<n-checkbox v-model:checked="cleanupModel.enableUnknowMailsAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanUnknowMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails_unknow', cleanupModel.cleanUnknowMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('addressUnActiveLabel')">
<n-checkbox v-model:checked="cleanupModel.enableAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('address', cleanupModel.cleanAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('mailBoxLabel')">
<n-checkbox v-model:checked="cleanupModel.enableSendBoxAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanSendBoxDays" :placeholder="t('tip')" />
<n-button @click="cleanup('sendbox', cleanupModel.cleanSendBoxDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>
</n-form>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
.n-alert {
margin-bottom: 20px;
}
.item {
display: flex;
margin: 10px;
}
</style>

View File

@@ -1,159 +0,0 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, adminAuth, adminSendBoxTabAddress, showAdminAuth } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
success: 'Success',
to_mail: 'To Mail',
subject: 'Subject',
created_at: 'Created At',
action: 'Action',
query: 'Query',
itemCount: 'itemCount',
view: 'View',
},
zh: {
address: '地址',
success: '成功',
to_mail: '收件人邮箱',
subject: '主题',
created_at: '创建时间',
action: '操作',
query: '查询',
itemCount: '总数',
view: '查看',
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curRow = ref({})
const showModal = ref(false)
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/admin/sendbox`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
);
data.value = results.map((item) => {
try {
const data = JSON.parse(item.raw);
item.to_mail = data?.personalizations?.map(
(p) => p.to?.map((t) => t.email).join(',')
).join(';');
item.subject = data.subject;
item.raw = JSON.stringify(data, null, 2);
} catch (error) {
console.log(error);
}
return item;
});
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('address'),
key: "address"
},
{
title: t('to_mail'),
key: "to_mail"
},
{
title: t('subject'),
key: "subject"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
tertiary: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
}
},
{ default: () => t('view') }
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
</n-modal>
<n-input-group>
<n-input v-model:value="adminSendBoxTabAddress" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -1,196 +0,0 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
success: 'Success',
is_enabled: 'Is Enabled',
enable: 'Enable',
disable: 'Disable',
modify: 'Modify',
created_at: 'Created At',
action: 'Action',
itemCount: 'itemCount',
modalTip: 'Please input the sender balance',
balance: 'Balance',
query: 'Query',
ok: 'OK'
},
zh: {
address: '地址',
success: '成功',
is_enabled: '是否启用',
enable: '启用',
disable: '禁用',
modify: '修改',
created_at: '创建时间',
action: '操作',
itemCount: '总数',
modalTip: '请输入发件额度',
balance: '余额',
query: '查询',
ok: '确定'
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curRow = ref({})
const showModal = ref(false)
const senderBalance = ref(0)
const senderEnabled = ref(false)
const addressQuery = ref('')
const updateData = async () => {
try {
await api.fetch(`/admin/address_sender`, {
method: 'POST',
body: JSON.stringify({
address: curRow.value.address,
address_id: curRow.value.id,
balance: senderBalance.value,
enabled: senderEnabled.value ? 1 : 0
})
});
showModal.value = false;
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/admin/address_sender`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (addressQuery.value ? `&address=${addressQuery.value}` : '')
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('address'),
key: "address"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('balance'),
key: "balance"
},
{
title: t('is_enabled'),
key: "enabled",
render(row) {
return h('div', [
h('span', row.enabled ? t('enable') : t('disable'))
])
}
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
tertiary: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
senderEnabled.value = row.enabled ? true : false;
senderBalance.value = row.balance;
}
},
{ default: () => t('modify') }
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showModal" preset="dialog">
<p>{{ curRow.address }}</p>
<p>{{ t('modalTip') }}</p>
<n-form-item :show-label="false">
<n-checkbox v-model:checked="senderEnabled">
{{ t('enable') }}
</n-checkbox>
</n-form-item>
<n-form-item :show-label="false">
<n-input-number v-model:value="senderBalance" :min="0" :max="1000" />
</n-form-item>
<template #action>
<n-button :loading="loading" @click="updateData()" size="small" tertiary type="primary">
{{ t('ok') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -1,94 +0,0 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
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',
mailCount: 'Mail Count',
sendMailCount: 'Send Mail Count'
},
zh: {
userCount: '地址总数',
activeUser: '周活跃邮箱地址',
mailCount: '邮件总数',
sendMailCount: '发送邮件总数'
}
}
});
const statistics = ref({
userCount: 0,
mailCount: 0,
activeUserCount7days: 0,
sendMailCount: 0,
})
const fetchStatistics = async () => {
try {
const {
userCount, activeUserCount7days, mailCount, sendMailCount
} = 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;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
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>
</template>

View File

@@ -1,289 +0,0 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { NMenu, NButton, NBadge } 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 message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'Success',
user_email: 'User Email',
address_count: 'Address Count',
created_at: 'Created At',
actions: 'Actions',
query: 'Query',
itemCount: 'itemCount',
deleteUser: 'Delete User',
delete: 'Delete',
deleteUserTip: 'Are you sure you want to delete this user?',
resetPassword: 'Reset Password',
pleaseInput: 'Please input complete information',
createUser: 'Create User',
email: 'Email',
password: 'Password',
},
zh: {
success: '成功',
user_email: '用户邮箱',
address_count: '地址数量',
created_at: '创建时间',
actions: '操作',
query: '查询',
itemCount: '总数',
deleteUser: '删除用户',
delete: '删除',
deleteUserTip: '确定要删除此用户吗?',
resetPassword: '重置密码',
pleaseInput: '请输入完整信息',
createUser: '创建用户',
email: '邮箱',
password: '密码',
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const userQuery = ref('')
const showResetPassword = ref(false)
const newResetPassword = ref('')
const showDeleteUser = ref(false)
const curUserId = ref(0)
const showCreateUser = ref(false)
const user = ref({
email: "",
password: ""
})
const fetchData = async () => {
try {
const { results, count: userCount } = await api.fetch(
`/admin/users`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (userQuery.value ? `&query=${userQuery.value}` : '')
);
data.value = results;
if (userCount > 0) {
count.value = userCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const resetPassword = async () => {
if (!newResetPassword.value) {
message.error(t('pleaseInput'));
return;
}
try {
await api.fetch(`/admin/users/${curUserId.value}/reset_password`, {
method: "POST",
body: JSON.stringify({
password: await hashPassword(newResetPassword.value)
})
});
message.success(t('success'));
showResetPassword.value = false;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const createUser = async () => {
if (!user.value.email || !user.value.password) {
message.error(t('pleaseInput'));
return;
}
try {
await api.fetch(`/admin/users`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
password: await hashPassword(user.value.password)
})
});
message.success(t('success'));
await fetchData();
showCreateUser.value = false;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const deleteUser = async () => {
try {
await api.fetch(`/admin/users/${curUserId.value}`, {
method: "DELETE"
});
message.success(t('success'));
showDeleteUser.value = false;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('user_email'),
key: "user_email"
},
{
title: t('address_count'),
key: "address_count",
render(row) {
return h(NBadge, {
value: row.address_count,
'show-zero': true,
max: 99,
type: "success"
})
}
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
h(NMenu, {
mode: "horizontal",
options: [
{
label: t('actions'),
icon: () => h(MenuFilled),
key: "action",
children: [
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curUserId.value = row.id;
newResetPassword.value = '';
showResetPassword.value = true;
}
},
{ default: () => t('resetPassword') }
),
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curUserId.value = row.id;
user.value.email = '';
user.value.password = '';
showDeleteUser.value = true;
}
},
{ default: () => t('delete') }
)
}
]
}
]
})
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showCreateUser" preset="dialog" :title="t('createUser')">
<n-form>
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
</n-form>
<template #action>
<n-button :loading="loading" @click="createUser" size="small" tertiary type="primary">
{{ t('createUser') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="newResetPassword" type="password" show-password-on="click" />
</n-form-item-row>
<template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="primary">
{{ t('resetPassword') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showDeleteUser" preset="dialog" :title="t('deleteUser')">
<p>{{ t('deleteUserTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteUser" size="small" tertiary type="error">
{{ t('deleteUser') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="userQuery" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
<template #suffix>
<n-button @click="showCreateUser = true" size="small" tertiary type="primary"
style="margin-left: 10px">
{{ t('createUser') }}
</n-button>
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -1,130 +0,0 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
save: 'Save',
successTip: 'Save Success',
enable: 'Enable',
enableUserRegister: 'Allow User Register',
enableMailVerify: 'Enable Mail Verify (Send address must be an address in the system with a balance and can send mail normally)',
verifyMailSender: 'Verify Mail Sender',
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
mailAllowList: 'Mail Address Allow List',
maxAddressCount: 'Maximum number of email addresses that can be binded',
},
zh: {
save: '保存',
successTip: '保存成功',
enable: '启用',
enableUserRegister: "允许用户注册",
enableMailVerify: '启用邮件验证(发送地址必须是系统中能有余额且能正常发送邮件的地址)',
verifyMailSender: '验证邮件发送地址',
enableMailAllowList: '启用邮件地址白名单(可手动输入)',
mailAllowList: '邮件地址白名单',
maxAddressCount: '可绑定最大邮箱地址数量',
}
}
});
const commonMail = [
"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com",
"icloud.com", "yahoo.com", "foxmail.com"
]
const mailAllowOptions = commonMail.map((item) => {
return { label: item, value: item }
})
const userSettings = ref({
enable: false,
enableMailVerify: false,
verifyMailSender: "",
enableMailAllowList: false,
mailAllowList: commonMail,
maxAddressCount: 5,
});
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/user_settings`)
Object.assign(userSettings.value, res)
} catch (error) {
message.error(error.message || "error");
}
}
const save = async () => {
try {
await api.fetch(`/admin/user_settings`, {
method: 'POST',
body: JSON.stringify(userSettings.value)
})
message.success(t('successTip'))
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card style="max-width: 600px;">
<n-form :model="userSettings">
<n-form-item-row :label="t('enableUserRegister')">
<n-checkbox v-model:checked="userSettings.enable" />
</n-form-item-row>
<n-form-item-row :label="t('enableMailVerify')">
<n-input-group>
<n-checkbox v-model:checked="userSettings.enableMailVerify" style="width: 20%;">
{{ t('enable') }}
</n-checkbox>
<n-input v-model:value="userSettings.verifyMailSender" style="width: 80%;"
:placeholder="t('verifyMailSender')" />
</n-input-group>
</n-form-item-row>
<n-form-item-row :label="t('enableMailAllowList')">
<n-input-group>
<n-checkbox v-model:checked="userSettings.enableMailAllowList" style="width: 20%;">
{{ t('enable') }}
</n-checkbox>
<n-select v-model:value="userSettings.mailAllowList" filterable multiple tag style="width: 80%;"
:options="mailAllowOptions" :placeholder="t('mailAllowList')" />
</n-input-group>
</n-form-item-row>
<n-form-item-row :label="t('maxAddressCount')">
<n-input-group>
<n-input-number v-model:value="userSettings.maxAddressCount"
:placeholder="t('maxAddressCount')" />
</n-input-group>
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>
</n-form>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -1,23 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
const { localeCache, openSettings } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
adminContact: 'If you need help, please contact the administrator ({msg})',
},
zh: {
adminContact: '如果你需要帮助,请联系管理员 ({msg})',
}
}
});
</script>
<template>
<n-alert v-if="openSettings.adminContact" :show-icon="false">
<span>{{ t('adminContact', { msg: openSettings.adminContact }) }}</span>
</n-alert>
</template>

View File

@@ -1,82 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
const {
localeCache, mailboxSplitSize, useIframeShowMail, preferShowTextMail,
globalTabplacement, useSideMargin
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
mailboxSplitSize: 'Mailbox Split Size',
useIframeShowMail: 'Use iframe Show HTML Mail',
preferShowTextMail: 'Display text Mail by default',
useSideMargin: 'Turn on the side margins on the left and right sides of the page',
globalTabplacement: 'Global Tab Placement',
left: 'left',
top: 'top',
right: 'right',
bottom: 'bottom',
},
zh: {
mailboxSplitSize: '邮箱界面分栏大小',
preferShowTextMail: '默认以文本显示邮件',
useIframeShowMail: '使用iframe显示HTML邮件',
globalTabplacement: '全局选项卡位置',
useSideMargin: '开启页面左右两侧侧边距',
left: '左侧',
top: '顶部',
right: '右侧',
bottom: '底部',
}
}
});
</script>
<template>
<div class="center">
<n-card>
<n-form-item-row :label="t('mailboxSplitSize')">
<n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{
0.25: '0.25',
0.5: '0.5',
0.75: '0.75'
}" />
</n-form-item-row>
<n-form-item-row :label="t('preferShowTextMail')">
<n-switch v-model:value="preferShowTextMail" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('useIframeShowMail')">
<n-switch v-model:value="useIframeShowMail" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('useSideMargin')">
<n-switch v-model:value="useSideMargin" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('globalTabplacement')">
<n-radio-group v-model:value="globalTabplacement">
<n-radio-button value="top" :label="t('top')" />
<n-radio-button value="left" :label="t('left')" />
<n-radio-button value="right" :label="t('right')" />
<n-radio-button value="bottom" :label="t('bottom')" />
</n-radio-group>
</n-form-item-row>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
justify-content: center;
}
.n-card {
max-width: 800px;
text-align: left;
}
</style>

View File

@@ -1,212 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
import AdminContact from '../common/AdminContact.vue'
import Turnstile from '../../components/Turnstile.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const message = useMessage()
const router = useRouter()
const {
jwt, localeCache, loading, openSettings,
showAddressCredential, userSettings
} = useGlobalState()
const tabValue = ref('signin')
const credential = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const login = async () => {
if (!credential.value) {
message.error(t('credentialInput'));
return;
}
try {
jwt.value = credential.value;
await api.getSettings();
try {
await api.bindUserAddress();
} catch (error) {
message.error(`${t('bindUserAddressError')}: ${error.message}`);
}
await router.push("/");
} catch (error) {
message.error(error.message || "error");
}
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
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',
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
credential: 'Email Address Credential',
ok: 'OK',
generateName: 'Generate Fake Name',
help: 'Help',
credentialInput: 'Please input the Mail Address Credential',
bindUserInfo: 'Logged in user, login without binding email or create new email address will bind to current user',
bindUserAddressError: 'Error when bind email address to user',
},
zh: {
login: '登录',
pleaseGetNewEmail: '请"登录"或点击 "注册新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '创建新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
credential: '邮箱地址凭据',
ok: '确定',
generateName: '生成随机名字',
help: '帮助',
credentialInput: '请输入邮箱地址凭据',
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
bindUserAddressError: '绑定邮箱地址到用户时错误',
}
}
});
const generateNameLoading = ref(false);
const generateName = async () => {
try {
generateNameLoading.value = true;
const { faker } = await import('https://esm.sh/@faker-js/faker');
emailName.value = faker.internet.email()
.split('@')[0]
.replace(/\s+/g, '.')
.replace(/\.{2,}/g, '.')
.replace(/[^a-zA-Z0-9.]/g, '')
.toLowerCase();
} catch (error) {
message.error(error.message || "error");
} finally {
generateNameLoading.value = false;
}
};
const newEmail = async () => {
try {
const res = await api.fetch(`/api/new_address`, {
method: "POST",
body: JSON.stringify({
name: emailName.value,
domain: emailDomain.value,
cf_token: cfToken.value,
}),
});
jwt.value = res["jwt"];
await api.getSettings();
await router.push("/");
showAddressCredential.value = true;
try {
await api.bindUserAddress();
} catch (error) {
message.error(`${t('bindUserAddressError')}: ${error.message}`);
}
} catch (error) {
message.error(error.message || "error");
}
};
onMounted(async () => {
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
await api.getOpenSettings();
}
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0]?.value : "";
});
</script>
<template>
<div>
<n-alert v-if="userSettings.user_email" :show-icon="false" closable>
<span>{{ t('bindUserInfo') }}</span>
</n-alert>
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tab-pane name="signin" :tab="t('login')">
<n-form>
<n-form-item-row :label="t('credential')" required>
<n-input v-model:value="credential" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-button @click="login" :loading="loading" type="primary" block secondary strong>
<template #icon>
<n-icon :component="EmailOutlined" />
</template>
{{ t('login') }}
</n-button>
<n-button v-if="openSettings.enableUserCreateEmail" @click="tabValue = 'register'" block secondary
strong>
<template #icon>
<n-icon :component="NewLabelOutlined" />
</template>
{{ t('getNewEmail') }}
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableUserCreateEmail" name="register" :tab="t('getNewEmail')">
<n-spin :show="generateNameLoading">
<n-form>
<span>
<p>{{ t("getNewEmailTip1") }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>
<n-button @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
<Turnstile v-model:value="cfToken" />
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
<template #icon>
<n-icon :component="NewLabelOutlined" />
</template>
{{ t('getNewEmail') }}
</n-button>
</n-form>
</n-spin>
</n-tab-pane>
<n-tab-pane name="help" :tab="t('help')">
<n-alert :show-icon="false">
<span>{{ t('pleaseGetNewEmail') }}</span>
</n-alert>
<AdminContact />
</n-tab-pane>
</n-tabs>
</div>
</template>
<style scoped>
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.n-form .n-button {
margin-top: 10px;
}
.n-form {
text-align: left;
}
</style>

View File

@@ -1,108 +0,0 @@
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import Appearance from '../common/Appearance.vue'
const {
jwt, localeCache, 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',
messages: {
en: {
logout: "Logout",
delteAccount: "Delete Account",
showAddressCredential: 'Show Address Credential',
logoutConfirm: 'Are you sure to logout?',
delteAccount: "Delete Account",
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
},
zh: {
logout: '退出登录',
delteAccount: "删除账户",
showAddressCredential: '查看邮箱地址凭证',
logoutConfirm: '确定要退出登录吗?',
delteAccount: "删除账户",
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
}
}
});
const logout = async () => {
jwt.value = '';
await router.push('/')
location.reload()
}
const deleteAccount = async () => {
try {
await api.fetch(`/api/delete_address`, {
method: 'DELETE'
});
jwt.value = '';
await router.push('/')
location.reload()
} catch (error) {
message.error(error.message || "error");
}
};
</script>
<template>
<div class="center" v-if="settings.address">
<n-card>
<Appearance />
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }}
</n-button>
<n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }}
</n-button>
<n-button @click="showDelteAccount = true" type="error" secondary block strong>
{{ t('delteAccount') }}
</n-button>
</n-card>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="logout" size="small" tertiary type="primary">
{{ t('logout') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
<p>{{ t('delteAccountConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
{{ t('delteAccount') }}
</n-button>
</template>
</n-modal>
</div>
</template>
<style scoped>
.center {
display: flex;
justify-content: center;
}
.n-card {
max-width: 800px;
text-align: left;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -1,128 +0,0 @@
<script setup>
import useClipboard from 'vue-clipboard3'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Copy, User } from '@vicons/fa'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import Login from '../common/Login.vue'
const { toClipboard } = useClipboard()
const message = useMessage()
const router = useRouter()
const {
jwt, localeCache, settings, showAddressCredential, openSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
yourAddress: 'Your email address is',
ok: 'OK',
copy: 'Copy',
copied: 'Copied',
fetchAddressError: 'Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
userLogin: 'User Login',
},
zh: {
yourAddress: '你的邮箱地址是',
ok: '确定',
copy: '复制',
copied: '已复制',
fetchAddressError: '邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
userLogin: '用户登录',
}
}
});
const copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
}
}
onMounted(async () => {
await api.getSettings();
});
</script>
<template>
<div>
<n-card v-if="!settings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<div v-else-if="settings.address">
<n-alert v-if="settings.has_v1_mails" type="warning" :show-icon="false" closable>
<span>
<n-button tag="a" target="_blank" tertiary type="info" size="small" href="https://mail-v1.awsl.uk">
<b>{{ t('mailV1Alert') }} </b>
</n-button>
</span>
</n-alert>
<n-alert type="info" :show-icon="false">
<span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</span>
</n-alert>
</div>
<div v-else class="center">
<n-card style="max-width: 600px;">
<n-alert v-if="jwt" type="warning" :show-icon="false" closable>
<span>{{ t('fetchAddressError') }}</span>
</n-alert>
<Login />
<n-divider />
<n-button @click="router.push('/user')" type="primary" block secondary strong>
<template #icon>
<n-icon :component="User" />
</template>
{{ t('userLogin') }}
</n-button>
</n-card>
</div>
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card>
<b>{{ jwt }}</b>
</n-card>
</n-modal>
</div>
</template>
<style scoped>
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.n-card {
margin-top: 10px;
}
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

View File

@@ -1,156 +0,0 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, settings } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
success: 'Success',
to_mail: 'To Mail',
subject: 'Subject',
created_at: 'Created At',
action: 'Action',
refresh: 'Refresh',
itemCount: 'itemCount',
view: 'View',
ok: 'OK'
},
zh: {
address: '地址',
success: '成功',
to_mail: '收件人邮箱',
subject: '主题',
created_at: '创建时间',
action: '操作',
refresh: '刷新',
itemCount: '总数',
view: '查看',
ok: '确定'
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curRow = ref({})
const showModal = ref(false)
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/api/sendbox`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
);
data.value = results.map((item) => {
try {
const data = JSON.parse(item.raw);
item.to_mail = data?.personalizations?.map(
(p) => p.to?.map((t) => t.email).join(',')
).join(';');
item.subject = data.subject;
item.raw = JSON.stringify(data, null, 2);
} catch (error) {
console.log(error);
}
return item;
});
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('address'),
key: "address"
},
{
title: t('to_mail'),
key: "to_mail"
},
{
title: t('subject'),
key: "subject"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
ghost: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
}
},
{ default: () => t('view') }
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div v-if="settings.address">
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
</n-modal>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
<template #suffix>
<n-button @click="fetchData" type="primary" size="small" tertiary>
{{ t('refresh') }}
</n-button>
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -1,238 +0,0 @@
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useI18n } from 'vue-i18n'
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
import AdminContact from '../common/AdminContact.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const message = useMessage()
const isPreview = ref(false)
const editorRef = shallowRef()
const { settings, sendMailModel, indexTab } = useGlobalState()
const { t } = useI18n({
locale: 'zh',
messages: {
en: {
successSend: 'Please check your sendbox. If failed, please check your balance or try again later.',
fromName: 'Your Name and Address, leave Name blank to use email address',
toName: 'Recipient Name and Address, leave Name blank to use email address',
subject: 'Subject',
options: 'Options',
edit: 'Edit',
preview: 'Preview',
content: 'Content',
send: 'Send',
requestAccess: 'Request Access',
requestAccessTip: 'You need to request access to send mail, if have request, please contact admin.',
send_balance: 'Send Mail Balance Left',
text: 'Text',
html: 'HTML',
'rich text': 'Rich Text',
tooLarge: 'Too large file, please upload file less than 1MB.',
},
zh: {
successSend: '请查看您的发件箱, 如果失败, 请检查您的余额或稍后重试。',
fromName: '你的名称和地址,名称不填写则使用邮箱地址',
toName: '收件人名称和地址,名称不填写则使用邮箱地址',
subject: '主题',
options: '选项',
edit: '编辑',
preview: '预览',
content: '内容',
send: '发送',
requestAccess: '申请权限',
requestAccessTip: '您需要申请权限才能发送邮件, 如果已经申请过, 请联系管理员提升额度。',
send_balance: '剩余发送邮件额度',
text: '文本',
html: 'HTML',
'rich text': '富文本',
tooLarge: '文件过大, 请上传小于1MB的文件。',
}
}
});
const contentTypes = [
{ label: t('text'), value: 'text' },
{ label: t('html'), value: 'html' },
{ label: t('rich text'), value: 'rich' },
]
const send = async () => {
try {
await api.fetch(`/api/send_mail`,
{
method: 'POST',
body:
JSON.stringify({
from_name: sendMailModel.value.fromName,
to_name: sendMailModel.value.toName,
to_mail: sendMailModel.value.toMail,
subject: sendMailModel.value.subject,
is_html: sendMailModel.value.contentType != 'text',
content: sendMailModel.value.content,
})
})
sendMailModel.value = {
fromName: "",
toName: "",
toMail: "",
subject: "",
contentType: 'text',
content: "",
}
} catch (error) {
message.error(error.message || "error");
} finally {
message.success(t("successSend"));
indexTab.value = 'sendbox'
}
}
const requestAccess = async () => {
try {
await api.fetch(`/api/requset_send_mail_access`,
{
method: 'POST',
body: JSON.stringify({})
}
)
message.success(t("success"))
await api.getSettings();
} catch (error) {
message.error(error.message || "error");
}
}
const toolbarConfig = {
excludeKeys: ["uploadVideo"]
}
const editorConfig = {
MENU_CONF: {
'uploadImage': {
async customUpload() {
message.error(t('tooLarge'))
},
maxFileSize: 1 * 1024 * 1024,
base64LimitSize: 1 * 1024 * 1024,
}
}
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor;
}
onMounted(async () => {
await api.getSettings();
})
</script>
<template>
<div class="center" v-if="settings.address">
<n-card>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<n-alert type="warning" :show-icon="false">
{{ t('requestAccessTip') }}
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
}}</n-button>
</n-alert>
<br />
<AdminContact />
</div>
<div v-else>
<n-alert type="info" :show-icon="false">
{{ t('send_balance') }}: {{ settings.send_balance }}
</n-alert>
<div class="right">
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
</div>
<div class="left">
<n-form :model="sendMailModel">
<n-form-item :label="t('fromName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.fromName" />
<n-input :value="settings.address" disabled />
</n-input-group>
</n-form-item>
<n-form-item :label="t('toName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.toName" />
<n-input v-model:value="sendMailModel.toMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('subject')" label-placement="top">
<n-input v-model:value="sendMailModel.subject" />
</n-form-item>
<n-form-item :label="t('options')" label-placement="top">
<n-radio-group v-model:value="sendMailModel.contentType">
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
:label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
style="margin-left: 10px;">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<n-card v-if="isPreview">
<div v-html="sendMailModel.content" />
</n-card>
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
:editor="editorRef" mode="default" />
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
</div>
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
minRows: 3
}" />
</n-form-item>
</n-form>
</div>
</div>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.n-button {
text-align: left;
margin-right: 10px;
}
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
.left {
text-align: left;
place-items: left;
justify-content: left;
}
.right {
text-align: right;
place-items: right;
justify-content: right;
}
</style>

View File

@@ -1,170 +0,0 @@
<script setup>
import { ref, h, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router';
import { NBadge, NPopconfirm, NButton } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, jwt } = useGlobalState()
const message = useMessage()
const router = useRouter()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'success',
name: 'Name',
mail_count: 'Mail Count',
send_count: 'Send Count',
actions: 'Actions',
changeMailAddress: 'Change Mail Address',
unbindAddress: 'Unbind Address',
unbindAddressTip: 'Before unbinding, please switch to this email address and save the email address credential.',
},
zh: {
success: '成功',
name: '名称',
mail_count: '邮件数量',
send_count: '发送数量',
actions: '操作',
changeMailAddress: '切换邮箱地址',
unbindAddress: '解绑地址',
unbindAddressTip: '解绑前请切换到此邮箱地址并保存邮箱地址凭证。',
}
}
});
const data = ref([])
const changeMailAddress = async (address_id) => {
try {
const res = await api.fetch(`/user_api/bind_address_jwt/${address_id}`);
message.success(t('changeMailAddress') + " " + t('success'));
if (!res.jwt) {
message.error("jwt not found");
return;
}
jwt.value = res.jwt;
await router.push('/');
location.reload();
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const unbindAddress = async (address_id) => {
try {
const res = await api.fetch(`/user_api/unbind_address`, {
method: 'POST',
body: JSON.stringify({ address_id })
});
message.success(t('unbindAddress') + " " + t('success'));
await fetchData();
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/user_api/bind_address`
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('name'),
key: "name"
},
{
title: t('mail_count'),
key: "mail_count",
render(row) {
return h(NBadge, {
value: row.mail_count,
'show-zero': true,
max: 99,
type: "success"
})
}
},
{
title: t('send_count'),
key: "send_count",
render(row) {
return h(NBadge, {
value: row.send_count,
'show-zero': true,
max: 99,
type: "success"
})
}
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
h(NPopconfirm,
{
onPositiveClick: () => changeMailAddress(row.id)
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "primary",
},
{ default: () => t('changeMailAddress') }
),
default: () => `${t('changeMailAddress')}?`
}
),
h(NPopconfirm,
{
onPositiveClick: () => unbindAddress(row.id)
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "error",
},
{ default: () => t('unbindAddress') }
),
default: () => t('unbindAddressTip')
}
),
])
}
}
]
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>

View File

@@ -1,46 +0,0 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import Login from '../common/Login.vue'
const { userJwt, localeCache, userSettings, } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
logout: 'Logout',
},
zh: {
logout: '退出登录',
}
}
});
const fetchData = async () => {
}
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div class="center" v-if="userSettings.user_email">
<n-card style="max-width: 600px;">
<Login />
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -1,76 +0,0 @@
<script setup>
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import UserLogin from './UserLogin.vue'
const message = useMessage()
const router = useRouter()
const {
localeCache, userSettings, userJwt, userOpenSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
currentUser: 'Current Login User',
fetchUserSettingsError: 'Login password is invalid or account not exist, it may be network connection issue, please try again later.',
},
zh: {
currentUser: '当前登录用户',
fetchUserSettingsError: '登录信息已过期或账号不存在,也可能是网络连接异常,请稍后再尝试。',
}
}
});
onMounted(async () => {
await api.getUserOpenSettings(message);
await api.getUserSettings(message);
});
</script>
<template>
<div>
<n-card 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">
<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>
<span>{{ t('fetchUserSettingsError') }}</span>
</n-alert>
<UserLogin />
</n-card>
</div>
</div>
</template>
<style scoped>
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

View File

@@ -1,248 +0,0 @@
<script setup>
import { useMessage } from 'naive-ui'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref } from "vue";
import { useI18n } from 'vue-i18n'
import { api } from '../../api';
import { useGlobalState } from '../../store'
import { hashPassword } from '../../utils';
import Turnstile from '../../components/Turnstile.vue';
const { userJwt, localeCache, userTab, userOpenSettings } = useGlobalState()
const message = useMessage();
const router = useRouter();
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
login: 'Login',
register: 'Register',
email: 'Email',
password: 'Password',
verifyCode: 'Verification Code',
verifyCodeSent: 'Verification Code Sent, expires in {timeout} seconds',
waitforVerifyCode: 'Wait for {timeout} seconds',
sendVerificationCode: 'Send Verification Code',
forgotPassword: 'Forgot Password',
cannotForgotPassword: 'Mail verification is disabled or register is disabled, cannot reset password, please contact administrator',
resetPassword: 'Reset Password',
pleaseInput: 'Please input email and password',
pleaseInputEmail: 'Please input email',
pleaseInputCode: 'Please input code',
pleaseCompleteTurnstile: 'Please complete turnstile',
pleaseLogin: 'Please login',
},
zh: {
login: '登录',
register: '注册',
email: '邮箱',
password: '密码',
verifyCode: '验证码',
sendVerificationCode: '发送验证码',
verifyCodeSent: '验证码已发送, {timeout} 秒后失效',
waitforVerifyCode: '等待{timeout}秒',
forgotPassword: '忘记密码',
cannotForgotPassword: '未开启邮箱验证或未开启注册功能,无法重置密码,请联系管理员',
resetPassword: '重置密码',
pleaseInput: '请输入邮箱和密码',
pleaseInputEmail: '请输入邮箱',
pleaseInputCode: '请输入验证码',
pleaseCompleteTurnstile: '请完成人机验证',
pleaseLogin: '请登录',
}
}
});
const tabValue = ref("signin");
const showModal = ref(false);
const user = ref({
email: "",
password: "",
code: ""
});
const cfToken = ref("")
const emailLogin = async () => {
if (!user.value.email || !user.value.password) {
message.error(t('pleaseInput'));
return;
}
try {
const res = await api.fetch(`/user_api/login`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
// hash password
password: await hashPassword(user.value.password)
})
});
userJwt.value = res.jwt;
location.reload();
} catch (error) {
message.error(error.message || "login failed");
}
};
const verifyCodeExpire = ref(0);
const verifyCodeTimeout = ref(0);
const getVerifyCodeTimeout = () => {
if (!verifyCodeExpire.value || verifyCodeExpire.value < new Date().getTime()) return 0;
return Math.round((verifyCodeExpire.value - new Date().getTime()) / 1000);
};
const sendVerificationCode = async () => {
if (!user.value.email) {
message.error(t('pleaseInputEmail'));
return;
}
if (!cfToken.value && userOpenSettings.value.enableMailVerify) {
message.error(t('pleaseCompleteTurnstile'));
return;
}
try {
const res = await api.fetch(`/user_api/verify_code`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
cf_token: cfToken.value
})
});
if (res && res.expirationTtl) {
message.success(t('verifyCodeSent', { timeout: res.expirationTtl }));
verifyCodeExpire.value = new Date().getTime() + res.expirationTtl * 1000;
const intervalId = setInterval(() => {
verifyCodeTimeout.value = getVerifyCodeTimeout();
if (verifyCodeTimeout.value <= 0) {
clearInterval(intervalId);
verifyCodeTimeout.value = 0;
}
}, 1000);
}
} catch (error) {
message.error(error.message || "send verification code failed");
}
};
const emailSignup = async () => {
if (!user.value.email || !user.value.password) {
message.error(t('pleaseInput'));
return;
}
if (!user.value.code && userOpenSettings.value.enableMailVerify) {
message.error(t('pleaseInputCode'));
return;
}
try {
const res = await api.fetch(`/user_api/register`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
// hash password
password: await hashPassword(user.value.password),
code: user.value.code
}),
message: message
});
if (res) {
tabValue.value = "signin";
message.success(t('pleaseLogin'));
}
showModal.value = false;
} catch (error) {
message.error(error.message || "register failed");
}
};
onMounted(async () => {
});
</script>
<template>
<div class="center">
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tab-pane name="signin" :tab="t('login')">
<n-form>
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<n-button @click="emailLogin" type="primary" block secondary strong>
{{ t('login') }}
</n-button>
<n-button @click="showModal = true" type="info" quaternary size="tiny">
{{ t('forgotPassword') }}
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="userOpenSettings.enable" name="signup" :tab="t('register')">
<n-form>
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile v-if="userOpenSettings.enableMailVerify" v-model:value="cfToken" />
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
<n-input-group>
<n-input v-model:value="user.code" />
<n-button @click="sendVerificationCode" style="margin-bottom: 0" type="primary" ghost
:disabled="verifyCodeTimeout > 0">
{{ verifyCodeTimeout > 0 ? t('waitforVerifyCode', { timeout: verifyCodeTimeout })
: t('sendVerificationCode') }}
</n-button>
</n-input-group>
</n-form-item-row>
</n-form>
<n-button @click="emailSignup" type="primary" block secondary strong>
{{ t('register') }}
</n-button>
</n-tab-pane>
</n-tabs>
<n-modal v-model:show="showModal" style="max-width: 600px;" preset="card" :title="t('forgotPassword')">
<n-form v-if="userOpenSettings.enable && userOpenSettings.enableMailVerify">
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile v-model:value="cfToken" />
<n-form-item-row :label="t('verifyCode')" required>
<n-input-group>
<n-input v-model:value="user.code" />
<n-button @click="sendVerificationCode" style="margin-bottom: 0" type="primary" ghost
:disabled="verifyCodeTimeout > 0">
{{ verifyCodeTimeout > 0 ? t('waitforVerifyCode', { timeout: verifyCodeTimeout })
: t('sendVerificationCode') }}
</n-button>
</n-input-group>
</n-form-item-row>
<n-button @click="emailSignup" type="primary" block secondary strong>
{{ t('resetPassword') }}
</n-button>
</n-form>
<n-alert v-else :show-icon="false">
<span>
{{ t('cannotForgotPassword') }}
</span>
</n-alert>
</n-modal>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -1,83 +0,0 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { userJwt, localeCache, userSettings, } = useGlobalState()
const router = useRouter()
const message = useMessage()
const showLogout = ref(false)
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',
},
zh: {
logout: '退出登录',
logoutConfirm: '确定要退出登录吗?',
passordTip: '服务器只会接收到密码的哈希值,不会接收到明文密码,因此无法查看或者找回您的密码, 如果管理员启用了邮件验证您可以在无痕模式重置密码',
}
}
});
const logout = async () => {
userJwt.value = '';
location.reload()
}
const fetchData = async () => {
}
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div class="center" v-if="userSettings.user_email">
<n-card>
<n-alert :show-icon="false">
<span>
{{ t('passordTip') }}
</span>
</n-alert>
<n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }}
</n-button>
</n-card>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="logout" size="small" tertiary type="primary">
{{ t('logout') }}
</n-button>
</template>
</n-modal>
</div>
</template>
<style scoped>
.center {
display: flex;
justify-content: center;
}
.n-card {
max-width: 800px;
text-align: left;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -3,30 +3,19 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { splitVendorChunkPlugin } from 'vite';
import AutoImport from 'unplugin-auto-import/vite' import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
build: { build: {
outDir: './dist', outDir: '../dist',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('wangeditor')) {
return 'vendor-wangeditor';
}
}
}
}
}, },
plugins: [ plugins: [
vue(), vue(),
wasm(), splitVendorChunkPlugin(),
topLevelAwait(),
AutoImport({ AutoImport({
imports: [ imports: [
'vue', 'vue',

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1,2 +0,0 @@
proxy_url=https://temp-email-api.xxx.xxx
port=8025

View File

@@ -1,161 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
test*

View File

@@ -1,12 +0,0 @@
services:
smtp_proxy_server:
image: ghcr.io/dreamhunter2333/cloudflare_temp_email/smtp_proxy_server:latest
# build:
# context: .
# dockerfile: dockerfile
container_name: "smtp_proxy_server"
ports:
- "8025:8025"
environment:
- proxy_url=https://temp-email-api.xxx.xxx
- port=8025

View File

@@ -1,7 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt /requirements.txt
RUN python3 -m pip install -r /requirements.txt
COPY . /app
ENTRYPOINT [ "python3", "server.py" ]

View File

@@ -1,3 +0,0 @@
aiosmtpd==1.4.5
pydantic-settings==2.2.1
requests==2.31.0

View File

@@ -1,147 +0,0 @@
import asyncio
import logging
import email
import requests
from pydantic_settings import BaseSettings
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import SMTP, Session, Envelope, AuthResult, LoginPassword
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
class Settings(BaseSettings):
proxy_url: str = "http://localhost:8787"
port: int = 8025
class Config:
env_file = ".env"
class CustomSMTPHandler:
def authenticator(self, server, session, envelope, mechanism, auth_data):
fail_nothandled = AuthResult(success=False, handled=False)
if mechanism not in ("LOGIN", "PLAIN"):
_logger.warning(f"Unsupported mechanism {mechanism}")
return fail_nothandled
if not isinstance(auth_data, LoginPassword):
_logger.warning(f"Invalid auth data {auth_data}")
return fail_nothandled
return AuthResult(success=True, auth_data=auth_data)
async def handle_DATA(self, server: SMTP, session: Session, envelope: Envelope) -> str:
_logger.info(
f"handle_DATA from {envelope.mail_from} to {envelope.rcpt_tos}"
)
if not isinstance(session.auth_data, LoginPassword):
return '530 Authentication required'
if len(envelope.rcpt_tos) != 1:
return '500 Only one recipient allowed'
# Only one recipient allowed
to_mail = envelope.rcpt_tos[0]
# Parse email
msg = email.message_from_string(envelope.content)
content_list = []
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
payload = part.get_payload(decode=True)
if content_type not in ["text/plain", "text/html"]:
_logger.warning(f"Skipping {content_type}")
continue
if not payload:
continue
content_list.append({
"type": content_type,
"value": payload.decode()
})
elif msg.get_content_type() in ["text/plain", "text/html"] and msg.get_payload(decode=True):
content_list.append({
"type": msg.get_content_type(),
"value": msg.get_payload(decode=True).decode()
})
if not content_list:
return '500 Invalid content'
body = max(
content_list,
key=lambda x: (x["type"] == "text/html", len(x["value"]))
)
from_name, _ = email.utils.parseaddr(
str(email.header.make_header(
email.header.decode_header(msg['From'])
))
)
to_mail_map = {}
for to in str(email.header.make_header(
email.header.decode_header(msg['To'])
)).split(","):
tmp_to_name, tmp_to_mail = email.utils.parseaddr(to)
to_mail_map[tmp_to_mail] = tmp_to_name
_logger.info(f"Parsed mail from {from_name} to {to_mail_map}")
# Send mail
send_body = {
"token": session.auth_data.password.decode(),
"from_name": from_name,
"to_name": to_mail_map.get(to_mail),
"to_mail": to_mail,
"subject": str(email.header.make_header(
email.header.decode_header(msg['Subject'])
)),
"is_html": body["type"] == "text/html",
"content": body["value"],
}
_logger.info(f"Send mail {send_body}")
try:
res = requests.post(
f"{settings.proxy_url}/external/api/send_mail",
json=send_body, headers={
"Content-Type": "application/json"
}
)
if res.status_code != 200:
_logger.error(
"Failed to send mail "
f"code=[{res.status_code}] text=[{res.text}]"
)
return f'500 Internal server error code=[{res.status_code}] text=[{res.text}]'
except Exception as e:
_logger.error(e)
return '500 Internal server error'
return '250 OK'
settings = Settings()
handler = CustomSMTPHandler()
server = Controller(
handler,
hostname="",
port=settings.port,
auth_require_tls=False,
decode_data=True,
authenticator=handler.authenticator,
auth_exclude_mechanism=["DONT"]
)
async def start():
_logger.info(f"Starting server settings[{settings}]")
server.start()
if __name__ == "__main__":
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task = loop.create_task(start())
try:
loop.run_forever()
except KeyboardInterrupt:
_logger.info("Got KeyboardInterrupt, stopping")
server.stop()

View File

@@ -1,307 +0,0 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# Custom
dist/
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
#**/Properties/launchSettings.json
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/packages/*
# except build/, which is used as an MSBuild target.
!**/packages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/packages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Typescript v1 declaration files
typings/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
/coverage
/src/client/shared.ts
/src/node/shared.ts
*.log
.DS_Store
.vite_opt_cache
dist
node_modules
TODOs.md
.vscode
docs/.vitepress/cache/
docs/.vitepress/dist/
.idea/
*.zip

View File

@@ -1,40 +0,0 @@
import { defineConfig } from 'vitepress'
import { zh } from './zh'
import { en } from './en'
export default defineConfig({
title: "Temp Mail Doc",
lang: 'zh-CN',
lastUpdated: true,
locales: {
root: { label: '简体中文', ...zh },
en: { label: 'English', ...en }
},
head: [
['link', { rel: 'icon', type: 'image/png', href: '/logo.png' }],
['meta', { name: 'theme-color', content: '#5f67ee' }],
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:locale', content: 'Temp Mail Doc' }],
['meta', { property: 'og:title', content: 'Temp Mail Doc' }],
['meta', { property: 'og:site_name', content: 'Temp Mail' }],
['meta', { property: 'og:image', content: 'https://temp-mail-docs.awsl.uk/logo.png' }],
['meta', { property: 'og:url', content: 'https://temp-mail-docs.awsl.uk' }],
],
sitemap: {
hostname: 'https://temp-mail-docs.awsl.uk',
transformItems(items) {
return items.filter((item) => !item.url.includes('migration'))
}
},
themeConfig: {
logo: { src: '/logo.png', width: 24, height: 24 },
search: { provider: 'local' },
socialLinks: [
{
icon: 'github',
link: 'https://github.com/dreamhunter2333/cloudflare_temp_email'
}
]
}
})

View File

@@ -1,55 +0,0 @@
import { defineConfig, type DefaultTheme } from 'vitepress'
export const en = defineConfig({
title: "Temp Mail Doc",
lang: 'zh-Hans',
description: 'CloudFlare Free sending and receiving of temporary domain name mailboxes',
themeConfig: {
nav: nav(),
editLink: {
pattern: 'https://github.com/dreamhunter2333/cloudflare_temp_email/edit/main/vitepress-docs/docs/:path',
text: 'Edit this page on GitHub'
},
footer: {
message: 'Based on MIT license',
copyright: `Copyright © 2023-${new Date().getFullYear()} Dream Hunter`
},
}
})
function nav(): DefaultTheme.NavItem[] {
return [
{
text: 'Home',
link: '/en/',
},
{
text: 'Guide',
link: '/en/cli',
},
{
text: 'Service Status',
link: '/status',
},
{
text: 'Reference',
link: '/reference',
},
{
text: process.env.TAG_NAME || 'v0.2.2',
items: [
{
text: 'CHANGELOG',
link: 'https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/CHANGELOG.md'
},
{
text: 'Contribute',
link: 'https://github.com/dreamhunter2333/cloudflare_temp_email'
}
]
}
]
}

View File

@@ -1,149 +0,0 @@
import { defineConfig, type DefaultTheme } from 'vitepress'
export const zh = defineConfig({
title: "临时邮箱文档",
lang: 'zh-Hans',
description: 'CloudFlare 免费收发 临时域名邮箱',
themeConfig: {
nav: nav(),
sidebar: {
'/zh/guide/': { base: '/zh/guide/', items: sidebarGuide() },
},
editLink: {
pattern: 'https://github.com/dreamhunter2333/cloudflare_temp_email/edit/main/vitepress-docs/docs/:path',
text: '在 GitHub 上编辑此页面'
},
footer: {
message: '基于 MIT 许可发布',
copyright: `版权所有 © 2023-${new Date().getFullYear()} Dream Hunter`
},
docFooter: {
prev: '上一页',
next: '下一页'
},
outline: {
label: '页面导航'
},
lastUpdated: {
text: '最后更新于',
formatOptions: {
dateStyle: 'short',
timeStyle: 'medium'
}
},
langMenuLabel: '多语言',
returnToTopLabel: '回到顶部',
sidebarMenuLabel: '菜单',
darkModeSwitchLabel: '主题',
lightModeSwitchTitle: '切换到浅色模式',
darkModeSwitchTitle: '切换到深色模式'
}
})
function nav(): DefaultTheme.NavItem[] {
return [
{
text: '主页',
link: '/',
},
{
text: '指南',
link: '/zh/guide/quick-start',
activeMatch: '/zh/guide/'
},
{
text: '服务状态',
link: '/status',
},
{
text: '参考',
link: '/reference',
},
{
text: process.env.TAG_NAME || 'v0.2.2',
items: [
{
text: '更新日志',
link: 'https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/CHANGELOG.md'
},
{
text: '参与贡献',
link: 'https://github.com/dreamhunter2333/cloudflare_temp_email'
}
]
}
]
}
function sidebarGuide(): DefaultTheme.SidebarItem[] {
return [
{
text: '简介',
collapsed: false,
items: [
{ text: '什么是临时邮箱', link: 'what-is-temp-mail' },
{ text: 'Star History', link: 'star-history' },
{ text: '快速开始部署', link: 'quick-start' },
]
},
{
text: '通过命令行部署',
collapsed: false,
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' },
{ text: '配置发送邮件', link: 'config-send-mail' },
]
},
{
text: '通过用户界面部署',
collapsed: false,
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' },
{ text: '配置发送邮件', link: 'config-send-mail' },
]
},
{
text: '通过 Github Actions 部署',
collapsed: false,
items: [
{ text: '开发中', link: 'github-action' },
]
},
{
text: '附加功能',
collapsed: false,
items: [
{ text: '配置 SMTP 代理服务', link: 'feature/config-smtp-proxy' },
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
{ text: '查看邮件 API', link: 'feature/mail-api' },
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
]
},
{
text: '功能简介',
collapsed: false,
items: [
{ text: 'Admin 控制台', link: 'feature/admin' },
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },
]
},
{ text: '参考', base: "/", link: 'reference' }
]
}

View File

@@ -1,180 +0,0 @@
# cloudflare temp email
This is a temporary email service that uses Cloudflare Workers to create a temporary email address and view the received email in web browser.
## Features
- [x] Cloudflare D1 as a database
- [x] Deploy the front end with Cloudflare Pages
- [x] Deploy the backend with Cloudflare Workers
- [x] Email forwarding using Cloudflare Email Routing
- [x] Use password to login to the previous mailbox again.
- [x] Get Custom Name Email
- [x] Support multiple languages
- [x] Add access authorization, which can be used as a private site
- [x] Add auto reply feature
- [x] Add attachment viewing function
- [x] use rust wasm to parse email
- [x] support send email
- [x] support DKIM
## Deploy
[Install/Update Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/)
```bash
npm install wrangler -g
git clone https://github.com/dreamhunter2333/cloudflare_temp_email.git
# Switch to the latest tag or the branch you want to deploy. You can also use the main branch directly.
# git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
```
## DB - Cloudflare D1
```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
# 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
# create a namespace, and copy the output to wrangler.toml in the next step
wrangler kv:namespace create DEV
```
![d1](/readme_assets/d1.png)
### Backend - Cloudflare workers
The first deployment will prompt you to create a project. Please fill in `production` for the `production` branch.
```bash
cd worker
pnpm install
cp wrangler.toml.template wrangler.toml
# deploy
pnpm run deploy
```
`wrangler.toml`
```toml
name = "cloudflare_temp_email"
main = "src/worker.js"
compatibility_date = "2023-08-14"
node_compat = true
# enable cron if you want set auto clean up
# [triggers]
# crons = [ "0 0 * * *" ]
[vars]
PREFIX = "tmp" # The mailbox name prefix to be processed
# 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"]
# 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
JWT_SECRET = "xxx" # Key used to generate jwt
BLACK_LIST = "" # Blacklist, used to filter senders, comma separated
# Allow users to create email addresses
ENABLE_USER_CREATE_EMAIL = true
# Allow users to delete messages
ENABLE_USER_DELETE_EMAIL = true
# Allow automatic replies to emails
ENABLE_AUTO_REPLY = false
# Footer text
# COPYRIGHT = "Dream Hunter"
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# 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
[[d1_databases]]
binding = "DB"
database_name = "xxx" # D1 database name
database_id = "xxx" # D1 database ID
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
# Create a new address current limiting configuration
# [[unsafe.bindings]]
# name = "RATE_LIMITER"
# type = "ratelimit"
# namespace_id = "1001"
# # 10 requests per minute
# simple = { limit = 10, period = 60 }
```
you can find and test the worker's url in the workers dashboard
![worker](/readme_assets/worker.png)
## Cloudflare Email Routing
Before you can bind an email address to your Worker, you need to enable Email Routing and have at least one verified email address.
enable email route and config email forward catch-all to the worker
![email](/readme_assets/email.png)
### Frontend - Cloudflare pages
The first deployment will prompt you to create a project. Please fill in `production` for the `production` branch.
```bash
cd frontend
pnpm install
# add .env.local and modify VITE_API_BASE to your worker's url
# VITE_API_BASE=https://xxx.xxx.workers.dev - don't put / in the end
cp .env.example .env.local
pnpm build --emptyOutDir
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

@@ -1,24 +0,0 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: "Temporary mailbox document"
tagline: "Build CloudFlare to send and receive free temporary domain name mailboxes"
actions:
- theme: brand
text: Try it now
link: https://mail.awsl.uk/
- theme: alt
text: command line deployment
link: /en/cli
features:
- title: Free hosting on CloudFlare, no server required
details: Cloudflare D1 database, Cloudflare Pages frontend, Cloudflare Workers backend, Cloudflare Email Routing
- title: Only domain name required for private deployment
details: Support password login email, access authorization can be used as a private site, support attachment function
- title: Use rust wasm to parse emails
details: Use rust wasm to parse emails, support various RFC standards for emails, support attachments, extremely fast
- title: Support sending emails
details: Support sending txt or html emails through domain name mailboxesSupport DKIM signature
---

View File

@@ -1,28 +0,0 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: "临时邮箱文档"
tagline: "搭建 CloudFlare 免费收发 临时域名邮箱"
actions:
- theme: brand
text: 立即试用
link: https://mail.awsl.uk/
- theme: alt
text: 命令行部署
link: /zh/guide/quick-start
- theme: alt
text: 通过用户界面部署
link: /zh/guide/quick-start
features:
- title: 免费托管在 CloudFlare无需服务器
details: Cloudflare D1 数据库Cloudflare Pages 前端Cloudflare Workers 后端, Cloudflare Email Routing
- title: 仅需域名即可私有部署
details: 支持 password 登录邮箱,使用访问密码可作为私人站点,支持附件功能
- title: 使用 rust wasm 解析邮件
details: 使用 rust wasm 解析邮件支持邮件各种RFC标准支持附件, 速度极快
- title: 支持发送邮件(UI/API/SMTP)
details: 支持通过域名邮箱发送 txt 或者 html 邮件,支持 DKIM 签名, UI/API/SMTP 发送邮件
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,6 +0,0 @@
# Reference
- https://developers.cloudflare.com/d1/
- https://developers.cloudflare.com/pages/
- https://developers.cloudflare.com/workers/
- https://developers.cloudflare.com/email-routing/

View File

@@ -1,8 +0,0 @@
# Status Page
[Status Link](https://uptime.aks.awsl.icu/status/temp-email)
| Service | Status |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Backend](https://temp-email-api.awsl.uk/) | ![](https://uptime.aks.awsl.icu/api/badge/10/status) ![](https://uptime.aks.awsl.icu/api/badge/10/uptime) ![](https://uptime.aks.awsl.icu/api/badge/10/ping) ![](https://uptime.aks.awsl.icu/api/badge/10/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/10/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/10/response) |
| [Frontend](https://mail.awsl.uk/) | ![](https://uptime.aks.awsl.icu/api/badge/12/status) ![](https://uptime.aks.awsl.icu/api/badge/12/uptime) ![](https://uptime.aks.awsl.icu/api/badge/12/ping) ![](https://uptime.aks.awsl.icu/api/badge/12/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/12/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/12/response) |

View File

@@ -1,30 +0,0 @@
# 初始化/更新 D1 数据库
第一次执行登录 wrangler 命令时,会提示登录, 按提示操作即可
## 初始化数据库
```bash
cd worker
cp wrangler.toml.template wrangler.toml
# 创建 D1 并执行 schema.sql
wrangler d1 create dev
wrangler d1 execute dev --file=../db/schema.sql
```
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
![D1](/readme_assets/d1.png)
## 更新数据库 schema
`schema` 更新,请确认你之前部署的版本,
查看 [更新日志](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/CHANGELOG.md)
找到需要执行的 `patch` 文件, 执行, 例如:
```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
```

View File

@@ -1,25 +0,0 @@
# Cloudflare Pages 前端
第一次部署会提示创建项目, `production` 分支请填写 `production`
```bash
cd frontend
pnpm install
cp .env.example .env.prod
```
修改 `.env.prod` 文件
`VITE_API_BASE` 修改为上一步创建的 `worker``url`, 不要在末尾加 `/`
例如: `VITE_API_BASE=https://xxx.xxx.workers.dev`
```bash
pnpm build --emptyOutDir
# 根据提示创建 pages
pnpm run deploy
```
部署完成之后你可以在 Cloudflare 控制台看到你的项目, 可以为 `pages` 配置自定义域名
![pages](/readme_assets/pages.png)

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