feat: admin cleanup tab && admin sendbox tab (#126)

This commit is contained in:
Dream Hunter
2024-04-14 22:41:16 +08:00
committed by GitHub
parent 63cf97f5e2
commit aea8b964bb
16 changed files with 1042 additions and 359 deletions

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

@@ -0,0 +1,46 @@
name: Deploy Docs
on:
push:
paths:
- "vitepress-docs/**"
branches:
- main
tags:
- "*"
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- uses: pnpm/action-setup@v3
name: Install pnpm
id: pnpm-install
with:
version: 8
run_install: false
- name: Deploy Docs for ${{github.ref_name}}
run: |
cd vitepress-docs/
pnpm install --no-frozen-lockfile
if [[ ${{github.ref}} == refs/tags/* ]]; then
export TAG_NAME=${{github.ref_name}}
else
export TAG_NAME=$(git describe --tags --abbrev=0)
fi
echo "Deploying docs for tag $TAG_NAME"
pnpm run deploy
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@@ -30,9 +30,12 @@ export const useGlobalState = createGlobalState(
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', '');
const localeCache = useStorage('locale', 'zhCN');
const localeCache = useStorage('locale', 'zh');
const themeSwitch = useStorage('themeSwitch', false);
const showLogin = ref(false);
const adminTab = ref("account");
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
return {
loading,
settings,
@@ -45,6 +48,9 @@ export const useGlobalState = createGlobalState(
adminAuth,
showAdminAuth,
showLogin,
adminTab,
adminMailTabAddress,
adminSendBoxTabAddress,
}
},
)

View File

@@ -1,22 +1,22 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { User, UserCheck, MailBulk } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
import { processItem } from '../utils/email-parser'
import SenderAccess from './admin/SenderAccess.vue'
import Statistics from "./admin/Statistics.vue"
import SendBox from './admin/SendBox.vue';
import Account from './admin/Account.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import Maintenance from './admin/Maintenance.vue';
const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
const router = useRouter()
const {
localeCache, adminAuth, showAdminAuth, adminTab
} = useGlobalState()
const message = useMessage()
const showEmailPassword = ref(false)
const curEmailPassword = ref("")
const addressQuery = ref("")
const authFunc = async () => {
try {
location.reload()
@@ -29,243 +29,34 @@ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
title: 'Temp Email Admin',
auth: 'Admin Auth',
home: 'Home',
authTip: 'Please enter the correct auth code',
name: 'Name',
created_at: 'Created At',
showPass: 'Show Passwrod',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
delete: 'Delete',
deleteTip: 'Are you sure to delete this email?',
refresh: 'Refresh',
mails: 'Emails',
itemCount: 'itemCount',
query: 'Query',
userCount: 'User Count',
activeUser: '7 days Active User',
mailCount: 'Mail Count',
account: 'Account',
unknow: 'Unknow',
addressQueryTip: 'Leave blank to query all addresses',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
maintenance: 'Maintenance',
},
zh: {
title: '临时邮件 Admin',
auth: 'Admin 授权',
home: '首页',
authTip: '请输入正确的授权码',
name: '名称',
created_at: '创建时间',
showPass: '显示密码',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
refresh: '刷新',
mails: '邮件',
itemCount: '总数',
query: '查询',
userCount: '用户总数',
activeUser: '周活跃用户',
mailCount: '邮件总数',
account: '账号',
unknow: '未知',
addressQueryTip: '留空查询所有地址',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
maintenance: '维护',
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showPassword = async (id) => {
try {
curEmailPassword.value = await api.adminShowPassword(id)
showEmailPassword.value = true
} catch (error) {
message.error(error.message || "error");
showEmailPassword.value = false
curEmailPassword.value = ""
}
}
const deleteEmail = async (id) => {
try {
await api.adminDeleteAddress(id)
message.success("success");
await fetchData()
} catch (error) {
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/admin/address`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('name'),
key: "name"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: 'Action',
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
ghost: true,
onClick: () => showPassword(row.id)
},
{ default: () => t('showPass') }
),
h(NButton,
{
type: 'success',
ghost: true,
onClick: () => {
mailAddress.value = row.name
tab.value = "mails"
}
},
{ default: () => t('mails') }
),
h(NPopconfirm,
{
onPositiveClick: () => deleteEmail(row.id)
},
{
trigger: () => h(NButton, { type: "error" }, () => t('delete')),
default: () => t('deleteTip')
}
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
const statistics = ref({
userCount: 0,
mailCount: 0,
activeUserCount7days: 0,
})
const fetchStatistics = async () => {
try {
const { userCount, activeUserCount7days, mailCount } = await api.fetch(`/admin/statistics`);
statistics.value.mailCount = mailCount || 0;
statistics.value.userCount = userCount || 0;
statistics.value.activeUserCount7days = activeUserCount7days || 0;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true
} else {
await fetchData()
await fetchStatistics()
showAdminAuth.value = true;
return;
}
})
const tab = ref("account")
const mailAddress = ref("")
const mailData = ref([])
const mailCount = ref(0)
const mailPage = ref(1)
const mailPageSize = ref(20)
watch([mailPage, mailPageSize, mailAddress], async () => {
await fetchMailData()
})
const fetchMailData = async () => {
if (!mailAddress.value) {
return
}
try {
const { results, count } = await api.fetch(
`/admin/mails`
+ `?address=${mailAddress.value}`
+ `&limit=${mailPageSize.value}`
+ `&offset=${(mailPage.value - 1) * mailPageSize.value}`
);
mailData.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (count > 0) {
mailCount.value = count;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const mailUnknowData = ref([])
const mailUnknowCount = ref(0)
const mailUnknowPage = ref(1)
const mailUnknowPageSize = ref(20)
watch([mailUnknowPage, mailUnknowPageSize], async () => {
await fetchMailUnknowData()
})
const fetchMailUnknowData = async () => {
try {
const { results, count } = await api.fetch(
`/admin/mails_unknow`
+ `?limit=${mailPageSize.value}`
+ `&offset=${(mailPage.value - 1) * mailPageSize.value}`
);
mailUnknowData.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (count > 0) {
mailUnknowCount.value = count;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
</script>
<template>
@@ -283,125 +74,26 @@ const fetchMailUnknowData = async () => {
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showEmailPassword" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("password") }}</div>
</template>
<span>
<p>{{ t("passwordTip") }}</p>
</span>
<n-card>
<b>{{ curEmailPassword }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<n-row>
<n-col :span="8">
<n-statistic :label="t('userCount')" :value="statistics.userCount">
<template #prefix>
<n-icon :component="User" />
</template>
</n-statistic>
</n-col>
<n-col :span="8">
<n-statistic :label="t('activeUser')" :value="statistics.activeUserCount7days">
<template #prefix>
<n-icon :component="UserCheck" />
</template>
</n-statistic>
</n-col>
<n-col :span="8">
<n-statistic :label="t('mailCount')" :value="statistics.mailCount">
<template #prefix>
<n-icon :component="MailBulk" />
</template>
</n-statistic>
</n-col>
</n-row>
<n-tabs type="segment" v-model:value="tab">
<Statistics />
<n-tabs type="card" v-model:value="adminTab">
<n-tab-pane name="account" :tab="t('account')">
<n-input-group>
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
<n-button @click="fetchData" type="primary" ghost>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
<Account />
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
<n-input-group>
<n-input v-model:value="mailAddress" />
<n-button @click="fetchMailData" type="primary" ghost>
{{ t('query') }}
</n-button>
</n-input-group>
<n-list hoverable clickable>
<div style="display: inline-block; margin-bottom: 10px;">
<n-pagination v-model:page="mailPage" v-model:page-size="mailPageSize" :item-count="mailCount" simple>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-list-item v-for="row in mailData" v-bind:key="row.id">
<n-thing class="center" :title="row.subject">
<template #description>
<n-space>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
</n-space>
</template>
<div v-html="row.message"></div>
</n-thing>
</n-list-item>
</n-list>
<Mails />
</n-tab-pane>
<n-tab-pane name="unknow" :tab="t('unknow')">
<n-button @click="fetchMailUnknowData" type="primary" ghost>
{{ t('query') }}
</n-button>
<n-list hoverable clickable>
<div style="display: inline-block; margin-bottom: 10px;">
<n-pagination v-model:page="mailUnknowPage" v-model:page-size="mailUnknowPageSize"
:item-count="mailUnknowCount" simple>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-list-item v-for="row in mailUnknowData" v-bind:key="row.id">
<n-thing class="center" :title="row.subject">
<template #description>
<n-space>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
</n-space>
</template>
<div v-html="row.message"></div>
</n-thing>
</n-list-item>
</n-list>
<MailsUnknow />
</n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="sendBox" :tab="t('sendBox')">
<SendBox />
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<Maintenance />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

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

View File

@@ -0,0 +1,248 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
localeCache, adminAuth, showAdminAuth,
adminTab, adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
name: 'Name',
created_at: 'Created At',
showPass: 'Show Passwrod',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
delete: 'Delete',
deleteTip: 'Are you sure to delete this email?',
delteAccount: 'Delete Account',
viewMails: 'View Mails',
viewSendBox: 'View SendBox',
itemCount: 'itemCount',
query: 'Query',
addressQueryTip: 'Leave blank to query all addresses',
actions: 'Actions'
},
zh: {
name: '名称',
created_at: '创建时间',
showPass: '显示密码',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
delteAccount: '删除邮箱',
viewMails: '查看邮件',
viewSendBox: '查看发件箱',
itemCount: '总数',
query: '查询',
addressQueryTip: '留空查询所有地址',
actions: '操作',
}
}
});
const showEmailPassword = ref(false)
const curEmailPassword = ref("")
const curDeleteAddressId = ref(0);
const addressQuery = ref("")
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showDelteAccount = ref(false)
const showPassword = async (id) => {
try {
curEmailPassword.value = await api.adminShowPassword(id)
showEmailPassword.value = true
} catch (error) {
message.error(error.message || "error");
showEmailPassword.value = false
curEmailPassword.value = ""
}
}
const deleteEmail = async () => {
try {
await api.adminDeleteAddress(curDeleteAddressId.value)
message.success("success");
await fetchData()
} catch (error) {
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/admin/address`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('name'),
key: "name"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
h(NMenu, {
mode: "horizontal",
options: [
{
label: t('actions'),
icon: () => h(MenuFilled),
key: "action",
children: [
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
onClick: () => showPassword(row.id)
},
{ default: () => t('showPass') }
),
},
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
onClick: () => {
adminMailTabAddress.value = row.name;
adminTab.value = "mails";
}
},
{ default: () => t('viewMails') }
)
},
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
onClick: () => {
adminSendBoxTabAddress.value = row.name;
adminTab.value = "sendBox";
}
},
{ default: () => t('viewSendBox') }
)
},
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
onClick: () => {
curDeleteAddressId.value = row.id;
showDelteAccount.value = true;
}
},
{ default: () => t('delete') }
)
}
]
}
]
})
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showEmailPassword" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("password") }}</div>
</template>
<span>
<p>{{ t("passwordTip") }}</p>
</span>
<n-card>
<b>{{ curEmailPassword }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" title="Dialog">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button @click="deleteEmail" size="small" tertiary round type="error">
{{ t('delteAccount') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
<n-button @click="fetchData" type="primary" ghost>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,112 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { processItem } from '../../utils/email-parser'
const {
localeCache, adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
mails: 'Emails',
itemCount: 'itemCount',
query: 'Query',
},
zh: {
mails: '邮件',
itemCount: '总数',
query: '查询',
}
}
});
const mailData = ref([])
const mailCount = ref(0)
const mailPage = ref(1)
const mailPageSize = ref(20)
watch([mailPage, mailPageSize, adminMailTabAddress], async () => {
await fetchMailData()
})
const fetchMailData = async () => {
if (!adminMailTabAddress.value) {
return
}
try {
const { results, count } = await api.fetch(
`/admin/mails`
+ `?address=${adminMailTabAddress.value}`
+ `&limit=${mailPageSize.value}`
+ `&offset=${(mailPage.value - 1) * mailPageSize.value}`
);
mailData.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (count > 0) {
mailCount.value = count;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchMailData()
})
</script>
<template>
<div>
<n-input-group>
<n-input v-model:value="adminMailTabAddress" />
<n-button @click="fetchMailData" type="primary" ghost>
{{ t('query') }}
</n-button>
</n-input-group>
<n-list hoverable clickable>
<div style="display: inline-block; margin-bottom: 10px;">
<n-pagination v-model:page="mailPage" v-model:page-size="mailPageSize" :item-count="mailCount" simple>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-list-item v-for="row in mailData" v-bind:key="row.id">
<n-thing class="center" :title="row.subject">
<template #description>
<n-space>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
</n-space>
</template>
<div v-html="row.message"></div>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,162 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, settings, adminAuth, adminSendBoxTabAddress } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
success: 'Success',
to_mail: 'To Mail',
subject: 'Subject',
created_at: 'Created At',
action: 'Action',
query: 'Query',
itemCount: 'itemCount',
view: 'View',
},
zh: {
address: '地址',
success: '成功',
to_mail: '收件人邮箱',
subject: '主题',
created_at: '创建时间',
action: '操作',
query: '查询',
itemCount: '总数',
view: '查看',
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curRow = ref({})
const showModal = ref(false)
const fetchData = async () => {
if (!adminSendBoxTabAddress.value) {
return
}
try {
const { results, count: addressCount } = await api.fetch(
`/admin/sendbox`
+ `?address=${adminSendBoxTabAddress.value}`
+ `&limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
);
data.value = results.map((item) => {
try {
const data = JSON.parse(item.raw);
item.to_mail = data?.personalizations?.map(
(p) => p.to?.map((t) => t.email).join(',')
).join(';');
item.subject = data.subject;
item.raw = JSON.stringify(data, null, 2);
} catch (error) {
console.log(error);
}
return item;
});
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('address'),
key: "address"
},
{
title: t('to_mail'),
key: "to_mail"
},
{
title: t('subject'),
key: "subject"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
ghost: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
}
},
{ default: () => t('view') }
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script>
<template>
<div v-if="settings.address">
<n-modal v-model:show="showModal" preset="dialog">
<pre>{{ curRow.raw }}</pre>
</n-modal>
<n-input-group>
<n-input v-model:value="adminSendBoxTabAddress" />
<n-button @click="fetchData" type="primary" ghost>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { User, UserCheck, MailBulk } from '@vicons/fa'
import { SendOutlined } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, adminAuth } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
userCount: 'User Count',
activeUser: '7 days Active User',
mailCount: 'Mail Count',
sendMailCount: 'Send Mail Count'
},
zh: {
userCount: '用户总数',
activeUser: '周活跃用户',
mailCount: '邮件总数',
sendMailCount: '发送邮件总数'
}
}
});
const statistics = ref({
userCount: 0,
mailCount: 0,
activeUserCount7days: 0,
sendMailCount: 0,
})
const fetchStatistics = async () => {
try {
const {
userCount, activeUserCount7days, mailCount, sendMailCount
} = await api.fetch(`/admin/statistics`);
statistics.value.mailCount = mailCount || 0;
statistics.value.userCount = userCount || 0;
statistics.value.activeUserCount7days = activeUserCount7days || 0;
statistics.value.sendMailCount = sendMailCount || 0;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
onMounted(async () => {
if (!adminAuth.value) {
return;
}
await fetchStatistics()
})
</script>
<template>
<n-row>
<n-col :span="6">
<n-statistic :label="t('userCount')" :value="statistics.userCount">
<template #prefix>
<n-icon :component="User" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('activeUser')" :value="statistics.activeUserCount7days">
<template #prefix>
<n-icon :component="UserCheck" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('mailCount')" :value="statistics.mailCount">
<template #prefix>
<n-icon :component="MailBulk" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('sendMailCount')" :value="statistics.sendMailCount">
<template #prefix>
<n-icon :component="SendOutlined" />
</template>
</n-statistic>
</n-col>
</n-row>
</template>

View File

@@ -12,7 +12,7 @@
"dev": "vitepress dev docs",
"build": "vitepress build docs",
"preview": "vitepress preview docs",
"deploy": "npm run build && wrangler pages deploy ./docs/.vitepress/dist --branch production"
"deploy": "npm run build && wrangler pages deploy ./docs/.vitepress/dist --project-name=temp-mail-docs --branch production"
},
"dependencies": {
"jszip": "^3.10.1"

View File

@@ -1,5 +1,6 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { getSendbox } from './send_mail_api'
const api = new Hono()
@@ -179,6 +180,11 @@ api.post('/admin/address_sender', async (c) => {
})
})
api.get('/admin/sendbox', async (c) => {
const { address, limit, offset } = c.req.query();
return getSendbox(c, address, limit, offset);
})
api.get('/admin/statistics', async (c) => {
const { count: mailCountV1 } = await c.env.DB.prepare(`
SELECT count(*) as count FROM mails`
@@ -192,11 +198,51 @@ api.get('/admin/statistics', async (c) => {
const { count: activeUserCount7days } = await c.env.DB.prepare(`
SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first();
const { count: sendMailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM sendbox`
).first();
return c.json({
mailCount: (mailCountV1 || 0) + (mailCount || 0),
userCount: addressCount,
activeUserCount7days: activeUserCount7days
activeUserCount7days: activeUserCount7days,
sendMailCount: sendMailCount
})
});
api.post('/admin/cleanup', async (c) => {
const { cleanType, cleanDays } = await c.req.json();
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
return c.text("Invalid cleanType or cleanDays", 400)
}
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
switch (cleanType) {
case "mails":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "mails_unknow":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE address NOT IN
(select concat('${c.env.PREFIX}', name) from address) AND created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "address":
await c.env.DB.prepare(`
DELETE FROM address WHERE updated_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "sendbox":
await c.env.DB.prepare(`
DELETE FROM sendbox WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
default:
return c.text("Invalid cleanType", 400)
}
return c.json({
success: true
})
})
export { api }

View File

@@ -1,6 +1,8 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { getDomains, getPasswords } from './utils';
const api = new Hono()
api.get('/api/mails', async (c) => {
@@ -124,13 +126,14 @@ api.post('/api/settings', async (c) => {
api.get('/open_api/settings', async (c) => {
// check header x-custom-auth
let needAuth = false;
if (c.env.PASSWORDS && c.env.PASSWORDS.length > 0) {
const passwords = getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
needAuth = !c.env.PASSWORDS.includes(auth);
needAuth = !passwords.includes(auth);
}
return c.json({
"prefix": c.env.PREFIX,
"domains": c.env.DOMAINS,
"domains": getDomains(c),
"needAuth": needAuth,
});
})
@@ -151,8 +154,9 @@ api.get('/api/new_address', async (c) => {
return c.text("Name too long (max 100)", 400)
}
// check domain, generate random domain
if (!domain || !c.env.DOMAINS.includes(domain)) {
domain = c.env.DOMAINS[Math.floor(Math.random() * c.env.DOMAINS.length)];
const domains = getDomains(c);
if (!domain || !domains.includes(domain)) {
domain = domains[Math.floor(Math.random() * domains.length)];
}
// create address
const emailAddress = c.env.PREFIX + name + "@" + domain

View File

@@ -110,12 +110,10 @@ api.post('/api/send_mail', async (c) => {
return c.json({ status: "ok" });
})
api.get('/api/sendbox', async (c) => {
const { address } = c.get("jwtPayload")
const getSendbox = async (c, address, limit, offset) => {
if (!address) {
return c.json({ "error": "No address" }, 400)
}
const { limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
@@ -137,6 +135,12 @@ api.get('/api/sendbox', async (c) => {
results: results,
count: count
})
}
api.get('/api/sendbox', async (c) => {
const { address } = c.get("jwtPayload")
const { limit, offset } = c.req.query();
return getSendbox(c, address, limit, offset);
})
export { api }
export { api, getSendbox }

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

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

View File

@@ -7,14 +7,16 @@ import { api as adminApi } from './admin_api';
import { api as apiV1 } from './api_v1';
import { api as apiSendMail } from './send_mail_api'
import { email } from './email';
import { getAdminPasswords, getPasswords } from './utils';
const app = new Hono()
app.use('/*', cors());
app.use('/api/*', async (c, next) => {
// check header x-custom-auth
if (c.env.PASSWORDS && c.env.PASSWORDS.length > 0) {
const passwords = getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
if (!auth || !c.env.PASSWORDS.includes(auth)) {
if (!auth || !passwords.includes(auth)) {
return c.text("Need Password", 401)
}
}
@@ -27,9 +29,10 @@ app.use('/api/*', async (c, next) => {
app.use('/admin/*', async (c, next) => {
// check header x-admin-auth
if (c.env.ADMIN_PASSWORDS && c.env.ADMIN_PASSWORDS.length > 0) {
const adminPasswords = getAdminPasswords(c);
if (adminPasswords && adminPasswords.length > 0) {
const adminAuth = c.req.raw.headers.get("x-admin-auth");
if (adminAuth && c.env.ADMIN_PASSWORDS.includes(adminAuth)) {
if (adminAuth && adminPasswords.includes(adminAuth)) {
await next();
return;
}