Compare commits

...

14 Commits

Author SHA1 Message Date
Dream Hunter
c6d0307eac Release v0.7.1 2024-08-11 22:46:40 +08:00
Dream Hunter
ac31042e69 feat: add EMAIL_KV_BLACK_LIST (#394) 2024-08-11 20:34:10 +08:00
Dream Hunter
c733d3bf4d fix: get user role before all requests (#393) 2024-08-11 19:29:49 +08:00
Dream Hunter
bf1243f4c4 release: v0.7.0 (#387) 2024-08-11 00:21:15 +08:00
Dream Hunter
15063b2e97 feat: add DISABLE_ADMIN_PASSWORD_CHECK (#386) 2024-08-11 00:10:16 +08:00
Dream Hunter
fc07f1cd87 feat: add passkey (#384) 2024-08-10 23:56:05 +08:00
Dream Hunter
9246550cc5 feat: add NO_LIMIT_SEND_ROLE (#373) 2024-08-04 21:02:11 +08:00
Dream Hunter
979b6eae1a feat: add SHOW_GITHUB config (#372) 2024-08-04 14:36:24 +08:00
Dream Hunter
10da337a9c feat: add SHOW_GITHUB config (#371) 2024-08-04 14:34:35 +08:00
Dream Hunter
9c5e8857af feat: add loading when process mails (#367) 2024-07-27 23:14:18 +08:00
Dream Hunter
84b4baa99e feat: add .github/workflows/pr_agent.yml (#366) 2024-07-27 23:06:54 +08:00
Dream Hunter
b57d46244a feat: add loading when process mails (#364) 2024-07-27 22:30:38 +08:00
Dream Hunter
5faae8796d feat: add ADMIN_USER_ROLE for user access admin panel (#363) 2024-07-27 22:04:18 +08:00
666-eth
a0805bc0ce Docs: Update new-address-api.md (#360) 2024-07-23 13:47:37 +08:00
45 changed files with 954 additions and 90 deletions

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

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

View File

@@ -1,6 +1,26 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 --> <!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG # CHANGE LOG
## v0.7.1
- fix: 修复用户角色加载失败的问题
- feat: admin 账号设置增加来源邮件地址黑名单配置
## v0.7.0
### Breaking Changes
DB changes: 增加用户 `passkey` 表, 需要执行 `db/2024-08-10-patch.sql` 更新 `D1` 数据库
### Changes
- Docs: Update new-address-api.md (#360)
- feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363)
- feat: worker 增加 `DISABLE_SHOW_GITHUB` 配置, 用于配置是否显示 github 链接
- feat: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 用于配置可以无限发送邮件的角色
- feat: 用户增加 `passkey` 登录方式, 用于用户登录, 无需输入密码
- feat: worker 增加 `DISABLE_ADMIN_PASSWORD_CHECK` 配置, 用于配置是否禁用 admin 控制台密码检查, 若你的网站只可私人访问,可通过此禁用检查
## v0.6.1 ## v0.6.1
- pages github actions && 修复清理邮件天数为 0 不生效 by @tqjason (#355) - pages github actions && 修复清理邮件天数为 0 不生效 by @tqjason (#355)

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

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "0.6.1", "version": "0.7.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -17,6 +17,7 @@
"deploy:actions": "npm run build && wrangler pages deploy ./dist" "deploy:actions": "npm run build && wrangler pages deploy ./dist"
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^10.0.0",
"@unhead/vue": "^1.9.15", "@unhead/vue": "^1.9.15",
"@vicons/material": "^0.12.0", "@vicons/material": "^0.12.0",
"@vueuse/core": "^10.11.0", "@vueuse/core": "^10.11.0",

View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@simplewebauthn/browser':
specifier: ^10.0.0
version: 10.0.0
'@unhead/vue': '@unhead/vue':
specifier: ^1.9.15 specifier: ^1.9.15
version: 1.9.15(vue@3.4.31(typescript@5.4.5)) version: 1.9.15(vue@3.4.31(typescript@5.4.5))
@@ -1188,6 +1191,12 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@simplewebauthn/browser@10.0.0':
resolution: {integrity: sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==}
'@simplewebauthn/types@10.0.0':
resolution: {integrity: sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==}
'@surma/rollup-plugin-off-main-thread@2.2.3': '@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -4075,6 +4084,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.18.0': '@rollup/rollup-win32-x64-msvc@4.18.0':
optional: true optional: true
'@simplewebauthn/browser@10.0.0':
dependencies:
'@simplewebauthn/types': 10.0.0
'@simplewebauthn/types@10.0.0': {}
'@surma/rollup-plugin-off-main-thread@2.2.3': '@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies: dependencies:
ejs: 3.1.10 ejs: 3.1.10

View File

@@ -6,7 +6,7 @@ import { useGlobalState } from './store'
import { useIsMobile } from './utils/composables' import { useIsMobile } from './utils/composables'
import Header from './views/Header.vue'; import Header from './views/Header.vue';
import Footer from './views/Footer.vue'; import Footer from './views/Footer.vue';
import { api } from './api'
const { const {
isDark, loading, useSideMargin, telegramApp, isTelegram isDark, loading, useSideMargin, telegramApp, isTelegram
@@ -19,6 +19,13 @@ const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
onMounted(async () => { onMounted(async () => {
try {
await api.getUserSettings();
} catch (error) {
console.error(error);
}
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN; const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null

View File

@@ -22,6 +22,7 @@ const apiFetch = async (path, options = {}) => {
data: options.body || null, data: options.body || null,
headers: { headers: {
'x-user-token': userJwt.value, 'x-user-token': userJwt.value,
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value, 'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value, 'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`, 'Authorization': `Bearer ${jwt.value}`,
@@ -127,7 +128,7 @@ const getUserSettings = async (message) => {
const res = await api.fetch("/user_api/settings") const res = await api.fetch("/user_api/settings")
Object.assign(userSettings.value, res) Object.assign(userSettings.value, res)
} catch (error) { } catch (error) {
message.error(error.message || "error"); message?.error(error.message || "error");
} finally { } finally {
userSettings.value.fetched = true; userSettings.value.fetched = true;
} }

View File

@@ -147,6 +147,7 @@ const refresh = async () => {
const { results, count: totalCount } = await props.fetchMailData( const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value pageSize.value, (page.value - 1) * pageSize.value
); );
loading.value = true;
data.value = await Promise.all(results.map(async (item) => { data.value = await Promise.all(results.map(async (item) => {
item.checked = false; item.checked = false;
return await processItem(item); return await processItem(item);
@@ -161,6 +162,8 @@ const refresh = async () => {
} catch (error) { } catch (error) {
message.error(error.message || "error"); message.error(error.message || "error");
console.error(error); console.error(error);
} finally {
loading.value = false;
} }
}; };

View File

@@ -1,4 +1,4 @@
import { ref } from "vue"; import { computed, ref } from "vue";
import { createGlobalState, useStorage, useDark, useToggle, useLocalStorage } from '@vueuse/core' import { createGlobalState, useStorage, useDark, useToggle, useLocalStorage } from '@vueuse/core'
export const useGlobalState = createGlobalState( export const useGlobalState = createGlobalState(
@@ -25,6 +25,7 @@ export const useGlobalState = createGlobalState(
cfTurnstileSiteKey: '', cfTurnstileSiteKey: '',
enableWebhook: false, enableWebhook: false,
isS3Enabled: false, isS3Enabled: false,
showGithub: true,
}) })
const settings = ref({ const settings = ref({
fetched: false, fetched: false,
@@ -74,9 +75,14 @@ export const useGlobalState = createGlobalState(
user_email: '', user_email: '',
/** @type {number} */ /** @type {number} */
user_id: 0, user_id: 0,
/** @type {boolean} */
is_admin: false,
/** @type {string | null} */
access_token: null,
/** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */ /** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
user_role: null, user_role: null,
}); });
const showAdminPage = computed(() => !!adminAuth.value || userSettings.value.is_admin);
const telegramApp = ref(window.Telegram?.WebApp || {}); const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData); const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
return { return {
@@ -108,6 +114,7 @@ export const useGlobalState = createGlobalState(
useSideMargin, useSideMargin,
telegramApp, telegramApp,
isTelegram, isTelegram,
showAdminPage,
} }
}, },
) )

View File

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

View File

@@ -17,8 +17,8 @@ import { getRouterPathWithLang } from '../utils'
const message = useMessage() const message = useMessage()
const { const {
toggleDark, isDark, isTelegram, toggleDark, isDark, isTelegram, showAdminPage,
showAuth, adminAuth, auth, loading, openSettings showAuth, auth, loading, openSettings, userSettings
} = useGlobalState() } = useGlobalState()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -134,7 +134,7 @@ const menuOptions = computed(() => [
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }), icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
} }
), ),
show: !!adminAuth.value, show: showAdminPage.value,
key: "admin" key: "admin"
}, },
{ {
@@ -192,6 +192,7 @@ const menuOptions = computed(() => [
icon: () => h(NIcon, { component: GithubAlt }) icon: () => h(NIcon, { component: GithubAlt })
} }
), ),
show: openSettings.value?.showGithub,
key: "github" key: "github"
} }
]); ]);
@@ -223,6 +224,8 @@ const logoClick = async () => {
onMounted(async () => { onMounted(async () => {
await api.getOpenSettings(message); await api.getOpenSettings(message);
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
}); });
</script> </script>

View File

@@ -9,8 +9,8 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material' import { MenuFilled } from '@vicons/material'
const { const {
adminAuth, showAdminAuth, loading, showAdminAuth, loading, adminTab,
adminTab, adminMailTabAddress, adminSendBoxTabAddress adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState() } = useGlobalState()
const message = useMessage() const message = useMessage()
@@ -252,10 +252,6 @@ watch([page, pageSize], async () => {
}) })
onMounted(async () => { onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData() await fetchData()
}) })
</script> </script>

View File

@@ -11,20 +11,24 @@ const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
messages: { messages: {
en: { en: {
tip: 'You can manually input the following multiple select input',
save: 'Save', save: 'Save',
successTip: 'Save Success', successTip: 'Save Success',
address_block_list: 'Address Block Keywords for Users(Admin can skip)', address_block_list: 'Address Block Keywords for Users(Admin can skip)',
address_block_list_placeholder: 'Please enter the keywords you want to block', address_block_list_placeholder: 'Please enter the keywords you want to block',
send_address_block_list: 'Address Block Keywords for send email', send_address_block_list: 'Address Block Keywords for send email',
verified_address_list: 'Verified Address List(Can send email by cf internal api)', verified_address_list: 'Verified Address List(Can send email by cf internal api)',
fromBlockList: 'Block Keywords for receive email',
}, },
zh: { zh: {
tip: '您可以手动输入以下多选输入框',
save: '保存', save: '保存',
successTip: '保存成功', successTip: '保存成功',
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)', address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
address_block_list_placeholder: '请输入您想要屏蔽的关键词', address_block_list_placeholder: '请输入您想要屏蔽的关键词',
send_address_block_list: '发送邮件地址屏蔽关键词', send_address_block_list: '发送邮件地址屏蔽关键词',
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)', verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
fromBlockList: '接收邮件地址屏蔽关键词',
} }
} }
}); });
@@ -32,6 +36,7 @@ const { t } = useI18n({
const addressBlockList = ref([]) const addressBlockList = ref([])
const sendAddressBlockList = ref([]) const sendAddressBlockList = ref([])
const verifiedAddressList = ref([]) const verifiedAddressList = ref([])
const fromBlockList = ref([])
const fetchData = async () => { const fetchData = async () => {
try { try {
@@ -39,6 +44,7 @@ const fetchData = async () => {
addressBlockList.value = res.blockList || [] addressBlockList.value = res.blockList || []
sendAddressBlockList.value = res.sendBlockList || [] sendAddressBlockList.value = res.sendBlockList || []
verifiedAddressList.value = res.verifiedAddressList || [] verifiedAddressList.value = res.verifiedAddressList || []
fromBlockList.value = res.fromBlockList || []
} catch (error) { } catch (error) {
message.error(error.message || "error"); message.error(error.message || "error");
} }
@@ -51,7 +57,8 @@ const save = async () => {
body: JSON.stringify({ body: JSON.stringify({
blockList: addressBlockList.value || [], blockList: addressBlockList.value || [],
sendBlockList: sendAddressBlockList.value || [], sendBlockList: sendAddressBlockList.value || [],
verifiedAddressList: verifiedAddressList.value || [] verifiedAddressList: verifiedAddressList.value || [],
fromBlockList: fromBlockList.value || [],
}) })
}) })
message.success(t('successTip')) message.success(t('successTip'))
@@ -69,6 +76,9 @@ onMounted(async () => {
<template> <template>
<div class="center"> <div class="center">
<n-card :bordered="false" embedded style="max-width: 600px;"> <n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert :show-icon="false" style="margin-bottom: 10px;">
{{ t("tip") }}
</n-alert>
<n-form-item-row :label="t('address_block_list')"> <n-form-item-row :label="t('address_block_list')">
<n-select v-model:value="addressBlockList" filterable multiple tag <n-select v-model:value="addressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" /> :placeholder="t('address_block_list_placeholder')" />
@@ -81,6 +91,9 @@ onMounted(async () => {
<n-select v-model:value="verifiedAddressList" filterable multiple tag <n-select v-model:value="verifiedAddressList" filterable multiple tag
:placeholder="t('verified_address_list')" /> :placeholder="t('verified_address_list')" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('fromBlockList')">
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')" />
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading"> <n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }} {{ t('save') }}
</n-button> </n-button>

View File

@@ -6,10 +6,7 @@ import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import MailBox from '../../components/MailBox.vue'; import MailBox from '../../components/MailBox.vue';
const { const { adminMailTabAddress } = useGlobalState()
adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const { t } = useI18n({ const { t } = useI18n({
messages: { messages: {
@@ -48,13 +45,6 @@ const fetchMailData = async (limit, offset) => {
const deleteMail = async (curMailId) => { const deleteMail = async (curMailId) => {
await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' }); await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' });
}; };
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script> </script>
<template> <template>

View File

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

View File

@@ -1,12 +1,10 @@
<script setup> <script setup>
import { ref, h, onMounted, watch } from 'vue'; import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { CleaningServicesFilled } from '@vicons/material' import { CleaningServicesFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { adminAuth, showAdminAuth } = useGlobalState()
const message = useMessage() const message = useMessage()
const cleanupModel = ref({ const cleanupModel = ref({
enableMailsAutoCleanup: false, enableMailsAutoCleanup: false,
@@ -80,10 +78,6 @@ const save = async () => {
} }
onMounted(async () => { onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData(); await fetchData();
}) })
</script> </script>

View File

@@ -4,10 +4,8 @@ import { useI18n } from 'vue-i18n'
import { User, UserCheck, MailBulk } from '@vicons/fa' import { User, UserCheck, MailBulk } from '@vicons/fa'
import { SendOutlined } from '@vicons/material' import { SendOutlined } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { adminAuth } = useGlobalState()
const message = useMessage() const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
@@ -60,9 +58,6 @@ const fetchStatistics = async () => {
} }
onMounted(async () => { onMounted(async () => {
if (!adminAuth.value) {
return;
}
await fetchStatistics() await fetchStatistics()
}) })
</script> </script>

View File

@@ -6,7 +6,7 @@ import { api } from '../../api'
import { onMounted, watch } from 'vue'; import { onMounted, watch } from 'vue';
import { processItem } from '../../utils/email-parser' import { processItem } from '../../utils/email-parser'
const { telegramApp } = useGlobalState() const { telegramApp, loading } = useGlobalState()
const route = useRoute() const route = useRoute()
const curMail = ref({}); const curMail = ref({});
@@ -26,12 +26,16 @@ const fetchMailData = async () => {
mailId: route.query.mail_id mailId: route.query.mail_id
}) })
}); });
loading.value = true;
return await processItem(res); return await processItem(res);
} }
catch (error) { catch (error) {
console.error(error); console.error(error);
return {}; return {};
} }
finally {
loading.value = false;
}
}; };
onMounted(async () => { onMounted(async () => {

View File

@@ -31,7 +31,8 @@ const { t } = useI18n({
onMounted(async () => { onMounted(async () => {
await api.getUserOpenSettings(message); await api.getUserOpenSettings(message);
await api.getUserSettings(message); // make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
}); });
</script> </script>

View File

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

View File

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

View File

@@ -84,6 +84,8 @@ PREFIX = "tmp" # The mailbox name prefix to be processed
# PASSWORDS = ["123", "456"] # PASSWORDS = ["123", "456"]
# admin console password, if not configured, access to the console is not allowed # admin console password, if not configured, access to the console is not allowed
# ADMIN_PASSWORDS = ["123", "456"] # ADMIN_PASSWORDS = ["123", "456"]
# warning: no password or user check for admin portal
# DISABLE_ADMIN_PASSWORD_CHECK = false
# admin contact information. If not configured, it will not be displayed. Any string can be configured. # admin contact information. If not configured, it will not be displayed. Any string can be configured.
# ADMIN_CONTACT = "xx@xx.xxx" # ADMIN_CONTACT = "xx@xx.xxx"
DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users
@@ -91,6 +93,7 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all your domain name
# For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name # For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"] # DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification) # USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification)
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix # User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix
# USER_ROLES = [ # USER_ROLES = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" }, # { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },
@@ -108,13 +111,15 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true # ENABLE_WEBHOOK = true
# Footer text # Footer text
# COPYRIGHT = "Dream Hunter" # COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # Disable Show GitHub link
# default send balance, if not set, it will be 0 # default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1 # DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # the role which can send emails without limit
# Turnstile verification configuration # Turnstile verification configuration
# CF_TURNSTILE_SITE_KEY = "" # CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = "" # CF_TURNSTILE_SECRET_KEY = ""
# telegram bot # telegram bot
# TG_MAX_ACCOUNTS = 5 # TG_MAX_ADDRESS = 5
# global forward address list, if set, all emails will be forwarded to these addresses # global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"] # FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]

View File

@@ -52,6 +52,8 @@ PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空
# PASSWORDS = ["123", "456"] # PASSWORDS = ["123", "456"]
# admin 控制台密码, 不配置则不允许访问控制台 # admin 控制台密码, 不配置则不允许访问控制台
# ADMIN_PASSWORDS = ["123", "456"] # ADMIN_PASSWORDS = ["123", "456"]
# 警告: 管理员控制台没有密码或用户检查
# DISABLE_ADMIN_PASSWORD_CHECK = false
# admin 联系方式,不配置则不显示,可配置任意字符串 # admin 联系方式,不配置则不显示,可配置任意字符串
# ADMIN_CONTACT = "xx@xx.xxx" # ADMIN_CONTACT = "xx@xx.xxx"
# DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 默认用户可用的域名(未登录或未分配角色的用户) # DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 默认用户可用的域名(未登录或未分配角色的用户)
@@ -60,6 +62,8 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名, 支持多个域名
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"] # DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# 新用户默认角色, 仅在启用邮件验证时有效 # 新用户默认角色, 仅在启用邮件验证时有效
# USER_DEFAULT_ROLE = "vip" # USER_DEFAULT_ROLE = "vip"
# admin 角色配置, 如果用户角色等于 ADMIN_USER_ROLE 则可以访问 admin 控制台
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# 用户角色配置, 如果 domains 为空将使用 default_domains # 用户角色配置, 如果 domains 为空将使用 default_domains
# 如果 prefix 为 null 将使用默认前缀, 如果 prefix 为空字符串将不使用前缀 # 如果 prefix 为 null 将使用默认前缀, 如果 prefix 为空字符串将不使用前缀
# USER_ROLES = [ # USER_ROLES = [
@@ -78,13 +82,15 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true # ENABLE_WEBHOOK = true
# 前端界面页脚文本 # 前端界面页脚文本
# COPYRIGHT = "Dream Hunter" # COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # 是否显示 GitHub 链接
# 默认发送邮件余额,如果不设置,将为 0 # 默认发送邮件余额,如果不设置,将为 0
# DEFAULT_SEND_BALANCE = 1 # DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # 可以无限发送邮件的角色
# Turnstile 人机验证配置 # Turnstile 人机验证配置
# CF_TURNSTILE_SITE_KEY = "" # CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = "" # CF_TURNSTILE_SECRET_KEY = ""
# telegram bot 最多绑定邮箱数量 # telegram bot 最多绑定邮箱数量
# TG_MAX_ACCOUNTS = 5 # TG_MAX_ADDRESS = 5
# 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址 # 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"] # FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20240620.0", "@cloudflare/workers-types": "^4.20240620.0",
"@eslint/js": "8.56.0", "@eslint/js": "8.56.0",
"@simplewebauthn/types": "^10.0.0",
"eslint": "8.56.0", "eslint": "8.56.0",
"globals": "^15.8.0", "globals": "^15.8.0",
"typescript-eslint": "^7.15.0", "typescript-eslint": "^7.15.0",
@@ -21,6 +22,7 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.609.0", "@aws-sdk/client-s3": "^3.609.0",
"@aws-sdk/s3-request-presigner": "^3.609.0", "@aws-sdk/s3-request-presigner": "^3.609.0",
"@simplewebauthn/server": "^10.0.1",
"hono": "^4.4.12", "hono": "^4.4.12",
"mimetext": "^3.0.24", "mimetext": "^3.0.24",
"postal-mime": "^2.2.5", "postal-mime": "^2.2.5",

126
worker/pnpm-lock.yaml generated
View File

@@ -19,6 +19,9 @@ importers:
'@aws-sdk/s3-request-presigner': '@aws-sdk/s3-request-presigner':
specifier: ^3.609.0 specifier: ^3.609.0
version: 3.609.0 version: 3.609.0
'@simplewebauthn/server':
specifier: ^10.0.1
version: 10.0.1
hono: hono:
specifier: ^4.4.12 specifier: ^4.4.12
version: 4.4.12 version: 4.4.12
@@ -41,6 +44,9 @@ importers:
'@eslint/js': '@eslint/js':
specifier: 8.56.0 specifier: 8.56.0
version: 8.56.0 version: 8.56.0
'@simplewebauthn/types':
specifier: ^10.0.0
version: 10.0.0
eslint: eslint:
specifier: 8.56.0 specifier: 8.56.0
version: 8.56.0 version: 8.56.0
@@ -444,6 +450,9 @@ packages:
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@humanwhocodes/config-array@0.11.14': '@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@@ -471,6 +480,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@levischuck/tiny-cbor@0.2.2':
resolution: {integrity: sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw==}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -486,6 +498,21 @@ packages:
'@one-ini/wasm@0.1.1': '@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@peculiar/asn1-android@2.3.10':
resolution: {integrity: sha512-z9Rx9cFJv7UUablZISe7uksNbFJCq13hO0yEAOoIpAymALTLlvUOSLnGiQS7okPaM5dP42oTLhezH6XDXRXjGw==}
'@peculiar/asn1-ecc@2.3.8':
resolution: {integrity: sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==}
'@peculiar/asn1-rsa@2.3.8':
resolution: {integrity: sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==}
'@peculiar/asn1-schema@2.3.8':
resolution: {integrity: sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==}
'@peculiar/asn1-x509@2.3.8':
resolution: {integrity: sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -497,6 +524,13 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0': '@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@simplewebauthn/server@10.0.1':
resolution: {integrity: sha512-djNWcRn+H+6zvihBFJSpG3fzb0NQS9c/Mw5dYOtZ9H+oDw8qn9Htqxt4cpqRvSOAfwqP7rOvE9rwqVaoGGc3hg==}
engines: {node: '>=20.0.0'}
'@simplewebauthn/types@10.0.0':
resolution: {integrity: sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==}
'@smithy/abort-controller@3.1.1': '@smithy/abort-controller@3.1.1':
resolution: {integrity: sha512-MBJBiidoe+0cTFhyxT8g+9g7CeVccLM0IOKKUMCNQ1CNMJ/eIfoo0RTfVrXOONEI1UCN1W+zkiHSbzUNE9dZtQ==} resolution: {integrity: sha512-MBJBiidoe+0cTFhyxT8g+9g7CeVccLM0IOKKUMCNQ1CNMJ/eIfoo0RTfVrXOONEI1UCN1W+zkiHSbzUNE9dZtQ==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -825,6 +859,10 @@ packages:
as-table@1.0.55: as-table@1.0.55:
resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==}
asn1js@3.0.5:
resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==}
engines: {node: '>=12.0.0'}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -900,6 +938,9 @@ packages:
core-js-pure@3.37.1: core-js-pure@3.37.1:
resolution: {integrity: sha512-J/r5JTHSmzTxbiYYrzXg9w1VpqrYt+gexenBE9pugeyhwPZTAEJddyiReJWsLO6uNQ8xJZFbod6XC7KKwatCiA==} resolution: {integrity: sha512-J/r5JTHSmzTxbiYYrzXg9w1VpqrYt+gexenBE9pugeyhwPZTAEJddyiReJWsLO6uNQ8xJZFbod6XC7KKwatCiA==}
cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
cross-spawn@7.0.3: cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1157,6 +1198,10 @@ packages:
ini@1.3.8: ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
ipaddr.js@2.2.0:
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
engines: {node: '>= 10'}
is-binary-path@2.1.0: is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1418,6 +1463,13 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
pvtsutils@1.3.5:
resolution: {integrity: sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==}
pvutils@1.1.3:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2344,6 +2396,8 @@ snapshots:
'@fastify/busboy@2.1.1': {} '@fastify/busboy@2.1.1': {}
'@hexagon/base64@1.1.28': {}
'@humanwhocodes/config-array@0.11.14': '@humanwhocodes/config-array@0.11.14':
dependencies: dependencies:
'@humanwhocodes/object-schema': 2.0.3 '@humanwhocodes/object-schema': 2.0.3
@@ -2374,6 +2428,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15
'@levischuck/tiny-cbor@0.2.2': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -2388,6 +2444,40 @@ snapshots:
'@one-ini/wasm@0.1.1': {} '@one-ini/wasm@0.1.1': {}
'@peculiar/asn1-android@2.3.10':
dependencies:
'@peculiar/asn1-schema': 2.3.8
asn1js: 3.0.5
tslib: 2.6.3
'@peculiar/asn1-ecc@2.3.8':
dependencies:
'@peculiar/asn1-schema': 2.3.8
'@peculiar/asn1-x509': 2.3.8
asn1js: 3.0.5
tslib: 2.6.3
'@peculiar/asn1-rsa@2.3.8':
dependencies:
'@peculiar/asn1-schema': 2.3.8
'@peculiar/asn1-x509': 2.3.8
asn1js: 3.0.5
tslib: 2.6.3
'@peculiar/asn1-schema@2.3.8':
dependencies:
asn1js: 3.0.5
pvtsutils: 1.3.5
tslib: 2.6.3
'@peculiar/asn1-x509@2.3.8':
dependencies:
'@peculiar/asn1-schema': 2.3.8
asn1js: 3.0.5
ipaddr.js: 2.2.0
pvtsutils: 1.3.5
tslib: 2.6.3
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -2404,6 +2494,22 @@ snapshots:
domhandler: 5.0.3 domhandler: 5.0.3
selderee: 0.11.0 selderee: 0.11.0
'@simplewebauthn/server@10.0.1':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.2
'@peculiar/asn1-android': 2.3.10
'@peculiar/asn1-ecc': 2.3.8
'@peculiar/asn1-rsa': 2.3.8
'@peculiar/asn1-schema': 2.3.8
'@peculiar/asn1-x509': 2.3.8
'@simplewebauthn/types': 10.0.0
cross-fetch: 4.0.0
transitivePeerDependencies:
- encoding
'@simplewebauthn/types@10.0.0': {}
'@smithy/abort-controller@3.1.1': '@smithy/abort-controller@3.1.1':
dependencies: dependencies:
'@smithy/types': 3.3.0 '@smithy/types': 3.3.0
@@ -2871,6 +2977,12 @@ snapshots:
dependencies: dependencies:
printable-characters: 1.0.42 printable-characters: 1.0.42
asn1js@3.0.5:
dependencies:
pvtsutils: 1.3.5
pvutils: 1.1.3
tslib: 2.6.3
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
@@ -2948,6 +3060,12 @@ snapshots:
core-js-pure@3.37.1: {} core-js-pure@3.37.1: {}
cross-fetch@4.0.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.3: cross-spawn@7.0.3:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -3258,6 +3376,8 @@ snapshots:
ini@1.3.8: {} ini@1.3.8: {}
ipaddr.js@2.2.0: {}
is-binary-path@2.1.0: is-binary-path@2.1.0:
dependencies: dependencies:
binary-extensions: 2.3.0 binary-extensions: 2.3.0
@@ -3483,6 +3603,12 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
pvtsutils@1.3.5:
dependencies:
tslib: 2.6.3
pvutils@1.1.3: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
react-dom@18.3.1(react@18.3.1): react-dom@18.3.1(react@18.3.1):

View File

@@ -246,10 +246,12 @@ api.get('/admin/account_settings', async (c) => {
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY); const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY); const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY); const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY);
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
return c.json({ return c.json({
blockList: blockList || [], blockList: blockList || [],
sendBlockList: sendBlockList || [], sendBlockList: sendBlockList || [],
verifiedAddressList: verifiedAddressList || [] verifiedAddressList: verifiedAddressList || [],
fromBlockList: fromBlockList || []
}) })
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -259,7 +261,7 @@ api.get('/admin/account_settings', async (c) => {
api.post('/admin/account_settings', async (c) => { api.post('/admin/account_settings', async (c) => {
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */ /** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const { blockList, sendBlockList, verifiedAddressList } = await c.req.json(); const { blockList, sendBlockList, verifiedAddressList, fromBlockList } = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) { if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text("Invalid blockList or sendBlockList", 400) return c.text("Invalid blockList or sendBlockList", 400)
} }
@@ -278,6 +280,12 @@ api.post('/admin/account_settings', async (c) => {
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY, c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
JSON.stringify(verifiedAddressList) JSON.stringify(verifiedAddressList)
) )
if (fromBlockList?.length > 0 && !c.env.KV) {
return c.text("Please enable KV to use fromBlockList", 400)
}
if (fromBlockList) {
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList || []))
}
return c.json({ return c.json({
success: true success: true
}) })

View File

@@ -35,6 +35,7 @@ api.get('/open_api/settings', async (c) => {
"enableWebhook": getBooleanValue(c.env.ENABLE_WEBHOOK), "enableWebhook": getBooleanValue(c.env.ENABLE_WEBHOOK),
"isS3Enabled": isS3Enabled(c), "isS3Enabled": isS3Enabled(c),
"version": CONSTANTS.VERSION, "version": CONSTANTS.VERSION,
"showGithub": !getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
}); });
}) })

View File

@@ -1,5 +1,5 @@
export const CONSTANTS = { export const CONSTANTS = {
VERSION: 'v0.6.1', VERSION: 'v0.7.1',
// DB settings // DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list', ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
@@ -13,4 +13,5 @@ export const CONSTANTS = {
TG_KV_SETTINGS_KEY: "temp-mail-telegram-settings", TG_KV_SETTINGS_KEY: "temp-mail-telegram-settings",
WEBHOOK_KV_SETTINGS_KEY: "temp-mail-webhook-settings", WEBHOOK_KV_SETTINGS_KEY: "temp-mail-webhook-settings",
WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings", WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings",
EMAIL_KV_BLACK_LIST: "temp-mail-email-black-list",
} }

View File

@@ -0,0 +1,16 @@
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))) {
return true;
}
if (!env.KV) {
return false;
}
const blockList = await env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') || [];
if (blockList.some(word => from.includes(word))) {
return true;
}
return false;
}

View File

@@ -5,11 +5,12 @@ import { sendMailToTelegram } from "../telegram_api";
import { Bindings, HonoCustomType } from "../types"; import { Bindings, HonoCustomType } from "../types";
import { auto_reply } from "./auto_reply"; import { auto_reply } from "./auto_reply";
import { trigerWebhook } from "../mails_api/webhook_settings"; import { trigerWebhook } from "../mails_api/webhook_settings";
import { isBlocked } from "./black_list";
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) { async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) { if (await isBlocked(message.from, env)) {
message.setReject("Missing from address"); message.setReject("Reject from address");
console.log(`Reject message from ${message.from} to ${message.to}`); console.log(`Reject message from ${message.from} to ${message.to}`);
return; return;
} }

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { HonoCustomType } from "../types"; import { HonoCustomType } from "../types";
import { getBooleanValue, getJsonSetting, checkCfTurnstile } from '../utils'; import { getBooleanValue, getJsonSetting, checkCfTurnstile, getStringValue } from '../utils';
import { newAddress, handleListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains } from '../common' import { newAddress, handleListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains } from '../common'
import { CONSTANTS } from '../constants' import { CONSTANTS } from '../constants'
import auto_reply from './auto_reply' import auto_reply from './auto_reply'
@@ -49,6 +49,7 @@ api.delete('/api/mails/:id', async (c) => {
api.get('/api/settings', async (c) => { api.get('/api/settings', async (c) => {
const { address, address_id } = c.get("jwtPayload") const { address, address_id } = c.get("jwtPayload")
const user_role = c.get("userRolePayload")
if (address_id && address_id > 0) { if (address_id && address_id > 0) {
try { try {
const db_address_id = await c.env.DB.prepare( const db_address_id = await c.env.DB.prepare(
@@ -82,7 +83,8 @@ api.get('/api/settings', async (c) => {
} catch (e) { } catch (e) {
console.warn("Failed to update address") console.warn("Failed to update address")
} }
const balance = await c.env.DB.prepare( const is_no_limit_send_balance = user_role && user_role === getStringValue(c.env.NO_LIMIT_SEND_ROLE);
const balance = is_no_limit_send_balance ? 99999 : await c.env.DB.prepare(
`SELECT balance FROM address_sender where address = ? and enabled = 1` `SELECT balance FROM address_sender where address = ? and enabled = 1`
).bind(address).first("balance"); ).bind(address).first("balance");
return c.json({ return c.json({

View File

@@ -4,7 +4,7 @@ import { createMimeMessage } from 'mimetext';
import { Resend } from 'resend'; import { Resend } from 'resend';
import { CONSTANTS } from '../constants' import { CONSTANTS } from '../constants'
import { getJsonSetting, getDomains, getIntValue, getBooleanValue } from '../utils'; import { getJsonSetting, getDomains, getIntValue, getBooleanValue, getStringValue } from '../utils';
import { GeoData } from '../models' import { GeoData } from '../models'
import { handleListQuery } from '../common' import { handleListQuery } from '../common'
import { HonoCustomType } from '../types'; import { HonoCustomType } from '../types';
@@ -105,13 +105,17 @@ export const sendMail = async (
if (!domains.includes(mailDomain)) { if (!domains.includes(mailDomain)) {
throw new Error("Invalid domain") throw new Error("Invalid domain")
} }
// check permission const user_role = c.get("userRolePayload");
const balance = await c.env.DB.prepare( const is_no_limit_send_balance = user_role && user_role === getStringValue(c.env.NO_LIMIT_SEND_ROLE);
`SELECT balance FROM address_sender if (!is_no_limit_send_balance) {
// check permission
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
where address = ? and enabled = 1` where address = ? and enabled = 1`
).bind(address).first<number>("balance"); ).bind(address).first<number>("balance");
if (!balance || balance <= 0) { if (!balance || balance <= 0) {
throw new Error("No balance") throw new Error("No balance")
}
} }
const { const {
from_name, to_mail, to_name, from_name, to_mail, to_name,
@@ -154,7 +158,7 @@ export const sendMail = async (
throw new Error("Please enable resend or verified address list") throw new Error("Please enable resend or verified address list")
} }
// update balance // update balance
if (!sendByVerifiedAddressList) { if (!sendByVerifiedAddressList && !is_no_limit_send_balance) {
try { try {
const { success } = await c.env.DB.prepare( const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET balance = balance - 1 where address = ?` `UPDATE address_sender SET balance = balance - 1 where address = ?`

View File

@@ -1,3 +1,18 @@
import type {
AuthenticatorTransportFuture,
CredentialDeviceType,
Base64URLString,
} from '@simplewebauthn/types';
export type Passkey = {
id: Base64URLString;
publicKey: string;
counter: number;
deviceType: CredentialDeviceType;
backedUp: boolean;
transports?: AuthenticatorTransportFuture[];
};
export class AdminWebhookSettings { export class AdminWebhookSettings {
allowList: string[]; allowList: string[];

View File

@@ -19,11 +19,13 @@ export type Bindings = {
MAX_ADDRESS_LEN: string | number | undefined MAX_ADDRESS_LEN: string | number | undefined
DEFAULT_DOMAINS: string | string[] | undefined DEFAULT_DOMAINS: string | string[] | undefined
DOMAINS: string | string[] | undefined DOMAINS: string | string[] | undefined
ADMIN_USER_ROLE: string | undefined
USER_DEFAULT_ROLE: string | UserRole | undefined USER_DEFAULT_ROLE: string | UserRole | undefined
USER_ROLES: string | UserRole[] | undefined USER_ROLES: string | UserRole[] | undefined
DOMAIN_LABELS: string | string[] | undefined DOMAIN_LABELS: string | string[] | undefined
PASSWORDS: string | string[] | undefined PASSWORDS: string | string[] | undefined
ADMIN_PASSWORDS: string | string[] | undefined ADMIN_PASSWORDS: string | string[] | undefined
DISABLE_ADMIN_PASSWORD_CHECK: string | boolean | undefined
JWT_SECRET: string JWT_SECRET: string
BLACK_LIST: string | undefined BLACK_LIST: string | undefined
ENABLE_AUTO_REPLY: string | boolean | undefined ENABLE_AUTO_REPLY: string | boolean | undefined
@@ -32,8 +34,10 @@ export type Bindings = {
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
ENABLE_INDEX_ABOUT: string | boolean | undefined ENABLE_INDEX_ABOUT: string | boolean | undefined
DEFAULT_SEND_BALANCE: number | string | undefined DEFAULT_SEND_BALANCE: number | string | undefined
NO_LIMIT_SEND_ROLE: string | undefined | null
ADMIN_CONTACT: string | undefined ADMIN_CONTACT: string | undefined
COPYRIGHT: string | undefined COPYRIGHT: string | undefined
DISABLE_SHOW_GITHUB: string | boolean | undefined
FORWARD_ADDRESS_LIST: string | string[] | undefined FORWARD_ADDRESS_LIST: string | string[] | undefined
// s3 config // s3 config
@@ -70,7 +74,8 @@ type UserPayload = {
type Variables = { type Variables = {
userPayload: UserPayload, userPayload: UserPayload,
jwtPayload: JwtPayload userRolePayload: string | undefined | null,
jwtPayload: JwtPayload,
} }
type HonoCustomType = { type HonoCustomType = {

View File

@@ -4,15 +4,30 @@ import { HonoCustomType } from '../types';
import settings from './settings'; import settings from './settings';
import user from './user'; import user from './user';
import bind_address from './bind_address'; import bind_address from './bind_address';
import passkey from './passkey';
export const api = new Hono<HonoCustomType>(); export const api = new Hono<HonoCustomType>();
// settings api
api.get('/user_api/open_settings', settings.openSettings); api.get('/user_api/open_settings', settings.openSettings);
api.get('/user_api/settings', settings.settings); api.get('/user_api/settings', settings.settings);
// user api
api.post('/user_api/login', user.login); api.post('/user_api/login', user.login);
api.post('/user_api/verify_code', user.verifyCode); api.post('/user_api/verify_code', user.verifyCode);
api.post('/user_api/register', user.register); api.post('/user_api/register', user.register);
// bind address api
api.get('/user_api/bind_address', bind_address.getBindedAddresses); api.get('/user_api/bind_address', bind_address.getBindedAddresses);
api.post('/user_api/bind_address', bind_address.bind); api.post('/user_api/bind_address', bind_address.bind);
api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt); api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt);
api.post('/user_api/unbind_address', bind_address.unbind); api.post('/user_api/unbind_address', bind_address.unbind);
// passkey api
api.get('/user_api/passkey', passkey.getPassKeys);
api.post('/user_api/passkey/rename', passkey.renamePassKey);
api.delete('/user_api/passkey/:passkey_id', passkey.deletePassKey);
api.post('/user_api/passkey/register_request', passkey.registerRequest);
api.post('/user_api/passkey/register_response', passkey.registerResponse);
api.post('/user_api/passkey/authenticate_request', passkey.authenticateRequest);
api.post('/user_api/passkey/authenticate_response', passkey.authenticateResponse);

View File

@@ -0,0 +1,204 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { HonoCustomType } from '../types';
import { Passkey } from '../models';
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
export default {
getPassKeys: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { results } = await c.env.DB.prepare(
`SELECT passkey_name, passkey_id, created_at, updated_at FROM user_passkeys WHERE user_id = ?`
).bind(user.user_id).all<Record<string, string>>();
return c.json(results);
},
renamePassKey: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { passkey_id, passkey_name } = await c.req.json();
if (!passkey_name || passkey_name.length > 255) {
return c.text("Invalid passkey name", 400);
}
const { success } = await c.env.DB.prepare(
`UPDATE user_passkeys SET passkey_name = ? WHERE user_id = ? AND passkey_id = ?`
).bind(passkey_name, user.user_id, passkey_id).run();
return c.json({ success });
},
deletePassKey: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { passkey_id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM user_passkeys WHERE user_id = ? AND passkey_id = ?`
).bind(user.user_id, passkey_id).run();
return c.json({ success });
},
registerRequest: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { domain } = await c.req.json();
const { results } = await c.env.DB.prepare(
`SELECT passkey FROM user_passkeys WHERE user_id = ?`
).bind(user.user_id).all<Record<string, string>>();
const excludeCredentials = results
.map((record: any) => JSON.parse(record.passkey) as Passkey)
.map((passkey: Passkey) => ({
id: passkey.id,
transports: passkey.transports,
}));
// create challenge with 1 hour expiration
const challenge = await Jwt.sign({
user_email: user.user_email,
user_id: user.user_id,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
// Use SimpleWebAuthn's handy function to create registration options.
const options = await generateRegistrationOptions({
rpName: c.env.TITLE || "Temp Mail",
rpID: domain,
userID: new TextEncoder().encode(user.user_id.toString()),
userName: user.user_email,
userDisplayName: user.user_email,
attestationType: 'none',
excludeCredentials: excludeCredentials,
challenge: challenge,
});
return c.json(options);
},
registerResponse: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { credential, origin, passkey_name } = await c.req.json();
// Verify the registration response
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: async (challenge: string) => {
const payload = await Jwt.verify(atob(challenge), c.env.JWT_SECRET, "HS256");
if (!payload || !payload.iat) return false;
// check iad is not older than 5 minutes
if (Math.floor(Date.now() / 1000) - payload.iat > 300) return false;
if (payload.user_id !== user.user_id) return false;
return true;
},
expectedOrigin: origin,
requireUserVerification: true,
});
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return c.text("Registration failed", 400);
}
const {
credentialID, credentialPublicKey,
counter, credentialDeviceType, credentialBackedUp,
} = registrationInfo;
// Base64URL encode ArrayBuffers.
const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);
const newPasskey: Passkey = {
id: credentialID,
publicKey: base64PublicKey,
counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: credential?.response?.transports,
};
// Store the credential ID in the database
const { success } = await c.env.DB.prepare(
`INSERT INTO user_passkeys (user_id, passkey_name, passkey_id, passkey, counter) VALUES (?, ?, ?, ?, ?)`
).bind(user.user_id, passkey_name, credentialID, JSON.stringify(newPasskey), counter).run();
return c.json({ success });
},
authenticateRequest: async (c: Context<HonoCustomType>) => {
const { domain } = await c.req.json();
const challenge = await Jwt.sign({
domain,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
const options: PublicKeyCredentialRequestOptionsJSON = await generateAuthenticationOptions({
rpID: domain,
challenge: challenge,
allowCredentials: [],
});
return c.json(options);
},
authenticateResponse: async (c: Context<HonoCustomType>) => {
const { domain, credential, origin } = await c.req.json();
const passkey_id = credential?.id;
if (!passkey_id) {
return c.text("Invalid request", 400);
}
const { user_id, counter, passkey } = await c.env.DB.prepare(
`SELECT user_id, counter, passkey FROM user_passkeys WHERE passkey_id = ?`
).bind(passkey_id).first<{
counter: number; passkey: string; user_id: number;
}>() || {};
if (!passkey) {
return c.text("Passkey not found", 404);
}
const passkeyData = JSON.parse(passkey) as Passkey;
// Verify the registration response
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: async (challenge: string) => {
const payload = await Jwt.verify(atob(challenge), c.env.JWT_SECRET, "HS256");
if (!payload || !payload.iat) return false;
// check iad is not older than 5 minutes
if (Math.floor(Date.now() / 1000) - payload.iat > 300) return false;
return true;
},
expectedOrigin: origin,
expectedRPID: domain,
authenticator: {
credentialID: passkeyData.id,
credentialPublicKey: isoBase64URL.toBuffer(passkeyData.publicKey),
counter: counter || passkeyData.counter,
transports: passkeyData.transports,
},
});
const { verified, authenticationInfo } = verification;
if (!verified) {
return c.text("Authentication failed", 400);
}
if (authenticationInfo) {
const { newCounter } = authenticationInfo;
// Update the counter in the database
await c.env.DB.prepare(
`UPDATE user_passkeys SET counter = ? WHERE passkey_id = ?`
).bind(newCounter, passkey_id).run();
}
// update passkey updated_at
await c.env.DB.prepare(
`UPDATE user_passkeys SET updated_at = datetime('now') WHERE passkey_id = ?`
).bind(passkey_id).run();
// return jwt
const { user_email } = await c.env.DB.prepare(
`SELECT user_email FROM users WHERE id = ?`
).bind(user_id).first<{ user_email: string }>() || {};
if (!user_email) {
return c.text("User not found", 404);
}
// create jwt
const jwt = await Jwt.sign({
user_email: user_email,
user_id: 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({
jwt: jwt
})
},
}

View File

@@ -5,6 +5,7 @@ import { UserSettings } from "../models";
import { getJsonSetting, getUserRoles } from "../utils" import { getJsonSetting, getUserRoles } from "../utils"
import { CONSTANTS } from "../constants"; import { CONSTANTS } from "../constants";
import { commonGetUserRole } from "../common"; import { commonGetUserRole } from "../common";
import { Jwt } from "hono/utils/jwt";
export default { export default {
openSettings: async (c: Context<HonoCustomType>) => { openSettings: async (c: Context<HonoCustomType>) => {
@@ -25,8 +26,23 @@ export default {
return c.text("User not found", 400); return c.text("User not found", 400);
} }
const user_role = await commonGetUserRole(c, db_user_id); const user_role = await commonGetUserRole(c, db_user_id);
const is_admin = (
c.env.ADMIN_USER_ROLE
&&
c.env.ADMIN_USER_ROLE === user_role?.role
);
const access_token = is_admin ? await Jwt.sign({
user_email: user.user_email,
user_id: user.user_id,
user_role: user_role?.role,
iat: Math.floor(Date.now() / 1000),
// 1 hour
exp: Math.floor(Date.now() / 1000) + 3600,
}, c.env.JWT_SECRET, "HS256") : null;
return c.json({ return c.json({
...user, ...user,
is_admin: is_admin,
access_token: access_token,
user_role: user_role user_role: user_role
}); });
}, },

View File

@@ -59,7 +59,6 @@ export const getBooleanValue = (
if (typeof value === "string") { if (typeof value === "string") {
return value === "true"; return value === "true";
} }
console.error(`Failed to parse boolean value: ${value}`);
return false; return false;
} }

View File

@@ -75,6 +75,26 @@ const checkUserPayload = async (
} }
} }
const checkoutUserRolePayload = async (
c: Context<HonoCustomType>
): Promise<void> => {
try {
const token = c.req.raw.headers.get("x-user-access-token");
if (!token) return;
const payload = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
// check expired
if (!payload.exp) return;
// exp is in seconds
if (payload.exp < Math.floor(Date.now() / 1000)) {
return;
}
if (typeof payload?.user_role !== "string") return;
c.set("userRolePayload", payload.user_role);
} catch (e) {
console.error(e);
}
}
// api auth // api auth
app.use('/api/*', async (c, next) => { app.use('/api/*', async (c, next) => {
// check header x-custom-auth // check header x-custom-auth
@@ -90,6 +110,11 @@ app.use('/api/*', async (c, next) => {
await next(); await next();
return; return;
} }
if (c.req.path.startsWith("/api/settings")
|| c.req.path.startsWith("/api/send_mail")
) {
await checkoutUserRolePayload(c);
}
return jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next); return jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
}); });
// user_api auth // user_api auth
@@ -99,6 +124,7 @@ app.use('/user_api/*', async (c, next) => {
|| c.req.path.startsWith("/user_api/register") || c.req.path.startsWith("/user_api/register")
|| c.req.path.startsWith("/user_api/login") || c.req.path.startsWith("/user_api/login")
|| c.req.path.startsWith("/user_api/verify_code") || c.req.path.startsWith("/user_api/verify_code")
|| c.req.path.startsWith("/user_api/passkey/authenticate_")
) { ) {
await next(); await next();
return; return;
@@ -127,6 +153,7 @@ app.use('/user_api/*', async (c, next) => {
}); });
// admin auth // admin auth
app.use('/admin/*', async (c, next) => { app.use('/admin/*', async (c, next) => {
// check header x-admin-auth // check header x-admin-auth
const adminPasswords = getAdminPasswords(c); const adminPasswords = getAdminPasswords(c);
if (adminPasswords && adminPasswords.length > 0) { if (adminPasswords && adminPasswords.length > 0) {
@@ -136,6 +163,33 @@ app.use('/admin/*', async (c, next) => {
return; return;
} }
} }
// check if user is admin
const access_token = c.req.raw.headers.get("x-user-access-token");
if (c.env.ADMIN_USER_ROLE && access_token) {
try {
const payload = await Jwt.verify(access_token, c.env.JWT_SECRET, "HS256");
// check expired
if (!payload.exp) return c.text("Invalid Token", 401);
// exp is in seconds
if (payload.exp < Math.floor(Date.now() / 1000)) {
return c.text("Token Expired", 401)
}
if (payload.user_role !== c.env.ADMIN_USER_ROLE) {
return c.text("Need Admin Role", 401)
}
await next();
return;
} catch (e) {
console.error(e);
}
}
// disable admin api check
if (getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK)) {
await next();
return;
}
return c.text("Need Admin Password", 401) return c.text("Need Admin Password", 401)
}); });

View File

@@ -26,6 +26,8 @@ PREFIX = "tmp"
# PASSWORDS = ["123", "456"] # PASSWORDS = ["123", "456"]
# For admin panel # For admin panel
# ADMIN_PASSWORDS = ["123", "456"] # ADMIN_PASSWORDS = ["123", "456"]
# warning: no password or user check for admin portal
# DISABLE_ADMIN_PASSWORD_CHECK = false
# ADMIN CONTACT, CAN BE ANY STRING # ADMIN CONTACT, CAN BE ANY STRING
# ADMIN_CONTACT = "xx@xx.xxx" # ADMIN_CONTACT = "xx@xx.xxx"
DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users
@@ -33,6 +35,7 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all domain names
# For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name # For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"] # DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification) # USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification)
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix # User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix
# USER_ROLES = [ # USER_ROLES = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" }, # { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },
@@ -50,13 +53,15 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true # ENABLE_WEBHOOK = true
# Footer text # Footer text
# COPYRIGHT = "Dream Hunter" # COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true
# default send balance, if not set, it will be 0 # default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1 # DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # the role which can send emails without limit
# Turnstile verification # Turnstile verification
# CF_TURNSTILE_SITE_KEY = "" # CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = "" # CF_TURNSTILE_SECRET_KEY = ""
# telegram bot # telegram bot
# TG_MAX_ACCOUNTS = 5 # TG_MAX_ADDRESS = 5
# global forward address list, if set, all emails will be forwarded to these addresses # global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"] # FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]