Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c964d77a59 | ||
|
|
8a03d3e57f | ||
|
|
6caba7c863 | ||
|
|
43e5bdc764 | ||
|
|
7bec0daba4 | ||
|
|
13e5adef17 | ||
|
|
440238133e | ||
|
|
4a881e2d2b | ||
|
|
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 |
3
.github/workflows/backend_deploy.yaml
vendored
@@ -1,6 +1,9 @@
|
||||
name: Deploy Backend Production
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Upstream Sync]
|
||||
types: [completed]
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
15
.github/workflows/frontend_deploy.yaml
vendored
@@ -1,9 +1,10 @@
|
||||
name: Deploy Frontend
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Upstream Sync]
|
||||
types: [completed]
|
||||
push:
|
||||
paths:
|
||||
- "frontend/**"
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
@@ -38,12 +39,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 +52,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 }}"
|
||||
|
||||
25
.github/workflows/sync.yaml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Upstream Sync
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync_latest_from_upstream:
|
||||
name: Sync latest commits from upstream repo
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync upstream changes
|
||||
id: sync
|
||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||
with:
|
||||
upstream_sync_repo: dreamhunter2333/cloudflare_temp_email
|
||||
upstream_sync_branch: main
|
||||
target_sync_branch: main
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||
test_mode: false
|
||||
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"
|
||||
]
|
||||
}
|
||||
58
CHANGELOG.md
@@ -1,6 +1,62 @@
|
||||
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
|
||||
# CHANGE LOG
|
||||
|
||||
## main(v0.8.3)
|
||||
|
||||
- feat: |Github Action| 增加自动更新并部署功能
|
||||
- feat: |UI| admin 用户设置,支持 oauth2 配置的删除
|
||||
- feat: 增加垃圾邮件检测必须通过的列表 `JUNK_MAIL_FORCE_PASS_LIST` 配置
|
||||
|
||||
## 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
|
||||
@@ -10,7 +66,7 @@
|
||||
### Changes
|
||||
|
||||
- fix: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 加载失败的问题
|
||||
- feat: worker 增加 `# ADDRESS_REGEX = "[^a-z.0-9]"` 配置, 用于配置地址的正则表达式,如果不设置,默认为 [^a-z0-9], 需谨慎使用, 有些符号可能导致无法收件
|
||||
- feat: worker 增加 `# ADDRESS_REGEX = "[^a-z.0-9]"` 配置, 替换非法符号的正则表达式,如果不设置,默认为 [^a-z0-9], 需谨慎使用, 有些符号可能导致无法收件
|
||||
- feat: worker 优化 webhook 逻辑, 支持 admin 配置全局 webhook, 添加 `message pusher` 集成示例
|
||||
|
||||
## v0.7.1
|
||||
|
||||
@@ -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.2",
|
||||
"version": "0.8.3",
|
||||
"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,33 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@unhead/vue": "^1.9.16",
|
||||
"@vicons/material": "^0.12.0",
|
||||
"@vueuse/core": "^10.11.1",
|
||||
"@unhead/vue": "^1.11.14",
|
||||
"@vueuse/core": "^12.2.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.7.3",
|
||||
"axios": "^1.7.9",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.1.8",
|
||||
"naive-ui": "^2.39.0",
|
||||
"postal-mime": "^2.2.7",
|
||||
"mail-parser-wasm": "^0.2.0",
|
||||
"naive-ui": "^2.40.4",
|
||||
"postal-mime": "^2.3.2",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.4.37",
|
||||
"vue": "^3.5.13",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^9.13.1",
|
||||
"vue-router": "^4.4.3"
|
||||
"vue-i18n": "^11.0.1",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.12.0",
|
||||
"@vitejs/plugin-vue": "^5.1.2",
|
||||
"unplugin-auto-import": "^0.18.2",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-pwa": "^0.19.8",
|
||||
"@vicons/fa": "^0.13.0",
|
||||
"@vicons/material": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"unplugin-auto-import": "^0.19.0",
|
||||
"unplugin-vue-components": "^0.28.0",
|
||||
"vite": "^6.0.6",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vite-plugin-top-level-await": "^1.4.4",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"workbox-window": "^7.1.0",
|
||||
"wrangler": "^3.70.0"
|
||||
"vite-plugin-wasm": "^3.4.1",
|
||||
"workbox-build": "^7.3.0",
|
||||
"workbox-window": "^7.3.0",
|
||||
"wrangler": "^3.99.0"
|
||||
}
|
||||
}
|
||||
|
||||
4472
frontend/pnpm-lock.yaml
generated
@@ -96,6 +96,8 @@ const getOpenSettings = async (message) => {
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
openSettings.value.fetched = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,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()
|
||||
@@ -49,7 +50,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
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()
|
||||
@@ -30,7 +31,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
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 }}
|
||||
|
||||
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,6 +11,7 @@ export const useGlobalState = createGlobalState(
|
||||
const loading = ref(false);
|
||||
const announcement = useLocalStorage('announcement', '');
|
||||
const openSettings = ref({
|
||||
fetched: false,
|
||||
title: '',
|
||||
announcement: '',
|
||||
prefix: '',
|
||||
@@ -41,7 +45,7 @@ export const useGlobalState = createGlobalState(
|
||||
name: '',
|
||||
}
|
||||
});
|
||||
const sendMailModel = useStorage('sendMailModel', {
|
||||
const sendMailModel = useSessionStorage('sendMailModel', {
|
||||
fromName: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
@@ -55,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} */
|
||||
@@ -91,6 +99,8 @@ export const useGlobalState = createGlobalState(
|
||||
);
|
||||
const telegramApp = ref(window.Telegram?.WebApp || {});
|
||||
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
|
||||
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
|
||||
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
|
||||
return {
|
||||
isDark,
|
||||
toggleDark,
|
||||
@@ -118,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,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
@@ -13,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';
|
||||
@@ -21,6 +22,8 @@ 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,
|
||||
@@ -30,6 +33,7 @@ const message = useMessage()
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
adminAuth.value = tmpAdminAuth.value;
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
@@ -42,12 +46,15 @@ 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',
|
||||
@@ -55,6 +62,7 @@ const { t } = useI18n({
|
||||
webhookSettings: 'Webhook Settings',
|
||||
statistics: 'Statistics',
|
||||
maintenance: 'Maintenance',
|
||||
workerconfig: 'Worker Config',
|
||||
appearance: 'Appearance',
|
||||
about: 'About',
|
||||
ok: 'OK',
|
||||
@@ -64,12 +72,15 @@ const { t } = useI18n({
|
||||
accessHeader: 'Admin 密码',
|
||||
accessTip: '请输入 Admin 密码',
|
||||
mails: '邮件',
|
||||
sendMail: '发送邮件',
|
||||
qucickSetup: '快速设置',
|
||||
account: '账号',
|
||||
account_create: '创建账号',
|
||||
account_settings: '账号设置',
|
||||
user: '用户',
|
||||
user_management: '用户管理',
|
||||
user_settings: '用户设置',
|
||||
userOauth2Settings: 'Oauth2 设置',
|
||||
unknow: '无收件人邮件',
|
||||
senderAccess: '发件权限控制',
|
||||
sendBox: '发件箱',
|
||||
@@ -77,6 +88,7 @@ const { t } = useI18n({
|
||||
webhookSettings: 'Webhook 设置',
|
||||
statistics: '统计',
|
||||
maintenance: '维护',
|
||||
workerconfig: 'Worker 配置',
|
||||
appearance: '外观',
|
||||
about: '关于',
|
||||
ok: '确定',
|
||||
@@ -85,11 +97,10 @@ const { t } = useI18n({
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
})
|
||||
@@ -97,10 +108,10 @@ onMounted(async () => {
|
||||
|
||||
<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') }}
|
||||
@@ -108,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>
|
||||
@@ -128,31 +152,37 @@ onMounted(async () => {
|
||||
</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>
|
||||
@@ -160,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: {
|
||||
@@ -30,6 +32,7 @@ const { t } = useI18n({
|
||||
s3Attachment: 'S3 Attachment',
|
||||
saveToS3Success: 'save to s3 success',
|
||||
webhookSettings: 'Webhook Settings',
|
||||
query: 'Query',
|
||||
},
|
||||
zh: {
|
||||
mailbox: '收件箱',
|
||||
@@ -41,11 +44,17 @@ const { t } = useI18n({
|
||||
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}`);
|
||||
};
|
||||
|
||||
@@ -80,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>
|
||||
@@ -87,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"
|
||||
|
||||
@@ -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')" />
|
||||
|
||||
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>
|
||||
|
||||
266
frontend/src/views/admin/UserOauth2Settings.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<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',
|
||||
delete: 'Delete',
|
||||
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: '保存',
|
||||
delete: '删除',
|
||||
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-divider />
|
||||
<n-collapse default-expanded-names="1" accordion :trigger-areas="['main', 'arrow']">
|
||||
<n-collapse-item v-for="(item, index) in userOauth2Settings" :key="index" :title="item.name">
|
||||
<template #header-extra>
|
||||
<n-popconfirm @positive-click="userOauth2Settings.splice(index, 1)">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error">
|
||||
{{ t('delete') }}
|
||||
</n-button>
|
||||
</template>
|
||||
{{ t('delete') }}
|
||||
</n-popconfirm>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
@@ -124,6 +124,10 @@ const generateName = async () => {
|
||||
.replace(/\.{2,}/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 {
|
||||
@@ -193,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>
|
||||
|
||||
@@ -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 @@
|
||||
[package]
|
||||
name = "mail-parser-wasm"
|
||||
version = "0.1.8"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
description = "A simple mail parser for wasm"
|
||||
license = "MIT"
|
||||
@@ -9,5 +9,5 @@ license = "MIT"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
mail-parser = "0.9.3"
|
||||
wasm-bindgen = "0.2.92"
|
||||
mail-parser = "0.9.4"
|
||||
wasm-bindgen = "0.2.99"
|
||||
|
||||
@@ -35,10 +35,31 @@ impl AttachmentResult {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[wasm_bindgen]
|
||||
pub struct MessageHeader {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl MessageHeader {
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn key(&self) -> String {
|
||||
self.key.clone()
|
||||
}
|
||||
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn value(&self) -> String {
|
||||
self.value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct MessageResult {
|
||||
sender: String,
|
||||
subject: String,
|
||||
headers: Vec<MessageHeader>,
|
||||
body_html: String,
|
||||
text: String,
|
||||
attachments: Vec<AttachmentResult>,
|
||||
@@ -56,6 +77,11 @@ impl MessageResult {
|
||||
self.subject.clone()
|
||||
}
|
||||
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn headers(&self) -> Vec<MessageHeader> {
|
||||
self.headers.clone()
|
||||
}
|
||||
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn body_html(&self) -> String {
|
||||
self.body_html.clone()
|
||||
@@ -119,6 +145,7 @@ pub fn parse_message(raw_message: &str) -> MessageResult {
|
||||
return MessageResult {
|
||||
sender: String::new(),
|
||||
subject: String::new(),
|
||||
headers: Vec::new(),
|
||||
body_html: String::new(),
|
||||
text: String::new(),
|
||||
attachments: Vec::new(),
|
||||
@@ -146,6 +173,14 @@ pub fn parse_message(raw_message: &str) -> MessageResult {
|
||||
.subject()
|
||||
.map(|subject| subject.to_owned())
|
||||
.unwrap_or(String::new()),
|
||||
headers: message
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|header| MessageHeader {
|
||||
key: header.name().to_owned(),
|
||||
value: header.value().as_text().unwrap_or("").to_owned(),
|
||||
})
|
||||
.collect(),
|
||||
body_html: message
|
||||
.body_html(0)
|
||||
.map(|html| html.into_owned())
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/dreamhunter2333/cloudflare_temp_email",
|
||||
"directory": "mail-parser-wasm"
|
||||
},
|
||||
"version": "0.1.8",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"mail_parser_wasm_bg.wasm",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp-email-pages",
|
||||
"version": "1.0.0",
|
||||
"version": "0.8.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,6 +11,6 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"wrangler": "^3.62.0"
|
||||
"wrangler": "^3.99.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' },
|
||||
@@ -137,11 +137,12 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
{ 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,7 +78,9 @@ 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 name REGEX, if not set, the default is [^a-z0-9]
|
||||
# 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
|
||||
@@ -122,8 +124,16 @@ 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
|
||||
# junk mail force check pass list, if no status or status is not pass, will be marked as junk mail
|
||||
# JUNK_MAIL_FORCE_PASS_LIST = ["spf", "dkim", "dmarc"]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB 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,7 +47,9 @@ node_compat = true
|
||||
PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串
|
||||
# (min, max) adderss的长度,如果不设置,默认为(1, 30)
|
||||
# ANNOUNCEMENT = "Custom Announcement" # 自定义公告
|
||||
# address name 的正则表达式,如果不设置,默认为 [^a-z0-9], 需谨慎使用, 有些符号可能导致无法收件
|
||||
# address name 的正则表达式, 只用于检查,符合条件将通过检查
|
||||
# ADDRESS_CHECK_REGEX = "^(?!.*admin).*"
|
||||
# address name 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 [^a-z0-9], 需谨慎使用, 有些符号可能导致无法收件
|
||||
# ADDRESS_REGEX = "[^a-z0-9]"
|
||||
# MIN_ADDRESS_LEN = 1
|
||||
# MAX_ADDRESS_LEN = 30
|
||||
@@ -93,8 +96,16 @@ 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
|
||||
# 垃圾邮件检查配置, 任何一项不存在或者不通过则被判定为垃圾邮件
|
||||
# JUNK_MAIL_FORCE_PASS_LIST = ["spf", "dkim", "dmarc"]
|
||||
|
||||
# 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`
|
||||
|
||||

|
||||
|
||||
@@ -32,6 +32,7 @@ export const commonParseMail = async (raw_mail: string | undefined | null): Prom
|
||||
sender: parsedEmail.sender || "",
|
||||
subject: parsedEmail.subject || "",
|
||||
text: parsedEmail.text || "",
|
||||
headers: parsedEmail.headers || [],
|
||||
html: parsedEmail.body_html || "",
|
||||
};
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# 配置子域名邮箱
|
||||
|
||||
::: warning
|
||||
::: warning 注意
|
||||
子域名邮箱发送邮件可能无法发送邮件,建议使用主域名邮箱发送邮件,子域名邮箱仅用于接收邮件。
|
||||
|
||||
mail channel 已不被支持,下面参考中仅限收件部分。
|
||||
:::
|
||||
|
||||
参考
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 配置 Telegram Bot
|
||||
|
||||
::: warning 注意
|
||||
worker 默认的 `worker.dev` 域名的证书是不被 telegram 支持的,配置 Telegram Bot 请使用自定义域名
|
||||
:::
|
||||
|
||||
> [!NOTE]
|
||||
> 如果要使用 Telegram Bot, 请先绑定 `KV`
|
||||
>
|
||||
@@ -9,9 +13,14 @@
|
||||
|
||||
请先创建一个 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
|
||||
```
|
||||
|
||||
@@ -25,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
|
||||
@@ -33,8 +57,6 @@ cp .env.example .env.prod
|
||||
pnpm run deploy:telegram --project-name=<你的项目名称>
|
||||
```
|
||||
|
||||
部署完成后,请在 admin 后台的 `设置` -> `电报小程序` 页面 `电报小程序 URL`。
|
||||
|
||||
请在 `@BotFather` 处执行 `/setmenubutton`,然后输入你的网页地址,设置左下角的 `Open App` 按钮。
|
||||
|
||||
你也可以在 `@BotFather` 处执行 `/newapp` 新建 app 来获得 mini app 的链接
|
||||
- 部署完成后,请在 admin 后台的 `设置` -> `电报小程序` 页面 `电报小程序 URL` 中填写网页 URL。
|
||||
- 请在 `@BotFather` 处执行 `/setmenubutton`,然后输入你的网页地址,设置左下角的 `Open App` 按钮。
|
||||
- 请在 `@BotFather` 处执行 `/newapp` 新建 app 来注册 mini app。
|
||||
|
||||
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
|
||||
|
||||

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

|
||||
@@ -3,7 +3,7 @@
|
||||
> [!NOTE]
|
||||
> 如果要使用 webhook,请先绑定 `KV` 并且 `worker` 变量配置 `ENABLE_WEBHOOK = true`
|
||||
>
|
||||
> 如果你想 webhook 的解析邮件能力更强,参考 [配置 worker 使用 wasm 解析邮件](feature/mail_parser_wasm_worker)
|
||||
> 如果你想 webhook 的解析邮件能力更强,参考 [配置 worker 使用 wasm 解析邮件](/zh/guide/feature/mail_parser_wasm_worker)
|
||||
|
||||
## 前提条件
|
||||
|
||||
@@ -25,3 +25,20 @@
|
||||
## 某个邮箱配置 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,23 +1,32 @@
|
||||
# 通过 Github Actions 部署
|
||||
|
||||
::: warning
|
||||
::: warning 注意
|
||||
目前只支持 worker 和 pages 的部署,D1 数据库以及 Email 部分请参考 [UI/CLI 部署](/)。
|
||||
有问题请通过 `Github Issues` 反馈,感谢。
|
||||
自动更新不会执行 sql 文件,需要手动执行。
|
||||
:::
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/dreamhunter2333/cloudflare_temp_email)
|
||||
|
||||
## 部署步骤
|
||||
|
||||
1. 点击按钮 fork 本仓库 或者直接 fork 本仓库
|
||||
|
||||
2. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production` 和 `Deploy Frontend`,点击 `enable workflow` 启用 `workflow`
|
||||
|
||||
3. 然后在仓库页面 `Settings` -> `Secrets and variables` -> `Actions` -> `Repository secrets`, 添加以下 `secrets`:
|
||||
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare 账户 ID, [参考文档](https://developers.cloudflare.com/workers/wrangler/ci-cd/#cloudflare-account-id)
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token, [参考文档](https://developers.cloudflare.com/workers/wrangler/ci-cd/#api-token)
|
||||
- `BACKEND_TOML`: 后端配置文件,[参考此处](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件)
|
||||
- `FRONTEND_ENV`: 前端配置文件,请复制 `frontend/.env.example` 的内容,[并参考此处修改](/zh/guide/cli/pages.html)
|
||||
- `FRONTEND_NAME`: 你在 Cloudflare Pages 创建的项目名称,可通过 [用户界面](https://temp-mail-docs.awsl.uk/zh/guide/ui/pages.html) 或者 [命令行](https://temp-mail-docs.awsl.uk/zh/guide/cli/pages.html) 创建
|
||||
- `FRONTEND_BRANCH`: (可选) pages 部署的分支,可不配置,默认 `production`
|
||||
- `TG_FRONTEND_NAME`: (可选) 你在 Cloudflare Pages 创建的项目名称,同 `FRONTEND_NAME`,如果需要 Telegram Mini App 功能,请填写
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare 账户 ID, [参考文档](https://developers.cloudflare.com/workers/wrangler/ci-cd/#cloudflare-account-id)
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token, [参考文档](https://developers.cloudflare.com/workers/wrangler/ci-cd/#api-token)
|
||||
- `BACKEND_TOML`: 后端配置文件,[参考此处](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件)
|
||||
- `FRONTEND_ENV`: 前端配置文件,请复制 `frontend/.env.example` 的内容,[并参考此处修改](/zh/guide/cli/pages.html)
|
||||
- `FRONTEND_NAME`: 你在 Cloudflare Pages 创建的项目名称,可通过 [用户界面](https://temp-mail-docs.awsl.uk/zh/guide/ui/pages.html) 或者 [命令行](https://temp-mail-docs.awsl.uk/zh/guide/cli/pages.html) 创建
|
||||
- `FRONTEND_BRANCH`: (可选) pages 部署的分支,可不配置,默认 `production`
|
||||
- `TG_FRONTEND_NAME`: (可选) 你在 Cloudflare Pages 创建的项目名称,同 `FRONTEND_NAME`,如果需要 Telegram Mini App 功能,请填写
|
||||
|
||||
1. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production` 和 `Deploy Frontend`,点击 `Run workflow` 选择分支手动部署
|
||||
4. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production` 和 `Deploy Frontend`,点击 `Run workflow` 选择分支手动部署
|
||||
|
||||
## 如何配置自动更新
|
||||
|
||||
1. 打开仓库的 `Actions` 页面,找到 `Upstream Sync`,点击 `enable workflow` 启用 `workflow`
|
||||
2. 如果 `Upstream Sync` 运行失败,到仓库主页点击 `Sync` 手动同步即可
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
请查看通过 [命令行部署](/zh/guide/cli/pre-requisite) 或者 [用户界面部署](/zh/guide/ui/d1)
|
||||
|
||||
## 网友提供的详细的小白教程
|
||||
|
||||
- [【教程】小白也能看懂的自建Cloudflare临时邮箱教程(域名邮箱)](https://linux.do/t/topic/316819/1)
|
||||
|
||||
## 升级流程
|
||||
|
||||
首先确认当前的版本,然后访问 [Release 页面](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/) 和 [CHANGELOG 页面](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/CHANGELOG.md) 中找到当前的版本
|
||||
|
||||
@@ -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,15 +26,6 @@
|
||||

|
||||

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

|
||||
|
||||
6. 点击 `Settings` -> `Variables`, 如图所示添加变量,参考 [修改 wrangler.toml 配置文件](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件) 中的 `[vars]` 部分
|
||||
|
||||
> [!NOTE]
|
||||
@@ -40,18 +35,44 @@
|
||||
|
||||

|
||||
|
||||
7. 点击 `Settings` -> `Variables`, 下拉找到 `D1 Database`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 D1 数据库,点击 `Deploy`
|
||||
7. 以下是 `Settings` -> `Variables` 中必须配置的变量列表
|
||||
|
||||
| 变量名 | 说明 | 示例 |
|
||||
| -------------------------- | ------------------------------------------------ | ------------------------------------ |
|
||||
| `PREFIX` | 要处理的邮箱名称前缀,不需要后缀可配置为空字符串 | `tmp` |
|
||||
| `DOMAINS` | 你的域名, 支持多个域名 | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `ADMIN_PASSWORDS` | admin 控制台密码, 不配置则不允许访问控制台 | `["123", "456"]` |
|
||||
| `JWT_SECRET` | 用于生成 jwt 的密钥, jwt 用于登录以及鉴权 | `xxx` |
|
||||
| `ENABLE_USER_CREATE_EMAIL` | 是否允许用户创建邮箱, 不配置则不允许 | `true` |
|
||||
| `ENABLE_USER_DELETE_EMAIL` | 是否允许用户删除邮箱, 不配置则不允许 | `true` |
|
||||
|
||||
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. 点击 `Settings` -> `Trggers`, 这里可以添加自己的域名,你也可以使用自动生成的 `*.workers.dev` 的域名。记录下这个域名,后面部署前端会用到。
|
||||
|
||||
> [!NOTE]
|
||||
> 打开 `worker` 的 `url`,如果显示 `OK` 说明部署成功
|
||||
>
|
||||
> 打开 `/health_check`,如果显示 `OK` 说明部署成功
|
||||
|
||||

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

|
||||

|
||||
|
||||
9. Telegram Bot 配置
|
||||
11. Telegram Bot 配置
|
||||
|
||||
> [!NOTE]
|
||||
> 如果不需要 Telegram Bot, 可跳过此步骤
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "temp-mail-docs",
|
||||
"private": true,
|
||||
"version": "0.2.6",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.10",
|
||||
"vitepress": "^1.2.3",
|
||||
"wrangler": "^3.63.1"
|
||||
"@types/node": "^22.10.2",
|
||||
"vitepress": "^1.5.0",
|
||||
"wrangler": "^3.99.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vitepress dev docs",
|
||||
|
||||
1522
vitepress-docs/pnpm-lock.yaml
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "0.0.0",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,22 +11,22 @@
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240806.0",
|
||||
"@eslint/js": "8.56.0",
|
||||
"@cloudflare/workers-types": "^4.20241224.0",
|
||||
"@eslint/js": "9.17.0",
|
||||
"@simplewebauthn/types": "^10.0.0",
|
||||
"eslint": "8.56.0",
|
||||
"globals": "^15.9.0",
|
||||
"typescript-eslint": "^7.18.0",
|
||||
"wrangler": "^3.70.0"
|
||||
"eslint": "9.17.0",
|
||||
"globals": "^15.14.0",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"wrangler": "^3.99.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.629.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.629.0",
|
||||
"@aws-sdk/client-s3": "^3.717.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.717.0",
|
||||
"@simplewebauthn/server": "^10.0.1",
|
||||
"hono": "^4.5.5",
|
||||
"hono": "^4.6.14",
|
||||
"mimetext": "^3.0.24",
|
||||
"postal-mime": "^2.2.7",
|
||||
"resend": "^3.5.0",
|
||||
"postal-mime": "^2.3.2",
|
||||
"resend": "^4.0.1",
|
||||
"telegraf": "4.16.3"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
2528
worker/pnpm-lock.yaml
generated
@@ -9,6 +9,9 @@ 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>()
|
||||
|
||||
@@ -41,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)
|
||||
@@ -248,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);
|
||||
@@ -262,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)
|
||||
}
|
||||
@@ -287,6 +301,10 @@ 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
|
||||
})
|
||||
@@ -307,6 +325,10 @@ 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);
|
||||
@@ -315,3 +337,9 @@ api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings);
|
||||
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);
|
||||
|
||||
@@ -2,16 +2,9 @@ import { Context } from "hono";
|
||||
import { HonoCustomType } from "../types";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { WebhookSettings } from "../models";
|
||||
import { getBooleanValue } from "../utils";
|
||||
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 settings = await c.env.KV.get<WebhookSettings>(
|
||||
CONSTANTS.WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY, "json"
|
||||
) || new WebhookSettings();
|
||||
@@ -29,12 +22,14 @@ async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response
|
||||
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.req.json<WebhookSettings>();
|
||||
// random raw email
|
||||
const raw = await c.env.DB.prepare(
|
||||
`SELECT raw FROM raw_mails ORDER BY RANDOM() LIMIT 1`
|
||||
).first<string>("raw");
|
||||
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",
|
||||
|
||||
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" });
|
||||
}
|
||||
48
worker/src/admin_api/worker_config.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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),
|
||||
"JUNK_MAIL_FORCE_PASS_LIST": getStringArray(c.env.JUNK_MAIL_FORCE_PASS_LIST),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
@@ -9,6 +9,24 @@ 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);
|
||||
@@ -25,14 +43,30 @@ const getNameRegex = (c: Context<HonoCustomType>): RegExp => {
|
||||
|
||||
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(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,
|
||||
@@ -96,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,
|
||||
@@ -210,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;
|
||||
@@ -224,6 +275,7 @@ export const commonParseMail = async (raw_mail: string | undefined | null): Prom
|
||||
// sender: parsedEmail.sender || "",
|
||||
// subject: parsedEmail.subject || "",
|
||||
// text: parsedEmail.text || "",
|
||||
// headers: parsedEmail.headers || [],
|
||||
// html: parsedEmail.body_html || "",
|
||||
// };
|
||||
// } catch (e) {
|
||||
@@ -237,6 +289,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) {
|
||||
@@ -289,6 +342,7 @@ export async function sendWebhook(settings: WebhookSettings, formatMap: WebhookM
|
||||
);
|
||||
/* 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),
|
||||
@@ -304,7 +358,8 @@ export async function sendWebhook(settings: WebhookSettings, formatMap: WebhookM
|
||||
export async function triggerWebhook(
|
||||
c: Context<HonoCustomType>,
|
||||
address: string,
|
||||
raw_mail: string
|
||||
raw_mail: string,
|
||||
message_id: string | null
|
||||
): Promise<void> {
|
||||
if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) {
|
||||
return
|
||||
@@ -332,8 +387,14 @@ export async function triggerWebhook(
|
||||
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 || "",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export const CONSTANTS = {
|
||||
VERSION: 'v0.7.2',
|
||||
VERSION: 'v0.8.3',
|
||||
|
||||
// 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",
|
||||
|
||||
54
worker/src/email/check_junk.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Bindings } from "../types";
|
||||
import { getBooleanValue, getStringArray } 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 forcePassList = getStringArray(env.JUNK_MAIL_FORCE_PASS_LIST);
|
||||
const passedList: string[] = [];
|
||||
|
||||
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") {
|
||||
if (!header["value"].toLowerCase().includes("pass")) {
|
||||
return true;
|
||||
}
|
||||
passedList.push("spf");
|
||||
}
|
||||
|
||||
// check dkim and dmarc
|
||||
if (header["key"].toLowerCase() == "authentication-results") {
|
||||
if (header["value"].toLowerCase().includes("dkim=")) {
|
||||
if (!header["value"].toLowerCase().includes("dkim=pass")) {
|
||||
return true;
|
||||
}
|
||||
passedList.push("dkim");
|
||||
}
|
||||
if (header["value"].toLowerCase().includes("dmarc=")) {
|
||||
if (!header["value"].toLowerCase().includes("dmarc=pass")) {
|
||||
return true;
|
||||
}
|
||||
passedList.push("dmarc");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (forcePassList?.length == 0) return false;
|
||||
|
||||
// check force pass list
|
||||
return forcePassList.some(
|
||||
(checkName) => !passedList.includes(checkName.toLowerCase())
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Bindings, HonoCustomType } from "../types";
|
||||
import { auto_reply } from "./auto_reply";
|
||||
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(
|
||||
@@ -50,7 +64,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
||||
try {
|
||||
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 = ?`
|
||||
|
||||
@@ -7,12 +7,6 @@ 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)) {
|
||||
@@ -42,12 +36,14 @@ 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;
|
||||
@@ -128,6 +130,8 @@ export class WebhookSettings {
|
||||
"Content-Type": "application/json"
|
||||
}, null, 2)
|
||||
body: string = JSON.stringify({
|
||||
"id": "${id}",
|
||||
"url": "${url}",
|
||||
"from": "${from}",
|
||||
"to": "${to}",
|
||||
"subject": "${subject}",
|
||||
@@ -136,3 +140,19 @@ export class WebhookSettings {
|
||||
"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,7 @@ 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
|
||||
@@ -41,6 +42,9 @@ export type Bindings = {
|
||||
DISABLE_SHOW_GITHUB: string | boolean | undefined
|
||||
FORWARD_ADDRESS_LIST: string | string[] | undefined
|
||||
|
||||
ENABLE_CHECK_JUNK_MAIL: string | boolean | undefined
|
||||
JUNK_MAIL_FORCE_PASS_LIST: string | string[] | undefined
|
||||
|
||||
// s3 config
|
||||
S3_ENDPOINT: string | undefined
|
||||
S3_ACCESS_KEY_ID: string | undefined
|
||||
@@ -59,6 +63,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>) => {
|
||||
|
||||
@@ -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,7 +19,9 @@ node_compat = true
|
||||
# TITLE = "Custom Title" # custom title
|
||||
# ANNOUNCEMENT = "Custom Announcement"
|
||||
PREFIX = "tmp"
|
||||
# address name REGEX, if not set, the default is [^a-z0-9]
|
||||
# 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
|
||||
@@ -64,8 +66,16 @@ 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
|
||||
# junk mail force check pass list, if no status or status is not pass, will be marked as junk mail
|
||||
# JUNK_MAIL_FORCE_PASS_LIST = ["spf", "dkim", "dmarc"]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
|
||||