Compare commits

..

45 Commits

Author SHA1 Message Date
Dream Hunter
c964d77a59 feat: |UI| add JUNK_MAIL_FORCE_PASS_LIST (#539) 2024-12-30 18:38:33 +08:00
Dream Hunter
8a03d3e57f feat: |UI| admin portal user oauth config support delete (#538) 2024-12-30 18:08:17 +08:00
Dream Hunter
6caba7c863 feat: add docs (#537) 2024-12-28 13:52:08 +08:00
Dream Hunter
43e5bdc764 feat: update dependencies (#536) 2024-12-28 00:32:07 +08:00
Dream Hunter
7bec0daba4 feat: update docs (#534) 2024-12-27 10:47:14 +08:00
Dream Hunter
13e5adef17 feat: update docs (#533) 2024-12-26 22:23:41 +08:00
Dream Hunter
440238133e feat: |Github Action| add upstream sync and auto deploy frontend&&bac… (#528)
feat: |Github Action| add upstream sync and auto deploy frontend&&backend
2024-12-23 22:55:10 +08:00
Dream Hunter
4a881e2d2b feat: upgrade dependencies (#527) 2024-12-23 21:10:45 +08:00
Dream Hunter
b0bf7a5f13 feat: add NO_LIMIT_SEND_ADDRESS_LIST_KEY in admin account settings page (#525) 2024-12-22 15:52:53 +08:00
Dream Hunter
a9bb8785ba feat: support send mail from admin portal(no balance limit) (#524) 2024-12-22 15:40:26 +08:00
Dream Hunter
0b48baff6d fix: frontend github actions cannot use branch param to deploy (#520)
* Update frontend_deploy.yaml

* Update frontend_deploy.yaml
2024-12-19 17:58:54 +08:00
Dream Hunter
e0b5e80efd feat: |doc| update doc (#510) 2024-12-04 00:56:52 +08:00
Dream Hunter
b0e36ac2aa feat: |doc| update Telegram Bot doc (#509) 2024-12-04 00:33:47 +08:00
Dream Hunter
51db19c85b feat: |UI| add tip for multiple tag (#508) 2024-12-04 00:29:01 +08:00
Dream Hunter
e52b010aa4 feat: |doc| update doc (#507) 2024-12-03 22:04:46 +08:00
Dream Hunter
8f6793402c feat: |UI| add forward in mail page (#502) 2024-11-30 15:53:48 +08:00
Dream Hunter
e86c530116 feat: |UI| hide ID for user (#501) 2024-11-30 15:11:37 +08:00
Dream Hunter
0308f518da feat: upgrade dependencies && |doc| update ui install worker doc (#494) 2024-11-22 14:42:35 +08:00
Dream Hunter
3c2a8ed056 feat: remove service workbox html cache (#486) 2024-11-15 01:39:14 +08:00
Dream Hunter
5f45ec7c14 feat: remove service workbox (#485) 2024-11-15 01:22:43 +08:00
Dream Hunter
1b7ebc98c5 feat: support transfer address from user to user (#484)
* feat: support transfer address from user to user

* feat: remove service worker
2024-11-15 01:10:25 +08:00
Dream Hunter
c102004f4d feat: |UI| show local datetime string and add useUTCDate option (#483) 2024-11-15 00:04:17 +08:00
Dream Hunter
3c81e05a2f feat: |UI| random fake name support MAX_ADDRESS_LEN (#482) 2024-11-14 23:58:42 +08:00
Dream Hunter
5ff2ceb5e8 feat: pages support Cloudflare Zero Trust (#477) 2024-11-11 23:55:49 +08:00
Dream Hunter
6c82efb738 feat: docs: ui_install worker update (#476) 2024-11-08 13:09:28 +08:00
Dream Hunter
e99acdcc6e fix: CI (#471) 2024-11-07 01:11:00 +08:00
Dream Hunter
8f30505706 feat: v0.7.6 (#470) 2024-11-07 01:00:26 +08:00
Dream Hunter
ddfa2c5d03 feat: add ENABLE_CHECK_JUNK_MAIL (#469) 2024-11-07 00:58:15 +08:00
Dream Hunter
49b3f10838 feat: upgrade dependencies && add ci build telegram-frontend.zip (#467) 2024-11-06 23:42:39 +08:00
Dream Hunter
cc9ac67319 feat: upgrade dependencies (#448) 2024-09-27 22:30:37 +08:00
Dream Hunter
7cc2a2b576 feat: doc: add mail id and url in webhook (#444) 2024-09-09 22:49:53 +08:00
Dream Hunter
393c5902c3 feat: add mail id and url in webhook (#443) 2024-09-09 22:29:18 +08:00
Dream Hunter
5ece49a576 feat: telegram Set manually to avoid implicit call in (#442) 2024-09-09 20:59:12 +08:00
dependabot[bot]
de80857e2c build(deps): bump twisted from 24.3.0 to 24.7.0 in /smtp_proxy_server (#385)
Bumps [twisted](https://github.com/twisted/twisted) from 24.3.0 to 24.7.0.
- [Release notes](https://github.com/twisted/twisted/releases)
- [Changelog](https://github.com/twisted/twisted/blob/trunk/NEWS.rst)
- [Commits](https://github.com/twisted/twisted/compare/twisted-24.3.0...twisted-24.7.0)

---
updated-dependencies:
- dependency-name: twisted
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-27 01:40:45 +08:00
Dream Hunter
a57a42b2a1 fix: name check bug (#434) 2024-08-25 16:39:55 +08:00
Dream Hunter
a24cc1f642 fix: bugs && release v0.7.4 (#432) 2024-08-24 15:07:07 +08:00
Dream Hunter
4c6fd3c2af feat: UI add min-width for table page (#428) 2024-08-19 22:53:13 +08:00
Dream Hunter
1cf38c1768 feat: UI: add WorkerConfig && release v0.7.3 (#421) 2024-08-18 14:58:57 +08:00
Dream Hunter
b5b59acdb3 feat: add Oauth2 Login (#420) 2024-08-18 14:39:50 +08:00
Dream Hunter
6d4783e1cd fix: UI admin page show modal when no need password (#419) 2024-08-17 23:54:03 +08:00
Dream Hunter
34e3e1b439 fix: UI admin page show modal when no need password (#418) 2024-08-17 23:14:35 +08:00
Dream Hunter
56104cd23a fix: UI tab active icon wrong position (#416) 2024-08-17 01:46:40 +08:00
Dream Hunter
3664028e06 feat: add ADDRESS_CHECK_REGEX (#415) 2024-08-17 00:11:28 +08:00
Dream Hunter
9888f98d74 feat: update dependencies (#411) 2024-08-15 01:05:05 +08:00
Dream Hunter
ac5605f17f release v0.7.2 doc (#410) 2024-08-15 01:02:15 +08:00
95 changed files with 6259 additions and 4486 deletions

View File

@@ -1,6 +1,9 @@
name: Deploy Backend Production
on:
workflow_run:
workflows: [Upstream Sync]
types: [completed]
push:
tags:
- "*"

View File

@@ -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
View 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

View File

@@ -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
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"ms-python.vscode-pylance",
"1yib.rust-bundle",
"rust-lang.rust-analyzer",
"vue.volar"
]
}

View File

@@ -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`
![worker-runtime](vitepress-docs/docs/public/ui_install/worker-runtime.png)
### 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

View File

@@ -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

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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 }}

View 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
}

View File

@@ -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

View 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;
}

View File

@@ -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",

View File

@@ -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,
}
},
)

View File

@@ -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;
}

View File

@@ -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 />

View File

@@ -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') }}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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')" />

View 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>

View File

@@ -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>

View File

@@ -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: '全局邮件推送用户列表',
}
}

View File

@@ -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>

View 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>

View File

@@ -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')">

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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',

View File

@@ -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"

View File

@@ -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())

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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' },

View File

@@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -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

View File

@@ -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]]

View File

@@ -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
```

View File

@@ -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`
![email](/readme_assets/email.png)

View File

@@ -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) {

View File

@@ -1,7 +1,9 @@
# 配置子域名邮箱
::: warning
::: warning 注意
子域名邮箱发送邮件可能无法发送邮件,建议使用主域名邮箱发送邮件,子域名邮箱仅用于接收邮件。
mail channel 已不被支持,下面参考中仅限收件部分。
:::
参考

View File

@@ -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。

View 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
![oauth2](/feature/oauth2.png)
## 测试用户登录页面
![oauth2 login](/feature/oauth2-login.png)

View File

@@ -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
![telegram](/feature/address-webhook.png)
## 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}",
}
```

View File

@@ -1,23 +1,32 @@
# 通过 Github Actions 部署
::: warning
::: warning 注意
目前只支持 worker 和 pages 的部署D1 数据库以及 Email 部分请参考 [UI/CLI 部署](/)。
有问题请通过 `Github Issues` 反馈,感谢。
自动更新不会执行 sql 文件,需要手动执行。
:::
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](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` 手动同步即可

View File

@@ -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) 中找到当前的版本

View File

@@ -8,9 +8,13 @@
![worker1](/ui_install/worker-1.png)
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`
![worker-runtime](/ui_install/worker-runtime.png)
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 @@
![worker2](/ui_install/worker-2.png)
![worker-upload](/ui_install/worker-upload.png)
5. 点击 `Settings` -> `Trggers`, 这里可以添加自己的域名,你也可以使用自动生成的 `*.workers.dev` 的域名。记录下这个域名,后面部署前端会用到。
> [!NOTE]
> 打开 `worker` 的 `url`,如果显示 `OK` 说明部署成功
>
> 打开 `/health_check`,如果显示 `OK` 说明部署成功
![worker3](/ui_install/worker-3.png)
6. 点击 `Settings` -> `Variables`, 如图所示添加变量,参考 [修改 wrangler.toml 配置文件](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件) 中的 `[vars]` 部分
> [!NOTE]
@@ -40,18 +35,44 @@
![worker-var](/ui_install/worker-var.png)
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`
![worker-d1](/ui_install/worker-d1.png)
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` 说明部署成功
![worker3](/ui_install/worker-3.png)
10. 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤,点击 `Workers & Pages` -> `KV` -> `Create Namespace`, 如图,点击 `Create Namespace`,然后在 `Settings` -> `Variables`, 下拉找到 `KV`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 `KV` 缓存,点击 `Deploy`
> [!NOTE] 重要
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
>
> 注意此处 `KV` 的绑定名称必须为 `KV`
![worker-kv](/ui_install/worker-kv.png)
![worker-kv-bind](/ui_install/worker-kv-bind.png)
9. Telegram Bot 配置
11. Telegram Bot 配置
> [!NOTE]
> 如果不需要 Telegram Bot, 可跳过此步骤

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -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",

View 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,
}

View 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" });
}

View 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),
})
}
}

View File

@@ -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 || "",

View File

@@ -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",

View 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())
);
}

View File

@@ -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);

View File

@@ -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)

View File

@@ -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 = ?`

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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());

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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 })
}
}

View File

@@ -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);

View 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
})
}
}

View File

@@ -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({

View File

@@ -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>) => {

View File

@@ -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({

View File

@@ -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[] => {

View File

@@ -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;

View File

@@ -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"