Compare commits

..

14 Commits

Author SHA1 Message Date
Dream Hunter
ee023ac2e9 feat: update changelog (#664) 2025-06-09 19:09:28 +08:00
Dream Hunter
cc77bdf36d feat: add ALWAYS_SHOW_ANNOUNCEMENT option (#663) 2025-06-09 19:06:49 +08:00
Dream Hunter
dec309a0fd fix: github actions node version (#660) 2025-06-02 11:28:41 +08:00
Dream Hunter
9488543e44 fix: ui admin portal show after fetch user data (#659) 2025-05-20 17:55:33 +08:00
Dream Hunter
50326bcc98 feature: support init db in admin portal (#658) 2025-05-20 17:45:55 +08:00
Dream Hunter
272b624b9b feature: utils import (#652) 2025-05-07 00:54:47 +08:00
Dream Hunter
e230801a1c feature: update dependencies (#651) 2025-05-07 00:13:26 +08:00
Zyx-A
07833d5ca9 feature: 基于子域名转发到不同的邮箱中去 (#645) (#647) 2025-04-30 10:41:09 +08:00
Dream Hunter
101a561894 feature: auto refresh user token when token exp in 7 days (#644) 2025-04-26 21:22:26 +08:00
Dream Hunter
327962432a fix: some oauth2 need redirect_uri when get token (#643) 2025-04-26 20:56:47 +08:00
Dream Hunter
6051d49315 feature: version 0.10.0 (#640) 2025-04-24 02:04:40 +08:00
Dream Hunter
95f361743b feature: add /user_api/mails with filter params address and keyword (#639) 2025-04-24 02:01:21 +08:00
Dream Hunter
c6afc5d425 feat: support admin api bind address to user (#635) 2025-04-16 13:36:41 +08:00
Dream Hunter
466f53254b feat: docs: update worker doc (#633) 2025-04-16 00:07:12 +08:00
71 changed files with 3510 additions and 2823 deletions

View File

@@ -21,7 +21,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v3
name: Install pnpm

View File

@@ -22,7 +22,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v3
name: Install pnpm

View File

@@ -21,7 +21,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v3
name: Install pnpm

View File

@@ -15,7 +15,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v3
name: Install pnpm

View File

@@ -17,7 +17,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v3
name: Install pnpm

View File

@@ -1,7 +1,15 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG
## main(v0.9.1)
## v0.10.0
- feat: 支持 User 查看收件箱,`/user_api/mails` 接口, 支持 `address``keyword` 过滤
- fix: 修复 Oauth2 登录获取 Token 时,一些 Oauth2 需要 `redirect_uri` 参数的问题
- feat: 用户访问网页时,如果 `user token` 在 7 天内过期,自动刷新
- feat: admin portal 中增加初始化 db 的功能
- feat: 增加 `ALWAYS_SHOW_ANNOUNCEMENT` 变量,用于配置是否总是显示公告
## v0.9.1
- feat: |UI| support google ads
- feat: |UI| 使用 shadow DOM 防止样式污染

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "0.9.1",
"version": "0.10.0",
"private": true,
"type": "module",
"scripts": {
@@ -24,29 +24,30 @@
"@vueuse/core": "^12.8.2",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.8.4",
"axios": "^1.9.0",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.41.0",
"naive-ui": "^2.41.1",
"postal-mime": "^2.4.3",
"vooks": "^0.2.12",
"vue": "^3.5.13",
"vue": "^3.5.16",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.1.3",
"vue-router": "^4.5.0"
"vue-i18n": "^11.1.5",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0",
"@vitejs/plugin-vue": "^5.2.3",
"unplugin-auto-import": "^19.1.2",
"unplugin-vue-components": "^28.4.1",
"vite": "^6.2.6",
"@vitejs/plugin-vue": "^5.2.4",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.7.0",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.4.1",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0",
"wrangler": "^4.10.0"
}
"wrangler": "^4.19.1"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

2462
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@ const apiFetch = async (path, options = {}) => {
data: options.body || null,
headers: {
'x-lang': i18n.global.locale.value,
'x-user-token': userJwt.value,
'x-user-token': options.userJwt || userJwt.value,
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
@@ -89,7 +89,11 @@ const getOpenSettings = async (message, notification) => {
if (openSettings.value.needAuth) {
showAuth.value = true;
}
if (openSettings.value.announcement && openSettings.value.announcement != announcement.value) {
if (openSettings.value.announcement
&& !openSettings.value.fetched
&& (openSettings.value.announcement != announcement.value
|| openSettings.value.alwaysShowAnnouncement)
) {
announcement.value = openSettings.value.announcement;
notification.info({
content: () => {
@@ -139,6 +143,19 @@ const getUserSettings = async (message) => {
if (!userJwt.value) return;
const res = await api.fetch("/user_api/settings")
Object.assign(userSettings.value, res)
// auto refresh user jwt
if (userSettings.value.new_user_token) {
try {
await api.fetch("/user_api/settings", {
userJwt: userSettings.value.new_user_token,
})
userJwt.value = userSettings.value.new_user_token;
console.log("User JWT updated successfully");
}
catch (error) {
console.error("Failed to update user JWT", error);
}
}
} catch (error) {
message?.error(error.message || "error");
} finally {

View File

@@ -14,6 +14,7 @@ export const useGlobalState = createGlobalState(
fetched: false,
title: '',
announcement: '',
alwaysShowAnnouncement: false,
prefix: '',
addressRegex: '',
needAuth: false,
@@ -92,6 +93,8 @@ export const useGlobalState = createGlobalState(
is_admin: false,
/** @type {string | null} */
access_token: null,
/** @type {string | null} */
new_user_token: null,
/** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
user_role: null,
});

View File

@@ -18,6 +18,7 @@ import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import About from './common/About.vue';
import Maintenance from './admin/Maintenance.vue';
import DatabaseManager from './admin/DatabaseManager.vue';
import Appearance from './common/Appearance.vue';
import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
@@ -67,6 +68,7 @@ const { t } = useI18n({
webhookSettings: 'Webhook Settings',
statistics: 'Statistics',
maintenance: 'Maintenance',
database: 'Database',
workerconfig: 'Worker Config',
appearance: 'Appearance',
about: 'About',
@@ -93,6 +95,7 @@ const { t } = useI18n({
webhookSettings: 'Webhook 设置',
statistics: '统计',
maintenance: '维护',
database: '数据库',
workerconfig: 'Worker 配置',
appearance: '外观',
about: '关于',
@@ -112,7 +115,7 @@ onMounted(async () => {
</script>
<template>
<div>
<div v-if="userSettings.fetched">
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
preset="dialog" :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
@@ -126,6 +129,9 @@ onMounted(async () => {
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings />
</n-tab-pane>
@@ -196,6 +202,9 @@ onMounted(async () => {
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>
<n-tab-pane name="workerconfig" :tab="t('workerconfig')">
<WorkerConfig />
</n-tab-pane>

View File

@@ -7,6 +7,7 @@ import AddressMangement from './user/AddressManagement.vue';
import UserSettingsPage from './user/UserSettings.vue';
import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue';
import UserMailBox from './user/UserMailBox.vue';
const {
userTab, globalTabplacement, userSettings
@@ -16,11 +17,13 @@ const { t } = useI18n({
messages: {
en: {
address_management: 'Address Management',
user_mail_box_tab: 'Mail Box',
user_settings: 'User Settings',
bind_address: 'Bind Mail Address',
},
zh: {
address_management: '地址管理',
user_mail_box_tab: '收件箱',
user_settings: '用户设置',
bind_address: '绑定邮箱地址',
}
@@ -36,6 +39,9 @@ const { t } = useI18n({
<n-tab-pane name="address_management" :tab="t('address_management')">
<AddressMangement />
</n-tab-pane>
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
<UserMailBox />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettingsPage />
</n-tab-pane>

View File

@@ -0,0 +1,126 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { CleaningServicesFilled } from '@vicons/material'
import { api } from '../../api'
import { init } from 'vooks/lib/on-fonts-ready';
const message = useMessage()
const dbVersionData = ref({
need_initialization: false,
need_migration: false,
current_db_version: '',
code_db_version: ''
})
const { t } = useI18n({
messages: {
en: {
need_initialization_tip: 'Database initialization is required. Please initialize the database.',
need_migration_tip: 'Database migration is required. Please migrate the database.',
current_db_version: 'Current DB Version',
code_db_version: 'Code Needed DB Version',
init: 'Initialize Database',
migration: 'Migrate Database',
initializationSuccess: 'Database initialized successfully',
migrationSuccess: 'Database migrated successfully',
},
zh: {
need_initialization_tip: '需要初始化数据库,请初始化数据库',
need_migration_tip: '需要迁移数据库,请迁移数据库',
current_db_version: '当前数据库版本',
code_db_version: '需要的数据库版本',
init: '初始化数据库',
migration: '升级数据库 Schema',
initializationSuccess: '数据库初始化成功',
migrationSuccess: '数据库升级成功',
}
}
});
const fetchData = async () => {
try {
const res = await api.fetch('/admin/db_version');
if (res) Object.assign(dbVersionData.value, res);
} catch (error) {
message.error(error.message || "error");
}
}
const initialization = async () => {
try {
await api.fetch('/admin/db_initialize', {
method: 'POST'
});
await fetchData();
message.success(t('initializationSuccess'));
} catch (error) {
message.error(error.message || "error");
}
}
const migration = async () => {
try {
await api.fetch('/admin/db_migration', {
method: 'POST'
});
await fetchData();
message.success(t('migrationSuccess'));
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded>
<n-alert v-if="dbVersionData.need_initialization" type="warning" :show-icon="false" :bordered="false">
<span>{{ t('need_initialization_tip') }}</span>
<n-button @click="initialization" type="primary" secondary block :loading="loading">
{{ t('init') }}
</n-button>
</n-alert>
<n-alert v-if="dbVersionData.need_migration" type="warning" :show-icon="false" :bordered="false">
<span>{{ t('need_migration_tip') }}</span>
<n-button @click="migration" type="primary" secondary block :loading="loading">
{{ t('migration') }}
</n-button>
</n-alert>
<n-alert type="info" :show-icon="false" :bordered="false">
<span>
{{ t('current_db_version') }}: {{ dbVersionData.current_db_version || "unknown" }},
{{ t('code_db_version') }}: {{ dbVersionData.code_db_version }}
</span>
</n-alert>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.n-alert {
margin-bottom: 10px;
}
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -224,7 +224,7 @@ onMounted(async () => {
<n-form-item-row label="Access Token URL" required>
<n-input v-model:value="item.accessTokenURL" />
</n-form-item-row>
<n-form-item-row label="Access Token accessTokenFormat" required>
<n-form-item-row label="Access Token Params Format" required>
<n-select v-model:value="item.accessTokenFormat" :options="accessTokenFormatOptions" />
</n-form-item-row>
<n-form-item-row label="User Info URL" required>

View File

@@ -0,0 +1,90 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { api } from '../../api'
import MailBox from '../../components/MailBox.vue';
const { t } = useI18n({
messages: {
en: {
addressQueryTip: 'Leave blank to query all addresses',
keywordQueryTip: 'Leave blank to not query by keyword',
query: 'Query',
},
zh: {
addressQueryTip: '留空查询所有地址',
keywordQueryTip: '留空不按关键字查询',
query: '查询',
}
}
});
const mailBoxKey = ref("")
const addressFilter = ref();
const mailKeyword = ref("")
const addressFilterOptions = ref([]);
const queryMail = () => {
addressFilter.value = addressFilter.value.trim();
mailKeyword.value = mailKeyword.value.trim();
mailBoxKey.value = Date.now();
}
const fetchMailData = async (limit, offset) => {
return await api.fetch(
`/user_api/mails`
+ `?limit=${limit}`
+ `&offset=${offset}`
+ (addressFilter.value ? `&address=${addressFilter.value}` : '')
+ (mailKeyword.value ? `&keyword=${mailKeyword.value}` : '')
);
}
const fetchAddresData = async () => {
try {
const { results } = await api.fetch(
`/user_api/bind_address`
);
addressFilterOptions.value = results.map((item) => {
return {
label: item.name,
value: item.name
}
});
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const deleteMail = async (curMailId) => {
await api.fetch(`/user_api/mails/${curMailId}`, { method: 'DELETE' });
};
watch(addressFilter, async (newValue) => {
console.log("addressFilter", newValue);
queryMail();
});
onMounted(() => {
fetchAddresData();
});
</script>
<template>
<div style="margin-top: 10px;">
<n-input-group>
<n-select v-model:value="addressFilter" :options="addressFilterOptions" clearable
:placeholder="t('addressQueryTip')" />
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" @keydown.enter="queryMail" />
<n-button @click="queryMail" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="margin-top: 10px;"></div>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="true" :fetchMailData="fetchMailData"
:deleteMail="deleteMail" />
</div>
</template>

View File

@@ -1,6 +1,6 @@
{
"name": "temp-email-pages",
"version": "0.9.1",
"version": "0.10.0",
"description": "",
"main": "index.js",
"scripts": {
@@ -11,6 +11,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^4.10.0"
}
"wrangler": "^4.19.1"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

View File

@@ -14,6 +14,6 @@ pnpm add -D wrangler@latest
cd ..
cd vitepress-docs/
pnpm up
pnpm up --latest
pnpm add -D wrangler@latest
cd ..

View File

@@ -81,6 +81,8 @@ PREFIX = "tmp" # The mailbox name prefix to be processed
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# ANNOUNCEMENT = "Custom Announcement"
# always show ANNOUNCEMENT even no changes
# ALWAYS_SHOW_ANNOUNCEMENT = true
# address check REGEX, if not set, will not check
# ADDRESS_CHECK_REGEX = "^(?!.*admin).*"
# address name replace REGEX, if not set, the default is [^a-z0-9]

View File

@@ -1,3 +1,17 @@
# 初始化/更新 D1 数据库
## 创建数据库
打开 cloudflare 控制台,选择 `Workers & Pages` -> `D1` -> `Create Database`,点击创建数据库
![d1](/ui_install/d1.png)
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库,并获取到数据库的 `名称``数据库 ID`
## 初始化数据库
在部署完成后,在 admin 页面的 `快速设置` -> `数据库` 中,点击 `初始化数据库` 按钮来初始化数据库
## 更新数据库 schema
参考 [命令行更新 d1](/zh/guide/cli/d1) 或者 [用户界面更新 d1](/zh/guide/ui/d1)

View File

@@ -3,6 +3,8 @@
::: warning 注意
目前只支持 worker 和 pages 的部署。
有问题请通过 `Github Issues` 反馈,感谢。
`worker.dev` 域名在中国无法访问,请自定义域名
:::
## 部署步骤

View File

@@ -1,5 +1,8 @@
# Cloudflare Worker 后端
> [!warning] 注意
> `worker.dev` 域名在中国无法访问,请自定义域名
## 初始化项目
```bash

View File

@@ -1,6 +1,6 @@
# 查看邮件 API
## 通过 HTTP API 查看邮件
## 通过 邮件 API 查看邮件
这是一个 `python` 的例子,使用 `requests` 库查看邮件。
@@ -8,7 +8,7 @@
limit = 10
offset = 0
res = requests.get(
f"http://localhost:8787/api/mails?limit={limit}&offset={offset}",
f"https://<你的worker地址>/api/mails?limit={limit}&offset={offset}",
headers={
"Authorization": f"Bearer {你的JWT密码}",
# "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码
@@ -16,3 +16,51 @@ res = requests.get(
}
)
```
## admin 邮件 API
支持 `address` filter 和 `keyword` filter
```python
import requests
url = "https://<你的worker地址>/admin/mails"
querystring = {
"limit":"20",
"offset":"0",
# adress 和 keyword 为可选参数
"address":"xxxx@awsl.uk",
"keyword":"xxxx"
}
headers = {"x-admin-auth": "<你的Admin密码>"}
response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
## user 邮件 API
支持 `address` filter 和 `keyword` filter
```python
import requests
url = "https://<你的worker地址>/user_api/mails"
querystring = {
"limit":"20",
"offset":"0",
# adress 和 keyword 为可选参数
"address":"xxxx@awsl.uk",
"keyword":"xxxx"
}
headers = {"x-admin-auth": "<你的Admin密码>"}
response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```

View File

@@ -1,6 +1,6 @@
# 初始化/更新 D1 数据库
## 初始化数据库
## 创建数据库
打开 cloudflare 控制台,选择 `Workers & Pages` -> `D1` -> `Create Database`,点击创建数据库
@@ -8,6 +8,10 @@
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
## 初始化数据库
你也可以跳过初始化数据库,在部署完成后,在 admin 页面的 `快速设置` -> `数据库` 中,点击 `初始化数据库` 按钮来初始化数据库
::: warning 注意
下面输入的是 `db/schema.sql` 的内容
:::

View File

@@ -54,6 +54,9 @@ const generate = async () => {
- 此处 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`
> [!warning] 注意
> `worker.dev` 域名在中国无法访问,请自定义域名
<div :class="$style.container">
<input :class="$style.input" type="text" v-model="domain" placeholder="请输入地址"></input>
<button :class="$style.button" @click="generate">生成</button>

View File

@@ -1,5 +1,8 @@
# Cloudflare workers 后端
> [!warning] 注意
> `worker.dev` 域名在中国无法访问,请自定义域名
1. 点击 `Workers & Pages` -> `Overview` -> `Create Application`
![create worker](/ui_install/worker_home.png)

View File

@@ -88,16 +88,17 @@
## 网页相关变量
| 变量名 | 类型 | 说明 | 示例 |
| ------------------------- | ----------- | ------------------------------------------------ | --------------------- |
| `DEFAULT_LANG` | 文本 | Worker 错误信息默认语言, zh/en | `zh` |
| `TITLE` | 文本 | 自定义前端页面网站标题,支持 html | `Custom Title` |
| `ANNOUNCEMENT` | 文本 | 自定义前端页面公告,支持 html | `Custom Announcement` |
| `COPYRIGHT` | 文本 | 自定义前端界面页脚文本,支持 html | `Dream Hunter` |
| `ADMIN_CONTACT` | 文本 | admin 联系方式,可配置任意字符串, 不配置则不显示 | `xxx@gmail.com` |
| `DISABLE_SHOW_GITHUB` | 文本/JSON | 是否显示 GitHub 链接 | `true` |
| `CF_TURNSTILE_SITE_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
| 变量名 | 类型 | 说明 | 示例 |
| -------------------------- | ----------- | ------------------------------------------------ | --------------------- |
| `DEFAULT_LANG` | 文本 | Worker 错误信息默认语言, zh/en | `zh` |
| `TITLE` | 文本 | 自定义前端页面网站标题,支持 html | `Custom Title` |
| `ANNOUNCEMENT` | 文本 | 自定义前端页面公告,支持 html | `Custom Announcement` |
| `ALWAYS_SHOW_ANNOUNCEMENT` | 文本/JSON | 是否总是显示公告(即使无更改), 默认 `false` | `true` |
| `COPYRIGHT` | 文本 | 自定义前端界面页脚文本,支持 html | `Dream Hunter` |
| `ADMIN_CONTACT` | 文本 | admin 联系方式,可配置任意字符串, 不配置则不显示 | `xxx@gmail.com` |
| `DISABLE_SHOW_GITHUB` | 文本/JSON | 是否显示 GitHub 链接 | `true` |
| `CF_TURNSTILE_SITE_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
## Telegram Bot 相关变量

View File

@@ -1,12 +1,12 @@
{
"name": "temp-mail-docs",
"private": true,
"version": "0.9.1",
"version": "0.10.0",
"type": "module",
"devDependencies": {
"@types/node": "^22.14.1",
"@types/node": "^22.15.30",
"vitepress": "^1.6.3",
"wrangler": "^4.10.0"
"wrangler": "^4.19.1"
},
"scripts": {
"dev": "vitepress dev docs",
@@ -16,5 +16,6 @@
},
"dependencies": {
"jszip": "^3.10.1"
}
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "0.9.1",
"version": "0.10.0",
"private": true,
"type": "module",
"scripts": {
@@ -11,29 +11,31 @@
"build": "wrangler deploy --dry-run --outdir dist --minify"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250412.0",
"@cloudflare/workers-types": "^4.20250607.0",
"@eslint/js": "9.18.0",
"@simplewebauthn/types": "10.0.0",
"@types/node": "^22.15.30",
"eslint": "9.18.0",
"globals": "^15.15.0",
"typescript-eslint": "^8.29.1",
"wrangler": "^4.10.0"
"typescript-eslint": "^8.33.1",
"wrangler": "^4.19.1"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.787.0",
"@aws-sdk/s3-request-presigner": "^3.787.0",
"@aws-sdk/client-s3": "^3.826.0",
"@aws-sdk/s3-request-presigner": "^3.826.0",
"@simplewebauthn/server": "10.0.1",
"hono": "^4.7.6",
"hono": "^4.7.11",
"jsonpath-plus": "^10.3.0",
"mimetext": "^3.0.27",
"postal-mime": "^2.4.3",
"resend": "^4.2.0",
"resend": "^4.5.2",
"telegraf": "4.16.3",
"worker-mailer": "^1.1.1"
"worker-mailer": "^1.1.4"
},
"pnpm": {
"patchedDependencies": {
"telegraf@4.16.3": "patches/telegraf@4.16.3.patch"
}
}
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

1795
worker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
import { Context } from "hono";
import { handleListQuery } from "../common";
export default {
getMails: async (c: Context<HonoCustomType>) => {
const { address, limit, offset, keyword } = c.req.query();
const addressQuery = address ? `address = ?` : "";
const addressParams = address ? [address] : [];
const keywordQuery = keyword ? `raw like ?` : "";
const keywordParams = keyword ? [`%${keyword}%`] : [];
const filterQuerys = [addressQuery, keywordQuery].filter((item) => item).join(" and ");
const finalQuery = filterQuerys.length > 0 ? `where ${filterQuerys}` : "";
const filterParams = [...addressParams, ...keywordParams]
return await handleListQuery(c,
`SELECT * FROM raw_mails ${finalQuery}`,
`SELECT count(*) as count FROM raw_mails ${finalQuery}`,
filterParams, limit, offset
);
},
getUnknowMails: async (c: Context<HonoCustomType>) => {
const { limit, offset } = c.req.query();
return await handleListQuery(c,
`SELECT * FROM raw_mails where address NOT IN (select name from address) `,
`SELECT count(*) as count FROM raw_mails`
+ ` where address NOT IN (select name from address) `,
[], limit, offset
);
},
deleteMail: async (c: Context<HonoCustomType>) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE id = ? `
).bind(id).run();
return c.json({
success: success
})
}
}

View File

@@ -4,8 +4,8 @@ import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting, checkUserPassword, getDomains, getUserRoles } from '../utils';
import { UserSettings, GeoData, UserInfo } from "../models";
import { handleListQuery } from '../common'
import { HonoCustomType } from '../types';
import UserBindAddressModule from '../user_api/bind_address';
import i18n from '../i18n';
export default {
getSetting: async (c: Context<HonoCustomType>) => {
@@ -90,7 +90,8 @@ export default {
},
deleteUser: async (c: Context<HonoCustomType>) => {
const { user_id } = c.req.param();
if (!user_id) return c.text("Invalid user_id", 400);
const msgs = i18n.getMessagesbyContext(c);
if (!user_id) return c.text(msgs.UserNotFoundMsg, 400);
const { success } = await c.env.DB.prepare(
`DELETE FROM users WHERE id = ?`
).bind(user_id).run();
@@ -105,7 +106,8 @@ export default {
resetPassword: async (c: Context<HonoCustomType>) => {
const { user_id } = c.req.param();
const { password } = await c.req.json();
if (!user_id) return c.text("Invalid user_id", 400);
const msgs = i18n.getMessagesbyContext(c);
if (!user_id) return c.text(msgs.UserNotFoundMsg, 400);
try {
checkUserPassword(password);
const { success } = await c.env.DB.prepare(
@@ -146,11 +148,22 @@ export default {
return c.json({ success: true })
},
bindAddress: async (c: Context<HonoCustomType>) => {
const { user_id, address_id } = await c.req.json();
return await UserBindAddressModule.bindByID(c, user_id, address_id);
const {
user_email, address, user_id, address_id
} = await c.req.json();
const db_user_id = user_id ?? await c.env.DB.prepare(
`SELECT id FROM users WHERE user_email = ?`
).bind(user_email).first<number | undefined | null>("id");
const db_address_id = address_id ?? await c.env.DB.prepare(
`SELECT id FROM address WHERE name = ?`
).bind(address).first<number | undefined | null>("id");
return await UserBindAddressModule.bindByID(c, db_user_id, db_address_id);
},
getBindedAddresses: async (c: Context<HonoCustomType>) => {
const { user_id } = c.req.param();
return await UserBindAddressModule.getBindedAddressesById(c, user_id);
const results = await UserBindAddressModule.getBindedAddressesById(c, user_id);
return c.json({
results: results,
});
},
}

View File

@@ -4,7 +4,6 @@ import { cleanup } from '../common';
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting } from '../utils';
import { CleanupSettings } from '../models';
import { HonoCustomType } from '../types';
export default {
cleanup: async (c: Context<HonoCustomType>) => {

View File

@@ -0,0 +1,156 @@
import { Context } from "hono";
import { CONSTANTS } from "../constants";
import utils from "../utils";
const DB_INIT_QUERIES = `
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 AUTOINCREMENT,
name TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY,
source_prefix TEXT,
name TEXT,
address TEXT UNIQUE,
subject TEXT,
message TEXT,
enabled INTEGER DEFAULT 1,
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 address_sender (
id INTEGER PRIMARY KEY,
address TEXT UNIQUE,
balance INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_address_sender_address ON address_sender(address);
CREATE TABLE IF NOT EXISTS sendbox (
id INTEGER PRIMARY KEY,
address TEXT,
raw TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
user_email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
user_info TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
CREATE TABLE IF NOT EXISTS users_address (
id INTEGER PRIMARY KEY,
user_id INTEGER,
address_id INTEGER UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);
CREATE TABLE IF NOT EXISTS user_roles (
id INTEGER PRIMARY KEY,
user_id INTEGER UNIQUE NOT NULL,
role_text TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE TABLE IF NOT EXISTS user_passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
passkey_name TEXT NOT NULL,
passkey_id TEXT NOT NULL,
passkey TEXT NOT NULL,
counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
`
export default {
initialize: async (c: Context<HonoCustomType>) => {
// remove all \r and \n characters from the query string
// split by ; and join with a ;\n
const query = DB_INIT_QUERIES.replace(/[\r\n]/g, "")
.split(";")
.map((query) => query.trim())
.join(";\n");
await c.env.DB.exec(query);
const version = await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY);
if (version) {
return c.json({ message: "Database already initialized" });
}
await utils.saveSetting(c, CONSTANTS.DB_VERSION_KEY, CONSTANTS.DB_VERSION);
return c.json({ message: "Database initialized" });
},
migrate: async (c: Context<HonoCustomType>) => {
const version = await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY);
if (version != CONSTANTS.DB_VERSION) {
// TODO: Perform migration logic here
// Update the version in the settings table
await utils.saveSetting(c, CONSTANTS.DB_VERSION_KEY, CONSTANTS.DB_VERSION);
return c.json({
success: true,
message: "Database migrated"
});
}
return c.json({
success: true,
message: "Database does not need migration"
});
},
getVersion: async (c: Context<HonoCustomType>) => {
const version = await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY);
return c.json({
need_initialization: !version,
need_migration: version && version != CONSTANTS.DB_VERSION,
current_db_version: version,
code_db_version: CONSTANTS.DB_VERSION
});
},
}

View File

@@ -2,7 +2,6 @@ import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n'
import { HonoCustomType } from '../types'
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles } from '../utils'
import { newAddress, handleListQuery } from '../common'
import { CONSTANTS } from '../constants'
@@ -12,7 +11,9 @@ import webhook_settings from './webhook_settings'
import mail_webhook_settings from './mail_webhook_settings'
import oauth2_settings from './oauth2_settings'
import worker_config from './worker_config'
import admin_mail_api from './admin_mail_api'
import { sendMailbyAdmin } from './send_mail'
import db_api from './db_api'
export const api = new Hono<HonoCustomType>()
@@ -101,54 +102,10 @@ api.get('/admin/show_password/:id', async (c) => {
})
})
api.get('/admin/mails', async (c) => {
const { address, limit, offset, keyword } = c.req.query();
if (address && keyword) {
return await handleListQuery(c,
`SELECT * FROM raw_mails where address = ? and raw like ? `,
`SELECT count(*) as count FROM raw_mails where address = ? and raw like ? `,
[address, `%${keyword}%`], limit, offset
);
} else if (keyword) {
return await handleListQuery(c,
`SELECT * FROM raw_mails where raw like ? `,
`SELECT count(*) as count FROM raw_mails where raw like ? `,
[`%${keyword}%`], limit, offset
);
} else if (address) {
return await handleListQuery(c,
`SELECT * FROM raw_mails where address = ? `,
`SELECT count(*) as count FROM raw_mails where address = ? `,
[address], limit, offset
);
} else {
return await handleListQuery(c,
`SELECT * FROM raw_mails `,
`SELECT count(*) as count FROM raw_mails `,
[], limit, offset
);
}
});
api.get('/admin/mails_unknow', async (c) => {
const { limit, offset } = c.req.query();
return await handleListQuery(c,
`SELECT * FROM raw_mails where address NOT IN (select name from address) `,
`SELECT count(*) as count FROM raw_mails`
+ ` where address NOT IN (select name from address) `,
[], limit, offset
);
});
api.delete('/admin/mails/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE id = ? `
).bind(id).run();
return c.json({
success: success
})
})
// mail api
api.get('/admin/mails', admin_mail_api.getMails);
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails);
api.delete('/admin/mails/:id', admin_mail_api.deleteMail)
api.get('/admin/address_sender', async (c) => {
const { address, limit, offset } = c.req.query();
@@ -348,3 +305,8 @@ api.get("/admin/worker/configs", worker_config.getConfig);
// send mail by admin
api.post("/admin/send_mail", sendMailbyAdmin);
// db api
api.get('admin/db_version', db_api.getVersion);
api.post('admin/db_initialize', db_api.initialize);
api.post('admin/db_migration', db_api.migrate);

View File

@@ -1,5 +1,4 @@
import { Context } from "hono";
import { HonoCustomType, ParsedEmailContext } from "../types";
import { CONSTANTS } from "../constants";
import { WebhookSettings } from "../models";
import { commonParseMail, sendWebhook } from "../common";

View File

@@ -2,7 +2,6 @@ import { Context } from 'hono';
import { CONSTANTS } from '../constants';
import { UserOauth2Settings } from "../models";
import { HonoCustomType } from '../types';
import { getJsonSetting, saveSetting } from '../utils';
async function getUserOauth2Settings(c: Context<HonoCustomType>): Promise<Response> {

View File

@@ -1,5 +1,4 @@
import { Context } from "hono";
import { HonoCustomType } from "../types";
import { sendMail } from "../mails_api/send_mail_api";
export const sendMailbyAdmin = async (c: Context<HonoCustomType>) => {

View File

@@ -1,5 +1,4 @@
import { Context } from "hono";
import { HonoCustomType } from "../types";
import { CONSTANTS } from "../constants";
import { AdminWebhookSettings } from "../models";

View File

@@ -1,7 +1,6 @@
import { Context } from 'hono';
import { HonoCustomType } from '../types';
import { getAdminPasswords, getBooleanValue, getDefaultDomains, getDomains, getIntValue, getPasswords, getStringArray, getStringValue, getUserRoles, getAnotherWorkerList, getSplitStringListValue } from '../utils';
import utils from '../utils';
import { CONSTANTS } from '../constants';
import { isS3Enabled } from '../mails_api/s3_attachment';
@@ -10,48 +9,50 @@ export default {
return c.json({
"DEFAULT_LANG": c.env.DEFAULT_LANG,
"TITLE": c.env.TITLE,
"HAS_PASSWORD": getPasswords(c).length,
"HAS_ADMIN_PASSWORDS": getAdminPasswords(c).length,
"ANNOUNCEMENT": getStringValue(c.env.ANNOUNCEMENT),
"HAS_PASSWORD": utils.getPasswords(c).length,
"HAS_ADMIN_PASSWORDS": utils.getAdminPasswords(c).length,
"ANNOUNCEMENT": utils.getStringValue(c.env.ANNOUNCEMENT),
"ALWAYS_SHOW_ANNOUNCEMENT": utils.getBooleanValue(c.env.ALWAYS_SHOW_ANNOUNCEMENT),
"PREFIX": getStringValue(c.env.PREFIX),
"ADDRESS_CHECK_REGEX": getStringValue(c.env.ADDRESS_CHECK_REGEX),
"ADDRESS_REGEX": getStringValue(c.env.ADDRESS_REGEX),
"MIN_ADDRESS_LEN": getIntValue(c.env.MIN_ADDRESS_LEN, 1),
"MAX_ADDRESS_LEN": getIntValue(c.env.MAX_ADDRESS_LEN, 30),
"PREFIX": utils.getStringValue(c.env.PREFIX),
"ADDRESS_CHECK_REGEX": utils.getStringValue(c.env.ADDRESS_CHECK_REGEX),
"ADDRESS_REGEX": utils.getStringValue(c.env.ADDRESS_REGEX),
"MIN_ADDRESS_LEN": utils.getIntValue(c.env.MIN_ADDRESS_LEN, 1),
"MAX_ADDRESS_LEN": utils.getIntValue(c.env.MAX_ADDRESS_LEN, 30),
"FORWARD_ADDRESS_LIST": getStringArray(c.env.FORWARD_ADDRESS_LIST),
"DEFAULT_DOMAINS": getDefaultDomains(c),
"DOMAINS": getDomains(c),
"DOMAIN_LABELS": getStringArray(c.env.DOMAIN_LABELS),
"FORWARD_ADDRESS_LIST": utils.getStringArray(c.env.FORWARD_ADDRESS_LIST),
"SUBDOMAIN_FORWARD_ADDRESS_LIST": utils.getJsonObjectValue<SubdomainForwardAddressList[]>(c.env.SUBDOMAIN_FORWARD_ADDRESS_LIST),
"DEFAULT_DOMAINS": utils.getDefaultDomains(c),
"DOMAINS": utils.getDomains(c),
"DOMAIN_LABELS": utils.getStringArray(c.env.DOMAIN_LABELS),
"HAS_JWT_SECRET": !!getStringValue(c.env.JWT_SECRET),
"HAS_JWT_SECRET": !!utils.getStringValue(c.env.JWT_SECRET),
"ADMIN_USER_ROLE": getStringValue(c.env.ADMIN_USER_ROLE),
"USER_DEFAULT_ROLE": getStringValue(c.env.USER_DEFAULT_ROLE),
"USER_ROLES": getUserRoles(c),
"NO_LIMIT_SEND_ROLE": getSplitStringListValue(c.env.NO_LIMIT_SEND_ROLE),
"ADMIN_USER_ROLE": utils.getStringValue(c.env.ADMIN_USER_ROLE),
"USER_DEFAULT_ROLE": utils.getStringValue(c.env.USER_DEFAULT_ROLE),
"USER_ROLES": utils.getUserRoles(c),
"NO_LIMIT_SEND_ROLE": utils.getSplitStringListValue(c.env.NO_LIMIT_SEND_ROLE),
"ADMIN_CONTACT": c.env.ADMIN_CONTACT,
"ENABLE_USER_CREATE_EMAIL": getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
"DISABLE_ANONYMOUS_USER_CREATE_EMAIL": getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
"ENABLE_USER_DELETE_EMAIL": getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
"ENABLE_AUTO_REPLY": getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"ENABLE_USER_CREATE_EMAIL": utils.getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
"DISABLE_ANONYMOUS_USER_CREATE_EMAIL": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
"ENABLE_USER_DELETE_EMAIL": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
"ENABLE_AUTO_REPLY": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"COPYRIGHT": c.env.COPYRIGHT,
"ENABLE_WEBHOOK": getBooleanValue(c.env.ENABLE_WEBHOOK),
"ENABLE_WEBHOOK": utils.getBooleanValue(c.env.ENABLE_WEBHOOK),
"S3_ENABLED": isS3Enabled(c),
"VERSION": CONSTANTS.VERSION,
"DISABLE_SHOW_GITHUB": !getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"DISABLE_ADMIN_PASSWORD_CHECK": getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
"ENABLE_CHECK_JUNK_MAIL": getBooleanValue(c.env.ENABLE_CHECK_JUNK_MAIL),
"JUNK_MAIL_CHECK_LIST": getStringArray(c.env.JUNK_MAIL_CHECK_LIST),
"JUNK_MAIL_FORCE_PASS_LIST": getStringArray(c.env.JUNK_MAIL_FORCE_PASS_LIST),
"DISABLE_SHOW_GITHUB": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"DISABLE_ADMIN_PASSWORD_CHECK": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
"ENABLE_CHECK_JUNK_MAIL": utils.getBooleanValue(c.env.ENABLE_CHECK_JUNK_MAIL),
"JUNK_MAIL_CHECK_LIST": utils.getStringArray(c.env.JUNK_MAIL_CHECK_LIST),
"JUNK_MAIL_FORCE_PASS_LIST": utils.getStringArray(c.env.JUNK_MAIL_FORCE_PASS_LIST),
"REMOVE_EXCEED_SIZE_ATTACHMENT": getBooleanValue(c.env.REMOVE_EXCEED_SIZE_ATTACHMENT),
"REMOVE_ALL_ATTACHMENT": getBooleanValue(c.env.REMOVE_ALL_ATTACHMENT),
"REMOVE_EXCEED_SIZE_ATTACHMENT": utils.getBooleanValue(c.env.REMOVE_EXCEED_SIZE_ATTACHMENT),
"REMOVE_ALL_ATTACHMENT": utils.getBooleanValue(c.env.REMOVE_ALL_ATTACHMENT),
"ENABLE_ANOTHER_WORKER": getBooleanValue(c.env.ENABLE_ANOTHER_WORKER),
"ANOTHER_WORKER_LIST": getAnotherWorkerList(c),
"ENABLE_ANOTHER_WORKER": utils.getBooleanValue(c.env.ENABLE_ANOTHER_WORKER),
"ANOTHER_WORKER_LIST": utils.getAnotherWorkerList(c),
})
}
}

View File

@@ -1,8 +1,7 @@
import { Hono } from 'hono'
import { getDomains, getPasswords, getBooleanValue, getIntValue, getStringArray, getDefaultDomains, getStringValue } from './utils';
import utils from './utils';
import { CONSTANTS } from './constants';
import { HonoCustomType } from './types';
import { isS3Enabled } from './mails_api/s3_attachment';
const api = new Hono<HonoCustomType>
@@ -10,35 +9,36 @@ const api = new Hono<HonoCustomType>
api.get('/open_api/settings', async (c) => {
// check header x-custom-auth
let needAuth = false;
const passwords = getPasswords(c);
const passwords = utils.getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
needAuth = !auth || !passwords.includes(auth);
}
return c.json({
"title": c.env.TITLE,
"announcement": getStringValue(c.env.ANNOUNCEMENT),
"prefix": getStringValue(c.env.PREFIX),
"addressRegex": getStringValue(c.env.ADDRESS_REGEX),
"minAddressLen": getIntValue(c.env.MIN_ADDRESS_LEN, 1),
"maxAddressLen": getIntValue(c.env.MAX_ADDRESS_LEN, 30),
"defaultDomains": getDefaultDomains(c),
"domains": getDomains(c),
"domainLabels": getStringArray(c.env.DOMAIN_LABELS),
"announcement": utils.getStringValue(c.env.ANNOUNCEMENT),
"alwaysShowAnnouncement": utils.getBooleanValue(c.env.ALWAYS_SHOW_ANNOUNCEMENT),
"prefix": utils.getStringValue(c.env.PREFIX),
"addressRegex": utils.getStringValue(c.env.ADDRESS_REGEX),
"minAddressLen": utils.getIntValue(c.env.MIN_ADDRESS_LEN, 1),
"maxAddressLen": utils.getIntValue(c.env.MAX_ADDRESS_LEN, 30),
"defaultDomains": utils.getDefaultDomains(c),
"domains": utils.getDomains(c),
"domainLabels": utils.getStringArray(c.env.DOMAIN_LABELS),
"needAuth": needAuth,
"adminContact": c.env.ADMIN_CONTACT,
"enableUserCreateEmail": getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
"disableAnonymousUserCreateEmail": getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
"enableUserDeleteEmail": getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
"enableAutoReply": getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"enableIndexAbout": getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
"enableUserCreateEmail": utils.getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
"disableAnonymousUserCreateEmail": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
"enableUserDeleteEmail": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
"enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
"copyright": c.env.COPYRIGHT,
"cfTurnstileSiteKey": c.env.CF_TURNSTILE_SITE_KEY,
"enableWebhook": getBooleanValue(c.env.ENABLE_WEBHOOK),
"enableWebhook": utils.getBooleanValue(c.env.ENABLE_WEBHOOK),
"isS3Enabled": isS3Enabled(c),
"version": CONSTANTS.VERSION,
"showGithub": !getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"disableAdminPasswordCheck": getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK)
"showGithub": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK)
});
})

View File

@@ -2,7 +2,6 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList } from './utils';
import { HonoCustomType, UserRole, AnotherWorker, RPCEmailMessage, ParsedEmailContext } from './types';
import { unbindTelegramByAddress } from './telegram_api/common';
import { CONSTANTS } from './constants';
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';

View File

@@ -1,5 +1,9 @@
export const CONSTANTS = {
VERSION: 'v' + '0.9.1',
VERSION: 'v' + '0.10.0',
// DB Version
DB_VERSION_KEY: 'db_version',
DB_VERSION: "v0.0.1",
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',

View File

@@ -1,6 +1,5 @@
import { createMimeMessage } from "mimetext";
import { getBooleanValue } from "../utils";
import { Bindings } from "../types";
export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings): Promise<void> => {
const message_id = message.headers.get("Message-ID");

View File

@@ -1,5 +1,4 @@
import { CONSTANTS } from "../constants";
import { Bindings } from "../types";
export const isBlocked = async (from: string, env: Bindings): Promise<boolean> => {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => from.includes(word))) {

View File

@@ -1,4 +1,3 @@
import { Bindings, ParsedEmailContext } from "../types";
import { getBooleanValue } from "../utils";
import { commonParseMail } from "../common";
import { createMimeMessage } from "mimetext";

View File

@@ -1,4 +1,3 @@
import { Bindings, ParsedEmailContext } from "../types";
import { getBooleanValue, getStringArray } from "../utils";
import { commonParseMail } from "../common";

View File

@@ -1,8 +1,7 @@
import { Context } from "hono";
import { getEnvStringList } from "../utils";
import { getEnvStringList, getJsonObjectValue } from "../utils";
import { sendMailToTelegram } from "../telegram_api";
import { Bindings, HonoCustomType, RPCEmailMessage, ParsedEmailContext } from "../types";
import { auto_reply } from "./auto_reply";
import { isBlocked } from "./black_list";
import { triggerWebhook, triggerAnotherWorker, commonParseMail } from "../common";
@@ -67,6 +66,30 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
console.error("forward email error", error);
}
// forward subdomain email
try {
// 遍历 FORWARD_ADDRESS_LIST
const subdomainForwardAddressList = getJsonObjectValue<SubdomainForwardAddressList[]>(env.SUBDOMAIN_FORWARD_ADDRESS_LIST) || [];
for (const subdomainForwardAddress of subdomainForwardAddressList) {
// 检查邮件是否匹配 domains
if (subdomainForwardAddress.domains && subdomainForwardAddress.domains.length > 0) {
for (const domain of subdomainForwardAddress.domains) {
if (message.to.endsWith(domain)) {
// 转发邮件
await message.forward(subdomainForwardAddress.forward);
// 支持多邮箱转发收件,不进行截止
// break;
}
}
} else {
// 如果 domains 为空,则转发所有邮件
await message.forward(subdomainForwardAddress.forward);
}
}
} catch (error) {
console.error("subdomain forward email error", error);
}
// send email to telegram
try {
await sendMailToTelegram(

View File

@@ -1,6 +1,7 @@
import { LocaleMessages } from "./type";
import zh from "./zh";
import en from "./en";
import { Context } from "hono";
export default {
getMessages: (
@@ -10,6 +11,17 @@ export default {
if (locale === "en") return en;
if (locale === "zh") return zh;
// fallback language
return en;
},
getMessagesbyContext: (
c: Context<HonoCustomType>
): LocaleMessages => {
const locale = c.get("lang") || c.env.DEFAULT_LANG;
// multi-language support
if (locale === "en") return en;
if (locale === "zh") return zh;
// fallback language
return en;
}

View File

@@ -1,6 +1,5 @@
import { Context } from "hono";
import { getBooleanValue } from "../utils";
import { HonoCustomType } from "../types";
export default {

View File

@@ -1,7 +1,6 @@
import { Hono } from 'hono'
import i18n from '../i18n';
import { HonoCustomType } from "../types";
import { getBooleanValue, getJsonSetting, checkCfTurnstile, getStringValue, getSplitStringListValue } from '../utils';
import { newAddress, handleListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains } from '../common'
import { CONSTANTS } from '../constants'

View File

@@ -1,4 +1,3 @@
import { HonoCustomType } from "../types";
import { Context } from "hono";
import {
S3Client,

View File

@@ -9,7 +9,6 @@ import { CONSTANTS } from '../constants'
import { getJsonSetting, getDomains, getIntValue, getBooleanValue, getStringValue, getJsonObjectValue, getSplitStringListValue } from '../utils';
import { GeoData } from '../models'
import { handleListQuery } from '../common'
import { HonoCustomType } from '../types';
export const api = new Hono<HonoCustomType>()

View File

@@ -1,8 +1,6 @@
import { Context } from "hono";
import { HonoCustomType, ParsedEmailContext } from "../types";
import { CONSTANTS } from "../constants";
import { AdminWebhookSettings, WebhookSettings } from "../models";
import { getBooleanValue } from "../utils";
import { commonParseMail, sendWebhook } from "../common";

View File

@@ -3,7 +3,6 @@ import { cleanup } from './common'
import { CONSTANTS } from './constants'
import { getJsonSetting } from './utils';
import { CleanupSettings } from './models';
import { Bindings, HonoCustomType } from './types';
export async function scheduled(event: ScheduledEvent, env: Bindings, ctx: any) {
console.log("Scheduled event: ", event);

View File

@@ -1,7 +1,6 @@
import { Context } from "hono";
import { Jwt } from "hono/utils/jwt";
import { CONSTANTS } from "../constants";
import { HonoCustomType } from "../types";
import { getIntValue, getJsonSetting } from "../utils";
import { deleteAddressWithData, newAddress } from "../common";

View File

@@ -2,7 +2,6 @@ import { Hono } from 'hono'
import { ServerResponse } from 'node:http'
import { Writable } from 'node:stream'
import { HonoCustomType } from '../types'
import { newTelegramBot, initTelegramBotCommands, sendMailToTelegram } from './telegram'
import settings from './settings'
import miniapp from './miniapp'

View File

@@ -1,6 +1,5 @@
import { Context } from "hono";
import { Jwt } from 'hono/utils/jwt'
import { HonoCustomType } from "../types";
import { CONSTANTS } from "../constants";
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress } from "./common";
import { checkCfTurnstile } from "../utils";

View File

@@ -1,5 +1,4 @@
import { Context } from "hono";
import { HonoCustomType } from "../types";
import { CONSTANTS } from "../constants";
export class TelegramSettings {

View File

@@ -5,7 +5,6 @@ import { callbackQuery } from "telegraf/filters";
import { CONSTANTS } from "../constants";
import { getDomains, getJsonObjectValue, getStringValue } from '../utils';
import { HonoCustomType, ParsedEmailContext } from "../types";
import { TelegramSettings } from "./settings";
import { bindTelegramAddress, deleteTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, unbindTelegramByAddress } from "./common";
import { commonParseMail } from "../common";
@@ -102,7 +101,10 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
const res = await tgUserNewAddress(c, userId.toString(), address);
return await ctx.reply(`创建地址成功:\n`
+ `地址: ${res.address}\n`
+ `凭证: ${res.jwt}\n`
+ `凭证: \`${res.jwt}\`\n`,
{
parse_mode: "Markdown"
}
);
} catch (e) {
return await ctx.reply(`创建地址失败: ${(e as Error).message}`);

12
worker/src/types.d.ts vendored
View File

@@ -1,10 +1,10 @@
export type UserRole = {
type UserRole = {
domains: string[] | undefined | null,
role: string,
prefix: string | undefined | null
}
export type Bindings = {
type Bindings = {
// bindings
DB: D1Database
KV: KVNamespace
@@ -16,6 +16,7 @@ export type Bindings = {
DEFAULT_LANG: string | undefined
TITLE: string | undefined
ANNOUNCEMENT: string | undefined | null
ALWAYS_SHOW_ANNOUNCEMENT: string | boolean | undefined
PREFIX: string | undefined
ADDRESS_CHECK_REGEX: string | undefined
ADDRESS_REGEX: string | undefined
@@ -52,6 +53,8 @@ export type Bindings = {
ENABLE_ANOTHER_WORKER: string | boolean | undefined
ANOTHER_WORKER_LIST: string | AnotherWorker[] | undefined
SUBDOMAIN_FORWARD_ADDRESS_LIST: string | SubdomainForwardAddressList[] | undefined
REMOVE_ALL_ATTACHMENT: string | boolean | undefined
REMOVE_EXCEED_SIZE_ATTACHMENT: string | boolean | undefined
@@ -129,3 +132,8 @@ type ParsedEmailContext = {
headers?: Record<string, string>[]
} | undefined
}
type SubdomainForwardAddressList = {
domains: string[] | undefined | null,
forward: string,
}

View File

@@ -1,11 +1,11 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { HonoCustomType } from '../types';
import { UserSettings } from "../models";
import { getJsonSetting } from "../utils"
import { CONSTANTS } from "../constants";
import { unbindTelegramByAddress } from '../telegram_api/common';
import i18n from '../i18n';
const UserBindAddressModule = {
bind: async (c: Context<HonoCustomType>) => {
@@ -102,13 +102,30 @@ const UserBindAddressModule = {
},
getBindedAddresses: async (c: Context<HonoCustomType>) => {
const { user_id } = c.get("userPayload");
return await UserBindAddressModule.getBindedAddressesById(c, user_id);
const results = await UserBindAddressModule.getBindedAddressesById(c, user_id);
return c.json({
results: results,
});
},
getBindedAddressListById: async (
c: Context<HonoCustomType>, user_id: number | string
): Promise<string[]> => {
const bindedAddressList = await UserBindAddressModule.getBindedAddressesById(c, user_id);
return bindedAddressList.map((item) => item.name);
},
getBindedAddressesById: async (
c: Context<HonoCustomType>, user_id: number | string
) => {
): Promise<{
id: number;
name: string;
mail_count: number;
send_count: number;
created_at: string;
updated_at: string;
}[]> => {
const msgs = i18n.getMessagesbyContext(c);
if (!user_id) {
return c.text("No user token", 400)
throw new Error(msgs.UserNotFoundMsg);
}
// select binded address
const { results } = await c.env.DB.prepare(
@@ -120,10 +137,15 @@ const UserBindAddressModule = {
+ ` ON ua.address_id = a.id `
+ ` WHERE ua.user_id = ?`
+ ` ORDER BY a.id DESC`
).bind(user_id).all();
return c.json({
results: results,
})
).bind(user_id).all<{
id: number;
name: string;
mail_count: number;
send_count: number;
created_at: string;
updated_at: string;
}>();
return results || [];
},
getBindedAddressJwt: async (c: Context<HonoCustomType>) => {
const { address_id } = c.req.param();
@@ -216,7 +238,7 @@ const UserBindAddressModule = {
throw new Error("Failed to create address")
}
// find new address id
let new_address_id = await c.env.DB.prepare(
const new_address_id = await c.env.DB.prepare(
`SELECT id FROM address WHERE name = ?`
).bind(address).first<number | null | undefined>("id");
if (!new_address_id) {

View File

@@ -1,11 +1,11 @@
import { Hono } from 'hono';
import { HonoCustomType } from '../types';
import settings from './settings';
import user from './user';
import bind_address from './bind_address';
import passkey from './passkey';
import oauth2 from './oauth2';
import user_mail_api from './user_mail_api';
export const api = new Hono<HonoCustomType>();
@@ -13,6 +13,10 @@ export const api = new Hono<HonoCustomType>();
api.get('/user_api/open_settings', settings.openSettings);
api.get('/user_api/settings', settings.settings);
// mail api
api.get('/user_api/mails', user_mail_api.getMails);
api.delete('/user_api/mails/:id', user_mail_api.deleteMail);
// user api
api.post('/user_api/login', user.login);
api.post('/user_api/verify_code', user.verifyCode);

View File

@@ -2,7 +2,6 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n';
import { HonoCustomType } from '../types';
import { getJsonSetting } from '../utils';
import { UserOauth2Settings } from '../models';
import { CONSTANTS } from '../constants';
@@ -38,6 +37,7 @@ export default {
client_id: setting.clientID,
client_secret: setting.clientSecret,
grant_type: 'authorization_code',
redirect_uri: setting.redirectURL,
}
const res = await fetch(setting.accessTokenURL, {
method: 'POST',
@@ -115,7 +115,7 @@ export default {
user_email: email,
user_id: user_id,
// 90 days expire in seconds
exp: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
return c.json({

View File

@@ -7,7 +7,6 @@ import {
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { HonoCustomType } from '../types';
import { Passkey } from '../models';
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
@@ -194,7 +193,7 @@ export default {
user_email: user_email,
user_id: user_id,
// 90 days expire in seconds
exp: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
return c.json({

View File

@@ -1,7 +1,6 @@
import { Context } from "hono";
import i18n from "../i18n";
import { HonoCustomType } from "../types";
import { UserOauth2Settings, UserSettings } from "../models";
import { getJsonSetting, getUserRoles } from "../utils"
import { CONSTANTS } from "../constants";
@@ -55,10 +54,21 @@ export default {
// 1 hour
exp: Math.floor(Date.now() / 1000) + 3600,
}, c.env.JWT_SECRET, "HS256") : null;
// create new if expired in 7 days
const new_user_token = user.exp > (
Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
) ? null : await Jwt.sign({
user_email: user.user_email,
user_id: user.user_id,
// 30 days expire in seconds
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256");
return c.json({
...user,
is_admin: is_admin,
access_token: access_token,
new_user_token: new_user_token,
user_role: user_role
});
},

View File

@@ -2,7 +2,6 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n';
import { HonoCustomType } from '../types';
import { checkCfTurnstile, getJsonSetting, checkUserPassword, getUserRoles, getStringValue } from "../utils"
import { CONSTANTS } from "../constants";
import { GeoData, UserInfo, UserSettings } from "../models";
@@ -173,7 +172,7 @@ export default {
user_email: email,
user_id: user_id,
// 90 days expire in seconds
exp: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
return c.json({

View File

@@ -0,0 +1,42 @@
import { Context } from "hono";
import { handleListQuery } from "../common";
import UserBindAddressModule from "./bind_address";
export default {
getMails: async (c: Context<HonoCustomType>) => {
const { user_id } = c.get("userPayload");
const { address, limit, offset, keyword } = c.req.query();
const bindedAddressList = await UserBindAddressModule.getBindedAddressListById(c, user_id);
const addressList = address ? bindedAddressList.filter((item) => item == address) : bindedAddressList;
const addressQuery = `address IN (${addressList.map(() => "?").join(",")})`;
const addressParams = addressList;
const keywordQuery = keyword ? `raw like ?` : "";
const keywordParams = keyword ? [`%${keyword}%`] : [];
// user must have at least one binded address to query mails
if (addressList.length <= 0) {
return c.json({ results: [], count: 0 });
}
const filterQuerys = [addressQuery, keywordQuery].filter((item) => item).join(" and ");
const finalQuery = filterQuerys.length > 0 ? `where ${filterQuerys}` : "";
const filterParams = [...addressParams, ...keywordParams]
return await handleListQuery(c,
`SELECT * FROM raw_mails ${finalQuery}`,
`SELECT count(*) as count FROM raw_mails ${finalQuery}`,
filterParams, limit, offset
);
},
deleteMail: async (c: Context<HonoCustomType>) => {
const { id } = c.req.param();
const { user_id } = c.get("userPayload");
const bindedAddressList = await UserBindAddressModule.getBindedAddressListById(c, user_id);
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE id = ?`
+ ` and address IN (${bindedAddressList.map(() => "?").join(",")})`
).bind(id, ...bindedAddressList).run();
return c.json({
success: success
})
}
}

View File

@@ -1,6 +1,5 @@
import { Context } from "hono";
import { createMimeMessage } from "mimetext";
import { HonoCustomType, UserRole, AnotherWorker } from "./types";
export const getJsonObjectValue = <T = any>(
value: string | any
@@ -296,3 +295,27 @@ export const checkUserPassword = (password: string) => {
}
return true;
}
export default {
getJsonObjectValue,
getSetting,
saveSetting,
getStringValue,
getSplitStringListValue,
getBooleanValue,
getIntValue,
getStringArray,
getDefaultDomains,
getDomains,
getUserRoles,
getAnotherWorkerList,
getPasswords,
getAdminPasswords,
getEnvStringList,
sendAdminInternalMail,
checkCfTurnstile,
checkUserPassword,
getJsonSetting,
getJsonValue: getJsonObjectValue,
getStringList: getStringArray
}

View File

@@ -14,7 +14,6 @@ import i18n from './i18n';
import { email } from './email';
import { scheduled } from './scheduled';
import { getAdminPasswords, getPasswords, getBooleanValue, getStringArray } from './utils';
import { HonoCustomType, UserPayload } from './types';
const API_PATHS = [
"/api/",

View File

@@ -25,6 +25,8 @@ compatibility_flags = [ "nodejs_compat" ]
# DEFAULT_LANG = "zh"
# TITLE = "Custom Title" # custom title
# ANNOUNCEMENT = "Custom Announcement"
# always show ANNOUNCEMENT even no changes
# ALWAYS_SHOW_ANNOUNCEMENT = true
PREFIX = "tmp"
# address check REGEX, if not set, will not check
# ADDRESS_CHECK_REGEX = "^(?!.*admin).*"
@@ -80,6 +82,13 @@ ENABLE_AUTO_REPLY = false
# TG_BOT_INFO = "{}"
# global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
# subdomain forward address list, if set, subdomain emails will be forwarded to these addresses
# SUBDOMAIN_FORWARD_ADDRESS_LIST = """
# [
# {"domains":[""],"forward":"xxx1@xxx.com"},
# {"domains":["subdomain-1.domain.com","subdomain-2.domain.com"],"forward":"xxx2@xxx.com"}
# ]
# """
# Frontend URL
# FRONTEND_URL = "https://xxxx.xxx"
# Enable check junk mail