Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0bf7a5f13 | ||
|
|
a9bb8785ba | ||
|
|
0b48baff6d | ||
|
|
e0b5e80efd | ||
|
|
b0e36ac2aa | ||
|
|
51db19c85b | ||
|
|
e52b010aa4 | ||
|
|
8f6793402c | ||
|
|
e86c530116 | ||
|
|
0308f518da | ||
|
|
3c2a8ed056 | ||
|
|
5f45ec7c14 | ||
|
|
1b7ebc98c5 | ||
|
|
c102004f4d | ||
|
|
3c81e05a2f | ||
|
|
5ff2ceb5e8 | ||
|
|
6c82efb738 | ||
|
|
e99acdcc6e | ||
|
|
8f30505706 | ||
|
|
ddfa2c5d03 | ||
|
|
49b3f10838 | ||
|
|
cc9ac67319 | ||
|
|
7cc2a2b576 | ||
|
|
393c5902c3 | ||
|
|
5ece49a576 | ||
|
|
de80857e2c | ||
|
|
a57a42b2a1 | ||
|
|
a24cc1f642 | ||
|
|
4c6fd3c2af | ||
|
|
1cf38c1768 | ||
|
|
b5b59acdb3 | ||
|
|
6d4783e1cd | ||
|
|
34e3e1b439 | ||
|
|
56104cd23a | ||
|
|
3664028e06 | ||
|
|
9888f98d74 | ||
|
|
ac5605f17f | ||
|
|
a9719cb3ec | ||
|
|
5f4978645b | ||
|
|
621476cb79 | ||
|
|
c969c4b082 | ||
|
|
d90f54345d | ||
|
|
797b8bb019 | ||
|
|
7e5d142924 |
10
.github/workflows/frontend_deploy.yaml
vendored
@@ -38,12 +38,12 @@ jobs:
|
||||
export frontend_branch=${{ secrets.FRONTEND_BRANCH }}
|
||||
if [ -n "$frontend_branch" ]; then
|
||||
echo "Deploying branch $frontend_branch"
|
||||
pnpm run deploy:actions --project-name=$project_name
|
||||
pnpm run deploy:actions --project-name=$project_name --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying branch prodcution"
|
||||
echo "Deploying branch production"
|
||||
pnpm run deploy --project-name=$project_name
|
||||
fi
|
||||
echo "Deploying prodcution for ${{ github.ref_name }}"
|
||||
echo "Deploying production for ${{ github.ref_name }}"
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
|
||||
export tg_mini_app_project_name=${{ secrets.TG_FRONTEND_NAME }}
|
||||
@@ -51,9 +51,9 @@ jobs:
|
||||
echo "Deploying telegram mini app $tg_mini_app_project_name"
|
||||
if [ -n "$frontend_branch" ]; then
|
||||
echo "Deploying telegram mini app branch $frontend_branch"
|
||||
pnpm run deploy:actions:telegram --project-name=$tg_mini_app_project_name
|
||||
pnpm run deploy:actions:telegram --project-name=$tg_mini_app_project_name --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying telegram mini app branch prodcution"
|
||||
echo "Deploying telegram mini app branch production"
|
||||
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
|
||||
fi
|
||||
echo "Deployed telegram mini app for ${{ github.ref_name }}"
|
||||
|
||||
11
.github/workflows/tag_build.yml
vendored
@@ -30,7 +30,13 @@ jobs:
|
||||
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:release
|
||||
|
||||
- name: Zip Frontend dist
|
||||
run: cd frontend/dist/ && zip -r frontend.zip *
|
||||
run: cd frontend/dist/ && zip -r frontend.zip * && mv frontend.zip ../
|
||||
|
||||
- name: Build Telegram Frontend
|
||||
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:telegram:release
|
||||
|
||||
- name: Zip Telegram Frontend dist
|
||||
run: cd frontend/dist/ && zip -r telegram-frontend.zip * && mv telegram-frontend.zip ../
|
||||
|
||||
- name: cp wrangler.toml
|
||||
run: cd worker && cp wrangler.toml.template wrangler.toml
|
||||
@@ -42,5 +48,6 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
frontend/dist/frontend.zip
|
||||
frontend/frontend.zip
|
||||
frontend/telegram-frontend.zip
|
||||
worker/dist/worker.js
|
||||
|
||||
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-python.vscode-pylance",
|
||||
"1yib.rust-bundle",
|
||||
"rust-lang.rust-analyzer",
|
||||
"vue.volar"
|
||||
]
|
||||
}
|
||||
62
CHANGELOG.md
@@ -1,6 +1,68 @@
|
||||
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
|
||||
# CHANGE LOG
|
||||
|
||||
# main(v0.8.2)
|
||||
|
||||
- fix: |Doc| 修复文档中的一些错误
|
||||
- fix: |Github Action| 修复 frontend 部署分支错误的问题
|
||||
- feat: admin 发送邮件功能
|
||||
- feat: admin 后台,账号配置页面添加无限发送邮件的地址列表
|
||||
|
||||
# v0.8.1
|
||||
|
||||
- feat: |Doc| 更新 UI 安装的文档
|
||||
- feat: |UI| 对用户隐藏邮箱账号的 ID
|
||||
- feat: |UI| 增加邮件详情页的 `转发` 按钮
|
||||
|
||||
## v0.8.0
|
||||
|
||||
- feat: |UI| 随机生成地址时不超过最大长度
|
||||
- feat: |UI| 邮件时间显示浏览器时区,可在设置中切换显示为 UTC 时间
|
||||
- feat: 支持转移邮件到其他用户
|
||||
|
||||
## v0.7.6
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
UI 部署 worker 需要点击 Settings -> Runtime, 修改 Compatibility flags, 增加 `nodejs_compat`
|
||||
|
||||

