Compare commits

...

8 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
62 changed files with 4208 additions and 405 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 }}

View File

@@ -1,6 +1,17 @@
# CHANGE LOG
## 2024-04-12 v0.2.1
## 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

View File

@@ -1,16 +1,13 @@
# 使用 cloudflare 免费服务,搭建临时邮箱
## [English](README_EN.md)
## [查看部署文档](https://temp-mail-docs.awsl.uk)
## [新版部署文档](https://temp-mail-docs.awsl.uk)
## [English](https://temp-mail-docs.awsl.uk/en/)
## [CHANGELOG](CHANGELOG.md)
## [在线演示](https://mail.awsl.uk/)
[https://mail.awsl.uk](https://mail.awsl.uk/)
或者 [https://temp-email.dreamhunter2333.xyz](https://temp-email.dreamhunter2333.xyz/)
[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)
@@ -34,6 +31,7 @@
</picture>
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
- [查看部署文档](#查看部署文档)
- [English](#english)
- [CHANGELOG](#changelog)
- [在线演示](#在线演示)
@@ -43,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)
- [参考资料](#参考资料)
## 功能/TODO
- [x] Cloudflare D1 作为数据库
@@ -64,6 +61,7 @@
- [x] 增加查看附件功能
- [x] 使用 rust wasm 解析邮件
- [x] 支持发送邮件
- [x] 支持 DKIM
---
@@ -96,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))
```
---
@@ -115,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)
---
@@ -146,17 +146,24 @@ PREFIX = "tmp" # 要处理的邮箱名称前缀
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_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`
@@ -167,7 +174,7 @@ pnpm run deploy
部署成功之后再路由中可以看到 `worker``url`,控制台也会输出 `worker``url`
![worker](readme_assets/worker.png)
![worker](vitepress-docs/docs/public/readme_assets/worker.png)
---
@@ -179,7 +186,7 @@ pnpm run deploy
配置 `Cloudflare Email Routing` catch-all 发送到 `worker`
![email](readme_assets/email.png)
![email](vitepress-docs/docs/public/readme_assets/email.png)
---
@@ -203,7 +210,7 @@ pnpm build --emptyOutDir
pnpm run deploy
```
![pages](readme_assets/pages.png)
![pages](vitepress-docs/docs/public/readme_assets/pages.png)
## 配置发送邮件
@@ -218,6 +225,29 @@ v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all
- 此处 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>`
## 参考资料
- https://developers.cloudflare.com/d1/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -30,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,
@@ -45,6 +48,9 @@ export const useGlobalState = createGlobalState(
adminAuth,
showAdminAuth,
showLogin,
adminTab,
adminMailTabAddress,
adminSendBoxTabAddress,
}
},
)

View File

@@ -1,22 +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'
import { processItem } from '../utils/email-parser'
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 } = useGlobalState()
const router = useRouter()
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()
@@ -29,243 +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 = 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");
}
}
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 = 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");
}
}
</script>
<template>
@@ -283,125 +74,26 @@ const fetchMailUnknowData = async () => {
</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>
</template>

View File

@@ -184,7 +184,7 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
tertiary: true,
bordered: false,
ghost: true,
size: "small",
onClick: () => router.push('/sendbox')
@@ -197,7 +197,7 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
tertiary: true,
bordered: false,
ghost: true,
size: "small",
onClick: () => { showPassword.value = true }
@@ -210,7 +210,7 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
tertiary: true,
bordered: false,
ghost: true,
size: "small",
onClick: () => { router.push('/settings') }
@@ -223,7 +223,7 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
tertiary: true,
bordered: false,
ghost: true,
size: "small",
onClick: () => { showLogout.value = true }
@@ -236,7 +236,7 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
tertiary: true,
bordered: false,
ghost: true,
size: "small",
onClick: () => { showDelteAccount.value = true }

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,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>

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

@@ -1,13 +1,5 @@
# cloudflare temp email
## [中文](README.md)
[CHANGELOG](CHANGELOG.md)
## [Live Demo](https://mail.awsl.uk/)
[https://mail.awsl.uk](https://mail.awsl.uk/)
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
@@ -24,13 +16,19 @@ This is a temporary email service that uses Cloudflare Workers to create a tempo
- [x] Add attachment viewing function
- [x] use rust wasm to parse email
- [x] support send email
![demo](readme_assets/demo.png)
- [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
@@ -42,7 +40,7 @@ wrangler d1 execute dev --file=db/schema.sql
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql
```
![d1](readme_assets/d1.png)
![d1](/readme_assets/d1.png)
### Backend - Cloudflare workers
@@ -51,24 +49,49 @@ The first deployment will prompt you to create a project. Please fill in `produc
```bash
cd worker
pnpm install
# copy wrangler.toml.template to wrangler.toml
# and add your d1 config and these config
# PREFIX = "tmp" - the email create will be like tmp<xxxxx>@DOMAIN
# IF YOU WANT TO MAKE YOUR SITE PRIVATE, UNCOMMENT THE FOLLOWING LINES
# PASSWORDS = ["123", "456"]
# For admin panel, if not set will no allow to access the admin panel
# ADMIN_PASSWORDS = ["123", "456"]
# DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] you domain name
# JWT_SECRET = "xxx"
# BLACK_LIST = ""
cp wrangler.toml.template wrangler.toml
# deploy
pnpm run deploy
```
`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)
![worker](/readme_assets/worker.png)
## Cloudflare Email Routing
@@ -76,7 +99,7 @@ Before you can bind an email address to your Worker, you need to enable Email Ro
enable email route and config email forward catch-all to the worker
![email](readme_assets/email.png)
![email](/readme_assets/email.png)
### Frontend - Cloudflare pages
@@ -92,7 +115,7 @@ pnpm build --emptyOutDir
pnpm run deploy
```
![pages](readme_assets/pages.png)
![pages](/readme_assets/pages.png)
## Configure sending emails
@@ -106,3 +129,26 @@ Create a new `_mailchannels` record, the type is `TXT`, the content is `v=mc1 cf
- 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

@@ -1,5 +1,6 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { getSendbox } from './send_mail_api'
const api = new Hono()
@@ -95,7 +96,7 @@ api.get('/admin/mails', async (c) => {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT id, source, raw, created_at FROM raw_mails where address = ? order by id desc limit ? offset ?`
`SELECT * FROM raw_mails where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
@@ -119,7 +120,7 @@ api.get('/admin/mails_unknow', async (c) => {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(`
SELECT id, source, raw, created_at FROM raw_mails
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();
@@ -179,6 +180,11 @@ api.post('/admin/address_sender', async (c) => {
})
})
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`
@@ -192,11 +198,51 @@ api.get('/admin/statistics', async (c) => {
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
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 }

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) => {
@@ -124,13 +126,14 @@ 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,
});
})
@@ -151,8 +154,9 @@ api.get('/api/new_address', async (c) => {
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

View File

@@ -52,13 +52,22 @@ api.post('/api/send_mail', async (c) => {
if (!content) {
return c.text("Invalid content", 400)
}
const body = JSON.stringify({
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": {
@@ -70,13 +79,13 @@ api.post('/api/send_mail', async (c) => {
"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": body,
"body": JSON.stringify(body),
});
const resp = await fetch(send_request);
const respText = await resp.text();
@@ -98,9 +107,12 @@ api.post('/api/send_mail', async (c) => {
}
// 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, body).run();
).bind(address, JSON.stringify(body)).run();
if (!success2) {
console.warn(`Failed to save to sendbox for ${address}`);
}
@@ -110,12 +122,10 @@ api.post('/api/send_mail', async (c) => {
return c.json({ status: "ok" });
})
api.get('/api/sendbox', async (c) => {
const { address } = c.get("jwtPayload")
const getSendbox = async (c, address, limit, offset) => {
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)
}
@@ -137,6 +147,12 @@ api.get('/api/sendbox', async (c) => {
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 }
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

@@ -7,18 +7,27 @@ 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;
};
@@ -27,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;
}

View File

@@ -16,8 +16,19 @@ PREFIX = "tmp"
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"]
JWT_SECRET = "xxx"
BLACK_LIST = ""
# 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 }