feat: support admin create address && add ENABLE_USER_CREATE_EMAIL config (#175)

This commit is contained in:
Dream Hunter
2024-04-29 00:09:53 +08:00
committed by GitHub
parent 6ce7e2e7f6
commit 23d1709ca1
16 changed files with 306 additions and 153 deletions

View File

@@ -4,8 +4,6 @@ on:
push:
paths:
- "vitepress-docs/**"
branches:
- main
tags:
- "*"
workflow_dispatch:

View File

@@ -1,8 +1,13 @@
# CHANGE LOG
## main branch to be released
- `ENABLE_USER_CREATE_EMAIL` 是否允许用户创建邮件
- 允许 admin 创建无前缀的邮件
## v0.3.0
### Breaking Changes:
### Breaking Changes
`address` 表的前缀将从代码中迁移到 db 中,请将下面 sql 中的 `tmp` 替换为你的前缀,然后执行。
如果你的数据很重要,请先备份数据库。

View File

@@ -59,6 +59,7 @@ const getOpenSettings = async (message) => {
}
}),
adminContact: res["adminContact"] || "",
enableUserCreateEmail: res["enableUserCreateEmail"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableAutoReply: res["enableAutoReply"] || false,
};

View File

@@ -1,5 +1,5 @@
<script setup>
import { watch, onMounted, ref } from "vue";
import { watch, onMounted, ref, onBeforeUnmount } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
@@ -149,6 +149,10 @@ const deleteMail = async () => {
onMounted(async () => {
await refresh();
});
onBeforeUnmount(() => {
clearInterval(timer.value)
})
</script>
<template>

View File

@@ -11,12 +11,10 @@ export const useGlobalState = createGlobalState(
prefix: '',
needAuth: false,
adminContact: '',
enableUserCreateEmail: false,
enableUserDeleteEmail: false,
enableAutoReply: false,
domains: [{
label: 'test.com',
value: 'test.com'
}]
domains: []
})
const settings = ref({
fetched: false,
@@ -38,7 +36,6 @@ export const useGlobalState = createGlobalState(
const jwt = useStorage('jwt', '');
const localeCache = useStorage('locale', 'zh');
const themeSwitch = useStorage('themeSwitch', false);
const showLogin = ref(false);
const adminTab = ref("account");
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
@@ -55,7 +52,6 @@ export const useGlobalState = createGlobalState(
themeSwitch,
adminAuth,
showAdminAuth,
showLogin,
adminTab,
adminMailTabAddress,
adminSendBoxTabAddress,

View File

@@ -8,6 +8,7 @@ import SenderAccess from './admin/SenderAccess.vue'
import Statistics from "./admin/Statistics.vue"
import SendBox from './admin/SendBox.vue';
import Account from './admin/Account.vue';
import CreateAccount from './admin/CreateAccount.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import Maintenance from './admin/Maintenance.vue';
@@ -33,6 +34,7 @@ const { t } = useI18n({
accessTip: 'Please enter the admin password',
mails: 'Emails',
account: 'Account',
account_create: 'Create Account',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
@@ -44,6 +46,7 @@ const { t } = useI18n({
accessTip: '请输入 Admin 密码',
mails: '邮件',
account: '账号',
account_create: '创建账号',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
@@ -64,10 +67,7 @@ onMounted(async () => {
<template>
<div>
<n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
title="Dialog">
<template #header>
<div>{{ t('accessHeader') }}</div>
</template>
:title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="adminAuth" type="textarea" :autosize="{ minRows: 3 }" />
<template #action>
@@ -81,6 +81,9 @@ onMounted(async () => {
<n-tab-pane name="account" :tab="t('account')">
<Account />
</n-tab-pane>
<n-tab-pane name="account_create" :tab="t('account_create')">
<CreateAccount />
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
<Mails />
</n-tab-pane>

View File

@@ -17,14 +17,14 @@ const {
jwt, localeCache, toggleDark, isDark,
showAuth, adminAuth, auth, loading
} = useGlobalState()
const { showLogin, openSettings, settings } = useGlobalState()
const { openSettings, settings } = useGlobalState()
const route = useRoute()
const router = useRouter()
const isMobile = useIsMobile()
const isAdminRoute = computed(() => route.path.includes('admin'))
const showMobileMenu = ref(false)
const showNewEmail = ref(false)
const tabValue = ref('signin')
const showLogout = ref(false)
const showDelteAccount = ref(false)
const password = ref('')
@@ -33,6 +33,10 @@ const emailName = ref("")
const emailDomain = ref("")
const login = async () => {
if (!password.value) {
message.error(t('passwordInput'));
return;
}
try {
jwt.value = password.value;
await api.getSettings()
@@ -96,14 +100,16 @@ const { t } = useI18n({
fetchAddressError: 'Login password is invalid or account not exist, it may be network connection issue, please try again later.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
generateName: 'Generate Fake Name',
help: 'Help',
passwordInput: 'Please input the password',
},
zh: {
title: 'Cloudflare 临时邮件',
dark: '暗色',
light: '亮色',
login: '登录',
logout: '登出',
logoutConfirm: '确定要登出吗?',
logout: '退出登录',
logoutConfirm: '确定要退出登录吗?',
delteAccount: "删除账户",
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
accessHeader: '访问密码',
@@ -115,7 +121,7 @@ const { t } = useI18n({
sendbox: '发件箱',
sendMail: '发送邮件',
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '获取新邮箱',
getNewEmail: '注册新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
@@ -130,6 +136,8 @@ const { t } = useI18n({
fetchAddressError: '登录密码无效或账号不存在,也可能是网络连接异常,请稍后再尝试。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
generateName: '生成随机名字',
help: '帮助',
passwordInput: '请输入密码',
}
}
});
@@ -349,7 +357,6 @@ const newEmail = async () => {
);
jwt.value = res["jwt"];
await api.getSettings();
showNewEmail.value = false;
showPassword.value = true;
} catch (error) {
message.error(error.message || "error");
@@ -417,68 +424,74 @@ onMounted(async () => {
<n-alert type="info" show-icon>
<span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
<n-button style="margin-left: 10px" @click="router.push('/send')" size="small" tertiary round
<n-button style="margin-left: 10px" @click="router.push('/send')" size="small" tertiary
type="primary">
<n-icon :component="SendFilled" /> {{ t('sendMail') }}
</n-button>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary round type="primary">
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</span>
</n-alert>
</div>
<n-card v-else>
<n-result status="info" :description="t('pleaseGetNewEmail')">
<template #footer>
<n-alert v-if="jwt" type="warning" show-icon>
<span>{{ t('fetchAddressError') }}</span>
</n-alert>
<n-button @click="showLogin = true" tertiary round type="primary">
{{ t('login') }}
</n-button>
<n-button @click="showNewEmail = true" tertiary round type="primary">
{{ t('getNewEmail') }}
</n-button>
</template>
</n-result>
</n-card>
<div v-else class="center">
<n-card style="max-width: 600px;">
<n-alert v-if="jwt" type="warning" show-icon>
<span>{{ t('fetchAddressError') }}</span>
</n-alert>
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tab-pane name="signin" :tab="t('login')">
<n-form>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="password" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-button @click="login" :loading="loading" type="primary" block secondary strong>
{{ t('login') }}
</n-button>
<n-button v-if="openSettings.enableUserCreateEmail" @click="tabValue = 'register'" block
secondary strong>
{{ t('getNewEmail') }}
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableUserCreateEmail" name="register" :tab="t('getNewEmail')">
<n-spin :show="generateNameLoading">
<n-form>
<span>
<p>{{ t("getNewEmailTip1") }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>
<n-button @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
<n-button type="primary" block secondary strong @click="newEmail"
:loading="loading">
{{ t('ok') }}
</n-button>
</n-form>
</n-spin>
</n-tab-pane>
<n-tab-pane name="help" :tab="t('help')">
<n-alert type="info" show-icon>
<span>{{ t('pleaseGetNewEmail') }}</span>
</n-alert>
<AdminContact />
</n-tab-pane>
</n-tabs>
</n-card>
</div>
</div>
<n-modal v-model:show="showNewEmail" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('getNewEmail') }}</div>
</template>
<n-spin :show="generateNameLoading">
<span>
<p>{{ t("getNewEmailTip1") }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>
<n-button @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
</n-spin>
<template #action>
<n-button @click="showNewEmail = false">
{{ t('cancel') }}
</n-button>
<n-button @click="newEmail" type="primary" :loading="loading">
{{ t('ok') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showPassword" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("password") }}</div>
</template>
<n-modal v-model:show="showPassword" preset="dialog" :title="t('password')">
<span>
<p>{{ t("passwordTip") }}</p>
</span>
@@ -488,51 +501,26 @@ onMounted(async () => {
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showLogin" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('login') }}</div>
</template>
<AdminContact />
<n-input v-model:value="password" type="textarea" :autosize="{
minRows: 3
}" />
<template #action>
<n-button @click="login" :loading="loading" size="small" tertiary round type="primary">
{{ t('login') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showLogout" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('logout') }}</div>
</template>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="logout" size="small" tertiary round type="primary">
<n-button :loading="loading" @click="logout" size="small" tertiary type="primary">
{{ t('logout') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('delteAccount') }}</div>
</template>
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
<p>{{ t('delteAccountConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary round type="error">
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
{{ t('delteAccount') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
title="Dialog">
<template #header>
<div>{{ t('accessHeader') }}</div>
</template>
:title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="auth" type="textarea" :autosize="{
minRows: 3
}" />
<n-input v-model:value="auth" type="textarea" :autosize="{ minRows: 3 }" />
<template #action>
<n-button :loading="loading" @click="authFunc" type="primary">
{{ t('ok') }}
@@ -563,4 +551,16 @@ onMounted(async () => {
.n-card {
margin-top: 10px;
}
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
.n-form .n-button {
margin-top: 10px;
}
</style>

View File

@@ -277,7 +277,7 @@ onMounted(async () => {
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary round type="error">
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
{{ t('delteAccount') }}
</n-button>
</template>

View File

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

View File

@@ -163,7 +163,7 @@ onMounted(async () => {
<n-input-number v-model:value="senderBalance" :min="0" :max="1000" />
</n-form-item>
<template #action>
<n-button :loading="loading" @click="updateData()" size="small" tertiary round type="primary">
<n-button :loading="loading" @click="updateData()" size="small" tertiary type="primary">
{{ t('ok') }}
</n-button>
</template>

View File

@@ -73,6 +73,8 @@ PREFIX = "tmp" # The mailbox name prefix to be processed
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # your domain name
JWT_SECRET = "xxx" # Key used to generate jwt
BLACK_LIST = "" # Blacklist, used to filter senders, comma separated
# Allow users to create email addresses
ENABLE_USER_CREATE_EMAIL = true
# Allow users to delete messages
ENABLE_USER_DELETE_EMAIL = true
# Allow automatic replies to emails

View File

@@ -31,6 +31,8 @@ PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名, 支持多个域名
JWT_SECRET = "xxx" # 用于生成 jwt 的密钥, jwt 用于给用户登录以及鉴权
BLACK_LIST = "" # 黑名单,用于过滤发件人,逗号分隔
# 是否允许用户创建邮件, 不配置则不允许
ENABLE_USER_CREATE_EMAIL = true
# 允许用户删除邮件, 不配置则不允许
ENABLE_USER_DELETE_EMAIL = true
# 允许自动回复邮件

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { getSendbox } from './send_mail_api'
import { sendAdminInternalMail } from './utils'
import { newAddress } from './common'
const api = new Hono()
@@ -54,6 +54,14 @@ api.get('/admin/address', async (c) => {
})
})
api.post('/admin/new_address', async (c) => {
let { name, domain, enablePrefix } = await c.req.json();
if (!name) {
return c.text("Please provide a name", 400)
}
return newAddress(c, name, domain, enablePrefix);
})
api.delete('/admin/delete_address/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(

55
worker/src/common.js Normal file
View File

@@ -0,0 +1,55 @@
import { Jwt } from 'hono/utils/jwt'
import { getDomains } from './utils';
export const newAddress = async (c, name, domain, enablePrefix) => {
// remove special characters
name = name.replace(/[^a-zA-Z0-9.]/g, '')
// check name length
if (name.length < 0) {
return c.text("Name too short", 400)
}
if (name.length > 100) {
return c.text("Name too long (max 100)", 400)
}
// check domain, generate random domain
const domains = getDomains(c);
if (!domain || !domains.includes(domain)) {
domain = domains[Math.floor(Math.random() * domains.length)];
}
// create address
if (enablePrefix) {
name = c.env.PREFIX + name + "@" + domain;
} else {
name = name + "@" + domain;
}
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO address(name) VALUES(?)`
).bind(name).run();
if (!success) {
return c.text("Failed to create address", 500)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("Address already exists, please retry a new address", 400)
}
return c.text("Failed to create address", 500)
}
let address_id = 0;
try {
address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(name).first("id");
} catch (error) {
console.log(error);
}
// create jwt
const jwt = await Jwt.sign({
address: name,
address_id: address_id
}, c.env.JWT_SECRET)
return c.json({
jwt: jwt
})
}

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { getDomains, getPasswords } from './utils';
import { newAddress } from './common'
const api = new Hono()
@@ -153,62 +153,22 @@ api.get('/open_api/settings', async (c) => {
"domains": getDomains(c),
"needAuth": needAuth,
"adminContact": c.env.ADMIN_CONTACT,
"enableUserCreateEmail": c.env.ENABLE_USER_CREATE_EMAIL,
"enableUserDeleteEmail": c.env.ENABLE_USER_DELETE_EMAIL,
"enableAutoReply": c.env.ENABLE_AUTO_REPLY,
});
})
api.get('/api/new_address', async (c) => {
if (!c.env.ENABLE_USER_CREATE_EMAIL) {
return c.text("New address is disabled", 403)
}
let { name, domain } = c.req.query();
// if no name, generate random name
if (!name) {
name = Math.random().toString(36).substring(2, 15);
}
// remove special characters
name = name.replace(/[^a-zA-Z0-9.]/g, '')
// check name length
if (name.length < 0) {
return c.text("Name too short", 400)
}
if (name.length > 100) {
return c.text("Name too long (max 100)", 400)
}
// check domain, generate random domain
const domains = getDomains(c);
if (!domain || !domains.includes(domain)) {
domain = domains[Math.floor(Math.random() * domains.length)];
}
// create address
name = c.env.PREFIX + name + "@" + domain
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO address(name) VALUES(?)`
).bind(name).run();
if (!success) {
return c.text("Failed to create address", 500)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("Please retry a new address", 400)
}
return c.text("Failed to create address", 500)
}
let address_id = 0;
try {
address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(name).first("id");
} catch (error) {
console.log(error);
}
// create jwt
const jwt = await Jwt.sign({
address: name,
address_id: address_id
}, c.env.JWT_SECRET)
return c.json({
jwt: jwt
})
return newAddress(c, name, domain, true);
})
api.delete('/api/delete_address', async (c) => {

View File

@@ -18,6 +18,8 @@ PREFIX = "tmp"
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"]
JWT_SECRET = "xxx"
BLACK_LIST = ""
# Allow users to create email addresses
ENABLE_USER_CREATE_EMAIL = true
# Allow users to delete messages
ENABLE_USER_DELETE_EMAIL = true
# Allow automatic replies to emails