|
||||
|
||||
### Changes
|
||||
|
||||
- feat: 支持提前设置 bot info, 降低 telegram 回调延迟 (#441)
|
||||
- feat: 增加 telegram mini app 的 build 压缩包
|
||||
- feat: 增加是否启用垃圾邮件检查 `ENABLE_CHECK_JUNK_MAIL` 配置
|
||||
|
||||
## v0.7.5
|
||||
|
||||
- fix: 修复 `name` 的校验检查
|
||||
|
||||
## v0.7.4
|
||||
|
||||
- feat: UI 列表页面增加最小宽度
|
||||
- fix: 修复 `name` 的校验检查
|
||||
- fix: 修复 `DEFAULT_DOMAINS` 配置为空不生效的问题
|
||||
|
||||
## v0.7.3
|
||||
|
||||
- feat: worker 增加 `ADDRESS_CHECK_REGEX`, address name 的正则表达式, 只用于检查,符合条件将通过检查
|
||||
- fix: UI 修复登录页面 tab 激活图标错位
|
||||
- fix: UI 修复 admin 页面刷新弹框输入密码的问题
|
||||
- feat: support `Oath2` 登录, 可以通过 `Github` `Authentik` 等第三方登录, 详情查看 [OAuth2 第三方登录](https://temp-mail-docs.awsl.uk/zh/guide/feature/user-oauth2.html)
|
||||
|
||||
## v0.7.2
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
`webhook` 的结构增加了 `enabled` 字段,已经配置了的需要重新在页面开启并保存。
|
||||
|
||||
### Changes
|
||||
|
||||
- fix: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 加载失败的问题
|
||||
- feat: worker 增加 `# ADDRESS_REGEX = "[^a-z.0-9]"` 配置, 替换非法符号的正则表达式,如果不设置,默认为 [^a-z0-9], 需谨慎使用, 有些符号可能导致无法收件
|
||||
- feat: worker 优化 webhook 逻辑, 支持 admin 配置全局 webhook, 添加 `message pusher` 集成示例
|
||||
|
||||
## v0.7.1
|
||||
|
||||
- fix: 修复用户角色加载失败的问题
|
||||
|
||||
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address (
|
||||
id INTEGER PRIMARY KEY,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
|
||||
3
frontend/.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,9 @@
|
||||
"build": "vite build -m prod --emptyOutDir",
|
||||
"build:release": "vite build -m example --emptyOutDir",
|
||||
"build:pages": "vite build -m pages --emptyOutDir",
|
||||
"build:pages:nopwa": "VITE_PWA_DISABLED=true vite build -m pages --emptyOutDir",
|
||||
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
|
||||
"build:telegram:release": "VITE_IS_TELEGRAM=true vite build -m example --emptyOutDir",
|
||||
"preview": "vite preview",
|
||||
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
|
||||
"deploy:actions:telegram": "npm run build:telegram && wrangler pages deploy ./dist",
|
||||
@@ -18,32 +20,32 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@unhead/vue": "^1.9.15",
|
||||
"@unhead/vue": "^1.11.13",
|
||||
"@vicons/material": "^0.12.0",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"@vueuse/core": "^10.11.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.7.2",
|
||||
"axios": "^1.7.8",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.1.8",
|
||||
"naive-ui": "^2.38.2",
|
||||
"postal-mime": "^2.2.5",
|
||||
"naive-ui": "^2.40.3",
|
||||
"postal-mime": "^2.3.2",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.4.31",
|
||||
"vue": "^3.5.13",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^9.13.1",
|
||||
"vue-router": "^4.4.0"
|
||||
"vue-i18n": "^9.14.2",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.12.0",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"unplugin-auto-import": "^0.17.6",
|
||||
"unplugin-vue-components": "^0.27.2",
|
||||
"vite": "^5.3.3",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"unplugin-auto-import": "^0.18.6",
|
||||
"unplugin-vue-components": "^0.27.5",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-pwa": "^0.19.8",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-plugin-top-level-await": "^1.4.4",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"workbox-window": "^7.1.0",
|
||||
"wrangler": "^3.63.1"
|
||||
"workbox-window": "^7.3.0",
|
||||
"wrangler": "^3.91.0"
|
||||
}
|
||||
}
|
||||
|
||||
3561
frontend/pnpm-lock.yaml
generated
@@ -56,6 +56,9 @@ const getOpenSettings = async (message) => {
|
||||
try {
|
||||
const res = await api.fetch("/open_api/settings");
|
||||
const domainLabels = res["domainLabels"] || [];
|
||||
if (res["domains"]?.length < 1) {
|
||||
message.error("No domains found, please check your worker settings");
|
||||
}
|
||||
Object.assign(openSettings.value, {
|
||||
...res,
|
||||
title: res["title"] || "",
|
||||
@@ -93,6 +96,8 @@ const getOpenSettings = async (message) => {
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
openSettings.value.fetched = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +124,8 @@ const getUserOpenSettings = async (message) => {
|
||||
Object.assign(userOpenSettings.value, res);
|
||||
} catch (error) {
|
||||
message.error(error.message || "fetch settings failed");
|
||||
} finally {
|
||||
userOpenSettings.value.fetched = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { watch, onMounted, ref, onBeforeUnmount } from "vue";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { CloudDownloadRound, ReplyFilled } from '@vicons/material'
|
||||
import { CloudDownloadRound, ReplyFilled, ForwardFilled } from '@vicons/material'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
|
||||
import { utcToLocalDate } from '../utils';
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
@@ -14,42 +15,42 @@ const props = defineProps({
|
||||
enableUserDeleteEmail: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
requried: false
|
||||
required: false
|
||||
},
|
||||
showEMailTo: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
requried: false
|
||||
required: false
|
||||
},
|
||||
fetchMailData: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
requried: true
|
||||
required: true
|
||||
},
|
||||
deleteMail: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
requried: false
|
||||
required: false
|
||||
},
|
||||
showReply: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
requried: false
|
||||
required: false
|
||||
},
|
||||
showSaveS3: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
requried: false
|
||||
required: false
|
||||
},
|
||||
saveToS3: {
|
||||
type: Function,
|
||||
default: (mail_id, filename, blob) => { },
|
||||
requried: false
|
||||
required: false
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
isDark, mailboxSplitSize, indexTab, loading,
|
||||
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
|
||||
useIframeShowMail, sendMailModel, preferShowTextMail
|
||||
} = useGlobalState()
|
||||
const autoRefresh = ref(false)
|
||||
@@ -85,6 +86,7 @@ const { t } = useI18n({
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete mail?',
|
||||
reply: 'Reply',
|
||||
forwardMail: 'Forward',
|
||||
showTextMail: 'Show Text Mail',
|
||||
showHtmlMail: 'Show Html Mail',
|
||||
saveToS3: 'Save to S3',
|
||||
@@ -104,6 +106,7 @@ const { t } = useI18n({
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
reply: '回复',
|
||||
forwardMail: '转发',
|
||||
showTextMail: '显示纯文本邮件',
|
||||
showHtmlMail: '显示HTML邮件',
|
||||
saveToS3: '保存到S3',
|
||||
@@ -214,6 +217,15 @@ const replyMail = async () => {
|
||||
indexTab.value = 'sendmail';
|
||||
};
|
||||
|
||||
const forwardMail = async () => {
|
||||
Object.assign(sendMailModel.value, {
|
||||
subject: `${t('forwardMail')}: ${curMail.value.subject}`,
|
||||
contentType: curMail.value.message ? 'html' : 'text',
|
||||
content: curMail.value.message || curMail.value.text,
|
||||
});
|
||||
indexTab.value = 'sendmail';
|
||||
};
|
||||
|
||||
const onSpiltSizeChange = (size) => {
|
||||
mailboxSplitSize.value = size;
|
||||
}
|
||||
@@ -375,7 +387,7 @@ onBeforeUnmount(() => {
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${row.created_at} UTC` }}
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
@@ -397,7 +409,7 @@ onBeforeUnmount(() => {
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${curMail.created_at} UTC` }}
|
||||
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
@@ -428,6 +440,12 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="forwardMail">
|
||||
<template #icon>
|
||||
<n-icon :component="ForwardFilled" />
|
||||
</template>
|
||||
{{ t('forwardMail') }}
|
||||
</n-button>
|
||||
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
|
||||
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
|
||||
</n-button>
|
||||
@@ -471,7 +489,7 @@ onBeforeUnmount(() => {
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${row.created_at} UTC` }}
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
@@ -493,7 +511,7 @@ onBeforeUnmount(() => {
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${curMail.created_at} UTC` }}
|
||||
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
@@ -522,6 +540,12 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="forwardMail">
|
||||
<template #icon>
|
||||
<n-icon :component="ForwardFilled" />
|
||||
</template>
|
||||
{{ t('forwardMail') }}
|
||||
</n-button>
|
||||
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
|
||||
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
|
||||
</n-button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { utcToLocalDate } from '../utils';
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
@@ -12,7 +13,7 @@ const props = defineProps({
|
||||
enableUserDeleteEmail: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
requried: false
|
||||
required: false
|
||||
},
|
||||
showEMailFrom: {
|
||||
type: Boolean,
|
||||
@@ -21,16 +22,16 @@ const props = defineProps({
|
||||
fetchMailData: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
requried: true
|
||||
required: true
|
||||
},
|
||||
deleteMail: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
requried: false
|
||||
required: false
|
||||
},
|
||||
})
|
||||
|
||||
const { isDark, mailboxSplitSize, loading } = useGlobalState()
|
||||
const { isDark, mailboxSplitSize, loading, useUTCDate } = useGlobalState()
|
||||
const data = ref([])
|
||||
|
||||
const count = ref(0)
|
||||
@@ -251,7 +252,7 @@ onMounted(async () => {
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${row.created_at} UTC` }}
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailFrom" type="info">
|
||||
FROM: {{ row.address }}
|
||||
@@ -273,7 +274,7 @@ onMounted(async () => {
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${curMail.created_at} UTC` }}
|
||||
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.address }}
|
||||
@@ -320,7 +321,7 @@ onMounted(async () => {
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${row.created_at} UTC` }}
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailFrom" type="info">
|
||||
FROM: {{ row.address }}
|
||||
@@ -342,7 +343,7 @@ onMounted(async () => {
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${curMail.created_at} UTC` }}
|
||||
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.address }}
|
||||
|
||||
179
frontend/src/components/WebhookComponent.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
fetchData: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
required: true
|
||||
},
|
||||
saveSettings: {
|
||||
type: Function,
|
||||
default: (webhookSettings: WebhookSettings) => { },
|
||||
required: true
|
||||
},
|
||||
testSettings: {
|
||||
type: Function,
|
||||
default: (webhookSettings: WebhookSettings) => { },
|
||||
required: true
|
||||
},
|
||||
})
|
||||
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
successTip: 'Success',
|
||||
test: 'Test',
|
||||
save: 'Save',
|
||||
notEnabled: 'Webhook is not enabled for you',
|
||||
urlMissing: 'URL is required',
|
||||
enable: 'Enable',
|
||||
messagePusherDemo: 'Fill with Message Pusher Demo',
|
||||
messagePusherDoc: 'Message Pusher Doc',
|
||||
fillInDemoTip: 'Please modify the URL and other settings to your own',
|
||||
},
|
||||
zh: {
|
||||
successTip: '成功',
|
||||
test: '测试',
|
||||
save: '保存',
|
||||
notEnabled: 'Webhook 未开启,请联系管理员开启',
|
||||
urlMissing: 'URL 不能为空',
|
||||
enable: '启用',
|
||||
messagePusherDemo: '填入MessagePusher示例',
|
||||
messagePusherDoc: 'MessagePusher文档',
|
||||
fillInDemoTip: '请修改URL和其他设置为您自己的配置',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
class WebhookSettings {
|
||||
enabled: boolean = false
|
||||
url: string = ''
|
||||
method: string = 'POST'
|
||||
headers: string = JSON.stringify({}, null, 2)
|
||||
body: string = JSON.stringify({}, null, 2)
|
||||
}
|
||||
|
||||
const messagePusherDocLink = "https://github.com/songquanpeng/message-pusher";
|
||||
|
||||
const messagePusherDemo = {
|
||||
enabled: true,
|
||||
url: 'https://msgpusher.com/push/username',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"token": "token",
|
||||
"title": "${subject}",
|
||||
"description": "${subject}",
|
||||
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
|
||||
}, null, 2),
|
||||
} as WebhookSettings;
|
||||
|
||||
const fillMessagePuhserDemo = () => {
|
||||
Object.assign(webhookSettings.value, messagePusherDemo)
|
||||
message.success(t('fillInDemoTip'))
|
||||
}
|
||||
|
||||
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
|
||||
const enableWebhook = ref(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await props.fetchData()
|
||||
Object.assign(webhookSettings.value, res)
|
||||
enableWebhook.value = true
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!webhookSettings.value.url) {
|
||||
message.error(t('urlMissing'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await props.saveSettings(webhookSettings.value)
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const testSettings = async () => {
|
||||
if (!webhookSettings.value.url) {
|
||||
message.error(t('urlMissing'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await props.testSettings(webhookSettings.value)
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
|
||||
<n-flex justify="end">
|
||||
<n-button tag="a" :href="messagePusherDocLink" target="_blank" secondary>
|
||||
{{ t('messagePusherDoc') }}
|
||||
</n-button>
|
||||
<n-button @click="fillMessagePuhserDemo" secondary>
|
||||
{{ t('messagePusherDemo') }}
|
||||
</n-button>
|
||||
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
|
||||
{{ t('test') }}
|
||||
</n-button>
|
||||
<n-button @click="saveSettings" type="primary">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-form-item-row :label="t('enable')">
|
||||
<n-switch v-model:value="webhookSettings.enabled" :round="false" />
|
||||
</n-form-item-row>
|
||||
<div v-if="webhookSettings.enabled">
|
||||
<n-form-item-row label="URL">
|
||||
<n-input v-model:value="webhookSettings.url" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="METHOD">
|
||||
<n-select v-model:value="webhookSettings.method" tag :options='[
|
||||
{ label: "POST", value: "POST" }
|
||||
]' />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="HEADERS">
|
||||
<n-input v-model:value="webhookSettings.headers" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="BODY">
|
||||
<n-input v-model:value="webhookSettings.body" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
</n-form-item-row>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-result v-else status="404" :title="t('notEnabled')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
8
frontend/src/constant/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
const COMMOM_MAIL = [
|
||||
"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com",
|
||||
"icloud.com", "yahoo.com", "foxmail.com"
|
||||
]
|
||||
|
||||
export default {
|
||||
COMMOM_MAIL
|
||||
}
|
||||
@@ -2,10 +2,8 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import router from './router'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { createHead } from '@unhead/vue'
|
||||
|
||||
registerSW({ immediate: true })
|
||||
const i18n = createI18n({
|
||||
legacy: false, // you must set `false`, to use Composition API
|
||||
locale: 'zh', // set locale
|
||||
|
||||
15
frontend/src/models/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type UserOauth2Settings = {
|
||||
name: string;
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
authorizationURL: string;
|
||||
accessTokenURL: string;
|
||||
accessTokenFormat?: string;
|
||||
userInfoURL: string;
|
||||
redirectURL: string;
|
||||
logoutURL?: string;
|
||||
userEmailKey: string;
|
||||
scope: string;
|
||||
enableMailAllowList?: boolean | undefined;
|
||||
mailAllowList?: string[] | undefined;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Index from '../views/Index.vue'
|
||||
import User from '../views/User.vue'
|
||||
import { useGlobalState } from '../store'
|
||||
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -16,6 +16,11 @@ const router = createRouter({
|
||||
alias: "/:lang/user",
|
||||
component: User
|
||||
},
|
||||
{
|
||||
path: '/user/oauth2/callback',
|
||||
alias: "/:lang/user/oauth2/callback",
|
||||
component: UserOauth2Callback
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
alias: "/:lang/admin",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { createGlobalState, useStorage, useDark, useToggle, useLocalStorage } from '@vueuse/core'
|
||||
import {
|
||||
createGlobalState, useStorage, useDark, useToggle,
|
||||
useLocalStorage, useSessionStorage
|
||||
} from '@vueuse/core'
|
||||
|
||||
export const useGlobalState = createGlobalState(
|
||||
() => {
|
||||
@@ -8,9 +11,11 @@ export const useGlobalState = createGlobalState(
|
||||
const loading = ref(false);
|
||||
const announcement = useLocalStorage('announcement', '');
|
||||
const openSettings = ref({
|
||||
fetched: false,
|
||||
title: '',
|
||||
announcement: '',
|
||||
prefix: '',
|
||||
addressRegex: '',
|
||||
needAuth: false,
|
||||
adminContact: '',
|
||||
enableUserCreateEmail: false,
|
||||
@@ -26,6 +31,7 @@ export const useGlobalState = createGlobalState(
|
||||
enableWebhook: false,
|
||||
isS3Enabled: false,
|
||||
showGithub: true,
|
||||
disableAdminPasswordCheck: false,
|
||||
})
|
||||
const settings = ref({
|
||||
fetched: false,
|
||||
@@ -39,7 +45,7 @@ export const useGlobalState = createGlobalState(
|
||||
name: '',
|
||||
}
|
||||
});
|
||||
const sendMailModel = useStorage('sendMailModel', {
|
||||
const sendMailModel = useSessionStorage('sendMailModel', {
|
||||
fromName: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
@@ -53,20 +59,24 @@ export const useGlobalState = createGlobalState(
|
||||
const auth = useStorage('auth', '');
|
||||
const adminAuth = useStorage('adminAuth', '');
|
||||
const jwt = useStorage('jwt', '');
|
||||
const adminTab = ref("account");
|
||||
const adminTab = useSessionStorage('adminTab', "account");
|
||||
const adminMailTabAddress = ref("");
|
||||
const adminSendBoxTabAddress = ref("");
|
||||
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
|
||||
const useIframeShowMail = useStorage('useIframeShowMail', false);
|
||||
const preferShowTextMail = useStorage('preferShowTextMail', false);
|
||||
const userJwt = useStorage('userJwt', '');
|
||||
const userTab = useStorage('userTab', 'user_settings');
|
||||
const indexTab = useStorage('indexTab', 'mailbox');
|
||||
const userTab = useSessionStorage('userTab', 'user_settings');
|
||||
const indexTab = useSessionStorage('indexTab', 'mailbox');
|
||||
const globalTabplacement = useStorage('globalTabplacement', 'top');
|
||||
const useSideMargin = useStorage('useSideMargin', true);
|
||||
const useUTCDate = useStorage('useUTCDate', false);
|
||||
const userOpenSettings = ref({
|
||||
fetched: false,
|
||||
enable: false,
|
||||
enableMailVerify: false,
|
||||
/** @type {{ clientID: string, name: string }[]} */
|
||||
oauth2ClientIDs: [],
|
||||
});
|
||||
const userSettings = ref({
|
||||
/** @type {boolean} */
|
||||
@@ -82,9 +92,15 @@ export const useGlobalState = createGlobalState(
|
||||
/** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
|
||||
user_role: null,
|
||||
});
|
||||
const showAdminPage = computed(() => !!adminAuth.value || userSettings.value.is_admin);
|
||||
const showAdminPage = computed(() =>
|
||||
!!adminAuth.value
|
||||
|| userSettings.value.is_admin
|
||||
|| openSettings.value.disableAdminPasswordCheck
|
||||
);
|
||||
const telegramApp = ref(window.Telegram?.WebApp || {});
|
||||
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
|
||||
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
|
||||
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
|
||||
return {
|
||||
isDark,
|
||||
toggleDark,
|
||||
@@ -112,9 +128,12 @@ export const useGlobalState = createGlobalState(
|
||||
userSettings,
|
||||
globalTabplacement,
|
||||
useSideMargin,
|
||||
useUTCDate,
|
||||
telegramApp,
|
||||
isTelegram,
|
||||
showAdminPage,
|
||||
userOauth2SessionState,
|
||||
userOauth2SessionClientID,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -11,3 +11,17 @@ export const getRouterPathWithLang = (path: string, lang: string) => {
|
||||
}
|
||||
return `/${lang}${path}`;
|
||||
}
|
||||
|
||||
export const utcToLocalDate = (utcDate: string, useUTCDate: boolean) => {
|
||||
const utcDateString = `${utcDate} UTC`;
|
||||
if (useUTCDate) {
|
||||
return utcDateString;
|
||||
}
|
||||
try {
|
||||
const date = new Date(utcDateString);
|
||||
return date.toLocaleString();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return utcDateString;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
|
||||
import SenderAccess from './admin/SenderAccess.vue'
|
||||
import Statistics from "./admin/Statistics.vue"
|
||||
@@ -12,6 +13,7 @@ import CreateAccount from './admin/CreateAccount.vue';
|
||||
import AccountSettings from './admin/AccountSettings.vue';
|
||||
import UserManagement from './admin/UserManagement.vue';
|
||||
import UserSettings from './admin/UserSettings.vue';
|
||||
import UserOauth2Settings from './admin/UserOauth2Settings.vue';
|
||||
import Mails from './admin/Mails.vue';
|
||||
import MailsUnknow from './admin/MailsUnknow.vue';
|
||||
import About from './common/About.vue';
|
||||
@@ -19,15 +21,19 @@ import Maintenance from './admin/Maintenance.vue';
|
||||
import Appearance from './common/Appearance.vue';
|
||||
import Telegram from './admin/Telegram.vue';
|
||||
import Webhook from './admin/Webhook.vue';
|
||||
import MailWebhook from './admin/MailWebhook.vue';
|
||||
import WorkerConfig from './admin/WorkerConfig.vue';
|
||||
import SendMail from './admin/SendMail.vue';
|
||||
|
||||
const {
|
||||
adminAuth, showAdminAuth, adminTab, loading,
|
||||
globalTabplacement, showAdminPage
|
||||
globalTabplacement, showAdminPage, userSettings
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
adminAuth.value = tmpAdminAuth.value;
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
@@ -40,61 +46,72 @@ const { t } = useI18n({
|
||||
accessHeader: 'Admin Password',
|
||||
accessTip: 'Please enter the admin password',
|
||||
mails: 'Emails',
|
||||
sendMail: 'Send Mail',
|
||||
qucickSetup: 'Quick Setup',
|
||||
account: 'Account',
|
||||
account_create: 'Create Account',
|
||||
account_settings: 'Account Settings',
|
||||
user: 'User',
|
||||
user_management: 'User Management',
|
||||
user_settings: 'User Settings',
|
||||
userOauth2Settings: 'Oauth2 Settings',
|
||||
unknow: 'Mails with unknow receiver',
|
||||
senderAccess: 'Sender Access Control',
|
||||
sendBox: 'Send Box',
|
||||
telegram: 'Telegram Bot',
|
||||
webhook: 'Webhook',
|
||||
webhookSettings: 'Webhook Settings',
|
||||
statistics: 'Statistics',
|
||||
maintenance: 'Maintenance',
|
||||
workerconfig: 'Worker Config',
|
||||
appearance: 'Appearance',
|
||||
about: 'About',
|
||||
ok: 'OK',
|
||||
mailWebhook: 'Mail Webhook',
|
||||
},
|
||||
zh: {
|
||||
accessHeader: 'Admin 密码',
|
||||
accessTip: '请输入 Admin 密码',
|
||||
mails: '邮件',
|
||||
sendMail: '发送邮件',
|
||||
qucickSetup: '快速设置',
|
||||
account: '账号',
|
||||
account_create: '创建账号',
|
||||
account_settings: '账号设置',
|
||||
user: '用户',
|
||||
user_management: '用户管理',
|
||||
user_settings: '用户设置',
|
||||
userOauth2Settings: 'Oauth2 设置',
|
||||
unknow: '无收件人邮件',
|
||||
senderAccess: '发件权限控制',
|
||||
sendBox: '发件箱',
|
||||
telegram: '电报机器人',
|
||||
webhook: 'Webhook',
|
||||
webhookSettings: 'Webhook 设置',
|
||||
statistics: '统计',
|
||||
maintenance: '维护',
|
||||
workerconfig: 'Worker 配置',
|
||||
appearance: '外观',
|
||||
about: '关于',
|
||||
ok: '确定',
|
||||
mailWebhook: '邮件 Webhook',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const showAdminPasswordModal = computed(() => !showAdminPage.value || showAdminAuth.value)
|
||||
const tmpAdminAuth = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!showAdminPage.value) {
|
||||
showAdminAuth.value = true;
|
||||
return;
|
||||
}
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
|
||||
:title="t('accessHeader')">
|
||||
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
|
||||
preset="dialog" :title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="adminAuth" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" />
|
||||
<template #action>
|
||||
<n-button @click="authFunc" type="primary" :loading="loading">
|
||||
{{ t('ok') }}
|
||||
@@ -102,8 +119,21 @@ onMounted(async () => {
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="workerconfig" :tab="t('workerconfig')">
|
||||
<WorkerConfig />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<n-tabs type="bar" animated>
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<Account />
|
||||
</n-tab-pane>
|
||||
@@ -116,34 +146,43 @@ onMounted(async () => {
|
||||
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
|
||||
<SenderAccess />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="webhook" :tab="t('webhook')">
|
||||
<n-tab-pane name="webhook" :tab="t('webhookSettings')">
|
||||
<Webhook />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user" :tab="t('user')">
|
||||
<n-tabs type="bar" animated>
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="user_management" :tab="t('user_management')">
|
||||
<UserManagement />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="userOauth2Settings" :tab="t('userOauth2Settings')">
|
||||
<UserOauth2Settings />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<n-tabs type="bar" animated>
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<Mails />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="unknow" :tab="t('unknow')">
|
||||
<MailsUnknow />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendBox" :tab="t('sendBox')">
|
||||
<SendBox />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendMail" :tab="t('sendMail')">
|
||||
<SendMail />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mailWebhook" :tab="t('mailWebhook')">
|
||||
<MailWebhook />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendBox" :tab="t('sendBox')">
|
||||
<SendBox />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="telegram" :tab="t('telegram')">
|
||||
<Telegram />
|
||||
</n-tab-pane>
|
||||
@@ -151,7 +190,14 @@ onMounted(async () => {
|
||||
<Statistics />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="maintenance" :tab="t('maintenance')">
|
||||
<Maintenance />
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="workerconfig" :tab="t('workerconfig')">
|
||||
<WorkerConfig />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="maintenance" :tab="t('maintenance')">
|
||||
<Maintenance />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="appearance" :tab="t('appearance')">
|
||||
<Appearance />
|
||||
|
||||
@@ -260,7 +260,7 @@ onMounted(async () => {
|
||||
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
|
||||
: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="password" show-password-on="click" />
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="authFunc" type="primary">
|
||||
{{ t('ok') }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { defineAsyncComponent, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
@@ -17,6 +18,7 @@ import About from './common/About.vue';
|
||||
const SendMail = defineAsyncComponent(() => import('./index/SendMail.vue'));
|
||||
const { settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
@@ -29,6 +31,8 @@ const { t } = useI18n({
|
||||
about: 'About',
|
||||
s3Attachment: 'S3 Attachment',
|
||||
saveToS3Success: 'save to s3 success',
|
||||
webhookSettings: 'Webhook Settings',
|
||||
query: 'Query',
|
||||
},
|
||||
zh: {
|
||||
mailbox: '收件箱',
|
||||
@@ -39,11 +43,18 @@ const { t } = useI18n({
|
||||
about: '关于',
|
||||
s3Attachment: 'S3附件',
|
||||
saveToS3Success: '保存到s3成功',
|
||||
webhookSettings: 'Webhook 设置',
|
||||
query: '查询',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
if (mailIdQuery.value > 0) {
|
||||
const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`);
|
||||
if (singleMail) return { results: [singleMail], count: 1 };
|
||||
return { results: [], count: 0 };
|
||||
}
|
||||
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
|
||||
};
|
||||
|
||||
@@ -78,6 +89,30 @@ const saveToS3 = async (mail_id, filename, blob) => {
|
||||
message.error(error.message || "save to s3 error");
|
||||
}
|
||||
}
|
||||
|
||||
const mailBoxKey = ref("")
|
||||
const mailIdQuery = ref("")
|
||||
const showMailIdQuery = ref(false)
|
||||
|
||||
const queryMail = () => {
|
||||
mailBoxKey.value = Date.now();
|
||||
}
|
||||
|
||||
watch(route, () => {
|
||||
if (!route.query.mail_id) {
|
||||
showMailIdQuery.value = false;
|
||||
mailIdQuery.value = "";
|
||||
queryMail();
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.mail_id) {
|
||||
showMailIdQuery.value = true;
|
||||
mailIdQuery.value = route.query.mail_id;
|
||||
queryMail();
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -85,9 +120,17 @@ const saveToS3 = async (mail_id, filename, blob) => {
|
||||
<AddressBar />
|
||||
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="mailbox" :tab="t('mailbox')">
|
||||
<MailBox :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled" :saveToS3="saveToS3"
|
||||
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
|
||||
:deleteMail="deleteMail" />
|
||||
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="mailIdQuery" />
|
||||
<n-button @click="queryMail" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</div>
|
||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled"
|
||||
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
@@ -102,7 +145,7 @@ const saveToS3 = async (mail_id, filename, blob) => {
|
||||
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
|
||||
<AutoReply />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhook')">
|
||||
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhookSettings')">
|
||||
<Webhook />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.isS3Enabled" name="s3_attachment" :tab="t('s3Attachment')">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { NButton, NMenu } from 'naive-ui';
|
||||
import { MenuFilled } from '@vicons/material'
|
||||
|
||||
const {
|
||||
showAdminAuth, loading, adminTab,
|
||||
loading, adminTab,
|
||||
adminMailTabAddress, adminSendBoxTabAddress
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
@@ -286,15 +286,17 @@ onMounted(async () => {
|
||||
{{ 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 style="overflow: auto;">
|
||||
<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" embedded />
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -303,4 +305,8 @@ onMounted(async () => {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.n-data-table {
|
||||
min-width: 1000px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,22 +11,24 @@ const message = useMessage()
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
tip: 'You can manually input the following multiple select input',
|
||||
tip: 'You can manually input the following multiple select input and enter',
|
||||
save: 'Save',
|
||||
successTip: 'Save Success',
|
||||
address_block_list: 'Address Block Keywords for Users(Admin can skip)',
|
||||
address_block_list_placeholder: 'Please enter the keywords you want to block',
|
||||
send_address_block_list: 'Address Block Keywords for send email',
|
||||
noLimitSendAddressList: 'No Balance Limit Send Address List',
|
||||
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
|
||||
fromBlockList: 'Block Keywords for receive email',
|
||||
},
|
||||
zh: {
|
||||
tip: '您可以手动输入以下多选输入框',
|
||||
tip: '您可以手动输入以下多选输入框, 回车增加',
|
||||
save: '保存',
|
||||
successTip: '保存成功',
|
||||
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
|
||||
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
|
||||
send_address_block_list: '发送邮件地址屏蔽关键词',
|
||||
noLimitSendAddressList: '无余额限制发送地址列表',
|
||||
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
|
||||
fromBlockList: '接收邮件地址屏蔽关键词',
|
||||
}
|
||||
@@ -35,6 +37,7 @@ const { t } = useI18n({
|
||||
|
||||
const addressBlockList = ref([])
|
||||
const sendAddressBlockList = ref([])
|
||||
const noLimitSendAddressList = ref([])
|
||||
const verifiedAddressList = ref([])
|
||||
const fromBlockList = ref([])
|
||||
|
||||
@@ -45,6 +48,7 @@ const fetchData = async () => {
|
||||
sendAddressBlockList.value = res.sendBlockList || []
|
||||
verifiedAddressList.value = res.verifiedAddressList || []
|
||||
fromBlockList.value = res.fromBlockList || []
|
||||
noLimitSendAddressList.value = res.noLimitSendAddressList || []
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
@@ -59,6 +63,7 @@ const save = async () => {
|
||||
sendBlockList: sendAddressBlockList.value || [],
|
||||
verifiedAddressList: verifiedAddressList.value || [],
|
||||
fromBlockList: fromBlockList.value || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList.value || [],
|
||||
})
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
@@ -76,7 +81,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<n-alert :show-icon="false" style="margin-bottom: 10px;">
|
||||
<n-alert :show-icon="false" type="warning" style="margin-bottom: 10px;">
|
||||
{{ t("tip") }}
|
||||
</n-alert>
|
||||
<n-form-item-row :label="t('address_block_list')">
|
||||
@@ -87,6 +92,10 @@ onMounted(async () => {
|
||||
<n-select v-model:value="sendAddressBlockList" filterable multiple tag
|
||||
:placeholder="t('address_block_list_placeholder')" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('noLimitSendAddressList')">
|
||||
<n-select v-model:value="noLimitSendAddressList" filterable multiple tag
|
||||
:placeholder="t('noLimitSendAddressList')" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('verified_address_list')">
|
||||
<n-select v-model:value="verifiedAddressList" filterable multiple tag
|
||||
:placeholder="t('verified_address_list')" />
|
||||
|
||||
30
frontend/src/views/admin/MailWebhook.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
|
||||
// @ts-ignore
|
||||
import WebhookComponent from '../../components/WebhookComponent.vue'
|
||||
|
||||
const fetchData = async () => {
|
||||
return await api.fetch(`/admin/mail_webhook/settings`)
|
||||
}
|
||||
|
||||
const saveSettings = async (webhookSettings: any) => {
|
||||
await api.fetch(`/admin/mail_webhook/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings),
|
||||
})
|
||||
}
|
||||
|
||||
const testSettings = async (webhookSettings: any) => {
|
||||
await api.fetch(`/admin/mail_webhook/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings),
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WebhookComponent :fetchData="fetchData" :saveSettings="saveSettings" :testSettings="testSettings" />
|
||||
</template>
|
||||
199
frontend/src/views/admin/SendMail.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
import { api } from '../../api'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
|
||||
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
||||
fromName: "",
|
||||
fromMail: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
subject: "",
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
});
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
successSend: 'Please check your sendbox. If failed, please try again later.',
|
||||
fromName: 'Your Name and Address, leave Name blank to use email address',
|
||||
toName: 'Recipient Name and Address, leave Name blank to use email address',
|
||||
subject: 'Subject',
|
||||
options: 'Options',
|
||||
edit: 'Edit',
|
||||
preview: 'Preview',
|
||||
content: 'Content',
|
||||
send: 'Send',
|
||||
text: 'Text',
|
||||
html: 'HTML',
|
||||
'rich text': 'Rich Text',
|
||||
tooLarge: 'Too large file, please upload file less than 1MB.',
|
||||
},
|
||||
zh: {
|
||||
successSend: '请查看您的发件箱, 如果失败, 请检查稍后重试。',
|
||||
fromName: '你的名称和地址,名称不填写则使用邮箱地址',
|
||||
toName: '收件人名称和地址,名称不填写则使用邮箱地址',
|
||||
subject: '主题',
|
||||
options: '选项',
|
||||
edit: '编辑',
|
||||
preview: '预览',
|
||||
content: '内容',
|
||||
send: '发送',
|
||||
text: '文本',
|
||||
html: 'HTML',
|
||||
'rich text': '富文本',
|
||||
tooLarge: '文件过大, 请上传小于1MB的文件。',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const contentTypes = [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
]
|
||||
|
||||
const send = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/send_mail`,
|
||||
{
|
||||
method: 'POST',
|
||||
body:
|
||||
JSON.stringify({
|
||||
from_name: sendMailModel.value.fromName,
|
||||
from_mail: sendMailModel.value.fromMail,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: sendMailModel.value.toMail,
|
||||
subject: sendMailModel.value.subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content: sendMailModel.value.content,
|
||||
})
|
||||
})
|
||||
sendMailModel.value = {
|
||||
fromName: "",
|
||||
fromMail: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
subject: "",
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
message.success(t("successSend"));
|
||||
}
|
||||
}
|
||||
|
||||
const toolbarConfig = {
|
||||
excludeKeys: ["uploadVideo"]
|
||||
}
|
||||
|
||||
const editorConfig = {
|
||||
MENU_CONF: {
|
||||
'uploadImage': {
|
||||
async customUpload() {
|
||||
message.error(t('tooLarge'))
|
||||
},
|
||||
maxFileSize: 1 * 1024 * 1024,
|
||||
base64LimitSize: 1 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.fromName" />
|
||||
<n-input v-model:value="sendMailModel.fromMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card :bordered="false" embedded v-if="isPreview">
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
text-align: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.left {
|
||||
text-align: left;
|
||||
place-items: left;
|
||||
justify-content: left;
|
||||
}
|
||||
</style>
|
||||
@@ -198,15 +198,17 @@ onMounted(async () => {
|
||||
{{ 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 style="overflow: auto;">
|
||||
<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" embedded />
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -215,4 +217,8 @@ onMounted(async () => {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.n-data-table {
|
||||
min-width: 700px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,7 @@ const { t } = useI18n({
|
||||
status: 'Check Status',
|
||||
enableTelegramAllowList: 'Enable Telegram Allow List(Manually input user ID)',
|
||||
enable: 'Enable',
|
||||
telegramAllowList: 'Telegram Allow List',
|
||||
telegramAllowList: 'Telegram Allow List(Manually input telegram user ID)',
|
||||
save: 'Save',
|
||||
miniAppUrl: 'Telegram Mini App URL',
|
||||
enableGlobalMailPush: 'Enable Global Mail Push(Manually input telegram user ID)',
|
||||
@@ -27,12 +27,12 @@ const { t } = useI18n({
|
||||
init: '初始化',
|
||||
successTip: '成功',
|
||||
status: '查看状态',
|
||||
enableTelegramAllowList: '启用 Telegram 白名单(手动输入用户 ID)',
|
||||
enableTelegramAllowList: '启用 Telegram 白名单(手动输入用户 ID, 回车增加)',
|
||||
enable: '启用',
|
||||
telegramAllowList: 'Telegram 白名单',
|
||||
telegramAllowList: 'Telegram 白名单(手动输入用户 ID, 回车增加)',
|
||||
save: '保存',
|
||||
miniAppUrl: '电报小程序 URL(请输入你部署的电报小程序网页地址)',
|
||||
enableGlobalMailPush: '启用全局邮件推送(手动输入邮箱管理员的 telegram 用户 ID)',
|
||||
enableGlobalMailPush: '启用全局邮件推送(手动输入邮箱管理员的 telegram 用户 ID, 回车增加)',
|
||||
globalMailPushList: '全局邮件推送用户列表',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,21 +368,23 @@ onMounted(async () => {
|
||||
{{ 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>
|
||||
<template #suffix>
|
||||
<n-button @click="showCreateUser = true" size="small" tertiary type="primary"
|
||||
style="margin-left: 10px">
|
||||
{{ t('createUser') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-pagination>
|
||||
<div style="overflow: auto;">
|
||||
<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>
|
||||
<template #suffix>
|
||||
<n-button @click="showCreateUser = true" size="small" tertiary type="primary"
|
||||
style="margin-left: 10px">
|
||||
{{ t('createUser') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -391,4 +393,8 @@ onMounted(async () => {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.n-data-table {
|
||||
min-width: 800px;
|
||||
}
|
||||
</style>
|
||||
|
||||
253
frontend/src/views/admin/UserOauth2Settings.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
import constant from '../../constant'
|
||||
import { UserOauth2Settings } from '../../models';
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
save: 'Save',
|
||||
successTip: 'Save Success',
|
||||
enable: 'Enable',
|
||||
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
|
||||
mailAllowList: 'Mail Address Allow List',
|
||||
addOauth2: 'Add Oauth2',
|
||||
name: 'Name',
|
||||
oauth2Type: 'Oauth2 Type',
|
||||
tip: 'Third-party login will automatically use the user\'s email to register an account (the same email will be regarded as the same account), this account is the same as the registered account, and you can also set the password through the forget password',
|
||||
},
|
||||
zh: {
|
||||
save: '保存',
|
||||
successTip: '保存成功',
|
||||
enable: '启用',
|
||||
enableMailAllowList: '启用邮件地址白名单(可手动输入, 回车增加)',
|
||||
mailAllowList: '邮件地址白名单',
|
||||
addOauth2: '添加 Oauth2',
|
||||
name: '名称',
|
||||
oauth2Type: 'Oauth2 类型',
|
||||
tip: '第三方登录会自动使用用户邮箱注册账号(邮箱相同将视为同一账号), 此账号和注册的账号相同, 也可以通过忘记密码设置密码',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const mailAllowOptions = constant.COMMOM_MAIL.map((item) => {
|
||||
return { label: item, value: item }
|
||||
})
|
||||
|
||||
const userOauth2Settings = ref([] as UserOauth2Settings[])
|
||||
const showAddOauth2 = ref(false)
|
||||
const newOauth2Name = ref('')
|
||||
const newOauth2Type = ref('custom')
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/user_oauth2_settings`)
|
||||
Object.assign(userOauth2Settings.value, res)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/user_oauth2_settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userOauth2Settings.value)
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const addNewOauth2 = () => {
|
||||
const authorizationURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://github.com/login/oauth/authorize'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/authorize/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const accessTokenURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://github.com/login/oauth/access_token'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/token/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const accessTokenFormat = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'json'
|
||||
case 'authentik':
|
||||
return 'urlencoded'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const userInfoURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://api.github.com/user'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/userinfo/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const userEmailKey = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'email'
|
||||
case 'authentik':
|
||||
return 'email'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const scope = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'user:email'
|
||||
case 'authentik':
|
||||
return 'email openid'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
userOauth2Settings.value.push({
|
||||
name: newOauth2Name.value,
|
||||
clientID: '',
|
||||
clientSecret: '',
|
||||
authorizationURL: authorizationURL(),
|
||||
accessTokenURL: accessTokenURL(),
|
||||
accessTokenFormat: accessTokenFormat(),
|
||||
userInfoURL: userInfoURL(),
|
||||
userEmailKey: userEmailKey(),
|
||||
redirectURL: `${window.location.origin}/user/oauth2/callback`,
|
||||
logoutURL: '',
|
||||
scope: scope(),
|
||||
enableMailAllowList: false,
|
||||
mailAllowList: constant.COMMOM_MAIL
|
||||
} as UserOauth2Settings)
|
||||
newOauth2Name.value = ''
|
||||
showAddOauth2.value = false
|
||||
}
|
||||
|
||||
const accessTokenFormatOptions = [
|
||||
{ label: 'json', value: 'json' },
|
||||
{ label: 'urlencoded', value: 'urlencoded' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-modal v-model:show="showAddOauth2" preset="dialog" :title="t('addOauth2')">
|
||||
<n-form>
|
||||
<n-form-item-row :label="t('name')" required>
|
||||
<n-input v-model:value="newOauth2Name" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('oauth2Type')" required>
|
||||
<n-radio-group v-model:value="newOauth2Type">
|
||||
<n-radio-button value="github" label="Github" />
|
||||
<n-radio-button value="authentik" label="Authentik" />
|
||||
<n-radio-button value="custom" label="Custom" />
|
||||
</n-radio-group>
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="addNewOauth2" size="small" tertiary type="primary">
|
||||
{{ t('addOauth2') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<n-alert :show-icon="false" type="warning" closable style="margin-bottom: 10px;">
|
||||
{{ t("tip") }}
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="showAddOauth2 = true" secondary :loading="loading">
|
||||
{{ t('addOauth2') }}
|
||||
</n-button>
|
||||
<n-button @click="save" type="primary" :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-collapse default-expanded-names="1" accordion>
|
||||
<n-collapse-item v-for="(item, index) in userOauth2Settings" :key="index" :title="item.name">
|
||||
<n-form :model="item">
|
||||
<n-form-item-row :label="t('name')" required>
|
||||
<n-input v-model:value="item.name" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Client ID" required>
|
||||
<n-input v-model:value="item.clientID" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Client Secret" required>
|
||||
<n-input v-model:value="item.clientSecret" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Authorization URL" required>
|
||||
<n-input v-model:value="item.authorizationURL" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Access Token URL" required>
|
||||
<n-input v-model:value="item.accessTokenURL" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Access Token accessTokenFormat" required>
|
||||
<n-select v-model:value="item.accessTokenFormat" :options="accessTokenFormatOptions" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="User Info URL" required>
|
||||
<n-input v-model:value="item.userInfoURL" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="User Email Key" required>
|
||||
<n-input v-model:value="item.userEmailKey" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Redirect URL" required>
|
||||
<n-input v-model:value="item.redirectURL" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Scope" required>
|
||||
<n-input v-model:value="item.scope" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableMailAllowList')">
|
||||
<n-input-group>
|
||||
<n-checkbox v-model:checked="item.enableMailAllowList" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-select v-model:value="item.mailAllowList" v-if="item.enableMailAllowList" filterable
|
||||
multiple tag style="width: 80%;" :options="mailAllowOptions"
|
||||
:placeholder="t('mailAllowList')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -28,7 +28,7 @@ const { t } = useI18n({
|
||||
enableUserRegister: "允许用户注册",
|
||||
enableMailVerify: '启用邮件验证(发送地址必须是系统中能有余额且能正常发送邮件的地址)',
|
||||
verifyMailSender: '验证邮件发送地址',
|
||||
enableMailAllowList: '启用邮件地址白名单(可手动输入)',
|
||||
enableMailAllowList: '启用邮件地址白名单(可手动输入, 回车增加)',
|
||||
mailAllowList: '邮件地址白名单',
|
||||
maxAddressCount: '可绑定最大邮箱地址数量',
|
||||
}
|
||||
@@ -92,8 +92,8 @@ onMounted(async () => {
|
||||
<n-checkbox v-model:checked="userSettings.enableMailVerify" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-input v-model:value="userSettings.verifyMailSender" style="width: 80%;"
|
||||
:placeholder="t('verifyMailSender')" />
|
||||
<n-input v-model:value="userSettings.verifyMailSender" v-if="userSettings.enableMailVerify"
|
||||
style="width: 80%;" :placeholder="t('verifyMailSender')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableMailAllowList')">
|
||||
@@ -101,8 +101,9 @@ onMounted(async () => {
|
||||
<n-checkbox v-model:checked="userSettings.enableMailAllowList" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-select v-model:value="userSettings.mailAllowList" filterable multiple tag style="width: 80%;"
|
||||
:options="mailAllowOptions" :placeholder="t('mailAllowList')" />
|
||||
<n-select v-model:value="userSettings.mailAllowList" v-if="userSettings.enableMailAllowList"
|
||||
filterable multiple tag style="width: 80%;" :options="mailAllowOptions"
|
||||
:placeholder="t('mailAllowList')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('maxAddressCount')">
|
||||
|
||||
@@ -13,13 +13,15 @@ const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
successTip: 'Success',
|
||||
webhookAllowList: 'Webhook Allow List(Enter the address that is allowed to use webhook)',
|
||||
webhookAllowList: 'Webhook Allow List(Enter the address that is allowed to use webhook and enter)',
|
||||
save: 'Save',
|
||||
notEnabled: 'Webhook is not enabled',
|
||||
},
|
||||
zh: {
|
||||
successTip: '成功',
|
||||
webhookAllowList: 'Webhook 白名单(请输入允许使用webhook 的地址)',
|
||||
webhookAllowList: 'Webhook 白名单(请输入允许使用webhook 的地址, 回车增加)',
|
||||
save: '保存',
|
||||
notEnabled: 'Webhook 未开启',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -33,13 +35,16 @@ class WebhookSettings {
|
||||
}
|
||||
|
||||
const webhookSettings = ref(new WebhookSettings([]))
|
||||
const webhookEnabled = ref(false)
|
||||
const errorInfo = ref('')
|
||||
|
||||
const getSettings = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/webhook/settings`)
|
||||
Object.assign(webhookSettings.value, res)
|
||||
webhookEnabled.value = true
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
errorInfo.value = (error as Error).message || "error";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +67,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 800px; overflow: auto;">
|
||||
<n-card v-if="webhookEnabled" :bordered="false" embedded style="max-width: 800px; overflow: auto;">
|
||||
<n-form-item-row :label="t('webhookAllowList')">
|
||||
<n-select v-model:value="webhookSettings.allowList" filterable multiple tag
|
||||
:placeholder="t('webhookAllowList')" />
|
||||
@@ -71,6 +76,7 @@ onMounted(async () => {
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
<n-result v-else status="404" :title="t('notEnabled')" :description="errorInfo" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
42
frontend/src/views/admin/WorkerConfig.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const settings = ref({})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/worker/configs`)
|
||||
Object.assign(settings.value, res)
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<pre>{{ JSON.stringify(settings, null, 2) }}</pre>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@ import { useGlobalState } from '../../store'
|
||||
|
||||
const {
|
||||
mailboxSplitSize, useIframeShowMail, preferShowTextMail,
|
||||
globalTabplacement, useSideMargin
|
||||
globalTabplacement, useSideMargin, useUTCDate
|
||||
} = useGlobalState()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
@@ -22,6 +22,7 @@ const { t } = useI18n({
|
||||
top: 'top',
|
||||
right: 'right',
|
||||
bottom: 'bottom',
|
||||
useUTCDate: 'Use UTC Date',
|
||||
},
|
||||
zh: {
|
||||
mailboxSplitSize: '邮箱界面分栏大小',
|
||||
@@ -33,6 +34,7 @@ const { t } = useI18n({
|
||||
top: '顶部',
|
||||
right: '右侧',
|
||||
bottom: '底部',
|
||||
useUTCDate: '使用 UTC 时间',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -54,6 +56,9 @@ const { t } = useI18n({
|
||||
<n-form-item-row :label="t('useIframeShowMail')">
|
||||
<n-switch v-model:value="useIframeShowMail" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('useUTCDate')">
|
||||
<n-switch v-model:value="useUTCDate" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="!isMobile" :label="t('useSideMargin')">
|
||||
<n-switch v-model:value="useSideMargin" :round="false" />
|
||||
</n-form-item-row>
|
||||
|
||||
@@ -15,7 +15,7 @@ const props = defineProps({
|
||||
bindUserAddress: {
|
||||
type: Function,
|
||||
default: async () => { await api.bindUserAddress(); },
|
||||
requried: true
|
||||
required: true
|
||||
},
|
||||
newAddressPath: {
|
||||
type: Function,
|
||||
@@ -29,7 +29,7 @@ const props = defineProps({
|
||||
}),
|
||||
});
|
||||
},
|
||||
requried: true
|
||||
required: true
|
||||
},
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ const { locale, t } = useI18n({
|
||||
login: 'Login',
|
||||
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
|
||||
getNewEmail: 'Create New Email',
|
||||
getNewEmailTip1: 'Please input the email you want to use. only allow a-z and 0-9',
|
||||
getNewEmailTip1: 'Please input the email you want to use. only allow: ',
|
||||
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
|
||||
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
|
||||
credential: 'Email Address Credential',
|
||||
@@ -87,7 +87,7 @@ const { locale, t } = useI18n({
|
||||
login: '登录',
|
||||
pleaseGetNewEmail: '请"登录"或点击 "注册新邮箱" 按钮来获取一个新的邮箱地址',
|
||||
getNewEmail: '创建新邮箱',
|
||||
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 a-z, 0-9',
|
||||
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许: ',
|
||||
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
|
||||
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
|
||||
credential: '邮箱地址凭据',
|
||||
@@ -101,6 +101,18 @@ const { locale, t } = useI18n({
|
||||
}
|
||||
});
|
||||
|
||||
const addressRegex = computed(() => {
|
||||
try {
|
||||
if (openSettings.value.addressRegex) {
|
||||
return new RegExp(openSettings.value.addressRegex, 'g');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(`Invalid addressRegex: ${openSettings.value.addressRegex}`);
|
||||
}
|
||||
return /[^a-z0-9]/g;
|
||||
});
|
||||
|
||||
const generateNameLoading = ref(false);
|
||||
const generateName = async () => {
|
||||
try {
|
||||
@@ -110,8 +122,12 @@ const generateName = async () => {
|
||||
.split('@')[0]
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.replace(addressRegex.value, '')
|
||||
.toLowerCase();
|
||||
// support maxAddressLen
|
||||
if (emailName.value.length > openSettings.value.maxAddressLen) {
|
||||
emailName.value = emailName.value.slice(0, openSettings.value.maxAddressLen);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
@@ -181,7 +197,7 @@ onMounted(async () => {
|
||||
<n-alert v-if="userSettings.user_email" :show-icon="false" :bordered="false" closable>
|
||||
<span>{{ t('bindUserInfo') }}</span>
|
||||
</n-alert>
|
||||
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
|
||||
<n-tabs v-if="openSettings.fetched" 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('credential')" required>
|
||||
@@ -206,7 +222,7 @@ onMounted(async () => {
|
||||
<n-spin :show="generateNameLoading">
|
||||
<n-form>
|
||||
<span>
|
||||
<p>{{ t("getNewEmailTip1") }}</p>
|
||||
<p>{{ t("getNewEmailTip1") + addressRegex.source }}</p>
|
||||
<p>{{ t("getNewEmailTip2") }}</p>
|
||||
<p>{{ t("getNewEmailTip3") }}</p>
|
||||
</span>
|
||||
|
||||
@@ -13,7 +13,7 @@ const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
|
||||
|
||||
const { settings, sendMailModel, indexTab } = useGlobalState()
|
||||
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: 'zh',
|
||||
@@ -136,6 +136,8 @@ const handleCreated = (editor) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
await api.getSettings();
|
||||
})
|
||||
</script>
|
||||
@@ -149,16 +151,15 @@ onMounted(async () => {
|
||||
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
|
||||
}}</n-button>
|
||||
</n-alert>
|
||||
<br />
|
||||
<AdminContact />
|
||||
</div>
|
||||
<div v-else>
|
||||
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||
<n-alert type="info" :show-icon="false" :bordered="false" closable>
|
||||
{{ t('send_balance') }}: {{ settings.send_balance }}
|
||||
</n-alert>
|
||||
<div class="right">
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
|
||||
</div>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
@@ -230,9 +231,7 @@ onMounted(async () => {
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
place-items: right;
|
||||
justify-content: right;
|
||||
.n-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,129 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
|
||||
const { settings } = useGlobalState()
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
successTip: 'Success',
|
||||
test: 'Test',
|
||||
save: 'Save',
|
||||
notEnabled: 'Webhook is not enabled for you',
|
||||
urlMissing: 'URL is required',
|
||||
},
|
||||
zh: {
|
||||
successTip: '成功',
|
||||
test: '测试',
|
||||
save: '保存',
|
||||
notEnabled: 'Webhook 未开启,请联系管理员开启',
|
||||
urlMissing: 'URL 不能为空',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
class WebhookSettings {
|
||||
url: string = ''
|
||||
method: string = 'POST'
|
||||
headers: string = JSON.stringify({}, null, 2)
|
||||
body: string = JSON.stringify({}, null, 2)
|
||||
}
|
||||
|
||||
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
|
||||
const enableWebhook = ref(false)
|
||||
import WebhookComponent from '../../components/WebhookComponent.vue'
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/api/webhook/settings`)
|
||||
Object.assign(webhookSettings.value, res)
|
||||
enableWebhook.value = true
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
return await api.fetch(`/api/webhook/settings`)
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!webhookSettings.value.url) {
|
||||
message.error(t('urlMissing'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.fetch(`/api/webhook/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings.value),
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
const saveSettings = async (webhookSettings: any) => {
|
||||
await api.fetch(`/api/webhook/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings),
|
||||
})
|
||||
}
|
||||
|
||||
const testSettings = async () => {
|
||||
if (!webhookSettings.value.url) {
|
||||
message.error(t('urlMissing'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.fetch(`/api/webhook/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings.value),
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
const testSettings = async (webhookSettings: any) => {
|
||||
await api.fetch(`/api/webhook/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings),
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
|
||||
<n-form-item-row label="URL">
|
||||
<n-input v-model:value="webhookSettings.url" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="METHOD">
|
||||
<n-select v-model:value="webhookSettings.method" tag :options='[
|
||||
{ label: "POST", value: "POST" }
|
||||
]' />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="HEADERS">
|
||||
<n-input v-model:value="webhookSettings.headers" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="BODY">
|
||||
<n-input v-model:value="webhookSettings.body" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
</n-form-item-row>
|
||||
<n-button @click="testSettings" secondary block strong>
|
||||
{{ t('test') }}
|
||||
</n-button>
|
||||
<n-button @click="saveSettings" type="primary" block>
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
<n-result v-else status="404" :title="t('notEnabled')" />
|
||||
</div>
|
||||
<WebhookComponent :fetchData="fetchData" :saveSettings="saveSettings" :testSettings="testSettings" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,8 +5,9 @@ import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { onMounted, watch } from 'vue';
|
||||
import { processItem } from '../../utils/email-parser'
|
||||
import { utcToLocalDate } from '../../utils';
|
||||
|
||||
const { telegramApp, loading } = useGlobalState()
|
||||
const { telegramApp, loading, useUTCDate } = useGlobalState()
|
||||
const route = useRoute()
|
||||
|
||||
const curMail = ref({});
|
||||
@@ -50,7 +51,7 @@ onMounted(async () => {
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
Date: {{ curMail.created_at }}
|
||||
Date: {{ utcToLocalDate(curMail.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
|
||||
@@ -20,9 +20,12 @@ const { locale, t } = useI18n({
|
||||
mail_count: 'Mail Count',
|
||||
send_count: 'Send Count',
|
||||
actions: 'Actions',
|
||||
changeMailAddress: 'Change Mail Address',
|
||||
changeMailAddress: 'Change Address',
|
||||
unbindAddress: 'Unbind Address',
|
||||
unbindAddressTip: 'Before unbinding, please switch to this email address and save the email address credential.',
|
||||
transferAddress: 'Transfer Address',
|
||||
targetUserEmail: 'Target User Email',
|
||||
transferAddressTip: 'Transfer address to another user will remove the address from your account and transfer it to another user. Are you sure to transfer the address?'
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
@@ -30,14 +33,21 @@ const { locale, t } = useI18n({
|
||||
mail_count: '邮件数量',
|
||||
send_count: '发送数量',
|
||||
actions: '操作',
|
||||
changeMailAddress: '切换邮箱地址',
|
||||
changeMailAddress: '切换地址',
|
||||
unbindAddress: '解绑地址',
|
||||
unbindAddressTip: '解绑前请切换到此邮箱地址并保存邮箱地址凭证。',
|
||||
transferAddress: '转移地址',
|
||||
targetUserEmail: '目标用户邮箱',
|
||||
transferAddressTip: '转移地址到其他用户将会从你的账户中移除此地址并转移给其他用户。确定要转移地址吗?'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const data = ref([])
|
||||
const showTranferAddress = ref(false)
|
||||
const currentAddress = ref("")
|
||||
const currentAddressId = ref(0)
|
||||
const targetUserEmail = ref('')
|
||||
|
||||
const changeMailAddress = async (address_id) => {
|
||||
try {
|
||||
@@ -70,6 +80,35 @@ const unbindAddress = async (address_id) => {
|
||||
}
|
||||
}
|
||||
|
||||
const transferAddress = async () => {
|
||||
if (!targetUserEmail.value) {
|
||||
message.error("targetUserEmail is required");
|
||||
return;
|
||||
}
|
||||
if (!currentAddressId.value) {
|
||||
message.error("currentAddressId is required");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/transfer_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
address_id: currentAddressId.value,
|
||||
target_user_email: targetUserEmail.value
|
||||
})
|
||||
});
|
||||
message.success(t('transferAddress') + " " + t('success'));
|
||||
await fetchData();
|
||||
showTranferAddress.value = false;
|
||||
currentAddressId.value = 0;
|
||||
currentAddress.value = "";
|
||||
targetUserEmail.value = "";
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const { results, count: addressCount } = await api.fetch(
|
||||
@@ -86,10 +125,6 @@ const fetchData = async () => {
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
},
|
||||
{
|
||||
title: t('name'),
|
||||
key: "name"
|
||||
@@ -138,6 +173,18 @@ const columns = [
|
||||
default: () => `${t('changeMailAddress')}?`
|
||||
}
|
||||
),
|
||||
h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
onClick: () => {
|
||||
currentAddressId.value = row.id;
|
||||
currentAddress.value = row.name;
|
||||
showTranferAddress.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('transferAddress') }
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => unbindAddress(row.id)
|
||||
@@ -164,7 +211,25 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
||||
<span>
|
||||
<p>{{ t("transferAddressTip") }}</p>
|
||||
<p>{{ t('transferAddress') + ": " + currentAddress }}</p>
|
||||
<n-input v-model:value="targetUserEmail" :placeholder="t('targetUserEmail')" />
|
||||
</span>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="transferAddress" size="small" tertiary type="error">
|
||||
{{ t('transferAddress') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<div style="overflow: auto;">
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-data-table {
|
||||
min-width: 700px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { KeyFilled } from '@vicons/material'
|
||||
|
||||
import { api } from '../../api';
|
||||
import { useGlobalState } from '../../store'
|
||||
@@ -10,7 +11,10 @@ import { startAuthentication } from '@simplewebauthn/browser';
|
||||
|
||||
import Turnstile from '../../components/Turnstile.vue';
|
||||
|
||||
const { userJwt, userOpenSettings, openSettings } = useGlobalState()
|
||||
const {
|
||||
userJwt, userOpenSettings, openSettings,
|
||||
userOauth2SessionState, userOauth2SessionClientID
|
||||
} = useGlobalState()
|
||||
const message = useMessage();
|
||||
|
||||
const { t } = useI18n({
|
||||
@@ -33,6 +37,7 @@ const { t } = useI18n({
|
||||
pleaseCompleteTurnstile: 'Please complete turnstile',
|
||||
pleaseLogin: 'Please login',
|
||||
loginWithPasskey: 'Login with Passkey',
|
||||
loginWith: 'Login with {provider}',
|
||||
},
|
||||
zh: {
|
||||
login: '登录',
|
||||
@@ -52,6 +57,7 @@ const { t } = useI18n({
|
||||
pleaseCompleteTurnstile: '请完成人机验证',
|
||||
pleaseLogin: '请登录',
|
||||
loginWithPasskey: '使用 Passkey 登录',
|
||||
loginWith: '使用 {provider} 登录',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -184,6 +190,18 @@ const passkeyLogin = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const oauth2Login = async (clientID) => {
|
||||
try {
|
||||
userOauth2SessionClientID.value = clientID;
|
||||
userOauth2SessionState.value = Math.random().toString(36).substring(2);
|
||||
const res = await api.fetch(`/user_api/oauth2/login_url?clientID=${clientID}&state=${userOauth2SessionState.value}`);
|
||||
// redirect to oauth2 login page
|
||||
location.href = res.url;
|
||||
} catch (error) {
|
||||
message.error(error.message || "login failed");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
});
|
||||
@@ -191,7 +209,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
|
||||
<n-tabs v-model:value="tabValue" size="large" v-if="userOpenSettings.fetched" justify-content="space-evenly">
|
||||
<n-tab-pane name="signin" :tab="t('login')">
|
||||
<n-form>
|
||||
<n-form-item-row :label="t('email')" required>
|
||||
@@ -208,8 +226,15 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
<n-divider />
|
||||
<n-button @click="passkeyLogin" type="primary" block secondary strong>
|
||||
<template #icon>
|
||||
<n-icon :component="KeyFilled" />
|
||||
</template>
|
||||
{{ t('loginWithPasskey') }}
|
||||
</n-button>
|
||||
<n-button @click="oauth2Login(item.clientID)" v-for="item in userOpenSettings.oauth2ClientIDs"
|
||||
:key="item.clientID" block secondary strong>
|
||||
{{ t('loginWith', { provider: item.name }) }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="userOpenSettings.enable" name="signup" :tab="t('register')">
|
||||
@@ -276,4 +301,8 @@ onMounted(async () => {
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
65
frontend/src/views/user/UserOauth2Callback.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api';
|
||||
|
||||
const {
|
||||
userJwt, userOauth2SessionState, userOauth2SessionClientID
|
||||
} = useGlobalState()
|
||||
|
||||
const message = useMessage();
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const errorInfo = ref('')
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
logging: 'Logging in...',
|
||||
stateNotMatch: 'state not match',
|
||||
},
|
||||
zh: {
|
||||
logging: '登录中...',
|
||||
stateNotMatch: 'state 不匹配',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const state = route.query.state;
|
||||
if (state != userOauth2SessionState.value) {
|
||||
console.error('state not match');
|
||||
message.error(t('stateNotMatch'));
|
||||
return;
|
||||
}
|
||||
const code = route.query.code;
|
||||
if (!code) {
|
||||
console.error('code not found');
|
||||
message.error('code not found');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/oauth2/callback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: code,
|
||||
clientID: userOauth2SessionClientID.value
|
||||
})
|
||||
});
|
||||
userJwt.value = res.jwt;
|
||||
router.push('/user');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-result status="info" :title="t('logging')" :description="errorInfo">
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -35,13 +35,16 @@ export default defineConfig({
|
||||
resolvers: [NaiveUiResolver()]
|
||||
}),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
registerType: null,
|
||||
devOptions: {
|
||||
enabled: true
|
||||
enabled: false
|
||||
},
|
||||
workbox: {
|
||||
disableDevLogs: true,
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
globPatterns: [],
|
||||
runtimeCaching: [],
|
||||
navigateFallback: null,
|
||||
cleanupOutdatedCaches: true,
|
||||
},
|
||||
manifest: {
|
||||
name: 'Temp Email',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp-email-pages",
|
||||
"version": "1.0.0",
|
||||
"version": "0.8.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,6 +11,6 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"wrangler": "^3.62.0"
|
||||
"wrangler": "^3.91.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
aiosmtpd==1.4.6
|
||||
pydantic-settings==2.2.1
|
||||
requests==2.32.0
|
||||
twisted==24.3.0
|
||||
twisted==24.7.0
|
||||
httpx==0.27.0
|
||||
|
||||
@@ -96,7 +96,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
},
|
||||
{
|
||||
text: '通过命令行部署',
|
||||
collapsed: true,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '命令行部署准备', link: 'cli/pre-requisite' },
|
||||
{ text: 'D1 数据库', link: 'cli/d1' },
|
||||
@@ -108,7 +108,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
},
|
||||
{
|
||||
text: '通过用户界面部署',
|
||||
collapsed: true,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'D1 数据库', link: 'ui/d1' },
|
||||
{ text: 'Cloudflare workers 后端', link: 'ui/worker' },
|
||||
@@ -119,14 +119,14 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
},
|
||||
{
|
||||
text: '通过 Github Actions 部署',
|
||||
collapsed: true,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '通过 Github Actions 部署', link: 'github-action' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '附加功能',
|
||||
collapsed: true,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '配置 SMTP IMAP 代理服务', link: 'feature/config-smtp-proxy' },
|
||||
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
|
||||
@@ -135,12 +135,14 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
{ text: '配置 Telegram Bot', link: 'feature/telegram' },
|
||||
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
|
||||
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
|
||||
{ text: '配置 webhook', link: 'feature/webhook' },
|
||||
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
|
||||
{ text: 'Oauth2 第三方登录', link: 'feature/user-oauth2' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '功能简介',
|
||||
collapsed: true,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Admin 控制台', link: 'feature/admin' },
|
||||
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },
|
||||
|
||||
@@ -61,8 +61,8 @@ pnpm run deploy
|
||||
```toml
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2023-08-14"
|
||||
node_compat = true
|
||||
compatibility_date = "2024-09-23"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
|
||||
# enable cron if you want set auto clean up
|
||||
# [triggers]
|
||||
@@ -78,6 +78,10 @@ node_compat = true
|
||||
PREFIX = "tmp" # The mailbox name prefix to be processed
|
||||
# (min, max) length of the adderss, if not set, the default is (1, 30)
|
||||
# ANNOUNCEMENT = "Custom Announcement"
|
||||
# address check REGEX, if not set, will not check
|
||||
# ADDRESS_CHECK_REGEX = "^(?!.*admin).*"
|
||||
# address name replace REGEX, if not set, the default is [^a-z0-9]
|
||||
# ADDRESS_REGEX = "[^a-z0-9]"
|
||||
# MIN_ADDRESS_LEN = 1
|
||||
# MAX_ADDRESS_LEN = 30
|
||||
# If you want your site to be private, uncomment below and change your password
|
||||
@@ -120,8 +124,14 @@ ENABLE_AUTO_REPLY = false
|
||||
# CF_TURNSTILE_SECRET_KEY = ""
|
||||
# telegram bot
|
||||
# TG_MAX_ADDRESS = 5
|
||||
# telegram bot info, predefined bot info can reduce latency of the webhook
|
||||
# TG_BOT_INFO = "{}"
|
||||
# global forward address list, if set, all emails will be forwarded to these addresses
|
||||
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
|
||||
# Frontend URL
|
||||
# FRONTEND_URL = "https://xxxx.xxx"
|
||||
# Enable check junk mail
|
||||
# ENABLE_CHECK_JUNK_MAIL = false
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
|
||||
BIN
vitepress-docs/docs/public/feature/address-webhook.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
vitepress-docs/docs/public/feature/admin-mail-webhook.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
BIN
vitepress-docs/docs/public/feature/admin-webhook-settings.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
BIN
vitepress-docs/docs/public/feature/oauth2-login.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
vitepress-docs/docs/public/feature/oauth2.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
vitepress-docs/docs/public/ui_install/worker-runtime.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -41,6 +41,7 @@ pnpm run deploy
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
# 如果你要启用 Cloudflare Zero Trust, 需要使用 pnpm build:pages:nopwa 来禁用缓存
|
||||
pnpm build:pages
|
||||
cd ../pages
|
||||
pnpm run deploy
|
||||
|
||||
@@ -25,12 +25,13 @@ wrangler kv:namespace create DEV
|
||||
```toml
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2023-12-01"
|
||||
compatibility_date = "2024-09-23"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
|
||||
# 如果你想使用自定义域名,你需要添加 routes 配置
|
||||
# routes = [
|
||||
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
|
||||
# ]
|
||||
node_compat = true
|
||||
|
||||
# 如果你想要使用定时任务清理邮件,取消下面的注释,并修改 cron 表达式
|
||||
# [triggers]
|
||||
@@ -46,6 +47,10 @@ node_compat = true
|
||||
PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串
|
||||
# (min, max) adderss的长度,如果不设置,默认为(1, 30)
|
||||
# ANNOUNCEMENT = "Custom Announcement" # 自定义公告
|
||||
# address name 的正则表达式, 只用于检查,符合条件将通过检查
|
||||
# ADDRESS_CHECK_REGEX = "^(?!.*admin).*"
|
||||
# address name 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 [^a-z0-9], 需谨慎使用, 有些符号可能导致无法收件
|
||||
# ADDRESS_REGEX = "[^a-z0-9]"
|
||||
# MIN_ADDRESS_LEN = 1
|
||||
# MAX_ADDRESS_LEN = 30
|
||||
# 如果你想要你的网站私有,取消下面的注释,并修改密码
|
||||
@@ -91,8 +96,14 @@ ENABLE_AUTO_REPLY = false
|
||||
# CF_TURNSTILE_SECRET_KEY = ""
|
||||
# telegram bot 最多绑定邮箱数量
|
||||
# TG_MAX_ADDRESS = 5
|
||||
# telegram BOT_INFO,预定义的 BOT_INFO 可以降低 webhook 的延迟
|
||||
# TG_BOT_INFO = "{}"
|
||||
# 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址
|
||||
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
|
||||
# 前端地址,用于发送 webhook 的邮件 url
|
||||
# FRONTEND_URL = "https://xxxx.xxx"
|
||||
# 是否启用垃圾邮件检查
|
||||
# ENABLE_CHECK_JUNK_MAIL = false
|
||||
|
||||
# D1 数据库的名称和 ID 可以在 cloudflare 控制台查看
|
||||
[[d1_databases]]
|
||||
|
||||
@@ -11,9 +11,16 @@ admin 后台 账号配置 `已验证地址列表(可通过 cf 内部 api 发送
|
||||
|
||||
`API KEYS` 页面创建 `api key`
|
||||
|
||||
使用 cli 或者直接添加到 `wrangler.toml` 的 `vars`,或者在 cloudflare worker 页面的变量中添加 `RESEND_TOKEN`
|
||||
然后执行下面的命令,将 `RESEND_TOKEN` 添加到 secrets 中
|
||||
|
||||
> [!NOTE]
|
||||
> 如果你觉得麻烦,也可以直接明文放在 `wrangler.toml` 中 `[vars]` 下面,但是不推荐这样做
|
||||
|
||||
如果你是通过 UI 部署的,可以在 Cloudflare 的 UI 界面中添加到 `Variables and Secrets` 下面
|
||||
|
||||
```bash
|
||||
# 切换到 worker 目录
|
||||
cd worker
|
||||
wrangler secret put RESEND_TOKEN
|
||||
```
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Cloudflare Email Routing
|
||||
|
||||
1. 配置对应域名的 `电子邮件 DNS 记录`, 如果是多个域名,需要配置多个域名的 `电子邮件 DNS 记录`
|
||||
1. 在 CF 控制台网页的对应域名的 `Email Routing` 下,配置 `电子邮件 DNS 记录`, 如果是多个域名,需要配置多个域名的 `电子邮件 DNS 记录`
|
||||
|
||||
2. 在将电子邮件地址绑定到您的 Worker 之前,您需要启用电子邮件路由并拥有至少一个经过验证的电子邮件地址。
|
||||
2. 在将电子邮件地址绑定到您的 Worker 之前,您需要启用电子邮件路由并拥有至少一个经过验证的电子邮件地址(目标地址)。
|
||||
|
||||
3. 配置每个域名的 `Cloudflare Email Routing` catch-all 发送到 `worker`
|
||||
3. 配置每个域名的 `Email Routing` 的路由规则中的 `Catch-all 地址` 发送到 `worker`
|
||||
|
||||

|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# 配置子域名邮箱
|
||||
|
||||
::: warning
|
||||
::: warning 注意
|
||||
子域名邮箱发送邮件可能无法发送邮件,建议使用主域名邮箱发送邮件,子域名邮箱仅用于接收邮件。
|
||||
|
||||
mail channel 已不被支持,下面参考中仅限收件部分。
|
||||
:::
|
||||
|
||||
参考
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
# 配置 Telegram Bot
|
||||
|
||||
::: warning 注意
|
||||
worker 默认的 `worker.dev` 域名的证书是不被 telegram 支持的,配置 Telegram Bot 请使用自定义域名
|
||||
:::
|
||||
|
||||
> [!NOTE]
|
||||
> 如果要使用 Telegram Bot, 请先绑定 `KV`
|
||||
>
|
||||
> 如果不需要 Telegram Bot, 可跳过此步骤
|
||||
|
||||
## Telegram Bot 配置
|
||||
|
||||
> [!NOTE]
|
||||
> 如果不需要 Telegram Bot, 可跳过此步骤
|
||||
|
||||
请先创建一个 Telegram Bot,然后获取 `token`,然后执行下面的命令,将 `token` 添加到 secrets 中
|
||||
|
||||
你也可以在 Cloudflare 的 UI 界面中添加 `secrets`
|
||||
> [!NOTE]
|
||||
> 如果你觉得麻烦,也可以直接明文放在 `wrangler.toml` 中 `[vars]` 下面,但是不推荐这样做
|
||||
|
||||
如果你是通过 UI 部署的,可以在 Cloudflare 的 UI 界面中添加到 `Variables and Secrets` 下面
|
||||
|
||||
```bash
|
||||
# 切换到 worker 目录
|
||||
cd worker
|
||||
pnpm wrangler secret put TELEGRAM_BOT_TOKEN
|
||||
```
|
||||
|
||||
@@ -23,6 +34,21 @@ pnpm wrangler secret put TELEGRAM_BOT_TOKEN
|
||||
|
||||
## Mini App
|
||||
|
||||
可以通过命令行部署,或者 UI 界面部署
|
||||
|
||||
### UI 部署
|
||||
|
||||
其他步骤参考 [UI 部署](/zh/guide/cli/pages) 中的 `前后端分离部署`
|
||||
|
||||
> [!NOTE]
|
||||
> 从这里下载 zip, [telegram-frontend.zip](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/telegram-frontend.zip)
|
||||
>
|
||||
> 修改压缩包里面的 index-xxx.js 文件 ,xx 是随机的字符串
|
||||
>
|
||||
> 搜索 `https://temp-email-api.xxx.xxx` ,替换成你worker 的域名,然后部署新的zip文件
|
||||
|
||||
### 命令行部署
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
|
||||
26
vitepress-docs/docs/zh/guide/feature/user-oauth2.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# OAuth2 第三方登录
|
||||
|
||||
> [!WARNING]
|
||||
> 第三方登录会自动使用用户邮箱注册账号(邮箱相同将视为同一账号)
|
||||
>
|
||||
> 此账号和注册的账号相同, 也可以通过忘记密码设置密码
|
||||
|
||||
## 在第三方平台注册 OAuth2
|
||||
|
||||
### GitHub
|
||||
|
||||
- 请先创建一个 OAuth App,然后获取 `Client ID` 和 `Client Secret`
|
||||
|
||||
参考 [Creating an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
|
||||
|
||||
### Authentik
|
||||
|
||||
- [Authentik OAuth2 Provider](https://docs.goauthentik.io/docs/providers/oauth2/)
|
||||
|
||||
## Admin 后台配置 OAuth2
|
||||
|
||||

|
||||
|
||||
## 测试用户登录页面
|
||||
|
||||

|
||||
44
vitepress-docs/docs/zh/guide/feature/webhook.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 配置 webhook
|
||||
|
||||
> [!NOTE]
|
||||
> 如果要使用 webhook,请先绑定 `KV` 并且 `worker` 变量配置 `ENABLE_WEBHOOK = true`
|
||||
>
|
||||
> 如果你想 webhook 的解析邮件能力更强,参考 [配置 worker 使用 wasm 解析邮件](/zh/guide/feature/mail_parser_wasm_worker)
|
||||
|
||||
## 前提条件
|
||||
|
||||
你需要自建一个 `webhook 服务` 或者 使用 `第三方平台`,这个服务需要能够接收 `POST` 请求,并且能够解析 `json` 数据。
|
||||
|
||||
本项目使用了 [songquanpeng/message-pusher](https://github.com/songquanpeng/message-pusher) 示例作为 webhook 服务。
|
||||
|
||||
- 可以使用 [msgpusher.com](https://msgpusher.com) 提供的服务
|
||||
- 也可以自建 `message-pusher` 服务,参考 [songquanpeng/message-pusher](https://github.com/songquanpeng/message-pusher)
|
||||
|
||||
## admin 配置全局 webhook
|
||||
|
||||

|
||||
|
||||
## admin 允许邮箱使用 webhook
|
||||
|
||||

|
||||
|
||||
## 某个邮箱配置 webhook
|
||||
|
||||

|
||||
|
||||
## webhook 数据格式
|
||||
|
||||
要获取 url 需要配置 worker 的 `FRONTEND_URL` 为你的前端地址,或者你可以通过 `id` 自己拼接 url = `${FRONTEND_URL}?mail_id=${id}`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "${id}",
|
||||
"url": "${url}",
|
||||
"from": "${from}",
|
||||
"to": "${to}",
|
||||
"subject": "${subject}",
|
||||
"raw": "${raw}",
|
||||
"parsedText": "${parsedText}",
|
||||
"parsedHtml": "${parsedHtml}",
|
||||
}
|
||||
```
|
||||
@@ -1,6 +1,7 @@
|
||||
# 通过 Github Actions 部署
|
||||
|
||||
::: warning
|
||||
::: warning 注意
|
||||
目前只支持 worker 和 pages 的部署,D1 数据库以及 Email 部分请参考 [UI/CLI 部署](/)。
|
||||
有问题请通过 `Github Issues` 反馈,感谢。
|
||||
:::
|
||||
|
||||
|
||||
@@ -6,3 +6,26 @@
|
||||
打开 [cloudflare控制台](https://dash.cloudflare.com/)
|
||||
|
||||
请查看通过 [命令行部署](/zh/guide/cli/pre-requisite) 或者 [用户界面部署](/zh/guide/ui/d1)
|
||||
|
||||
## 升级流程
|
||||
|
||||
首先确认当前的版本,然后访问 [Release 页面](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/) 和 [CHANGELOG 页面](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/CHANGELOG.md) 中找到当前的版本
|
||||
|
||||
> [!WARNING] 注意
|
||||
> 需要注意 `Breaking Changes` 是必须进行 `数据库 sql 执行` 或者 `变量配置` 的
|
||||
|
||||
然后查看从当前版本往后的所有更改,需要注意 `Breaking Changes` 是必须进行 `数据库 sql 执行` 或者 `变量配置` 的, 其他的功能更新按需配置即可
|
||||
|
||||
然后参考下面的文档使用 `CLI` 或者 `UI` 覆盖部署之前的 `worker` 和 `pages` 即可
|
||||
|
||||
CLI 部署
|
||||
|
||||
- [命令行更新 d1](/zh/guide/cli/d1)
|
||||
- [命令行部署 worker](/zh/guide/cli/worker)
|
||||
- [命令行部署 pages](/zh/guide/cli/worker)
|
||||
|
||||
UI 部署
|
||||
|
||||
- [用户界面更新 d1](/zh/guide/ui/d1)
|
||||
- [用户界面部署 worker](/zh/guide/ui/worker)
|
||||
- [用户界面部署 pages](/zh/guide/ui/pages)
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
|
||||

|
||||
|
||||
3. 下载 [worker.js](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/worker.js)
|
||||
3. 回到 `Overview`,找到刚刚创建的 worker,点击 `Settings` -> `Runtime`, 修改 `Compatibility flags`, 增加 `nodejs_compat`
|
||||
|
||||
4. 回到 `Overview`,找到刚刚创建的 worker,点击 `Edit Code`, 删除原来的文件,上传 `worker.js`, 点击 `Deploy`
|
||||

|
||||
|
||||
4. 下载 [worker.js](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/worker.js)
|
||||
|
||||
5. 回到 `Overview`,找到刚刚创建的 worker,点击 `Edit Code`, 删除原来的文件,上传 `worker.js`, 点击 `Deploy`
|
||||
|
||||
> [!NOTE]
|
||||
> 上传需要先点击左侧菜单的 Explorer,
|
||||
@@ -22,7 +26,7 @@
|
||||

|
||||

|
||||
|
||||
5. 点击 `Settings` -> `Trggers`, 这里可以添加自己的域名,你也可以使用自动生成的 `*.workers.dev` 的域名。记录下这个域名,后面部署前端会用到。
|
||||
6. 点击 `Settings` -> `Trggers`, 这里可以添加自己的域名,你也可以使用自动生成的 `*.workers.dev` 的域名。记录下这个域名,后面部署前端会用到。
|
||||
|
||||
> [!NOTE]
|
||||
> 打开 `worker` 的 `url`,如果显示 `OK` 说明部署成功
|
||||
@@ -31,22 +35,33 @@
|
||||
|
||||

|
||||
|
||||
6. 点击 `Settings` -> `Variables`, 如图所示添加变量,参考 [修改 wrangler.toml 配置文件](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件) 中的 `vars` 部分
|
||||
7. 点击 `Settings` -> `Variables`, 如图所示添加变量,参考 [修改 wrangler.toml 配置文件](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件) 中的 `[vars]` 部分
|
||||
|
||||
> [!NOTE]
|
||||
> 注意字符串格式的变量的最外层的引号是不需要的
|
||||
>
|
||||
> - 对于 `USER_ROLES` 请配置为此格式 `[{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"vip","prefix":"vip"},{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"admin","prefix":""}]`
|
||||
|
||||

|
||||
|
||||
7. 点击 `Settings` -> `Variables`, 下拉找到 `D1 Database`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 D1 数据库,点击 `Deploy`
|
||||
8. 点击 `Settings` -> `Variables`, 下拉找到 `D1 Database`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 D1 数据库,点击 `Deploy`
|
||||
|
||||
> [!NOTE]
|
||||
> 注意此处 `D1 Database` 的绑定名称必须为 `DB`
|
||||
|
||||

|
||||
|
||||
8. 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤,点击 `Workers & Pages` -> `KV` -> `Create Namespace`, 如图,点击 `Create Namespace`,然后在 `Settings` -> `Variables`, 下拉找到 `KV`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 `KV` 缓存,点击 `Deploy`
|
||||
9. 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤,点击 `Workers & Pages` -> `KV` -> `Create Namespace`, 如图,点击 `Create Namespace`,然后在 `Settings` -> `Variables`, 下拉找到 `KV`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 `KV` 缓存,点击 `Deploy`
|
||||
|
||||
> [!NOTE]
|
||||
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
|
||||
>
|
||||
> 注意此处 `KV` 的绑定名称必须为 `KV`
|
||||
|
||||

|
||||

|
||||
|
||||
9. Telegram Bot 配置
|
||||
10. Telegram Bot 配置
|
||||
|
||||
> [!NOTE]
|
||||
> 如果不需要 Telegram Bot, 可跳过此步骤
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "temp-mail-docs",
|
||||
"private": true,
|
||||
"version": "0.2.6",
|
||||
"version": "0.8.2",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.10",
|
||||
"vitepress": "^1.2.3",
|
||||
"wrangler": "^3.63.1"
|
||||
"@types/node": "^22.10.1",
|
||||
"vitepress": "^1.5.0",
|
||||
"wrangler": "^3.91.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vitepress dev docs",
|
||||
|
||||
1500
vitepress-docs/pnpm-lock.yaml
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "0.0.0",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,22 +11,22 @@
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240620.0",
|
||||
"@cloudflare/workers-types": "^4.20241202.0",
|
||||
"@eslint/js": "8.56.0",
|
||||
"@simplewebauthn/types": "^10.0.0",
|
||||
"eslint": "8.56.0",
|
||||
"globals": "^15.8.0",
|
||||
"typescript-eslint": "^7.15.0",
|
||||
"wrangler": "^3.63.1"
|
||||
"globals": "^15.13.0",
|
||||
"typescript-eslint": "^7.18.0",
|
||||
"wrangler": "^3.91.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.609.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.609.0",
|
||||
"@aws-sdk/client-s3": "^3.703.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.703.0",
|
||||
"@simplewebauthn/server": "^10.0.1",
|
||||
"hono": "^4.4.12",
|
||||
"hono": "^4.6.12",
|
||||
"mimetext": "^3.0.24",
|
||||
"postal-mime": "^2.2.5",
|
||||
"resend": "^3.4.0",
|
||||
"postal-mime": "^2.3.2",
|
||||
"resend": "^3.5.0",
|
||||
"telegraf": "4.16.3"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
2174
worker/pnpm-lock.yaml
generated
@@ -8,6 +8,10 @@ import { CONSTANTS } from '../constants'
|
||||
import cleanup_api from './cleanup_api'
|
||||
import admin_user_api from './admin_user_api'
|
||||
import webhook_settings from './webhook_settings'
|
||||
import mail_webhook_settings from './mail_webhook_settings'
|
||||
import oauth2_settings from './oauth2_settings'
|
||||
import worker_config from './worker_config'
|
||||
import { sendMailbyAdmin } from './send_mail'
|
||||
|
||||
export const api = new Hono<HonoCustomType>()
|
||||
|
||||
@@ -40,7 +44,13 @@ api.post('/admin/new_address', async (c) => {
|
||||
return c.text("Please provide a name", 400)
|
||||
}
|
||||
try {
|
||||
const res = await newAddress(c, name, domain, enablePrefix, false, null, false);
|
||||
const res = await newAddress(c, {
|
||||
name, domain, enablePrefix,
|
||||
checkLengthByConfig: false,
|
||||
addressPrefix: null,
|
||||
checkAllowDomains: false,
|
||||
enableCheckNameRegex: false,
|
||||
});
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`Failed create address: ${(e as Error).message}`, 400)
|
||||
@@ -247,11 +257,13 @@ api.get('/admin/account_settings', async (c) => {
|
||||
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_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') : [];
|
||||
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
|
||||
return c.json({
|
||||
blockList: blockList || [],
|
||||
sendBlockList: sendBlockList || [],
|
||||
verifiedAddressList: verifiedAddressList || [],
|
||||
fromBlockList: fromBlockList || []
|
||||
fromBlockList: fromBlockList || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList || []
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -261,7 +273,10 @@ api.get('/admin/account_settings', async (c) => {
|
||||
|
||||
api.post('/admin/account_settings', async (c) => {
|
||||
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
|
||||
const { blockList, sendBlockList, verifiedAddressList, fromBlockList } = await c.req.json();
|
||||
const {
|
||||
blockList, sendBlockList, noLimitSendAddressList,
|
||||
verifiedAddressList, fromBlockList
|
||||
} = await c.req.json();
|
||||
if (!blockList || !sendBlockList || !verifiedAddressList) {
|
||||
return c.text("Invalid blockList or sendBlockList", 400)
|
||||
}
|
||||
@@ -286,14 +301,21 @@ api.post('/admin/account_settings', async (c) => {
|
||||
if (fromBlockList) {
|
||||
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList || []))
|
||||
}
|
||||
await saveSetting(
|
||||
c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY,
|
||||
JSON.stringify(noLimitSendAddressList || [])
|
||||
)
|
||||
return c.json({
|
||||
success: true
|
||||
})
|
||||
})
|
||||
|
||||
// cleanup
|
||||
api.post('/admin/cleanup', cleanup_api.cleanup)
|
||||
api.get('/admin/auto_cleanup', cleanup_api.getCleanup)
|
||||
api.post('/admin/auto_cleanup', cleanup_api.saveCleanup)
|
||||
|
||||
// user settings
|
||||
api.get('/admin/user_settings', admin_user_api.getSetting)
|
||||
api.post('/admin/user_settings', admin_user_api.saveSetting)
|
||||
api.get('/admin/users', admin_user_api.getUsers)
|
||||
@@ -302,5 +324,22 @@ api.post('/admin/users', admin_user_api.createUser)
|
||||
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
|
||||
api.get('/admin/user_roles', async (c) => c.json(getUserRoles(c)))
|
||||
api.post('/admin/user_roles', admin_user_api.updateUserRoles)
|
||||
|
||||
// user oauth2 settings
|
||||
api.get('/admin/user_oauth2_settings', oauth2_settings.getUserOauth2Settings)
|
||||
api.post('/admin/user_oauth2_settings', oauth2_settings.saveUserOauth2Settings)
|
||||
|
||||
// webhook settings
|
||||
api.get("/admin/webhook/settings", webhook_settings.getWebhookSettings);
|
||||
api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings);
|
||||
|
||||
// mail webhook settings
|
||||
api.get("/admin/mail_webhook/settings", mail_webhook_settings.getWebhookSettings);
|
||||
api.post("/admin/mail_webhook/settings", mail_webhook_settings.saveWebhookSettings);
|
||||
api.post("/admin/mail_webhook/test", mail_webhook_settings.testWebhookSettings);
|
||||
|
||||
// worker config
|
||||
api.get("/admin/worker/configs", worker_config.getConfig);
|
||||
|
||||
// send mail by admin
|
||||
api.post("/admin/send_mail", sendMailbyAdmin);
|
||||
|
||||
50
worker/src/admin_api/mail_webhook_settings.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Context } from "hono";
|
||||
import { HonoCustomType } from "../types";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { WebhookSettings } from "../models";
|
||||
import { commonParseMail, sendWebhook } from "../common";
|
||||
|
||||
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.env.KV.get<WebhookSettings>(
|
||||
CONSTANTS.WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY, "json"
|
||||
) || new WebhookSettings();
|
||||
return c.json(settings);
|
||||
}
|
||||
|
||||
async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.req.json<WebhookSettings>();
|
||||
await c.env.KV.put(
|
||||
CONSTANTS.WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY,
|
||||
JSON.stringify(settings));
|
||||
return c.json({ success: true })
|
||||
}
|
||||
|
||||
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.req.json<WebhookSettings>();
|
||||
// random raw email
|
||||
const { id: mailId, raw } = await c.env.DB.prepare(
|
||||
`SELECT id, raw FROM raw_mails ORDER BY RANDOM() LIMIT 1`
|
||||
).first<{ id: string, raw: string }>() || {};
|
||||
|
||||
const parsedEmail = await commonParseMail(raw);
|
||||
const res = await sendWebhook(settings, {
|
||||
id: mailId || "0",
|
||||
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
|
||||
from: parsedEmail?.sender || "test@test.com",
|
||||
to: "admin@test.com",
|
||||
subject: parsedEmail?.subject || "test subject",
|
||||
raw: raw || "test raw email",
|
||||
parsedText: parsedEmail?.text || "test parsed text",
|
||||
parsedHtml: parsedEmail?.html || "test parsed html"
|
||||
});
|
||||
if (!res.success) {
|
||||
return c.text(res.message || "send webhook error", 400);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
}
|
||||
|
||||
export default {
|
||||
getWebhookSettings,
|
||||
saveWebhookSettings,
|
||||
testWebhookSettings,
|
||||
}
|
||||
34
worker/src/admin_api/oauth2_settings.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Context } from 'hono';
|
||||
|
||||
import { CONSTANTS } from '../constants';
|
||||
import { UserOauth2Settings } from "../models";
|
||||
import { HonoCustomType } from '../types';
|
||||
import { getJsonSetting, saveSetting } from '../utils';
|
||||
|
||||
async function getUserOauth2Settings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await getJsonSetting<UserOauth2Settings[]>(c, CONSTANTS.OAUTH2_SETTINGS_KEY);
|
||||
return c.json(settings || []);
|
||||
}
|
||||
|
||||
async function saveUserOauth2Settings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.req.json<UserOauth2Settings[]>();
|
||||
for (const setting of settings) {
|
||||
if (!setting.name || !setting.clientID || !setting.clientSecret
|
||||
|| !setting.authorizationURL || !setting.accessTokenURL
|
||||
|| !setting.accessTokenFormat
|
||||
|| !setting.userInfoURL || !setting.redirectURL
|
||||
|| !setting.userEmailKey || !setting.scope) {
|
||||
return c.text(`${setting.name} is missing required fields`, 400);
|
||||
}
|
||||
if (setting.enableMailAllowList && (setting.mailAllowList?.length || 0) < 1) {
|
||||
return c.text(`${setting.name} is missing mail allow list`, 400);
|
||||
}
|
||||
}
|
||||
await saveSetting(c, CONSTANTS.OAUTH2_SETTINGS_KEY, JSON.stringify(settings));
|
||||
return c.json({ success: true })
|
||||
}
|
||||
|
||||
export default {
|
||||
getUserOauth2Settings,
|
||||
saveUserOauth2Settings,
|
||||
}
|
||||
22
worker/src/admin_api/send_mail.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Context } from "hono";
|
||||
import { HonoCustomType } from "../types";
|
||||
import { sendMail } from "../mails_api/send_mail_api";
|
||||
|
||||
export const sendMailbyAdmin = async (c: Context<HonoCustomType>) => {
|
||||
const {
|
||||
from_name, from_mail,
|
||||
to_mail, to_name,
|
||||
subject, content, is_html
|
||||
} = await c.req.json();
|
||||
await sendMail(c, from_mail, {
|
||||
from_name: from_name,
|
||||
to_name: to_name,
|
||||
to_mail: to_mail,
|
||||
subject: subject,
|
||||
content: content,
|
||||
is_html: is_html,
|
||||
}, {
|
||||
isAdmin: true
|
||||
})
|
||||
return c.json({ status: "ok" });
|
||||
}
|
||||
47
worker/src/admin_api/worker_config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Context } from 'hono';
|
||||
|
||||
import { HonoCustomType } from '../types';
|
||||
import { getAdminPasswords, getBooleanValue, getDefaultDomains, getDomains, getIntValue, getPasswords, getStringArray, getStringValue, getUserRoles } from '../utils';
|
||||
import { CONSTANTS } from '../constants';
|
||||
import { isS3Enabled } from '../mails_api/s3_attachment';
|
||||
|
||||
export default {
|
||||
getConfig: async (c: Context<HonoCustomType>) => {
|
||||
return c.json({
|
||||
"TITLE": c.env.TITLE,
|
||||
"HAS_PASSWORD": getPasswords(c).length,
|
||||
"HAS_ADMIN_PASSWORDS": getAdminPasswords(c).length,
|
||||
"ANNOUNCEMENT": getStringValue(c.env.ANNOUNCEMENT),
|
||||
|
||||
"PREFIX": c.env.PREFIX,
|
||||
"ADDRESS_CHECK_REGEX": getStringValue(c.env.ADDRESS_CHECK_REGEX),
|
||||
"ADDRESS_REGEX": getStringValue(c.env.ADDRESS_REGEX),
|
||||
"MIN_ADDRESS_LEN": getIntValue(c.env.MIN_ADDRESS_LEN, 1),
|
||||
"MAX_ADDRESS_LEN": getIntValue(c.env.MAX_ADDRESS_LEN, 30),
|
||||
|
||||
"FORWARD_ADDRESS_LIST": getStringArray(c.env.FORWARD_ADDRESS_LIST),
|
||||
"DEFAULT_DOMAINS": getDefaultDomains(c),
|
||||
"DOMAINS": getDomains(c),
|
||||
"DOMAIN_LABELS": getStringArray(c.env.DOMAIN_LABELS),
|
||||
|
||||
"HAS_JWT_SECRET": !!getStringValue(c.env.JWT_SECRET),
|
||||
|
||||
"ADMIN_USER_ROLE": getStringValue(c.env.ADMIN_USER_ROLE),
|
||||
"USER_DEFAULT_ROLE": getStringValue(c.env.USER_DEFAULT_ROLE),
|
||||
"USER_ROLES": getUserRoles(c),
|
||||
"NO_LIMIT_SEND_ROLE": getStringValue(c.env.NO_LIMIT_SEND_ROLE),
|
||||
|
||||
"ADMIN_CONTACT": c.env.ADMIN_CONTACT,
|
||||
"ENABLE_USER_CREATE_EMAIL": getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
|
||||
"ENABLE_USER_DELETE_EMAIL": getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
|
||||
"ENABLE_AUTO_REPLY": getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
||||
"COPYRIGHT": c.env.COPYRIGHT,
|
||||
"ENABLE_WEBHOOK": getBooleanValue(c.env.ENABLE_WEBHOOK),
|
||||
"S3_ENABLED": isS3Enabled(c),
|
||||
"VERSION": CONSTANTS.VERSION,
|
||||
"DISABLE_SHOW_GITHUB": !getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
|
||||
"DISABLE_ADMIN_PASSWORD_CHECK": getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
|
||||
"ENABLE_CHECK_JUNK_MAIL": getBooleanValue(c.env.ENABLE_CHECK_JUNK_MAIL),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ api.get('/open_api/settings', async (c) => {
|
||||
"title": c.env.TITLE,
|
||||
"announcement": getStringValue(c.env.ANNOUNCEMENT),
|
||||
"prefix": c.env.PREFIX,
|
||||
"addressRegex": getStringValue(c.env.ADDRESS_REGEX),
|
||||
"minAddressLen": getIntValue(c.env.MIN_ADDRESS_LEN, 1),
|
||||
"maxAddressLen": getIntValue(c.env.MAX_ADDRESS_LEN, 30),
|
||||
"defaultDomains": getDefaultDomains(c),
|
||||
@@ -36,6 +37,7 @@ api.get('/open_api/settings', async (c) => {
|
||||
"isS3Enabled": isS3Enabled(c),
|
||||
"version": CONSTANTS.VERSION,
|
||||
"showGithub": !getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
|
||||
"disableAdminPasswordCheck": getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK)
|
||||
});
|
||||
})
|
||||
|
||||
|
||||
@@ -1,20 +1,72 @@
|
||||
import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains } from './utils';
|
||||
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting } from './utils';
|
||||
import { HonoCustomType, UserRole } from './types';
|
||||
import { unbindTelegramByAddress } from './telegram_api/common';
|
||||
import { CONSTANTS } from './constants';
|
||||
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
|
||||
|
||||
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
||||
|
||||
const checkNameRegex = (c: Context<HonoCustomType>, name: string) => {
|
||||
let error = null;
|
||||
try {
|
||||
const regexStr = getStringValue(c.env.ADDRESS_CHECK_REGEX);
|
||||
if (!regexStr) return;
|
||||
const regex = new RegExp(regexStr);
|
||||
if (!regex.test(name)) {
|
||||
error = new Error(`Name not match regex: /${regexStr}/`);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Failed to check address regex", e);
|
||||
}
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const getNameRegex = (c: Context<HonoCustomType>): RegExp => {
|
||||
try {
|
||||
const regex = getStringValue(c.env.ADDRESS_REGEX);
|
||||
if (!regex) {
|
||||
return DEFAULT_NAME_REGEX;
|
||||
}
|
||||
return new RegExp(regex, 'g');
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Failed to get address regex", e);
|
||||
}
|
||||
return DEFAULT_NAME_REGEX;
|
||||
}
|
||||
|
||||
export const newAddress = async (
|
||||
c: Context<HonoCustomType>,
|
||||
name: string, domain: string | undefined | null,
|
||||
enablePrefix: boolean,
|
||||
checkLengthByConfig: boolean = true,
|
||||
addressPrefix: string | undefined | null = null,
|
||||
checkAllowDomains: boolean = true
|
||||
{
|
||||
name,
|
||||
domain,
|
||||
enablePrefix,
|
||||
checkLengthByConfig = true,
|
||||
addressPrefix = null,
|
||||
checkAllowDomains = true,
|
||||
enableCheckNameRegex = true,
|
||||
}: {
|
||||
name: string, domain: string | undefined | null,
|
||||
enablePrefix: boolean,
|
||||
checkLengthByConfig?: boolean,
|
||||
addressPrefix?: string | undefined | null,
|
||||
checkAllowDomains?: boolean,
|
||||
enableCheckNameRegex?: boolean,
|
||||
}
|
||||
): Promise<{ address: string, jwt: string }> => {
|
||||
// remove special characters
|
||||
name = name.replace(/[^a-z0-9]/g, '')
|
||||
name = name.replace(getNameRegex(c), '')
|
||||
// check name
|
||||
if (enableCheckNameRegex) {
|
||||
await checkNameBlockList(c, name);
|
||||
checkNameRegex(c, name);
|
||||
}
|
||||
// name min length min 1
|
||||
const minAddressLength = Math.max(
|
||||
checkLengthByConfig ? getIntValue(c.env.MIN_ADDRESS_LEN, 1) : 1,
|
||||
@@ -78,6 +130,22 @@ export const newAddress = async (
|
||||
}
|
||||
}
|
||||
|
||||
const checkNameBlockList = async (
|
||||
c: Context<HonoCustomType>, name: string
|
||||
): Promise<void> => {
|
||||
// check name block list
|
||||
const blockList = [] as string[];
|
||||
try {
|
||||
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
|
||||
blockList.push(...(value || []));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
if (blockList.some((item) => name.includes(item))) {
|
||||
throw new Error(`Name[${name}]is blocked`);
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanup = async (
|
||||
c: Context<HonoCustomType>,
|
||||
cleanType: string | undefined | null,
|
||||
@@ -192,7 +260,8 @@ export const commonParseMail = async (raw_mail: string | undefined | null): Prom
|
||||
sender: string,
|
||||
subject: string,
|
||||
text: string,
|
||||
html: string
|
||||
html: string,
|
||||
headers?: Record<string, string>[]
|
||||
} | undefined> => {
|
||||
if (!raw_mail) {
|
||||
return undefined;
|
||||
@@ -219,6 +288,7 @@ export const commonParseMail = async (raw_mail: string | undefined | null): Prom
|
||||
subject: parsedEmail.subject || "",
|
||||
text: parsedEmail.text || "",
|
||||
html: parsedEmail.html || "",
|
||||
headers: parsedEmail.headers || [],
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
@@ -257,3 +327,84 @@ export const getAllowDomains = async (c: Context<HonoCustomType>): Promise<strin
|
||||
const user_role = await commonGetUserRole(c, user.user_id);
|
||||
return user_role?.domains || getDefaultDomains(c);;
|
||||
}
|
||||
|
||||
export async function sendWebhook(settings: WebhookSettings, formatMap: WebhookMail): Promise<{ success: boolean, message?: string }> {
|
||||
// send webhook
|
||||
let body = settings.body;
|
||||
for (const key of Object.keys(formatMap)) {
|
||||
/* eslint-disable no-useless-escape */
|
||||
body = body.replace(
|
||||
new RegExp(`\\$\\{${key}\\}`, "g"),
|
||||
JSON.stringify(
|
||||
formatMap[key as keyof WebhookMail]
|
||||
).replace(/^"(.*)"$/, '\$1')
|
||||
);
|
||||
/* eslint-enable no-useless-escape */
|
||||
}
|
||||
console.log("send webhook", settings.url, settings.method, settings.headers, body);
|
||||
const response = await fetch(settings.url, {
|
||||
method: settings.method,
|
||||
headers: JSON.parse(settings.headers),
|
||||
body: body
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.log("send webhook error", response.status, response.statusText);
|
||||
return { success: false, message: `send webhook error: ${response.status} ${response.statusText}` };
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function triggerWebhook(
|
||||
c: Context<HonoCustomType>,
|
||||
address: string,
|
||||
raw_mail: string,
|
||||
message_id: string | null
|
||||
): Promise<void> {
|
||||
if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) {
|
||||
return
|
||||
}
|
||||
const webhookList: WebhookSettings[] = []
|
||||
|
||||
// admin mail webhook
|
||||
const adminMailWebhookSettings = await c.env.KV.get<WebhookSettings>(CONSTANTS.WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY, "json");
|
||||
if (adminMailWebhookSettings?.enabled) {
|
||||
webhookList.push(adminMailWebhookSettings)
|
||||
}
|
||||
|
||||
// user mail webhook
|
||||
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
|
||||
if (adminSettings?.allowList.includes(address)) {
|
||||
const settings = await c.env.KV.get<WebhookSettings>(
|
||||
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json"
|
||||
);
|
||||
if (settings?.enabled) {
|
||||
webhookList.push(settings)
|
||||
}
|
||||
}
|
||||
|
||||
// no webhook
|
||||
if (webhookList.length === 0) {
|
||||
return
|
||||
}
|
||||
const mailId = await c.env.DB.prepare(
|
||||
`SELECT id FROM raw_mails where address = ? and message_id = ?`
|
||||
).bind(address, message_id).first<string>("id");
|
||||
|
||||
const parsedEmail = await commonParseMail(raw_mail);
|
||||
const webhookMail = {
|
||||
id: mailId || "",
|
||||
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
|
||||
from: parsedEmail?.sender || "",
|
||||
to: address,
|
||||
subject: parsedEmail?.subject || "",
|
||||
raw: raw_mail,
|
||||
parsedText: parsedEmail?.text || "",
|
||||
parsedHtml: parsedEmail?.html || ""
|
||||
}
|
||||
for (const settings of webhookList) {
|
||||
const res = await sendWebhook(settings, webhookMail);
|
||||
if (!res.success) {
|
||||
console.error(res.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export const CONSTANTS = {
|
||||
VERSION: 'v0.7.1',
|
||||
VERSION: 'v0.8.2',
|
||||
|
||||
// DB settings
|
||||
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
|
||||
SEND_BLOCK_LIST_KEY: 'send_block_list',
|
||||
AUTO_CLEANUP_KEY: 'auto_cleanup',
|
||||
USER_SETTINGS_KEY: 'user_settings',
|
||||
OAUTH2_SETTINGS_KEY: 'oauth2_settings',
|
||||
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
|
||||
NO_LIMIT_SEND_ADDRESS_LIST_KEY: 'no_limit_send_address_list',
|
||||
|
||||
// KV
|
||||
TG_KV_PREFIX: "temp-mail-telegram",
|
||||
@@ -14,4 +16,5 @@ export const CONSTANTS = {
|
||||
WEBHOOK_KV_SETTINGS_KEY: "temp-mail-webhook-settings",
|
||||
WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings",
|
||||
EMAIL_KV_BLACK_LIST: "temp-mail-email-black-list",
|
||||
WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY: "temp-mail-webhook-admin-mail-settings",
|
||||
}
|
||||
|
||||
44
worker/src/email/check_junk.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Bindings } from "../types";
|
||||
import { getBooleanValue } from "../utils";
|
||||
import { commonParseMail } from "../common";
|
||||
|
||||
export const check_if_junk_mail = async (
|
||||
env: Bindings, address: string,
|
||||
raw_mail: string, message_id: string | null
|
||||
): Promise<boolean> => {
|
||||
if (!getBooleanValue(env.ENABLE_CHECK_JUNK_MAIL)) {
|
||||
return false;
|
||||
}
|
||||
const parsedEmail = await commonParseMail(raw_mail);
|
||||
if (!parsedEmail?.headers) return false;
|
||||
const headers = parsedEmail.headers;
|
||||
for (const header of headers) {
|
||||
if (!header["key"]) continue;
|
||||
if (!header["value"]) continue;
|
||||
|
||||
// check spf
|
||||
if (header["key"].toLowerCase() == "received-spf"
|
||||
&&
|
||||
!header["value"].toLowerCase().includes("pass")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check dkim and dmarc
|
||||
if (header["key"].toLowerCase() == "authentication-results") {
|
||||
if (header["value"].toLowerCase().includes("dkim=")
|
||||
&&
|
||||
!header["value"].toLowerCase().includes("dkim=pass")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (header["value"].toLowerCase().includes("dmarc=")
|
||||
&&
|
||||
!header["value"].toLowerCase().includes("dmarc=pass")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import { getEnvStringList } from "../utils";
|
||||
import { sendMailToTelegram } from "../telegram_api";
|
||||
import { Bindings, HonoCustomType } from "../types";
|
||||
import { auto_reply } from "./auto_reply";
|
||||
import { trigerWebhook } from "../mails_api/webhook_settings";
|
||||
import { isBlocked } from "./black_list";
|
||||
import { triggerWebhook } from "../common";
|
||||
import { check_if_junk_mail } from "./check_junk";
|
||||
|
||||
|
||||
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
|
||||
@@ -15,6 +16,19 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
||||
return;
|
||||
}
|
||||
const rawEmail = await new Response(message.raw).text();
|
||||
|
||||
// check if junk mail
|
||||
try {
|
||||
const is_junk = await check_if_junk_mail(env, message.to, rawEmail, message.headers.get("Message-ID"));
|
||||
if (is_junk) {
|
||||
message.setReject("Junk mail");
|
||||
console.log(`Junk mail from ${message.from} to ${message.to}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("check junk mail error", error);
|
||||
}
|
||||
|
||||
const message_id = message.headers.get("Message-ID");
|
||||
// save email
|
||||
const { success } = await env.DB.prepare(
|
||||
@@ -48,9 +62,9 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
||||
|
||||
// send webhook
|
||||
try {
|
||||
await trigerWebhook(
|
||||
await triggerWebhook(
|
||||
{ env: env } as Context<HonoCustomType>,
|
||||
message.to, rawEmail
|
||||
message.to, rawEmail, message_id
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("send webhook error", error);
|
||||
|
||||
@@ -32,6 +32,15 @@ api.get('/api/mails', async (c) => {
|
||||
);
|
||||
})
|
||||
|
||||
api.get('/api/mail/:mail_id', async (c) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { mail_id } = c.req.param();
|
||||
const result = await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails where id = ? and address = ?`
|
||||
).bind(mail_id, address).first();
|
||||
return c.json(result);
|
||||
})
|
||||
|
||||
api.delete('/api/mails/:id', async (c) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text("User delete email is disabled", 403)
|
||||
@@ -121,7 +130,12 @@ api.post('/api/new_address', async (c) => {
|
||||
}
|
||||
try {
|
||||
const addressPrefix = await getAddressPrefix(c);
|
||||
const res = await newAddress(c, name, domain, true, true, addressPrefix);
|
||||
const res = await newAddress(c, {
|
||||
name, domain,
|
||||
enablePrefix: true,
|
||||
checkLengthByConfig: true,
|
||||
addressPrefix
|
||||
});
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`Failed create address: ${(e as Error).message}`, 400)
|
||||
|
||||
@@ -94,6 +94,9 @@ export const sendMail = async (
|
||||
reqJson: {
|
||||
from_name: string, to_mail: string, to_name: string,
|
||||
subject: string, content: string, is_html: boolean
|
||||
},
|
||||
options?: {
|
||||
isAdmin?: boolean
|
||||
}
|
||||
): Promise<void> => {
|
||||
if (!address) {
|
||||
@@ -107,7 +110,12 @@ export const sendMail = async (
|
||||
}
|
||||
const user_role = c.get("userRolePayload");
|
||||
const is_no_limit_send_balance = user_role && user_role === getStringValue(c.env.NO_LIMIT_SEND_ROLE);
|
||||
if (!is_no_limit_send_balance) {
|
||||
// no need find noLimitSendAddressList if is_no_limit_send_balance
|
||||
const noLimitSendAddressList = is_no_limit_send_balance ?
|
||||
[] : await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY) || [];
|
||||
const isNoLimitSendAddress = noLimitSendAddressList?.includes(address);
|
||||
const needCheckBalance = !is_no_limit_send_balance && !options?.isAdmin && !isNoLimitSendAddress;
|
||||
if (needCheckBalance) {
|
||||
// check permission
|
||||
const balance = await c.env.DB.prepare(
|
||||
`SELECT balance FROM address_sender
|
||||
@@ -158,7 +166,7 @@ export const sendMail = async (
|
||||
throw new Error("Please enable resend or verified address list")
|
||||
}
|
||||
// update balance
|
||||
if (!sendByVerifiedAddressList && !is_no_limit_send_balance) {
|
||||
if (!sendByVerifiedAddressList && needCheckBalance) {
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address_sender SET balance = balance - 1 where address = ?`
|
||||
|
||||
@@ -1,35 +1,12 @@
|
||||
import { Context } from "hono";
|
||||
import { HonoCustomType } from "../types";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { AdminWebhookSettings, WebhookMail } from "../models";
|
||||
import { AdminWebhookSettings, WebhookSettings } from "../models";
|
||||
import { getBooleanValue } from "../utils";
|
||||
import { commonParseMail } from "../common";
|
||||
|
||||
|
||||
class WebhookSettings {
|
||||
url: string = ''
|
||||
method: string = 'POST'
|
||||
headers: string = JSON.stringify({
|
||||
"Content-Type": "application/json"
|
||||
}, null, 2)
|
||||
body: string = JSON.stringify({
|
||||
"from": "${from}",
|
||||
"to": "${to}",
|
||||
"subject": "${subject}",
|
||||
"raw": "${raw}",
|
||||
"parsedText": "${parsedText}",
|
||||
"parsedHtml": "${parsedHtml}",
|
||||
}, null, 2)
|
||||
}
|
||||
import { commonParseMail, sendWebhook } from "../common";
|
||||
|
||||
|
||||
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
if (!c.env.KV) {
|
||||
return c.text("KV is not available", 400);
|
||||
}
|
||||
if (!getBooleanValue(c.env.ENABLE_WEBHOOK)) {
|
||||
return c.text("Webhook is disabled", 403);
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
|
||||
if (!adminSettings?.allowList.includes(address)) {
|
||||
@@ -55,73 +32,18 @@ async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response
|
||||
return c.json({ success: true })
|
||||
}
|
||||
|
||||
async function sendWebhook(settings: WebhookSettings, formatMap: WebhookMail): Promise<{ success: boolean, message?: string }> {
|
||||
// send webhook
|
||||
let body = settings.body;
|
||||
for (const key of Object.keys(formatMap)) {
|
||||
/* eslint-disable no-useless-escape */
|
||||
body = body.replace(
|
||||
new RegExp(`\\$\\{${key}\\}`, "g"),
|
||||
JSON.stringify(
|
||||
formatMap[key as keyof WebhookMail]
|
||||
).replace(/^"(.*)"$/, '\$1')
|
||||
);
|
||||
/* eslint-enable no-useless-escape */
|
||||
}
|
||||
const response = await fetch(settings.url, {
|
||||
method: settings.method,
|
||||
headers: JSON.parse(settings.headers),
|
||||
body: body
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.log("send webhook error", response.status, response.statusText);
|
||||
return { success: false, message: `send webhook error: ${response.status} ${response.statusText}` };
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function trigerWebhook(
|
||||
c: Context<HonoCustomType>,
|
||||
address: string,
|
||||
raw_mail: string
|
||||
): Promise<void> {
|
||||
if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) {
|
||||
return
|
||||
}
|
||||
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
|
||||
if (!adminSettings?.allowList.includes(address)) {
|
||||
return;
|
||||
}
|
||||
const settings = await c.env.KV.get<WebhookSettings>(
|
||||
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json"
|
||||
);
|
||||
if (!settings) {
|
||||
return;
|
||||
}
|
||||
const parsedEmail = await commonParseMail(raw_mail);
|
||||
const res = await sendWebhook(settings, {
|
||||
from: parsedEmail?.sender || "",
|
||||
to: address,
|
||||
subject: parsedEmail?.subject || "",
|
||||
raw: raw_mail,
|
||||
parsedText: parsedEmail?.text || "",
|
||||
parsedHtml: parsedEmail?.html || ""
|
||||
});
|
||||
if (!res.success) {
|
||||
console.log(res.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.req.json<WebhookSettings>();
|
||||
const { address } = c.get("jwtPayload");
|
||||
// random raw email
|
||||
const raw = await c.env.DB.prepare(
|
||||
`SELECT raw FROM raw_mails WHERE address = ? ORDER BY RANDOM() LIMIT 1`
|
||||
).bind(address).first<string>("raw");
|
||||
const { id: mailId, raw } = await c.env.DB.prepare(
|
||||
`SELECT id, raw FROM raw_mails WHERE address = ? ORDER BY RANDOM() LIMIT 1`
|
||||
).bind(address).first<{ id: string, raw: string }>() || {};
|
||||
|
||||
const parsedEmail = await commonParseMail(raw);
|
||||
const res = await sendWebhook(settings, {
|
||||
id: mailId || "0",
|
||||
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
|
||||
from: parsedEmail?.sender || "test@test.com",
|
||||
to: address,
|
||||
subject: parsedEmail?.subject || "test subject",
|
||||
|
||||
@@ -22,6 +22,8 @@ export class AdminWebhookSettings {
|
||||
}
|
||||
|
||||
export type WebhookMail = {
|
||||
id: string;
|
||||
url?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
@@ -119,3 +121,38 @@ export class UserInfo {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
}
|
||||
|
||||
export class WebhookSettings {
|
||||
enabled: boolean = false
|
||||
url: string = ''
|
||||
method: string = 'POST'
|
||||
headers: string = JSON.stringify({
|
||||
"Content-Type": "application/json"
|
||||
}, null, 2)
|
||||
body: string = JSON.stringify({
|
||||
"id": "${id}",
|
||||
"url": "${url}",
|
||||
"from": "${from}",
|
||||
"to": "${to}",
|
||||
"subject": "${subject}",
|
||||
"raw": "${raw}",
|
||||
"parsedText": "${parsedText}",
|
||||
"parsedHtml": "${parsedHtml}",
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
export type UserOauth2Settings = {
|
||||
name: string;
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
authorizationURL: string;
|
||||
accessTokenURL: string;
|
||||
accessTokenFormat: string;
|
||||
userInfoURL: string;
|
||||
redirectURL: string;
|
||||
logoutURL?: string;
|
||||
userEmailKey: string;
|
||||
scope: string;
|
||||
enableMailAllowList?: boolean | undefined;
|
||||
mailAllowList?: string[] | undefined;
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ export const tgUserNewAddress = async (
|
||||
if (blockList.some((item) => name.includes(item))) {
|
||||
throw Error(`Name[${name}]is blocked`);
|
||||
}
|
||||
const res = await newAddress(c,
|
||||
name || Math.random().toString(36).substring(2, 15),
|
||||
domain, true
|
||||
);
|
||||
const res = await newAddress(c, {
|
||||
name: name || Math.random().toString(36).substring(2, 15),
|
||||
domain,
|
||||
enablePrefix: true
|
||||
});
|
||||
// for mail push to telegram
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, res.jwt]));
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${res.address}`, userId.toString());
|
||||
|
||||
@@ -4,11 +4,12 @@ import { Telegraf, Context as TgContext, Markup } from "telegraf";
|
||||
import { callbackQuery } from "telegraf/filters";
|
||||
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { getDomains, getStringValue } from '../utils';
|
||||
import { getDomains, getJsonObjectValue, getStringValue } from '../utils';
|
||||
import { HonoCustomType } from "../types";
|
||||
import { TelegramSettings } from "./settings";
|
||||
import { bindTelegramAddress, deleteTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, unbindTelegramByAddress } from "./common";
|
||||
import { commonParseMail } from "../common";
|
||||
import { UserFromGetMe } from "telegraf/types";
|
||||
|
||||
|
||||
const COMMANDS = [
|
||||
@@ -44,6 +45,10 @@ const COMMANDS = [
|
||||
|
||||
export function newTelegramBot(c: Context<HonoCustomType>, token: string): Telegraf {
|
||||
const bot = new Telegraf(token);
|
||||
const botInfo = getJsonObjectValue<UserFromGetMe>(c.env.TG_BOT_INFO);
|
||||
if (botInfo) {
|
||||
bot.botInfo = botInfo;
|
||||
}
|
||||
|
||||
bot.use(async (ctx, next) => {
|
||||
// check if in private chat
|
||||
|
||||
8
worker/src/types.d.ts
vendored
@@ -15,6 +15,8 @@ export type Bindings = {
|
||||
TITLE: string | undefined
|
||||
ANNOUNCEMENT: string | undefined | null
|
||||
PREFIX: string | undefined
|
||||
ADDRESS_CHECK_REGEX: string | undefined
|
||||
ADDRESS_REGEX: string | undefined
|
||||
MIN_ADDRESS_LEN: string | number | undefined
|
||||
MAX_ADDRESS_LEN: string | number | undefined
|
||||
DEFAULT_DOMAINS: string | string[] | undefined
|
||||
@@ -40,6 +42,8 @@ export type Bindings = {
|
||||
DISABLE_SHOW_GITHUB: string | boolean | undefined
|
||||
FORWARD_ADDRESS_LIST: string | string[] | undefined
|
||||
|
||||
ENABLE_CHECK_JUNK_MAIL: string | boolean | undefined
|
||||
|
||||
// s3 config
|
||||
S3_ENDPOINT: string | undefined
|
||||
S3_ACCESS_KEY_ID: string | undefined
|
||||
@@ -58,6 +62,10 @@ export type Bindings = {
|
||||
// telegram config
|
||||
TELEGRAM_BOT_TOKEN: string
|
||||
TG_MAX_ADDRESS: number | undefined
|
||||
TG_BOT_INFO: string | object | undefined
|
||||
|
||||
// webhook config
|
||||
FRONTEND_URL: string | undefined
|
||||
}
|
||||
|
||||
type JwtPayload = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HonoCustomType } from '../types';
|
||||
import { UserSettings } from "../models";
|
||||
import { getJsonSetting } from "../utils"
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { unbindTelegramByAddress } from '../telegram_api/common';
|
||||
|
||||
export default {
|
||||
bind: async (c: Context<HonoCustomType>) => {
|
||||
@@ -89,7 +90,7 @@ export default {
|
||||
return c.text("Failed to unbind", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
return c.text("Invalid address token", 400)
|
||||
return c.text("Failed to unbind", 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
@@ -139,4 +140,92 @@ export default {
|
||||
jwt: jwt
|
||||
})
|
||||
},
|
||||
transferAddress: async (c: Context<HonoCustomType>) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { address_id, target_user_email } = await c.req.json();
|
||||
// check if address exists
|
||||
const address = await c.env.DB.prepare(
|
||||
`SELECT name FROM address where id = ?`
|
||||
).bind(address_id).first<string>("name");
|
||||
if (!address) {
|
||||
return c.text("Address not found", 400)
|
||||
}
|
||||
// check if user exists
|
||||
const db_user_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM users where id = ?`
|
||||
).bind(user_id).first("id");
|
||||
if (!db_user_id) {
|
||||
return c.text("User not found", 400)
|
||||
}
|
||||
// check if target user exists
|
||||
const target_user_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM users where user_email = ?`
|
||||
).bind(target_user_email).first("id");
|
||||
if (!target_user_id) {
|
||||
return c.text("Target user not found", 400)
|
||||
}
|
||||
// check target user binded address count
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value);
|
||||
if (settings.maxAddressCount > 0) {
|
||||
const { count } = await c.env.DB.prepare(
|
||||
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
|
||||
).bind(target_user_id).first<{ count: number }>() || { count: 0 };
|
||||
if (count >= settings.maxAddressCount) {
|
||||
return c.text("Target User Max address count reached", 400)
|
||||
}
|
||||
}
|
||||
// check if binded
|
||||
const db_user_address_id = await c.env.DB.prepare(
|
||||
`SELECT user_id FROM users_address where user_id = ? and address_id = ?`
|
||||
).bind(user_id, address_id).first("user_id");
|
||||
if (!db_user_address_id) return c.text("Address not binded", 400)
|
||||
// unbind telegram address
|
||||
await unbindTelegramByAddress(c, address);
|
||||
// unbind user address
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM users_address where user_id = ? and address_id = ?`
|
||||
).bind(user_id, address_id).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to unbind", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
return c.text("Failed to unbind user", 500)
|
||||
}
|
||||
// delete address
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE id = ? `
|
||||
).bind(address_id).run();
|
||||
// new address
|
||||
const { success: newAddressSuccess } = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name) VALUES(?)`
|
||||
).bind(address).run();
|
||||
if (!newAddressSuccess) {
|
||||
throw new Error("Failed to create address")
|
||||
}
|
||||
// find new address id
|
||||
let new_address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address WHERE name = ?`
|
||||
).bind(address).first<number | null | undefined>("id");
|
||||
if (!new_address_id) {
|
||||
throw new Error("Failed to find new address id")
|
||||
}
|
||||
// bind
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO users_address (user_id, address_id) VALUES (?, ?)`
|
||||
).bind(target_user_id, new_address_id).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to bind", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
if (error.message && error.message.includes("UNIQUE")) {
|
||||
return c.text("Address already binded, please unbind first", 400)
|
||||
}
|
||||
return c.text("Failed to bind", 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import settings from './settings';
|
||||
import user from './user';
|
||||
import bind_address from './bind_address';
|
||||
import passkey from './passkey';
|
||||
import oauth2 from './oauth2';
|
||||
|
||||
export const api = new Hono<HonoCustomType>();
|
||||
|
||||
@@ -17,11 +18,16 @@ api.post('/user_api/login', user.login);
|
||||
api.post('/user_api/verify_code', user.verifyCode);
|
||||
api.post('/user_api/register', user.register);
|
||||
|
||||
// oauth2 api
|
||||
api.get('/user_api/oauth2/login_url', oauth2.getOauth2LoginUrl);
|
||||
api.post('/user_api/oauth2/callback', oauth2.oauth2Login);
|
||||
|
||||
// bind address api
|
||||
api.get('/user_api/bind_address', bind_address.getBindedAddresses);
|
||||
api.post('/user_api/bind_address', bind_address.bind);
|
||||
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/transfer_address', bind_address.transferAddress);
|
||||
|
||||
// passkey api
|
||||
api.get('/user_api/passkey', passkey.getPassKeys);
|
||||
|
||||
105
worker/src/user_api/oauth2.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { HonoCustomType } from '../types';
|
||||
import { getJsonSetting } from '../utils';
|
||||
import { UserOauth2Settings } from '../models';
|
||||
import { CONSTANTS } from '../constants';
|
||||
|
||||
|
||||
export default {
|
||||
getOauth2LoginUrl: async (c: Context<HonoCustomType>) => {
|
||||
const settings = await getJsonSetting<UserOauth2Settings[]>(c, CONSTANTS.OAUTH2_SETTINGS_KEY);
|
||||
const { clientID, state } = c.req.query();
|
||||
const setting = settings?.find(s => s.clientID === clientID);
|
||||
if (!setting) {
|
||||
return c.text("Client not found", 400);
|
||||
}
|
||||
const url = `${setting.authorizationURL}?client_id=${setting.clientID}&response_type=code&redirect_uri=${setting.redirectURL}&scope=${setting.scope}&state=${state}`
|
||||
return c.json({ url });
|
||||
},
|
||||
oauth2Login: async (c: Context<HonoCustomType>) => {
|
||||
const { clientID, code } = await c.req.json<{ clientID?: string, code?: string }>();
|
||||
if (!clientID || !code) {
|
||||
return c.text("clientID or code is missing", 400);
|
||||
}
|
||||
const settings = await getJsonSetting<UserOauth2Settings[]>(c, CONSTANTS.OAUTH2_SETTINGS_KEY);
|
||||
const setting = settings?.find(s => s.clientID === clientID);
|
||||
if (!setting) {
|
||||
return c.text("Client not found", 400);
|
||||
}
|
||||
const params = {
|
||||
code,
|
||||
client_id: setting.clientID,
|
||||
client_secret: setting.clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
}
|
||||
const res = await fetch(setting.accessTokenURL, {
|
||||
method: 'POST',
|
||||
body: setting.accessTokenFormat === 'json'
|
||||
? JSON.stringify(params) :
|
||||
new URLSearchParams(params).toString(),
|
||||
headers: {
|
||||
'Content-Type': setting.accessTokenFormat === 'json'
|
||||
? 'application/json'
|
||||
: 'application/x-www-form-urlencoded',
|
||||
"Accept": "application/json"
|
||||
}
|
||||
})
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to get access token: ${res.status} ${res.statusText} ${await res.text()}`)
|
||||
return c.text("Failed to get access token", 400);
|
||||
}
|
||||
const resJson = await res.json();
|
||||
const { access_token, token_type } = resJson as { access_token: string, token_type?: string };
|
||||
const user = await fetch(setting.userInfoURL, {
|
||||
headers: {
|
||||
"Authorization": `${token_type || 'Bearer'} ${access_token}`,
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "Cloudflare Workers"
|
||||
}
|
||||
})
|
||||
if (!user.ok) {
|
||||
console.error(`Failed to get user info: ${res.status} ${res.statusText} ${await res.text()}`)
|
||||
return c.text("Failed to get user info", 400);
|
||||
}
|
||||
const userInfo = await user.json()
|
||||
const { [setting.userEmailKey]: email } = userInfo as { [key: string]: string };
|
||||
if (!email) {
|
||||
return c.text("Failed to get user email", 400);
|
||||
}
|
||||
// check email in mail allow list
|
||||
const mailDomain = email.split("@")[1];
|
||||
if (setting.enableMailAllowList && !setting.mailAllowList?.includes(mailDomain)) {
|
||||
return c.text(`Mail domain must in ${JSON.stringify(setting.mailAllowList, null, 2)}`, 400)
|
||||
}
|
||||
// insert or update user
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO users (user_email, password, user_info)`
|
||||
+ ` VALUES (?, '', ?)`
|
||||
+ ` ON CONFLICT(user_email) DO UPDATE SET updated_at = datetime('now')`
|
||||
).bind(
|
||||
email, JSON.stringify(userInfo)
|
||||
).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to register", 500)
|
||||
}
|
||||
const { id: user_id } = await c.env.DB.prepare(
|
||||
`SELECT id FROM users where user_email = ?`
|
||||
).bind(email).first() || {};
|
||||
if (!user_id) {
|
||||
return c.text("User not found", 400)
|
||||
}
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
user_email: email,
|
||||
user_id: user_id,
|
||||
// 90 days expire in seconds
|
||||
exp: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return c.json({
|
||||
jwt: jwt
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -193,8 +193,8 @@ export default {
|
||||
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,
|
||||
// 90 days expire in seconds
|
||||
exp: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return c.json({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Context } from "hono";
|
||||
|
||||
import { HonoCustomType } from "../types";
|
||||
import { UserSettings } from "../models";
|
||||
import { UserOauth2Settings, UserSettings } from "../models";
|
||||
import { getJsonSetting, getUserRoles } from "../utils"
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { commonGetUserRole } from "../common";
|
||||
@@ -11,9 +11,22 @@ export default {
|
||||
openSettings: async (c: Context<HonoCustomType>) => {
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value);
|
||||
const oauth2ClientIDs = [] as { clientID: string, name: string }[];
|
||||
try {
|
||||
const oauth2Settings = await getJsonSetting<UserOauth2Settings[]>(c, CONSTANTS.OAUTH2_SETTINGS_KEY);
|
||||
oauth2ClientIDs.push(
|
||||
...oauth2Settings?.map(s => ({
|
||||
clientID: s.clientID,
|
||||
name: s.name
|
||||
})) || []
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to get oauth2 settings", e);
|
||||
}
|
||||
return c.json({
|
||||
enable: settings.enable,
|
||||
enableMailVerify: settings.enableMailVerify,
|
||||
oauth2ClientIDs: oauth2ClientIDs,
|
||||
})
|
||||
},
|
||||
settings: async (c: Context<HonoCustomType>) => {
|
||||
@@ -31,10 +44,10 @@ export default {
|
||||
&&
|
||||
c.env.ADMIN_USER_ROLE === user_role?.role
|
||||
);
|
||||
const access_token = is_admin ? await Jwt.sign({
|
||||
const access_token = user_role?.role ? await Jwt.sign({
|
||||
user_email: user.user_email,
|
||||
user_id: user.user_id,
|
||||
user_role: user_role?.role,
|
||||
user_role: user_role.role,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
// 1 hour
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
|
||||
@@ -165,8 +165,8 @@ export default {
|
||||
const jwt = await Jwt.sign({
|
||||
user_email: email,
|
||||
user_id: user_id,
|
||||
// 30 days expire in seconds
|
||||
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
||||
// 90 days expire in seconds
|
||||
exp: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return c.json({
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import { Context } from "hono";
|
||||
import { createMimeMessage } from "mimetext";
|
||||
import { HonoCustomType, UserRole } from "./types";
|
||||
import { User } from "telegraf/types";
|
||||
|
||||
export const getJsonSetting = async (
|
||||
export const getJsonObjectValue = <T = any>(
|
||||
value: string | any
|
||||
): T | null => {
|
||||
if (value == undefined || value == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return value as T;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (e) {
|
||||
console.error(`GetJsonValue: Failed to parse ${value}`, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getJsonSetting = async <T = any>(
|
||||
c: Context<HonoCustomType>, key: string
|
||||
): Promise<any> => {
|
||||
): Promise<T | null> => {
|
||||
const value = await getSetting(c, key);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
return JSON.parse(value) as T;
|
||||
} catch (e) {
|
||||
console.error(`GetJsonSetting: Failed to parse ${key}`, e);
|
||||
}
|
||||
@@ -98,9 +117,11 @@ export const getStringArray = (
|
||||
}
|
||||
|
||||
export const getDefaultDomains = (c: Context<HonoCustomType>): string[] => {
|
||||
if (c.env.DEFAULT_DOMAINS == undefined || c.env.DEFAULT_DOMAINS == null) {
|
||||
return getDomains(c);
|
||||
}
|
||||
const domains = getStringArray(c.env.DEFAULT_DOMAINS);
|
||||
if (domains && domains.length > 0) return domains;
|
||||
return getDomains(c);
|
||||
return domains || getDomains(c);
|
||||
}
|
||||
|
||||
export const getDomains = (c: Context<HonoCustomType>): string[] => {
|
||||
|
||||
@@ -42,9 +42,11 @@ app.use('/*', async (c, next) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
// webhook check
|
||||
if (
|
||||
c.req.path.startsWith("/api/webhook")
|
||||
|| c.req.path.startsWith("/admin/webhook")
|
||||
|| c.req.path.startsWith("/admin/mail_webhook")
|
||||
) {
|
||||
if (!c.env.KV) {
|
||||
return c.text("KV is not available", 400);
|
||||
@@ -53,6 +55,12 @@ app.use('/*', async (c, next) => {
|
||||
return c.text("Webhook is disabled", 403);
|
||||
}
|
||||
}
|
||||
if (!c.env.DB) {
|
||||
return c.text("DB is not available", 400);
|
||||
}
|
||||
if (!c.env.JWT_SECRET) {
|
||||
return c.text("JWT_SECRET is not set", 400);
|
||||
}
|
||||
await next()
|
||||
});
|
||||
|
||||
@@ -125,6 +133,7 @@ app.use('/user_api/*', async (c, next) => {
|
||||
|| c.req.path.startsWith("/user_api/login")
|
||||
|| c.req.path.startsWith("/user_api/verify_code")
|
||||
|| c.req.path.startsWith("/user_api/passkey/authenticate_")
|
||||
|| c.req.path.startsWith("/user_api/oauth2")
|
||||
) {
|
||||
await next();
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2023-12-01"
|
||||
node_compat = true
|
||||
compatibility_date = "2024-09-23"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
# if you want use custom_domain, you need to add routes
|
||||
# routes = [
|
||||
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
|
||||
@@ -19,6 +19,10 @@ node_compat = true
|
||||
# TITLE = "Custom Title" # custom title
|
||||
# ANNOUNCEMENT = "Custom Announcement"
|
||||
PREFIX = "tmp"
|
||||
# address check REGEX, if not set, will not check
|
||||
# ADDRESS_CHECK_REGEX = "^(?!.*admin).*"
|
||||
# address name replace REGEX, if not set, the default is [^a-z0-9]
|
||||
# ADDRESS_REGEX = "[^a-z0-9]"
|
||||
# (min, max) length of the adderss, if not set, the default is (1, 30)
|
||||
# MIN_ADDRESS_LEN = 1
|
||||
# MAX_ADDRESS_LEN = 30
|
||||
@@ -62,8 +66,14 @@ ENABLE_AUTO_REPLY = false
|
||||
# CF_TURNSTILE_SECRET_KEY = ""
|
||||
# telegram bot
|
||||
# TG_MAX_ADDRESS = 5
|
||||
# telegram bot info, predefined bot info can reduce latency of the webhook
|
||||
# TG_BOT_INFO = "{}"
|
||||
# global forward address list, if set, all emails will be forwarded to these addresses
|
||||
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
|
||||
# Frontend URL
|
||||
# FRONTEND_URL = "https://xxxx.xxx"
|
||||
# Enable check junk mail
|
||||
# ENABLE_CHECK_JUNK_MAIL = false
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
|
||||