Compare commits

...

30 Commits

Author SHA1 Message Date
Dream Hunter
50f04b2456 feat: update CHANGE LOG (#131) 2024-04-15 14:19:33 +08:00
Dream Hunter
2ec1a61608 feat: add /api/new_address ratelimit (#130) 2024-04-15 14:14:38 +08:00
Dream Hunter
eafcf00e5e feat: support DKIM (#129) 2024-04-15 13:20:22 +08:00
Dream Hunter
d73fee2c97 fix: docs depoly (#128) 2024-04-14 23:07:18 +08:00
Dream Hunter
4bb1016887 feat: add docs for ui install (#127) 2024-04-14 23:01:53 +08:00
Dream Hunter
aea8b964bb feat: admin cleanup tab && admin sendbox tab (#126) 2024-04-14 22:41:16 +08:00
Dream Hunter
63cf97f5e2 feat: add docs (#123) 2024-04-14 16:22:54 +08:00
Dream Hunter
209693673d feat: add docs (#122) 2024-04-14 15:15:09 +08:00
Dream Hunter
fb74504282 Update tag_build.yml 2024-04-14 15:07:45 +08:00
Dream Hunter
cb758ec012 Update tag_build.yml 2024-04-14 13:47:52 +08:00
Dream Hunter
02835c18e9 Update tag_build.yml 2024-04-14 13:38:46 +08:00
Dream Hunter
f83492e683 Update README.md 2024-04-14 00:42:57 +08:00
Dream Hunter
074a3b6f2a Update tag_build.yml 2024-04-12 23:28:41 +08:00
Dream Hunter
d738210cb5 feat: add Tag Build CI (#120) 2024-04-12 23:24:21 +08:00
Dream Hunter
cfeafb2d30 Update tag_build.yml 2024-04-12 23:17:14 +08:00
Dream Hunter
49d29ac7cc feat: add Tag Build CI (#119) 2024-04-12 23:14:25 +08:00
Dream Hunter
372b71b08b feat: add Tag Build CI (#118) 2024-04-12 23:08:51 +08:00
Dream Hunter
0c5365da1f feat: add Tag Build CI (#117) 2024-04-12 23:04:39 +08:00
Dream Hunter
58ad025e61 fix: UI senderEnabled not changed when open modal (#116) 2024-04-12 22:10:45 +08:00
Dream Hunter
6c41288a7b feat: update changelog (#114) 2024-04-12 13:28:57 +08:00
Dream Hunter
b8f0fa49cf feat: init send mail (#113)
* feat: init send mail

* feat: init send mail
2024-04-12 13:26:42 +08:00
Dream Hunter
ee2fdab279 feat: update readme (#112) 2024-04-10 13:28:44 +08:00
Dream Hunter
fbd2e0e844 fix: UI overflow (#111)
* feat: UI overflow

* feat: add domain
2024-04-10 13:25:26 +08:00
Dream Hunter
165efa69cc feat: UI humanFileSize (#110) 2024-04-09 22:42:51 +08:00
Dream Hunter
2790f65a5f Rename CHANGELOG to CHANGELOG.md (#109)
* Rename CHANGELOG to CHANGELOG.md

* Update README.md

* Update README_EN.md
2024-04-09 21:59:39 +08:00
Dream Hunter
37a9b0557a feat: UI use fakerjs cdn && overflow: auto (#108) 2024-04-09 21:58:07 +08:00
Dream Hunter
42a828e98b feat: add faker-js (#107) 2024-04-09 19:30:35 +08:00
Dream Hunter
a124e00766 feat: add readme (#106) 2024-04-09 18:55:53 +08:00
Dream Hunter
796d72badb fix: button size && admin statistics (#105) 2024-04-09 18:30:50 +08:00
Dream Hunter
def400eb09 feat: use rust mail-parser (#104)
* feat: imp worker v2

* feat: add rust mail-parser

* feat: imp frontend v2

* feat: imp frontend v2

* feat: update doc

* feat: add mailV1Alert

* feat: remove unused
2024-04-09 14:58:19 +08:00
90 changed files with 6408 additions and 1291 deletions

48
.github/workflows/docs_deploy.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Deploy Docs
on:
push:
paths:
- "vitepress-docs/**"
branches:
- main
tags:
- "*"
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/
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 }}

46
.github/workflows/tag_build.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
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

View File

@@ -1,18 +0,0 @@
# CHANGE LOG
## 2024-01-13
DB changes
- `db/2024-01-13-patch.sql`
## 2024-04-03
DB changes
- `db/2024-04-03-patch.sql`
Changes:
- add delete account
- add admin panel search

87
CHANGELOG.md Normal file
View File

@@ -0,0 +1,87 @@
# CHANGE LOG
## 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`

View File

@@ -1,10 +1,14 @@
# 使用 cloudflare 免费服务,搭建临时邮箱
## [English](README_EN.md)
## [查看部署文档](https://temp-mail-docs.awsl.uk)
[CHANGELOG](CHANGELOG)
## [English](https://temp-mail-docs.awsl.uk/en/)
[Backend](https://temp-email-api.dreamhunter2333.xyz/)
## [CHANGELOG](CHANGELOG.md)
## [在线演示](https://mail.awsl.uk/)
[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)
@@ -12,7 +16,7 @@
![](https://uptime.aks.awsl.icu/api/badge/10/cert-exp)
![](https://uptime.aks.awsl.icu/api/badge/10/response)
[Frontend](https://temp-email.dreamhunter2333.xyz/)
[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)
@@ -27,7 +31,9 @@
</picture>
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
- [查看部署文档](#查看部署文档)
- [English](#english)
- [CHANGELOG](#changelog)
- [在线演示](#在线演示)
- [功能/TODO](#功能todo)
- [什么是临时邮箱](#什么是临时邮箱)
@@ -35,13 +41,12 @@
- [wrangler 的安装](#wrangler-的安装)
- [D1 数据库](#d1-数据库)
- [Cloudflare workers 后端](#cloudflare-workers-后端)
- [Cloudflare Workers 后端](#cloudflare-workers-后端-1)
- [Cloudflare Email Routing](#cloudflare-email-routing)
- [Cloudflare Pages 前端](#cloudflare-pages-前端)
- [配置发送邮件](#配置发送邮件)
- [配置 DKIM](#配置-dkim)
- [参考资料](#参考资料)
## [在线演示](https://temp-email.dreamhunter2333.xyz/)
## 功能/TODO
- [x] Cloudflare D1 作为数据库
@@ -54,7 +59,9 @@
- [x] 增加访问授权,可作为私人站点
- [x] 增加自动回复功能
- [x] 增加查看附件功能
- [ ] 免费版附件过大会造成 Exceeded CPU Limit 错误
- [x] 使用 rust wasm 解析邮件
- [x] 支持发送邮件
- [x] 支持 DKIM
---
@@ -87,6 +94,8 @@ npm install wrangler -g
```bash
git clone https://github.com/dreamhunter2333/cloudflare_temp_email.git
# 切换到最新 tag 或者你想部署的分支,你也可以直接使用 main 分支
# git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
```
---
@@ -106,7 +115,7 @@ wrangler d1 execute dev --file=db/schema.sql
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
![D1](readme_assets/d1.png)
![D1](vitepress-docs/docs/public/readme_assets/d1.png)
---
@@ -137,43 +146,54 @@ PREFIX = "tmp" # 要处理的邮箱名称前缀
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名
JWT_SECRET = "xxx" # 用于生成 jwt 的密钥
BLACK_LIST = "" # 黑名单,用于过滤发件人,逗号分隔
# 免费版附件过大会造成 Exceeded CPU Limit 错误,如果不需要附件功能,可以关闭
ENABLE_ATTACHMENT = true
# dkim config
# DKIM_SELECTOR = "mailchannels" # 参考 DKIM 部分 mailchannels._domainkey 的 mailchannels
# DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容
[[d1_databases]]
binding = "DB"
database_name = "xxx" # D1 数据库名称
database_id = "xxx" # D1 数据库 ID
# 新建地址限流配置
# [[unsafe.bindings]]
# name = "RATE_LIMITER"
# type = "ratelimit"
# namespace_id = "1001"
# # 10 requests per minute
# simple = { limit = 10, period = 60 }
```
---
## Cloudflare Workers 后端
部署
第一次部署会提示创建项目, `production` 分支请填写 `production`
```bash
pnpm run deploy
```
部署成功之后再路由中可以看到 `worker``url`,控制台也会输出 `worker``url`
![worker](readme_assets/worker.png)
![worker](vitepress-docs/docs/public/readme_assets/worker.png)
---
## Cloudflare Email Routing
在将电子邮件地址绑定到您的 Worker 之前,您需要启用电子邮件路由并拥有至少一个经过验证的电子邮件地址。
配置对应域名的 `电子邮件 DNS 记录`
配置 `Cloudflare Email Routing` catch-all 发送到 `worker`
![email](readme_assets/email.png)
![email](vitepress-docs/docs/public/readme_assets/email.png)
---
## Cloudflare Pages 前端
第一次部署会提示创建项目, `production` 分支请填写 `production`
```bash
cd frontend
pnpm install
@@ -190,7 +210,43 @@ pnpm build --emptyOutDir
pnpm run deploy
```
![pages](readme_assets/pages.png)
![pages](vitepress-docs/docs/public/readme_assets/pages.png)
## 配置发送邮件
找到域名 `DNS` 记录的 `TXT``SPF` 记录, 增加 `include:relay.mailchannels.net`
```bash
v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all
```
新建 `_mailchannels` 记录, 类型为 `TXT`, 内容为 `v=mc1 cfid=你的worker域名`
- 此处 worker 域名为后端 api 的域名,比如我部署在 `https://temp-email-api.awsl.uk/`,则填写 `v=mc1 cfid=awsl.uk`
- 如果你的域名是 `https://temp-email-api.xxx.workers.dev`,则填写 `v=mc1 cfid=xxx.workers.dev`
## 配置 DKIM
参考: [Adding-a-DKIM-Signature](https://support.mailchannels.com/hc/en-us/articles/7122849237389-Adding-a-DKIM-Signature)
Creating a DKIM private and public key:
Private key as PEM file and base64 encoded txt file:
```bash
openssl genrsa 2048 | tee priv_key.pem | openssl rsa -outform der | openssl base64 -A > priv_key.txt
```
Public key as DNS record:
```bash
echo -n "v=DKIM1;p=" > pub_key_record.txt && \
openssl rsa -in priv_key.pem -pubout -outform der | openssl base64 -A >> pub_key_record.txt
```
`Cloudflare``DNS` 记录中添加 `TXT` 记录
- `_dmarc`: `v=DMARC1; p=none; adkim=r; aspf=r;`
- `mailchannels._domainkey`: `v=DKIM1; p=<content of the file pub_key_record.txt>`
## 参考资料

View File

@@ -1,86 +0,0 @@
# 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
- [ ] Exceeded CPU Limit error caused by the free version of the attachment
![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 = ""
# free version attachment too large will cause Exceeded CPU Limit error, if you don't need attachment function, you can close
# ENABLE_ATTACHMENT = true
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)

8
db/2024-04-09-patch.sql Normal file
View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS raw_mails (
id INTEGER PRIMARY KEY,
message_id TEXT,
source TEXT,
address TEXT,
raw TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

14
db/2024-04-12-patch.sql Normal file
View File

@@ -0,0 +1,14 @@
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

@@ -8,6 +8,19 @@ CREATE TABLE IF NOT EXISTS mails (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mails_address ON mails(address);
CREATE TABLE IF NOT EXISTS raw_mails (
id INTEGER PRIMARY KEY,
message_id TEXT,
source TEXT,
address TEXT,
raw TEXT,
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 (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE,
@@ -15,6 +28,8 @@ CREATE TABLE IF NOT EXISTS address (
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 (
id INTEGER PRIMARY KEY,
source_prefix TEXT,
@@ -26,6 +41,8 @@ CREATE TABLE IF NOT EXISTS auto_reply_mails (
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 (
id INTEGER PRIMARY KEY,
source TEXT,
@@ -34,3 +51,22 @@ CREATE TABLE IF NOT EXISTS attachments (
data TEXT,
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);

View File

@@ -6,14 +6,17 @@
"scripts": {
"dev": "vite",
"build": "vite build -m prod --emptyOutDir",
"build:release": "vite build -m example --emptyOutDir",
"preview": "vite preview",
"deploy": "npm run build && wrangler pages deploy ../dist --branch production"
"deploy": "npm run build && wrangler pages deploy ./dist --branch production"
},
"dependencies": {
"@vicons/material": "^0.12.0",
"@vueuse/core": "^10.9.0",
"axios": "^1.6.8",
"mail-parser-wasm": "^0.1.6",
"naive-ui": "^2.38.1",
"postal-mime": "^2.2.1",
"vooks": "^0.2.12",
"vue": "^3.4.21",
"vue-clipboard3": "^2.0.0",
@@ -27,6 +30,8 @@
"unplugin-vue-components": "^0.26.0",
"vite": "^5.2.6",
"vite-plugin-pwa": "^0.19.7",
"vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-wasm": "^3.3.0",
"workbox-window": "^7.0.0"
}
}

184
frontend/pnpm-lock.yaml generated
View File

@@ -14,9 +14,15 @@ dependencies:
axios:
specifier: ^1.6.8
version: 1.6.8
mail-parser-wasm:
specifier: ^0.1.6
version: 0.1.6
naive-ui:
specifier: ^2.38.1
version: 2.38.1(vue@3.4.21)
postal-mime:
specifier: ^2.2.1
version: 2.2.1
vooks:
specifier: ^0.2.12
version: 0.2.12(vue@3.4.21)
@@ -52,6 +58,12 @@ devDependencies:
vite-plugin-pwa:
specifier: ^0.19.7
version: 0.19.7(vite@5.2.6)(workbox-build@7.0.0)(workbox-window@7.0.0)
vite-plugin-top-level-await:
specifier: ^1.4.1
version: 1.4.1(rollup@2.79.1)(vite@5.2.6)
vite-plugin-wasm:
specifier: ^3.3.0
version: 3.3.0(vite@5.2.6)
workbox-window:
specifier: ^7.0.0
version: 7.0.0
@@ -1593,6 +1605,18 @@ packages:
rollup: 2.79.1
dev: true
/@rollup/plugin-virtual@3.0.2(rollup@2.79.1):
resolution: {integrity: sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
dependencies:
rollup: 2.79.1
dev: true
/@rollup/pluginutils@3.1.0(rollup@2.79.1):
resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==}
engines: {node: '>= 8.0.0'}
@@ -1741,6 +1765,131 @@ packages:
string.prototype.matchall: 4.0.11
dev: true
/@swc/core-darwin-arm64@1.4.12:
resolution: {integrity: sha512-BZUUq91LGJsLI2BQrhYL3yARkcdN4TS3YGNS6aRYUtyeWrGCTKHL90erF2BMU2rEwZLLkOC/U899R4o4oiSHfA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@swc/core-darwin-x64@1.4.12:
resolution: {integrity: sha512-Wkk8rq1RwCOgg5ybTlfVtOYXLZATZ+QjgiBNM7pIn03A5/zZicokNTYd8L26/mifly2e74Dz34tlIZBT4aTGDA==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@swc/core-linux-arm-gnueabihf@1.4.12:
resolution: {integrity: sha512-8jb/SN67oTQ5KSThWlKLchhU6xnlAlnmnLCCOKK1xGtFS6vD+By9uL+qeEY2krV98UCRTf68WSmC0SLZhVoz5A==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@swc/core-linux-arm64-gnu@1.4.12:
resolution: {integrity: sha512-DhW47DQEZKCdSq92v5F03rqdpjRXdDMqxfu4uAlZ9Uo1wJEGvY23e1SNmhji2sVHsZbBjSvoXoBLk0v00nSG8w==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@swc/core-linux-arm64-musl@1.4.12:
resolution: {integrity: sha512-PR57pT3TssnCRvdsaKNsxZy9N8rFg9AKA1U7W+LxbZ/7Z7PHc5PjxF0GgZpE/aLmU6xOn5VyQTlzjoamVkt05g==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@swc/core-linux-x64-gnu@1.4.12:
resolution: {integrity: sha512-HLZIWNHWuFIlH+LEmXr1lBiwGQeCshKOGcqbJyz7xpqTh7m2IPAxPWEhr/qmMTMsjluGxeIsLrcsgreTyXtgNA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@swc/core-linux-x64-musl@1.4.12:
resolution: {integrity: sha512-M5fBAtoOcpz2YQAFtNemrPod5BqmzAJc8pYtT3dVTn1MJllhmLHlphU8BQytvoGr1PHgJL8ZJBlBGdt70LQ7Mw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@swc/core-win32-arm64-msvc@1.4.12:
resolution: {integrity: sha512-K8LjjgZ7VQFtM+eXqjfAJ0z+TKVDng3r59QYn7CL6cyxZI2brLU3lNknZcUFSouZD+gsghZI/Zb8tQjVk7aKDQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@swc/core-win32-ia32-msvc@1.4.12:
resolution: {integrity: sha512-hflO5LCxozngoOmiQbDPyvt6ODc5Cu9AwTJP9uH/BSMPdEQ6PCnefuUOJLAKew2q9o+NmDORuJk+vgqQz9Uzpg==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@swc/core-win32-x64-msvc@1.4.12:
resolution: {integrity: sha512-3A4qMtddBDbtprV5edTB/SgJn9L+X5TL7RGgS3eWtEgn/NG8gA80X/scjf1v2MMeOsrcxiYhnemI2gXCKuQN2g==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@swc/core@1.4.12:
resolution: {integrity: sha512-QljRxTaUajSLB9ui93cZ38/lmThwIw/BPxjn+TphrYN6LPU3vu9/ykjgHtlpmaXDDcngL4K5i396E7iwwEUxYg==}
engines: {node: '>=10'}
requiresBuild: true
peerDependencies:
'@swc/helpers': ^0.5.0
peerDependenciesMeta:
'@swc/helpers':
optional: true
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.6
optionalDependencies:
'@swc/core-darwin-arm64': 1.4.12
'@swc/core-darwin-x64': 1.4.12
'@swc/core-linux-arm-gnueabihf': 1.4.12
'@swc/core-linux-arm64-gnu': 1.4.12
'@swc/core-linux-arm64-musl': 1.4.12
'@swc/core-linux-x64-gnu': 1.4.12
'@swc/core-linux-x64-musl': 1.4.12
'@swc/core-win32-arm64-msvc': 1.4.12
'@swc/core-win32-ia32-msvc': 1.4.12
'@swc/core-win32-x64-msvc': 1.4.12
dev: true
/@swc/counter@0.1.3:
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
dev: true
/@swc/types@0.1.6:
resolution: {integrity: sha512-/JLo/l2JsT/LRd80C3HfbmVpxOAJ11FO2RCEslFrgzLltoP9j8XIbsyDcfCt2WWyX+CM96rBoNM+IToAkFOugg==}
dependencies:
'@swc/counter': 0.1.3
dev: true
/@types/estree@0.0.39:
resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
dev: true
@@ -2969,6 +3118,10 @@ packages:
dependencies:
'@jridgewell/sourcemap-codec': 1.4.15
/mail-parser-wasm@0.1.6:
resolution: {integrity: sha512-RoPPXqpGcCe4BcnXmxH4Cl5u0AH8y0JUNutksg2xzK0qFGEVE3xipx90JHzUUZ3MuMxo7doQTRktcABTIb3aeg==}
dev: false
/merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
dev: true
@@ -3131,6 +3284,10 @@ packages:
engines: {node: '>= 0.4'}
dev: true
/postal-mime@2.2.1:
resolution: {integrity: sha512-YqGeFmiKXUxv32hOy2t47VX67mYydC47CTCc7+HKd3xlNKPDhivnO/ZovN3iWXxvyyL2TRTxusuuq3etWeCKsw==}
dev: false
/postcss@8.4.38:
resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==}
engines: {node: ^10 || ^12 || >=14}
@@ -3742,6 +3899,11 @@ packages:
punycode: 2.3.1
dev: true
/uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
dev: true
/vdirs@0.1.8(vue@3.4.21):
resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==}
peerDependencies:
@@ -3773,6 +3935,28 @@ packages:
- supports-color
dev: true
/vite-plugin-top-level-await@1.4.1(rollup@2.79.1)(vite@5.2.6):
resolution: {integrity: sha512-hogbZ6yT7+AqBaV6lK9JRNvJDn4/IJvHLu6ET06arNfo0t2IsyCaon7el9Xa8OumH+ESuq//SDf8xscZFE0rWw==}
peerDependencies:
vite: '>=2.8'
dependencies:
'@rollup/plugin-virtual': 3.0.2(rollup@2.79.1)
'@swc/core': 1.4.12
uuid: 9.0.1
vite: 5.2.6
transitivePeerDependencies:
- '@swc/helpers'
- rollup
dev: true
/vite-plugin-wasm@3.3.0(vite@5.2.6):
resolution: {integrity: sha512-tVhz6w+W9MVsOCHzxo6SSMSswCeIw4HTrXEi6qL3IRzATl83jl09JVO1djBqPSwfjgnpVHNLYcaMbaDX5WB/pg==}
peerDependencies:
vite: ^2 || ^3 || ^4 || ^5
dependencies:
vite: 5.2.6
dev: true
/vite@5.2.6:
resolution: {integrity: sha512-FPtnxFlSIKYjZ2eosBQamz4CbyrTizbZ3hnGJlh/wMtCrlp1Hah6AzBLjGI5I2urTfNnpovpHdrL6YRuBOPnCA==}
engines: {node: ^18.0.0 || >=20.0.0}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -75,7 +75,9 @@ const getSettings = async () => {
const res = await apiFetch("/api/settings");;
settings.value = {
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 {
settings.value.fetched = true;

View File

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

View File

@@ -1,7 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue'
import Settings from '../views/Settings.vue'
import SendMail from '../views/send/SendMail.vue'
import Admin from '../views/Admin.vue'
import SendBox from '../views/send/SendBox.vue'
const router = createRouter({
history: createWebHistory(),
@@ -14,6 +16,14 @@ const router = createRouter({
path: '/settings',
component: Settings
},
{
path: '/send',
component: SendMail
},
{
path: '/sendbox',
component: SendBox
},
{
path: '/admin',
component: Admin

View File

@@ -14,6 +14,8 @@ export const useGlobalState = createGlobalState(
})
const settings = ref({
fetched: false,
has_v1_mails: false,
send_balance: 0,
address: '',
auto_reply: {
subject: '',
@@ -28,9 +30,12 @@ export const useGlobalState = createGlobalState(
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', '');
const localeCache = useStorage('locale', 'zhCN');
const localeCache = useStorage('locale', 'zh');
const themeSwitch = useStorage('themeSwitch', false);
const showLogin = ref(false);
const adminTab = ref("account");
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
return {
loading,
settings,
@@ -43,6 +48,9 @@ export const useGlobalState = createGlobalState(
adminAuth,
showAdminAuth,
showLogin,
adminTab,
adminMailTabAddress,
adminSendBoxTabAddress,
}
},
)

View File

@@ -0,0 +1,76 @@
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) {
// Try to parse the email using mail-parser-wasm
try {
const parsedEmail = parse_message(item.raw);
item.source = parsedEmail.sender || item.source;
item.subject = parsedEmail.subject || '';
item.message = parsedEmail.body_html || parsedEmail.text || '';
item.attachments = parsedEmail.attachments?.map((a_item) => {
const blob_url = URL.createObjectURL(
new Blob(
[a_item.content],
{ type: a_item.content_type || 'application/octet-stream' }
))
if (a_item.content_id && a_item.content_id.length > 0) {
item.message = item.message.replace(`cid:${a_item.content_id}`, blob_url);
}
return {
id: a_item.content_id || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.content_id || "",
size: humanFileSize(a_item.content?.length || 0),
url: blob_url
}
}) || [];
} catch (error) {
console.log('Error parsing email with mail-parser-wasm');
console.error(error);
}
if (item.subject && item.subject.length > 0 && item.message && item.message.length > 0) {
return item;
}
// Fallback to PostalMime
try {
const parsedEmail = await PostalMime.parse(item.raw);
item.source = parsedEmail.from.address || item.source;
if (parsedEmail.from.address && parsedEmail.from.name) {
item.source = `${parsedEmail.from.name} <${parsedEmail.from.address}>`;
}
item.subject = parsedEmail.subject || 'No Subject';
item.message = parsedEmail.html || parsedEmail.text || item.raw;
item.attachments = parsedEmail.attachments?.map((a_item) => {
const blob_url = URL.createObjectURL(
new Blob(
[a_item.content],
{ type: a_item.mimeType || 'application/octet-stream' }
))
if (a_item.contentId && a_item.contentId.length > 0) {
item.message = item.message.replace(`cid:${a_item.contentId}`, blob_url);
}
return {
id: a_item.contentId || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.contentId || "",
size: humanFileSize(a_item.content?.length || 0),
url: blob_url
}
}) || [];
} catch (error) {
console.log('Error parsing email with PostalMime');
console.error(error);
item.subject = 'No Subject';
item.message = item.raw;
}
}
export function getDownloadEmlUrl(raw) {
return URL.createObjectURL(
new Blob([raw], { type: 'text/plain' }
))
}

View File

@@ -1,20 +1,22 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { User, UserCheck, MailBulk } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
const router = useRouter()
import SenderAccess from './admin/SenderAccess.vue'
import Statistics from "./admin/Statistics.vue"
import SendBox from './admin/SendBox.vue';
import Account from './admin/Account.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import Maintenance from './admin/Maintenance.vue';
const {
localeCache, adminAuth, showAdminAuth, adminTab
} = useGlobalState()
const message = useMessage()
const showEmailPassword = ref(false)
const curEmailPassword = ref("")
const addressQuery = ref("")
const authFunc = async () => {
try {
location.reload()
@@ -27,237 +29,34 @@ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
title: 'Temp Email Admin',
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',
itemCount: 'itemCount',
query: 'Query',
userCount: 'User Count',
activeUser: '7 days Active User',
mailCount: 'Mail Count',
account: 'Account',
unknow: 'Unknow',
addressQueryTip: 'Leave blank to query all addresses',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
maintenance: 'Maintenance',
},
zh: {
title: '临时邮件 Admin',
auth: 'Admin 授权',
home: '首页',
authTip: '请输入正确的授权码',
name: '名称',
created_at: '创建时间',
showPass: '显示密码',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
refresh: '刷新',
mails: '邮件',
itemCount: '总数',
query: '查询',
userCount: '用户总数',
activeUser: '周活跃用户',
mailCount: '邮件总数',
account: '账号',
unknow: '未知',
addressQueryTip: '留空查询所有地址',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
maintenance: '维护',
}
}
});
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 () => {
if (!adminAuth.value) {
showAdminAuth.value = true
} else {
await fetchData()
await fetchStatistics()
showAdminAuth.value = true;
return;
}
})
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/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/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>
<template>
@@ -268,130 +67,32 @@ const fetchMailUnknowData = async () => {
<div>{{ t('auth') }}</div>
</template>
<p>{{ t('authTip') }}</p>
<n-input v-model:value="adminAuth" type="textarea" :autosize="{
minRows: 3
}" />
<n-input v-model:value="adminAuth" type="textarea" :autosize="{ minRows: 3 }" />
<template #action>
<n-button @click="authFunc" size="small" tertiary round type="primary">
{{ t('auth') }}
</n-button>
</template>
</n-modal>
<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">
<Statistics />
<n-tabs type="card" v-model:value="adminTab">
<n-tab-pane name="account" :tab="t('account')">
<n-input-group>
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
<n-button @click="fetchData" type="primary" ghost>
{{ 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" />
<Account />
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('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>
<Mails />
</n-tab-pane>
<n-tab-pane name="unknow" :tab="t('unknow')">
<n-button @click="fetchMailUnknowData" type="primary" ghost>
{{ t('query') }}
</n-button>
<n-list hoverable clickable>
<div style="display: inline-block; margin-bottom: 10px;">
<n-pagination v-model:page="mailUnknowPage" v-model:page-size="mailUnknowPageSize"
:item-count="mailUnknowCount" simple>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-list-item v-for="row in mailUnknowData" 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>
<MailsUnknow />
</n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="sendBox" :tab="t('sendBox')">
<SendBox />
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<Maintenance />
</n-tab-pane>
</n-tabs>
</div>

View File

@@ -4,7 +4,7 @@ import { ref, h, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useIsMobile } from '../utils/composables'
import { DarkModeFilled, LightModeFilled, MenuFilled, AdminPanelSettingsFilled } from '@vicons/material'
import { DarkModeFilled, LightModeFilled, MenuFilled, AdminPanelSettingsFilled, SendFilled } from '@vicons/material'
import { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
import { useGlobalState } from '../store'
@@ -29,8 +29,8 @@ const emailDomain = ref("")
const login = async () => {
try {
await api.getSettings()
jwt.value = password.value;
await api.getSettings()
location.reload()
} catch (error) {
message.error(error.message || "error");
@@ -73,10 +73,13 @@ const { t } = useI18n({
home: 'Home',
menu: 'Menu',
user: 'User',
sendbox: 'Send Box',
sendMail: 'Send Mail',
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.',
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.',
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',
@@ -85,6 +88,8 @@ const { t } = useI18n({
copied: 'Copied',
showPassword: 'Show Password',
fetchAddressError: 'Fetch address error, maybe your jwt is invalid or network error.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
generateName: 'Generate Fake Name',
},
zh: {
title: 'Cloudflare 临时邮件',
@@ -101,10 +106,13 @@ const { t } = useI18n({
home: '主页',
menu: '菜单',
user: '用户',
sendbox: '发件箱',
sendMail: '发送邮件',
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '获取新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
yourAddress: '你的邮箱地址是',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
@@ -114,6 +122,8 @@ const { t } = useI18n({
copied: '已复制',
showPassword: '查看密码',
fetchAddressError: '获取地址失败, 请检查你的 jwt 是否有效 或 网络是否正常。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
generateName: '生成随机名字',
}
}
});
@@ -174,7 +184,20 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
tertiary: true,
bordered: false,
ghost: true,
size: "small",
onClick: () => router.push('/sendbox')
},
{ default: () => t('sendbox') }
),
key: "sendbox"
},
{
label: () => h(
NButton,
{
bordered: false,
ghost: true,
size: "small",
onClick: () => { showPassword.value = true }
@@ -187,7 +210,7 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
tertiary: true,
bordered: false,
ghost: true,
size: "small",
onClick: () => { router.push('/settings') }
@@ -200,7 +223,7 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
tertiary: true,
bordered: false,
ghost: true,
size: "small",
onClick: () => { showLogout.value = true }
@@ -213,7 +236,7 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
tertiary: true,
bordered: false,
ghost: true,
size: "small",
onClick: () => { showDelteAccount.value = true }
@@ -304,6 +327,23 @@ const copy = async () => {
}
}
const generateNameLoading = ref(false);
const generateName = async () => {
try {
generateNameLoading.value = true;
const { faker } = await import('https://esm.sh/@faker-js/faker');
emailName.value = faker.person
.fullName()
.replace(/\s+/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(
@@ -335,6 +375,7 @@ const deleteAccount = async () => {
onMounted(async () => {
await api.getOpenSettings(message);
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
await api.getSettings();
});
</script>
@@ -351,14 +392,28 @@ onMounted(async () => {
<n-card v-if="!settings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<n-alert v-else-if="settings.address" type="info" show-icon>
<span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary round type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</span>
</n-alert>
<div v-else-if="settings.address">
<n-alert v-if="settings.has_v1_mails" type="warning" show-icon 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>
<span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
<n-button style="margin-left: 10px" @click="router.push('/send')" size="small" tertiary round
type="primary">
<n-icon :component="SendFilled" /> {{ t('sendMail') }}
</n-button>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary round type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</span>
</n-alert>
</div>
<n-card v-else>
<n-result status="info" :description="t('pleaseGetNewEmail')">
<template #footer>
@@ -379,18 +434,25 @@ onMounted(async () => {
<template #header>
<div>{{ t('getNewEmail') }}</div>
</template>
<span>
<p>{{ t("getNewEmailTip1") }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
</span>
<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>
<n-spin :show="generateNameLoading">
<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>
</n-spin>
<template #action>
<n-button @click="showNewEmail = false">
{{ t('cancel') }}

View File

@@ -1,304 +1,11 @@
<script setup>
import { watch, onMounted, ref } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import MailBox from './MailBox.vue';
import { useGlobalState } from '../store'
import { api } from '../api'
import { CloudDownloadRound } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
const message = useMessage()
const isMobile = useIsMobile()
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({
locale: 'zh',
messages: {
en: {
autoRefresh: 'Auto Refresh',
refresh: 'Refresh',
attachments: 'Show Attachments',
pleaseSelectMail: "Please select a mail to view."
},
zh: {
autoRefresh: '自动刷新',
refresh: '刷新',
attachments: '查看附件',
pleaseSelectMail: "请选择一封邮件查看。"
}
}
});
const setupAutoRefresh = async (autoRefresh) => {
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/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 clickRow = async (row) => {
curMail.value = row;
};
const getAttachments = async (attachment_id) => {
try {
const res = await api.fetch(
`/api/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();
});
const { settings } = useGlobalState()
</script>
<template>
<div>
<n-layout v-if="settings.address">
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25" :default-size="0.25">
<template #1>
<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 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>
<MailBox v-if="settings.address" />
</div>
</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

@@ -0,0 +1,294 @@
<script setup>
import { watch, onMounted, ref } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { api } from '../api'
import { CloudDownloadRound } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
const message = useMessage()
const isMobile = useIsMobile()
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({
locale: 'zh',
messages: {
en: {
autoRefresh: 'Auto Refresh',
refresh: 'Refresh',
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
pleaseSelectMail: "Please select a mail to view."
},
zh: {
autoRefresh: '自动刷新',
refresh: '刷新',
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择一封邮件查看。"
}
}
});
const setupAutoRefresh = async (autoRefresh) => {
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 () => {
try {
const { results, count: totalCount } = await api.fetch(
`/api/mails`
+ `?limit=${pageSize.value}`
+ `&offset=${(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 ? (themeSwitch.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
};
onMounted(async () => {
await refresh();
});
</script>
<template>
<div>
<n-layout v-if="settings.address">
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25" :default-size="0.25">
<template #1>
<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 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 class="center" :title="row.subject">
<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: 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-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)">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') }}
</n-button>
</n-space>
<div 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>
<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: 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 class="center" :title="row.subject">
<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: 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-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-space>
<div v-html="curMail.message" style="margin-top: 10px;"></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>
</template>
<style scoped>
.left {
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,7 +2,6 @@
import { useI18n } from 'vue-i18n'
import { onMounted, ref } from 'vue'
import Header from './Header.vue'
import { useGlobalState } from '../store'
import { api } from '../api'
@@ -43,7 +42,6 @@ const { t } = useI18n({
});
const getSettings = async () => {
await api.getSettings()
sourcePrefix.value = settings.value.auto_reply.source_prefix || ""
enableAutoReply.value = settings.value.auto_reply.enabled || false
name.value = settings.value.auto_reply.name || ""

View File

@@ -0,0 +1,248 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
localeCache, adminAuth, showAdminAuth,
adminTab, adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
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?',
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: '创建时间',
showPass: '显示密码',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
delteAccount: '删除邮箱',
viewMails: '查看邮件',
viewSendBox: '查看发件箱',
itemCount: '总数',
query: '查询',
addressQueryTip: '留空查询所有地址',
actions: '操作',
}
}
});
const showEmailPassword = ref(false)
const curEmailPassword = ref("")
const curDeleteAddressId = ref(0);
const addressQuery = ref("")
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showDelteAccount = ref(false)
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 () => {
try {
await api.adminDeleteAddress(curDeleteAddressId.value)
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: 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,
{
bordered: false,
ghost: true,
onClick: () => showPassword(row.id)
},
{ default: () => t('showPass') }
),
},
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
onClick: () => {
adminMailTabAddress.value = row.name;
adminTab.value = "mails";
}
},
{ default: () => t('viewMails') }
)
},
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
onClick: () => {
adminSendBoxTabAddress.value = row.name;
adminTab.value = "sendBox";
}
},
{ default: () => t('viewSendBox') }
)
},
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
onClick: () => {
curDeleteAddressId.value = row.id;
showDelteAccount.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="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-modal v-model:show="showDelteAccount" preset="dialog" title="Dialog">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button @click="deleteEmail" size="small" tertiary round 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" ghost>
{{ 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

@@ -0,0 +1,112 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { processItem } from '../../utils/email-parser'
const {
localeCache, adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
mails: 'Emails',
itemCount: 'itemCount',
query: 'Query',
},
zh: {
mails: '邮件',
itemCount: '总数',
query: '查询',
}
}
});
const mailData = ref([])
const mailCount = ref(0)
const mailPage = ref(1)
const mailPageSize = ref(20)
watch([mailPage, mailPageSize, adminMailTabAddress], async () => {
await fetchMailData()
})
const fetchMailData = async () => {
if (!adminMailTabAddress.value) {
return
}
try {
const { results, count } = await api.fetch(
`/admin/mails`
+ `?address=${adminMailTabAddress.value}`
+ `&limit=${mailPageSize.value}`
+ `&offset=${(mailPage.value - 1) * mailPageSize.value}`
);
mailData.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (count > 0) {
mailCount.value = count;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchMailData()
})
</script>
<template>
<div>
<n-input-group>
<n-input v-model:value="adminMailTabAddress" />
<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>
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,103 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { processItem } from '../../utils/email-parser'
const {
localeCache, adminAuth, showAdminAuth
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
itemCount: 'itemCount',
refresh: 'Refresh'
},
zh: {
itemCount: '总数',
refresh: '刷新'
}
}
});
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/mails_unknow`
+ `?limit=${mailUnknowPageSize.value}`
+ `&offset=${(mailUnknowPage.value - 1) * mailUnknowPage.value}`
);
mailUnknowData.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (count > 0) {
mailUnknowCount.value = count;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchMailUnknowData();
})
</script>
<template>
<div>
<n-button @click="fetchMailUnknowData" type="primary" ghost>
{{ t('refresh') }}
</n-button>
<n-list hoverable clickable>
<div style="display: inline-block; margin-bottom: 10px;">
<n-pagination v-model:page="mailUnknowPage" v-model:page-size="mailUnknowPageSize"
:item-count="mailUnknowCount" simple>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-list-item v-for="row in mailUnknowData" 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>
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,118 @@
<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 cleanMailsDays = ref(30)
const cleanUnknowMailsDays = ref(30)
const cleanAddressDays = ref(30)
const cleanSendBoxDays = ref(30)
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
tip: 'Please input the cleanup days',
mailBoxTip: "Clean up {day} days ago mailbox",
mailUnknowTip: "Clean up {day} days ago mails with unknow receiver",
addressUnActiveTip: "Clean up {day} days ago unactive address",
sendBoxTip: "Clean up {day} days ago sendbox",
cleanupSuccess: "Cleanup success",
},
zh: {
tip: '请输入清理天数',
mailBoxTip: "清理{day}天前的收件箱",
mailUnknowTip: "清理{day}天前的无收件人邮件",
addressUnActiveTip: "清理{day}天前的未活动地址",
sendBoxTip: "清理{day}天前的发件箱",
cleanupSuccess: "清理成功",
}
}
});
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");
}
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script>
<template>
<div class="center">
<n-card>
<div class="item">
<n-input-number v-model:value="cleanMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails', cleanMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('mailBoxTip', { day: cleanMailsDays }) }}
</n-button>
</div>
<div class="item">
<n-input-number v-model:value="cleanUnknowMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails_unknow', cleanUnknowMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('mailUnknowTip', { day: cleanUnknowMailsDays }) }}
</n-button>
</div>
<div class="item">
<n-input-number v-model:value="cleanAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('address', cleanAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('addressUnActiveTip', { day: cleanAddressDays }) }}
</n-button>
</div>
<div class="item">
<n-input-number v-model:value="cleanSendBoxDays" :placeholder="t('tip')" />
<n-button @click="cleanup('sendbox', cleanSendBoxDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('sendBoxTip', { day: cleanSendBoxDays }) }}
</n-button>
</div>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
.item {
display: flex;
margin: 10px;
}
</style>

View File

@@ -0,0 +1,162 @@
<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, adminAuth, adminSendBoxTabAddress } = 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 () => {
if (!adminSendBoxTabAddress.value) {
return
}
try {
const { results, count: addressCount } = await api.fetch(
`/admin/sendbox`
+ `?address=${adminSendBoxTabAddress.value}`
+ `&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 () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script>
<template>
<div v-if="settings.address">
<n-modal v-model:show="showModal" preset="dialog">
<pre>{{ curRow.raw }}</pre>
</n-modal>
<n-input-group>
<n-input v-model:value="adminSendBoxTabAddress" />
<n-button @click="fetchData" type="primary" ghost>
{{ 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

@@ -0,0 +1,190 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
success: 'Success',
enable: 'Enable',
disable: 'Disable',
modify: 'Modify',
created_at: 'Created At',
action: 'Action',
itemCount: 'itemCount',
modalTip: 'Please input the sender balance',
balance: 'Balance',
refresh: 'Refresh',
ok: 'OK'
},
zh: {
address: '地址',
success: '成功',
enable: '启用',
disable: '禁用',
modify: '修改',
created_at: '创建时间',
action: '操作',
itemCount: '总数',
modalTip: '请输入发件额度',
balance: '余额',
refresh: '刷新',
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 updateData = async () => {
try {
await api.fetch(`/admin/address_sender`, {
method: 'POST',
body: JSON.stringify({
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}`
);
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: "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',
ghost: 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 @click="updateData()" size="small" tertiary round type="primary">
{{ t('ok') }}
</n-button>
</template>
</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" ghost>
{{ 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

@@ -0,0 +1,92 @@
<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: 'User Count',
activeUser: '7 days Active User',
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-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>
</template>

View File

@@ -0,0 +1,156 @@
<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">
<pre>{{ 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" ghost>
{{ 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

@@ -0,0 +1,189 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import { useStorage } from '@vueuse/core'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import router from '../../router'
const message = useMessage()
const isPreview = ref(false)
const mailModel = useStorage('mailModelCache', {
fromName: "",
toName: "",
toMail: "",
subject: "",
isHtml: false,
content: "",
})
const { settings } = 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',
isHtml: 'Enable HTML',
edit: 'Edit',
preview: 'Preview',
content: 'Content',
send: 'Send',
requestAccess: 'Request Access',
requestAccessTip: 'You need to request access to send mail',
send_balance: 'Send Mail Balance Left',
},
zh: {
successSend: '请查看您的发件箱, 如果失败, 请检查您的余额或稍后重试。',
fromName: '你的名称和地址,名称不填写则使用邮箱地址',
toName: '收件人名称和地址,名称不填写则使用邮箱地址',
subject: '主题',
options: '选项',
isHtml: '启用HTML',
edit: '编辑',
preview: '预览',
content: '内容',
send: '发送',
requestAccess: '申请权限',
requestAccessTip: '您需要申请权限才能发送邮件',
send_balance: '剩余发送邮件额度',
}
}
});
const send = async () => {
try {
await api.fetch(`/api/send_mail`,
{
method: 'POST',
body:
JSON.stringify({
from_name: mailModel.value.fromName,
to_name: mailModel.value.toName,
to_mail: mailModel.value.toMail,
subject: mailModel.value.subject,
is_html: mailModel.value.isHtml,
content: mailModel.value.content,
})
})
mailModel.value = {
fromName: "",
toName: "",
toMail: "",
subject: "",
isHtml: false,
content: "",
}
} catch (error) {
message.error(error.message || "error");
} finally {
message.success(t("successSend"));
router.push('/');
}
}
const requestAccess = async () => {
try {
await api.fetch(`/api/requset_send_mail_access`,
{
method: 'POST',
body: JSON.stringify({})
}
)
message.success(t("success"))
} catch (error) {
message.error(error.message || "error");
}
}
</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>
{{ t('requestAccessTip') }}
<n-button type="primary" ghost @click="requestAccess">{{ t('requestAccess') }}</n-button>
</n-alert>
<br />
</div>
<div v-else>
<n-alert type="info" show-icon>
{{ 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="mailModel">
<n-form-item :label="t('fromName')" label-placement="top">
<n-input-group>
<n-input v-model:value="mailModel.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="mailModel.toName" />
<n-input v-model:value="mailModel.toMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('subject')" label-placement="top">
<n-input v-model:value="mailModel.subject" />
</n-form-item>
<n-form-item :label="t('options')" label-placement="top">
<n-checkbox v-model:checked="mailModel.isHtml">
{{ t('isHtml') }}
</n-checkbox>
<n-button v-if="mailModel.isHtml" @click="isPreview = !isPreview">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<div v-if="isPreview" v-html="mailModel.content" />
<n-input v-else type="textarea" v-model:value="mailModel.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

@@ -7,14 +7,18 @@ import { splitVendorChunkPlugin } from 'vite';
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
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/
export default defineConfig({
build: {
outDir: '../dist',
outDir: './dist',
},
plugins: [
vue(),
wasm(),
topLevelAwait(),
splitVendorChunkPlugin(),
AutoImport({
imports: [

14
mail-parser-wasm/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb

View File

@@ -0,0 +1,13 @@
[package]
name = "mail-parser-wasm"
version = "0.1.6"
edition = "2021"
description = "A simple mail parser for wasm"
license = "MIT"
[lib]
crate-type = ["cdylib"]
[dependencies]
mail-parser = "0.9.3"
wasm-bindgen = "0.2.92"

View File

@@ -0,0 +1,16 @@
# mail-parser-wasm
## usage
```js
import { parse_message } from 'mail-parser-wasm'
const parsedEmail = parse_message(item.raw);
```
## build
```bash
wasm-pack build --release
wasm-pack publish
```

159
mail-parser-wasm/src/lib.rs Normal file
View File

@@ -0,0 +1,159 @@
extern crate wasm_bindgen;
use mail_parser::{MessageParser, MimeHeaders};
use wasm_bindgen::prelude::*;
#[derive(Clone)]
#[wasm_bindgen]
pub struct AttachmentResult {
content_id: String,
content_type: String,
filename: String,
content: Vec<u8>,
}
#[wasm_bindgen]
impl AttachmentResult {
#[wasm_bindgen(getter)]
pub fn content_id(&self) -> String {
self.content_id.clone()
}
#[wasm_bindgen(getter)]
pub fn content_type(&self) -> String {
self.content_type.clone()
}
#[wasm_bindgen(getter)]
pub fn filename(&self) -> String {
self.filename.clone()
}
#[wasm_bindgen(getter)]
pub fn content(&self) -> Vec<u8> {
self.content.clone()
}
}
#[wasm_bindgen]
pub struct MessageResult {
sender: String,
subject: String,
body_html: String,
text: String,
attachments: Vec<AttachmentResult>,
}
#[wasm_bindgen]
impl MessageResult {
#[wasm_bindgen(getter)]
pub fn sender(&self) -> String {
self.sender.clone()
}
#[wasm_bindgen(getter)]
pub fn subject(&self) -> String {
self.subject.clone()
}
#[wasm_bindgen(getter)]
pub fn body_html(&self) -> String {
self.body_html.clone()
}
#[wasm_bindgen(getter)]
pub fn text(&self) -> String {
self.text.clone()
}
#[wasm_bindgen(getter)]
pub fn attachments(&self) -> Vec<AttachmentResult> {
self.attachments.clone()
}
}
pub fn parse_attachment(message: &mail_parser::Message) -> Vec<AttachmentResult> {
let mut attachments: Vec<AttachmentResult> = Vec::new();
for attachment in message.attachments() {
if !attachment.is_message() {
attachments.push(AttachmentResult {
content_id: attachment
.content_id()
.map(|id| id.to_owned())
.unwrap_or(String::new()),
content_type: attachment
.content_type()
.map(|ct| {
let c_type = ct.c_type.clone().into_owned();
let c_subtype = ct.c_subtype.clone();
if c_subtype.is_none() {
return c_type;
} else {
return format!("{}/{}", c_type, c_subtype.unwrap());
}
})
.unwrap_or(String::new()),
filename: attachment
.attachment_name()
.map(|name| name.to_owned())
.unwrap_or(String::new()),
content: attachment.contents().to_vec(),
});
} else {
attachments.append(
&mut attachment
.message()
.map(|msg| parse_attachment(msg))
.unwrap_or(Vec::new()),
);
}
}
attachments
}
#[wasm_bindgen]
pub fn parse_message(raw_message: &str) -> MessageResult {
// check if the message is valid
let res = MessageParser::default().parse(raw_message);
if res.is_none() {
return MessageResult {
sender: String::new(),
subject: String::new(),
body_html: String::new(),
text: String::new(),
attachments: Vec::new(),
};
}
let message = res.unwrap();
MessageResult {
sender: message
.from()
.and_then(|from| from.first())
.map(|addr| {
if addr.name().is_some() {
return format!(
"{} <{}>",
addr.name().unwrap(),
addr.address().unwrap_or("")
);
} else {
return addr.address().unwrap_or("").to_owned();
}
})
.unwrap_or(String::new()),
subject: message
.subject()
.map(|subject| subject.to_owned())
.unwrap_or(String::new()),
body_html: message
.body_html(0)
.map(|html| html.into_owned())
.unwrap_or(String::new()),
text: message
.body_text(0)
.map(|text| text.into_owned())
.unwrap_or(String::new()),
attachments: parse_attachment(&message),
}
}

307
vitepress-docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,307 @@
## 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

@@ -0,0 +1,40 @@
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 },
socialLinks: [
{
icon: 'github',
link: 'https://github.com/dreamhunter2333/cloudflare_temp_email'
}
]
}
})

View File

@@ -0,0 +1,55 @@
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

@@ -0,0 +1,131 @@
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: '功能简介',
collapsed: false,
items: [
{ text: 'Admin 控制台', link: 'feature/admin' },
]
},
{ text: '参考', base: "/", link: 'reference' }
]
}

View File

@@ -0,0 +1,154 @@
# 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
```
![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`
```bash
name = "cloudflare_temp_email"
main = "src/worker.js"
compatibility_date = "2023-08-14"
node_compat = true
[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"]
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
# 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
# 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

@@ -0,0 +1,24 @@
---
# 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 mailboxes
---

View File

@@ -0,0 +1,28 @@
---
# 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: 支持发送邮件
details: 支持通过域名邮箱发送 txt 或者 html 邮件
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
# 初始化/更新 D1 数据库
第一次执行登录 wrangler 命令时,会提示登录, 按提示操作即可
## 初始化数据库
```bash
# 创建 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
wrangler d1 execute dev --file=db/2024-01-13-patch.sql
wrangler d1 execute dev --file=db/2024-04-03-patch.sql
```

View File

@@ -0,0 +1,25 @@
# 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)

View File

@@ -0,0 +1,17 @@
# 先决条件
## wrangler 的安装
安装 wrangler
```bash
npm install wrangler -g
```
## 克隆项目
```bash
git clone https://github.com/dreamhunter2333/cloudflare_temp_email.git
# 切换到最新 tag 或者你想部署的分支,你也可以直接使用 main 分支
# git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
```

View File

@@ -0,0 +1,61 @@
# Cloudflare workers 后端
## 初始化项目
```bash
cd worker
pnpm install
cp wrangler.toml.template wrangler.toml
```
## 修改 `wrangler.toml` 配置文件
```toml
name = "cloudflare_temp_email"
main = "src/worker.js"
compatibility_date = "2023-12-01"
# 如果你想使用自定义域名,你需要添加 routes 配置
# routes = [
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
# ]
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 = "" # 黑名单,用于过滤发件人,逗号分隔
# dkim config
# DKIM_SELECTOR = "mailchannels" # 参考 DKIM 部分 mailchannels._domainkey 的 mailchannels
# DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容
# D1 数据库的名称和 ID 可以在 cloudflare 控制台查看
[[d1_databases]]
binding = "DB"
database_name = "xxx" # D1 数据库名称
database_id = "xxx" # D1 数据库 ID
# 新建地址限流配置 /api/new_address
# [[unsafe.bindings]]
# name = "RATE_LIMITER"
# type = "ratelimit"
# namespace_id = "1001"
# # 10 requests per minute
# simple = { limit = 10, period = 60 }
```
## 部署
第一次部署会提示创建项目, `production` 分支请填写 `production`
```bash
pnpm run deploy
```
部署成功之后再路由中可以看到 `worker``url`,控制台也会输出 `worker``url`
![worker](/readme_assets/worker.png)

View File

@@ -0,0 +1,12 @@
# 配置发送邮件
1. 找到域名 `DNS` 记录的 `TXT``SPF` 记录, 增加 `include:relay.mailchannels.net`
`v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all`
2. 新建 `_mailchannels` 记录, 类型为 `TXT`, 内容为 `v=mc1 cfid=你的worker域名`
- 此处 worker 域名为后端 api 的域名,比如我部署在 `https://temp-email-api.awsl.uk/`,则填写 `v=mc1 cfid=awsl.uk`
- 如果你的域名是 `https://temp-email-api.xxx.workers.dev`,则填写 `v=mc1 cfid=xxx.workers.dev`

View File

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

View File

@@ -0,0 +1,9 @@
# Cloudflare Email Routing
1. 配置对应域名的 `电子邮件 DNS 记录`, 如果是多个域名,需要配置多个域名的 `电子邮件 DNS 记录`
2. 在将电子邮件地址绑定到您的 Worker 之前,您需要启用电子邮件路由并拥有至少一个经过验证的电子邮件地址。
3. 配置每个域名的 `Cloudflare Email Routing` catch-all 发送到 `worker`
![email](/readme_assets/email.png)

View File

@@ -0,0 +1,7 @@
# Admin 控制台
部署前端应用之后,访问 `/admin` 路径即可进入管理控制台。
需要在后端配置 `admin 控制台密码`, 不配置则不允许访问控制台。
![admin](/feature/admin.png)

View File

@@ -0,0 +1,8 @@
# 快速开始
- 良好的网络环境
- cloudflare 账号
打开 [cloudflare控制台](https://dash.cloudflare.com/)
请查看通过 [命令行部署](/zh/guide/cli/pre-requisite) 或者 [用户界面部署](/zh/guide/ui/d1)

View File

@@ -0,0 +1,7 @@
# Star History
<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: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
</picture>

View File

@@ -0,0 +1,25 @@
# 初始化/更新 D1 数据库
## 初始化数据库
打开 cloudflare 控制台,选择 `Workers & Pages` -> `D1` -> `Create Database`,点击创建数据库
![d1](/ui_install/d1.png)
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
打开 `Console` 标签页,输入 `db/schema.sql` 的内容,点击 `Execute` 执行
![d1](/ui_install/d1-exec.png)
## 更新数据库 schema
`schema` 更新,请确认你之前部署的版本,
查看 [更新日志](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/CHANGELOG.md)
找到需要执行的 `patch` 文件, 执行, 例如: `db/2024-01-13-patch.sql`
打开 `Console` 标签页,输入 `patch` 文件的内容,点击 `Execute` 执行
![d1](/ui_install/d1-exec.png)

View File

@@ -0,0 +1,95 @@
# Cloudflare Pages 前端
<script setup>
import { ref } from 'vue'
import JSZip from 'jszip';
const domain = ref("")
const downloadUrl = ref("")
const tip = ref("下载")
const generate = async () => {
try {
const response = await fetch("/ui_install/frontend.zip");
const arrayBuffer = await response.arrayBuffer();
var zip = new JSZip();
await zip.loadAsync(arrayBuffer);
let target_content = ""
let target_path = ""
const directory = zip.folder("assets");
if (directory) {
for (const [relativePath, zipEntry] of Object.entries(directory.files)) {
console.log(relativePath);
if (relativePath.startsWith("assets/index-") && relativePath.endsWith(".js")){
let content = await zipEntry.async("string");
content = content.replace("https://temp-email-api.xxx.xxx", domain.value);
target_path = relativePath;
zip.file(relativePath, content);
break;
}
}
}
if (!target_path) {
tip.value = "生成失败";
downloadUrl.value = '';
}
const blob = await zip.generateAsync({ type: "blob" });
const url = window.URL.createObjectURL(blob);
downloadUrl.value = url;
} catch (error) {
console.error("Error: ", error);
}
}
</script>
1. 点击 `Workers & Pages` -> `Overview` -> `Create Application`
![create pages](/ui_install/worker_home.png)
2. 选择 `Pages`,选择 `Create using direct upload`
![pages](/ui_install/pages.png)
3. 输入部署的 worker 的地址, 地址不要带 `/`,点击生成,成功会出现下载按钮,你会得到一个 zip 包
- 此处 worker 域名为后端 api 的域名,比如我部署在 `https://temp-email-api.awsl.uk`,则填写 `https://temp-email-api.awsl.uk`
- 如果你的域名是 `https://temp-email-api.xxx.workers.dev`,则填写 `https://temp-email-api.xxx.workers.dev`
<div :class="$style.container">
<input :class="$style.input" type="text" v-model="domain" placeholder="请输入地址"></input>
<button :class="$style.button" @click="generate">生成</button>
<a v-if="downloadUrl" :href="downloadUrl" download="frontend.zip">{{ tip }}</a>
</div>
4. 选择 `Pages`,点击 `Create Pages`, 修改名称,上传下载的 zip 包,然后点击 `Deploy`
![pages1](/ui_install/pages-1.png)
5. 打开 刚刚部署的 `Pages`,点击 `Custom Domain` 这里可以添加自己的域名,你也可以使用自动生成的 `*.pages.dev` 的域名。能打开域名说明部署成功。
![pages domain](/ui_install/pages-domain.png)
<style module>
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.input {
border: 2px solid deepskyblue;
margin-right: 10px;
width: 75%;
border-radius: 5px;
}
.button {
background-color: deepskyblue;
padding: 5px 10px;
border-radius: 5px;
margin-right: 10px;
}
.button:hover {
background-color: green;
}
</style>

View File

@@ -0,0 +1,27 @@
# Cloudflare workers 后端
1. 点击 `Workers & Pages` -> `Overview` -> `Create Application`
![create worker](/ui_install/worker_home.png)
2. 选择 `Worker`,点击 `Create Worker`, 修改名称然后点击 `Deploy`
![worker1](/ui_install/worker-1.png)
3. 下载 [worker.js](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/worker.js)
4. 回到 `Overview`,找到刚刚创建的 worker点击 `Edit Code`, 上传 `worker.js`, 删除 `index.js`,然后重命名 `worker.js``index.js`, 点击 `Deploy`
![worker2](/ui_install/worker-2.png)
5. 点击 `Settings` -> `Trggers`, 这里可以添加自己的域名,你也可以使用自动生成的 `*.workers.dev` 的域名。能打开域名说明部署成功,记录下这个域名,后面部署前端会用到。
![worker3](/ui_install/worker-3.png)
6. 点击 `Settings` -> `Variables`, 如图所示添加变量,参考 [修改 wrangler.toml 配置文件](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件) 中的 `vars` 部分
![worker-var](/ui_install/worker-var.png)
7. 点击 `Settings` -> `Variables`, 下拉找到 `D1 Database`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 D1 数据库,点击 `Deploy`
![worker-d1](/ui_install/worker-d1.png)

View File

@@ -0,0 +1,7 @@
# 临时邮箱简介
## 什么是临时邮箱
临时邮箱,也被称为一次性邮箱或临时邮件地址,是一种用于临时接收邮件的虚拟邮箱。与常规邮箱不同,临时邮箱旨在提供一种匿名且临时的邮件接收解决方案。
临时邮箱往往由网站或在线服务提供商提供,用户可以在需要注册或接收验证邮件时使用临时邮箱地址,而无需暴露自己的真实邮箱地址。这样做的好处是可以保护个人隐私

View File

@@ -0,0 +1,20 @@
{
"name": "temp-mail-docs",
"private": true,
"version": "0.2.6",
"type": "module",
"devDependencies": {
"@types/node": "^20.12.7",
"vitepress": "^1.1.0",
"wrangler": "^3.50.0"
},
"scripts": {
"dev": "vitepress dev docs",
"build": "vitepress build docs",
"preview": "vitepress preview docs",
"deploy": "npm run build && wrangler pages deploy ./docs/.vitepress/dist --project-name=temp-mail-docs --branch production"
},
"dependencies": {
"jszip": "^3.10.1"
}
}

2001
vitepress-docs/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,20 +6,14 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"start": "wrangler dev"
"start": "wrangler dev",
"build": "wrangler deploy src/worker.js --dry-run --outdir dist --minify"
},
"devDependencies": {
"wrangler": "^3.48.0"
},
"dependencies": {
"hono": "^4.2.2",
"mailparser": "^3.6.9",
"mimetext": "^3.0.24",
"postal-mime": "^2.2.1"
},
"pnpm": {
"patchedDependencies": {
"mailparser@3.6.9": "patches/mailparser@3.6.9.patch"
}
"mimetext": "^3.0.24"
}
}

View File

@@ -1,24 +0,0 @@
diff --git a/lib/stream-hash.js b/lib/stream-hash.js
index 3f9b44133766c04866ab2ab178c061f35dbf8f42..368ed6d94da4401909b7eddc87d18354947daf33 100644
--- a/lib/stream-hash.js
+++ b/lib/stream-hash.js
@@ -7,19 +7,15 @@ class StreamHash extends Transform {
constructor(attachment, algo) {
super();
this.attachment = attachment;
- this.algo = (algo || 'md5').toLowerCase();
- this.hash = crypto.createHash(algo);
this.byteCount = 0;
}
_transform(chunk, encoding, done) {
- this.hash.update(chunk);
this.byteCount += chunk.length;
done(null, chunk);
}
_flush(done) {
- this.attachment.checksum = this.hash.digest('hex');
this.attachment.size = this.byteCount;
done();
}

204
worker/pnpm-lock.yaml generated
View File

@@ -4,24 +4,13 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
mailparser@3.6.9:
hash: vtv6mupuxeqjidadcgidi322su
path: patches/mailparser@3.6.9.patch
dependencies:
hono:
specifier: ^4.2.2
version: 4.2.2
mailparser:
specifier: ^3.6.9
version: 3.6.9(patch_hash=vtv6mupuxeqjidadcgidi322su)
mimetext:
specifier: ^3.0.24
version: 3.0.24
postal-mime:
specifier: ^2.2.1
version: 2.2.1
devDependencies:
wrangler:
@@ -340,13 +329,6 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.15
dev: true
/@selderee/plugin-htmlparser2@0.11.0:
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
dependencies:
domhandler: 5.0.3
selderee: 0.11.0
dev: false
/@types/node-forge@1.3.11:
resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==}
dependencies:
@@ -450,48 +432,6 @@ packages:
ms: 2.1.2
dev: true
/deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
dev: false
/dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
dev: false
/domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
dev: false
/domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dependencies:
domelementtype: 2.3.0
dev: false
/domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dev: false
/encoding-japanese@2.0.0:
resolution: {integrity: sha512-++P0RhebUC8MJAwJOsT93dT+5oc5oPImp1HubZpAuCZ5kTLnhuuBhKHj2jJeO/Gj93idPBWmIuQ9QWMe5rX3pQ==}
engines: {node: '>=8.10.0'}
dev: false
/entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
dev: false
/esbuild@0.17.19:
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
engines: {node: '>=12'}
@@ -580,43 +520,11 @@ packages:
function-bind: 1.1.2
dev: true
/he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
dev: false
/hono@4.2.2:
resolution: {integrity: sha512-mDmjBHF6uBNN3TASdAbDCFsN9FLbrlgXyFZkhLEkU7hUgk0+T9hcsUrL/nho4qV+Xk0RDHx7gop4Q1gelZZVRw==}
engines: {node: '>=16.0.0'}
dev: false
/html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0
deepmerge: 4.3.1
dom-serializer: 2.0.0
htmlparser2: 8.0.2
selderee: 0.11.0
dev: false
/htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
entities: 4.5.0
dev: false
/iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
dependencies:
safer-buffer: 2.1.2
dev: false
/is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -651,80 +559,12 @@ packages:
resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==}
dev: false
/leac@0.6.0:
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
dev: false
/libbase64@1.2.1:
resolution: {integrity: sha512-l+nePcPbIG1fNlqMzrh68MLkX/gTxk/+vdvAb388Ssi7UuUN31MI44w4Yf33mM3Cm4xDfw48mdf3rkdHszLNew==}
dev: false
/libbase64@1.3.0:
resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==}
dev: false
/libmime@5.2.0:
resolution: {integrity: sha512-X2U5Wx0YmK0rXFbk67ASMeqYIkZ6E5vY7pNWRKtnNzqjvdYYG8xtPDpCnuUEnPU9vlgNev+JoSrcaKSUaNvfsw==}
dependencies:
encoding-japanese: 2.0.0
iconv-lite: 0.6.3
libbase64: 1.2.1
libqp: 2.0.1
dev: false
/libmime@5.3.4:
resolution: {integrity: sha512-TsqPdercr6DHrnoQx1F0nS2Y4yPT+fWuOjEP2rqzvV77hMYWomTe/rpm0u9JORQ/FavEXybAGcBJsQbLr9+hjA==}
dependencies:
encoding-japanese: 2.0.0
iconv-lite: 0.6.3
libbase64: 1.3.0
libqp: 2.1.0
dev: false
/libqp@2.0.1:
resolution: {integrity: sha512-Ka0eC5LkF3IPNQHJmYBWljJsw0UvM6j+QdKRbWyCdTmYwvIDE6a7bCm0UkTAL/K+3KXK5qXT/ClcInU01OpdLg==}
dev: false
/libqp@2.1.0:
resolution: {integrity: sha512-O6O6/fsG5jiUVbvdgT7YX3xY3uIadR6wEZ7+vy9u7PKHAlSEB6blvC1o5pHBjgsi95Uo0aiBBdkyFecj6jtb7A==}
dev: false
/linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
dependencies:
uc.micro: 2.1.0
dev: false
/magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
dependencies:
sourcemap-codec: 1.4.8
dev: true
/mailparser@3.6.9(patch_hash=vtv6mupuxeqjidadcgidi322su):
resolution: {integrity: sha512-1fIDZlgN1NnuzmTSEUxkaViquXYkw5NbQehVc+kz55QRy98QgLdTtRSKv289Jy4NrCiDchRx6zAijB4HrPsvkA==}
dependencies:
encoding-japanese: 2.0.0
he: 1.2.0
html-to-text: 9.0.5
iconv-lite: 0.6.3
libmime: 5.3.4
linkify-it: 5.0.0
mailsplit: 5.4.0
nodemailer: 6.9.11
punycode: 2.3.1
tlds: 1.250.0
dev: false
patched: true
/mailsplit@5.4.0:
resolution: {integrity: sha512-wnYxX5D5qymGIPYLwnp6h8n1+6P6vz/MJn5AzGjZ8pwICWssL+CCQjWBIToOVHASmATot4ktvlLo6CyLfOXWYA==}
dependencies:
libbase64: 1.2.1
libmime: 5.2.0
libqp: 2.0.1
dev: false
/mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
@@ -795,23 +635,11 @@ packages:
engines: {node: '>= 6.13.0'}
dev: true
/nodemailer@6.9.11:
resolution: {integrity: sha512-UiAkgiERuG94kl/3bKfE8o10epvDnl0vokNEtZDPTq9BWzIl6EFT9336SbIT4oaTBD8NmmUTLsQyXHV82eXSWg==}
engines: {node: '>=6.0.0'}
dev: false
/normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
dev: true
/parseley@0.12.1:
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
dependencies:
leac: 0.6.0
peberminta: 0.9.0
dev: false
/path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
dev: true
@@ -820,28 +648,15 @@ packages:
resolution: {integrity: sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==}
dev: true
/peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
dev: false
/picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
dev: true
/postal-mime@2.2.1:
resolution: {integrity: sha512-YqGeFmiKXUxv32hOy2t47VX67mYydC47CTCc7+HKd3xlNKPDhivnO/ZovN3iWXxvyyL2TRTxusuuq3etWeCKsw==}
dev: false
/printable-characters@1.0.42:
resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
dev: true
/punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
dev: false
/readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
@@ -888,16 +703,6 @@ packages:
estree-walker: 0.6.1
dev: true
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
dev: false
/selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
dependencies:
parseley: 0.12.1
dev: false
/selfsigned@2.4.1:
resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==}
engines: {node: '>=10'}
@@ -933,11 +738,6 @@ packages:
engines: {node: '>= 0.4'}
dev: true
/tlds@1.250.0:
resolution: {integrity: sha512-rWsBfFCWKrjM/o2Q1TTUeYQv6tHSd/umUutDjVs6taTuEgRDIreVYIBgWRWW4ot7jp6n0UVUuxhTLWBtUmPu/w==}
hasBin: true
dev: false
/to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -949,10 +749,6 @@ packages:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
dev: true
/uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
dev: false
/undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
dev: true

248
worker/src/admin_api.js Normal file
View File

@@ -0,0 +1,248 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { getSendbox } from './send_mail_api'
const api = new Hono()
api.get('/admin/address', async (c) => {
const { limit, offset, query } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (query) {
const { results } = await c.env.DB.prepare(
`SELECT * FROM address where concat('${c.env.PREFIX}', name) like ? order by id desc limit ? offset ? `
).bind(`%${query}%`, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where concat('${c.env.PREFIX}', name) like ?`
).bind(`%${query}%`).first();
count = addressCount;
}
return c.json({
results: results.map((r) => {
r.name = c.env.PREFIX + r.name;
return r;
}),
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM address order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address`
).first();
count = addressCount;
}
return c.json({
results: results.map((r) => {
r.name = c.env.PREFIX + r.name;
return r;
}),
count: count
})
})
api.delete('/admin/delete_address/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE id = ? `
).bind(id).run();
if (!success) {
return c.text("Failed to delete address", 500)
}
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM mails WHERE address IN
(select concat('${c.env.PREFIX}', name) from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text("Failed to delete mails", 500)
}
return c.json({
success: success
})
})
api.get('/admin/show_password/:id', async (c) => {
const { id } = c.req.param();
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(id).first("name");
// compute address
const emailAddress = c.env.PREFIX + name
const jwt = await Jwt.sign({
address: emailAddress,
address_id: id
}, c.env.JWT_SECRET)
return c.json({
password: jwt
})
})
api.get('/admin/mails', async (c) => {
const { address, limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM raw_mails where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM raw_mails where address = ? `
).bind(address).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
});
api.get('/admin/mails_unknow', async (c) => {
const { limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(`
SELECT * FROM raw_mails
where address NOT IN(select concat('${c.env.PREFIX}', name) from address)
order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM raw_mails
where address NOT IN
(select concat('${c.env.PREFIX}', name) from address)`
).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
});
api.get('/admin/address_sender', async (c) => {
const { limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM address_sender order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address_sender`
).first();
count = addressCount;
}
return c.json({
results: results,
count: count
})
})
api.post('/admin/address_sender', async (c) => {
let { address_id, balance, enabled } = await c.req.json();
if (!address_id) {
return c.text("Invalid address_id", 400)
}
enabled = enabled ? 1 : 0;
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
).bind(enabled, balance, address_id).run();
if (!success) {
return c.text("Failed to update address sender", 500)
}
return c.json({
success: success
})
})
api.get('/admin/sendbox', async (c) => {
const { address, limit, offset } = c.req.query();
return getSendbox(c, address, limit, offset);
})
api.get('/admin/statistics', async (c) => {
const { count: mailCountV1 } = await c.env.DB.prepare(`
SELECT count(*) as count FROM mails`
).first();
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM raw_mails`
).first();
const { count: addressCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM address`
).first();
const { count: activeUserCount7days } = await c.env.DB.prepare(`
SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first();
const { count: sendMailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM sendbox`
).first();
return c.json({
mailCount: (mailCountV1 || 0) + (mailCount || 0),
userCount: addressCount,
activeUserCount7days: activeUserCount7days,
sendMailCount: sendMailCount
})
});
api.post('/admin/cleanup', async (c) => {
const { cleanType, cleanDays } = await c.req.json();
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
return c.text("Invalid cleanType or cleanDays", 400)
}
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
switch (cleanType) {
case "mails":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "mails_unknow":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE address NOT IN
(select concat('${c.env.PREFIX}', name) from address) AND created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "address":
await c.env.DB.prepare(`
DELETE FROM address WHERE updated_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "sendbox":
await c.env.DB.prepare(`
DELETE FROM sendbox WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
default:
return c.text("Invalid cleanType", 400)
}
return c.json({
success: true
})
})
export { api }

114
worker/src/api_v1.js Normal file
View File

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

View File

@@ -1,8 +1,5 @@
import { createMimeMessage } from "mimetext";
import { EmailMessage } from "cloudflare:email";
import { simpleParser } from 'mailparser';
import PostalMime from 'postal-mime';
global.setImmediate = (callback) => callback();
async function email(message, env, ctx) {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
@@ -11,46 +8,19 @@ async function email(message, env, ctx) {
return;
}
if (!env.PREFIX || (message.to && message.to.startsWith(env.PREFIX))) {
const reader = message.raw.getReader();
const decoder = new TextDecoder("utf-8");
let rawEmail = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
rawEmail += decoder.decode(value);
}
const rawEmail = await new Response(message.raw).text();
const message_id = message.headers.get("Message-ID");
let parsedEmail = {};
// todo fix this
if (!message.from.endsWith("@mega.nz")) {
try {
parsedEmail = await simpleParser(rawEmail)
} catch (error) {
console.log(error)
}
}
if (!parsedEmail.html && !parsedEmail.textAsHtml && !parsedEmail.text) {
console.log("Failed parse email, try postal-mime");
parsedEmail = await PostalMime.parse(rawEmail);
}
// process email
// save email
const { success } = await env.DB.prepare(
`INSERT INTO mails (source, address, subject, message, message_id) VALUES (?, ?, ?, ?, ?)`
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to,
parsedEmail.subject || "",
parsedEmail.html || parsedEmail.textAsHtml || parsedEmail.text || "",
message_id
message.from, message.to, rawEmail, message_id
).run();
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.log(`Failed save message from ${message.from} to ${message.to}`);
}
// auto reply email
try {
const results = await env.DB.prepare(
@@ -80,28 +50,6 @@ async function email(message, env, ctx) {
} catch (error) {
console.log("reply email error", error);
}
// process attachments
try {
if (
env.ENABLE_ATTACHMENT
&& parsedEmail.attachments
&& parsedEmail.attachments.length > 0
) {
const { success } = await env.DB.prepare(
`INSERT INTO attachments (source, address, message_id, data) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, message_id,
JSON.stringify(parsedEmail.attachments)
).run();
if (!success) {
message.setReject(`Failed save attachment to ${message.to}`);
console.log(`Failed save attachment from ${message.from} to ${message.to}`);
}
}
}
catch (error) {
console.log("save attachment error", error);
}
} else {
message.setReject(`Unknown address ${message.to}`);
console.log(`Unknown address ${message.to}`);

View File

@@ -1,6 +1,8 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { getDomains, getPasswords } from './utils';
const api = new Hono()
api.get('/api/mails', async (c) => {
@@ -16,31 +18,15 @@ api.get('/api/mails', async (c) => {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT id, source, subject, message, message_id, created_at FROM mails where address = ? order by id desc limit ? offset ?`
`SELECT id, source, raw, created_at FROM raw_mails where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM mails where address = ?`
`SELECT count(*) as count FROM raw_mails where address = ?`
).bind(address).first();
count = mailCount;
}
// add attachments
let attachmentResults = [];
const message_ids = results.map((r) => r.message_id).filter((r) => r);
if (message_ids && message_ids.length > 0) {
const { results: innerAttachmentResults } = await c.env.DB.prepare(
`SELECT id, message_id FROM attachments where message_id in (${message_ids.map((id) => `'${id}'`).join(",")})`
).all();
attachmentResults = innerAttachmentResults || [];
}
results.forEach((r) => {
const attachment_id = attachmentResults.filter((ar) => ar.message_id == r.message_id).map((ar) => ar.id);
if (attachment_id && attachment_id.length > 0) {
r.attachment_id = attachment_id[0];
}
delete r.message_id;
})
return c.json({
results: results,
count: count
@@ -84,28 +70,34 @@ api.get('/api/settings', async (c) => {
console.warn("Failed to update address")
}
}
let auto_reply = {};
const results = await c.env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? `
).bind(address).first();
if (!results) {
return c.json({
auto_reply: {},
address: address
});
}
return c.json({
auto_reply: {
if (results) {
auto_reply = {
subject: results.subject,
message: results.message,
enabled: results.enabled == 1,
source_prefix: results.source_prefix,
name: results.name,
},
address: address
}
}
const { count: mailCountV1 } = await c.env.DB.prepare(
`SELECT count(*) as count FROM mails where address = ?`
).bind(address).first();
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
where address = ? and enabled = 1`
).bind(address).first("balance");
return c.json({
auto_reply: auto_reply,
address: address,
has_v1_mails: mailCountV1 && mailCountV1 > 0,
send_balance: balance || 0,
});
})
api.post('/api/settings', async (c) => {
const { address } = c.get("jwtPayload")
const { auto_reply } = await c.req.json();
@@ -134,26 +126,37 @@ api.post('/api/settings', async (c) => {
api.get('/open_api/settings', async (c) => {
// check header x-custom-auth
let needAuth = false;
if (c.env.PASSWORDS && c.env.PASSWORDS.length > 0) {
const passwords = getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
needAuth = !c.env.PASSWORDS.includes(auth);
needAuth = !passwords.includes(auth);
}
return c.json({
"prefix": c.env.PREFIX,
"domains": c.env.DOMAINS,
"domains": getDomains(c),
"needAuth": needAuth,
});
})
api.get('/api/new_address', async (c) => {
let { name, domain } = await c.req.query();
let { name, domain } = c.req.query();
// if no name, generate random name
if (!name) {
name = Math.random().toString(36).substring(2, 15);
}
// remove special characters
name = name.replace(/[^a-zA-Z0-9.]/g, '')
// check name length
if (name.length < 0) {
return c.text("Name too short", 400)
}
if (name.length > 100) {
return c.text("Name too long (max 100)", 400)
}
// check domain, generate random domain
if (!domain || !c.env.DOMAINS.includes(domain)) {
domain = c.env.DOMAINS[Math.floor(Math.random() * c.env.DOMAINS.length)];
const domains = getDomains(c);
if (!domain || !domains.includes(domain)) {
domain = domains[Math.floor(Math.random() * domains.length)];
}
// create address
const emailAddress = c.env.PREFIX + name + "@" + domain
@@ -211,169 +214,4 @@ api.delete('/api/delete_address', async (c) => {
})
})
api.get('/admin/address', async (c) => {
const { limit, offset, query } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (query) {
const { results } = await c.env.DB.prepare(
`SELECT * FROM address where concat('${c.env.PREFIX}', name) like ? order by id desc limit ? offset ? `
).bind(`%${query}%`, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where concat('${c.env.PREFIX}', name) like ?`
).bind(`%${query}%`).first();
count = addressCount;
}
return c.json({
results: results.map((r) => {
r.name = c.env.PREFIX + r.name;
return r;
}),
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM address order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address`
).first();
count = addressCount;
}
return c.json({
results: results.map((r) => {
r.name = c.env.PREFIX + r.name;
return r;
}),
count: count
})
})
api.delete('/admin/delete_address/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE id = ? `
).bind(id).run();
if (!success) {
return c.text("Failed to delete address", 500)
}
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM mails WHERE address IN
(select concat('${c.env.PREFIX}', name) from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text("Failed to delete mails", 500)
}
return c.json({
success: success
})
})
api.get('/admin/show_password/:id', async (c) => {
const { id } = c.req.param();
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(id).first("name");
// compute address
const emailAddress = c.env.PREFIX + name
const jwt = await Jwt.sign({
address: emailAddress,
address_id: id
}, c.env.JWT_SECRET)
return c.json({
password: jwt
})
})
api.get('/admin/mails', async (c) => {
const { address, limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT id, source, subject, message FROM mails where address = ? order by id desc limit ? offset ? `
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM mails where address = ? `
).bind(address).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
});
api.get('/admin/mails_unknow', async (c) => {
const { limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(`
SELECT id, source, subject, message FROM mails
where address NOT IN(select concat('${c.env.PREFIX}', name) from address)
order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM mails
where address NOT IN
(select concat('${c.env.PREFIX}', name) from address)`
).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
});
api.get('/admin/statistics', async (c) => {
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM mails`
).first();
const { count: addressCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM address`
).first();
const { count: activeUserCount7days } = await c.env.DB.prepare(`
SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first();
return c.json({
mailCount: mailCount,
userCount: addressCount,
activeUserCount7days: activeUserCount7days
})
});
// attachments
api.get("/api/attachment/:attachment_id", async (c) => {
const { attachment_id } = c.req.param();
const { data } = await c.env.DB.prepare(
`SELECT data FROM attachments where id = ? `
).bind(attachment_id).first();
if (!data) {
return c.text("Not found", 404)
}
return c.json(JSON.parse(data))
})
export { api }

158
worker/src/send_mail_api.js Normal file
View File

@@ -0,0 +1,158 @@
import { Hono } from 'hono'
const api = new Hono()
api.post('/api/requset_send_mail_access', async (c) => {
const { address } = c.get("jwtPayload")
if (!address) {
return c.text("No address", 400)
}
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO address_sender (address, enabled) VALUES (?, 0)`
).bind(address).run();
if (!success) {
return c.text("Failed to request send mail access", 500)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("Already requested", 400)
}
return c.text("Failed to request send mail access", 500)
}
return c.json({ status: "ok" })
})
api.post('/api/send_mail', async (c) => {
const { address } = c.get("jwtPayload")
// check permission
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
where address = ? and enabled = 1`
).bind(address).first("balance");
if (!balance || balance <= 0) {
return c.text("No balance", 400);
}
let {
from_name, to_mail, to_name,
subject, content, is_html
} = await c.req.json();
if (!address) {
return c.text("No address", 400)
}
if (!to_mail) {
return c.text("Invalid to mail", 400)
}
from_name = from_name || address;
to_name = to_name || to_mail;
if (!subject) {
return c.text("Invalid subject", 400)
}
if (!content) {
return c.text("Invalid content", 400)
}
let dmikBody = {}
if (c.env.DKIM_SELECTOR && c.env.DKIM_PRIVATE_KEY && address.includes("@")) {
dmikBody = {
"dkim_domain": address.split("@")[1],
"dkim_selector": c.env.DKIM_SELECTOR,
"dkim_private_key": c.env.DKIM_PRIVATE_KEY,
}
}
const body = {
"personalizations": [
{
"to": [{
"email": to_mail,
"name": to_name,
}],
...dmikBody,
}
],
"from": {
"email": address,
"name": from_name,
},
"subject": subject,
"content": [{
"type": is_html ? "text/html" : "text/plain",
"value": content,
}],
};
let send_request = new Request("https://api.mailchannels.net/tx/v1/send", {
"method": "POST",
"headers": {
"content-type": "application/json",
},
"body": JSON.stringify(body),
});
const resp = await fetch(send_request);
const respText = await resp.text();
console.log(resp.status + " " + resp.statusText + ": " + respText);
if (resp.status >= 300) {
return c.text("Failed to send mail", 500)
}
// update balance
try {
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET balance = balance - 1
where address = ?`
).bind(address).run();
if (!success) {
console.warn(`Failed to update balance for ${address}`);
}
} catch (e) {
console.warn(`Failed to update balance for ${address}`);
}
// save to sendbox
try {
if (body?.personalizations?.[0]?.dkim_private_key) {
delete body.personalizations[0].dkim_private_key;
}
const { success: success2 } = await c.env.DB.prepare(
`INSERT INTO sendbox (address, raw) VALUES (?, ?)`
).bind(address, JSON.stringify(body)).run();
if (!success2) {
console.warn(`Failed to save to sendbox for ${address}`);
}
} catch (e) {
console.warn(`Failed to save to sendbox for ${address}`);
}
return c.json({ status: "ok" });
})
const getSendbox = async (c, address, limit, offset) => {
if (!address) {
return c.json({ "error": "No address" }, 400)
}
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM sendbox where address = ?
order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox where address = ?`
).bind(address).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
}
api.get('/api/sendbox', async (c) => {
const { address } = c.get("jwtPayload")
const { limit, offset } = c.req.query();
return getSendbox(c, address, limit, offset);
})
export { api, getSendbox }

47
worker/src/utils.js Normal file
View File

@@ -0,0 +1,47 @@
export const getDomains = (c) => {
if (!c.env.DOMAINS) {
return [];
}
// check if DOMAINS is an array, if not use json.parse
if (!Array.isArray(c.env.DOMAINS)) {
try {
return JSON.parse(c.env.DOMAINS);
} catch (e) {
console.error("Failed to parse DOMAINS", e);
return [];
}
}
return c.env.DOMAINS;
}
export const getPasswords = (c) => {
if (!c.env.PASSWORDS) {
return [];
}
// check if PASSWORDS is an array, if not use json.parse
if (!Array.isArray(c.env.PASSWORDS)) {
try {
return JSON.parse(c.env.PASSWORDS);
} catch (e) {
console.error("Failed to parse PASSWORDS", e);
return [];
}
}
return c.env.PASSWORDS;
}
export const getAdminPasswords = (c) => {
if (!c.env.ADMIN_PASSWORDS) {
return [];
}
// check if ADMIN_PASSWORDS is an array, if not use json.parse
if (!Array.isArray(c.env.ADMIN_PASSWORDS)) {
try {
return JSON.parse(c.env.ADMIN_PASSWORDS);
} catch (e) {
console.error("Failed to parse ADMIN_PASSWORDS", e);
return [];
}
}
return c.env.ADMIN_PASSWORDS;
}

View File

@@ -3,19 +3,31 @@ import { cors } from 'hono/cors';
import { jwt } from 'hono/jwt'
import { api } from './router';
import { api as adminApi } from './admin_api';
import { api as apiV1 } from './api_v1';
import { api as apiSendMail } from './send_mail_api'
import { email } from './email';
import { getAdminPasswords, getPasswords } from './utils';
const app = new Hono()
app.use('/*', cors());
app.use('/api/*', async (c, next) => {
// check header x-custom-auth
if (c.env.PASSWORDS && c.env.PASSWORDS.length > 0) {
const passwords = getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
if (!auth || !c.env.PASSWORDS.includes(auth)) {
if (!auth || !passwords.includes(auth)) {
return c.text("Need Password", 401)
}
}
if (c.req.path.startsWith("/api/new_address")) {
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
if (reqIp && c.env.RATE_LIMITER) {
const { success } = await c.env.RATE_LIMITER.limit({ key: reqIp })
if (!success) {
return c.text(`IP=${reqIp} Rate limit exceeded for /api/new_address`, 429)
}
}
await next();
return;
};
@@ -24,9 +36,10 @@ app.use('/api/*', async (c, next) => {
app.use('/admin/*', async (c, next) => {
// check header x-admin-auth
if (c.env.ADMIN_PASSWORDS && c.env.ADMIN_PASSWORDS.length > 0) {
const adminPasswords = getAdminPasswords(c);
if (adminPasswords && adminPasswords.length > 0) {
const adminAuth = c.req.raw.headers.get("x-admin-auth");
if (adminAuth && c.env.ADMIN_PASSWORDS.includes(adminAuth)) {
if (adminAuth && adminPasswords.includes(adminAuth)) {
await next();
return;
}
@@ -36,8 +49,11 @@ app.use('/admin/*', async (c, next) => {
app.route('/', api)
app.route('/', adminApi)
app.route('/', apiV1)
app.route('/', apiSendMail)
app.all('/*', async c => c.html(`<h1>Hello World</h1>`))
app.all('/*', async c => c.text("Not Found", 404))
export default {

View File

@@ -1,7 +1,11 @@
name = "cloudflare_temp_email"
main = "src/worker.js"
compatibility_date = "2023-08-14"
compatibility_date = "2023-12-01"
node_compat = true
# if you want use custom_domain, you need to add routes
# routes = [
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
# ]
[vars]
PREFIX = "tmp"
@@ -12,10 +16,19 @@ PREFIX = "tmp"
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"]
JWT_SECRET = "xxx"
BLACK_LIST = ""
# IF YOU WANT DISABLE ATTACHMENT, SET IT TO false or COMMENT IT
ENABLE_ATTACHMENT = true
# dkim config
# DKIM_SELECTOR = ""
# DKIM_PRIVATE_KEY = ""
[[d1_databases]]
binding = "DB"
database_name = "xxx"
database_id = "xxx"
# ratelimit config for /api/new_address
# [[unsafe.bindings]]
# name = "RATE_LIMITER"
# type = "ratelimit"
# namespace_id = "1001"
# # 10 requests per minute
# simple = { limit = 10, period = 60 }