Compare commits

..

51 Commits

Author SHA1 Message Date
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
Dream Hunter
a9719cb3ec release v0.7.2 (#409) 2024-08-15 00:56:15 +08:00
Dream Hunter
5f4978645b release v0.7.2 (#408) 2024-08-15 00:52:18 +08:00
Dream Hunter
621476cb79 feat: update webhook to support global webhook (#407) 2024-08-15 00:23:31 +08:00
Dream Hunter
c969c4b082 fix: DISABLE_ADMIN_PASSWORD_CHECK still show admin password modal (#406) 2024-08-14 22:52:45 +08:00
Dream Hunter
d90f54345d feat: add ADDRESS_REGEX (#401) 2024-08-13 23:21:19 +08:00
Dream Hunter
797b8bb019 fix: NO_LIMIT_SEND_ROLE no access token (#400) 2024-08-13 01:38:20 +08:00
Dream Hunter
7e5d142924 fix: NO_LIMIT_SEND_ROLE when user settings not call (#396) 2024-08-11 23:45:24 +08:00
Dream Hunter
c6d0307eac Release v0.7.1 2024-08-11 22:46:40 +08:00
Dream Hunter
ac31042e69 feat: add EMAIL_KV_BLACK_LIST (#394) 2024-08-11 20:34:10 +08:00
Dream Hunter
c733d3bf4d fix: get user role before all requests (#393) 2024-08-11 19:29:49 +08:00
Dream Hunter
bf1243f4c4 release: v0.7.0 (#387) 2024-08-11 00:21:15 +08:00
Dream Hunter
15063b2e97 feat: add DISABLE_ADMIN_PASSWORD_CHECK (#386) 2024-08-11 00:10:16 +08:00
Dream Hunter
fc07f1cd87 feat: add passkey (#384) 2024-08-10 23:56:05 +08:00
Dream Hunter
9246550cc5 feat: add NO_LIMIT_SEND_ROLE (#373) 2024-08-04 21:02:11 +08:00
Dream Hunter
979b6eae1a feat: add SHOW_GITHUB config (#372) 2024-08-04 14:36:24 +08:00
Dream Hunter
10da337a9c feat: add SHOW_GITHUB config (#371) 2024-08-04 14:34:35 +08:00
Dream Hunter
9c5e8857af feat: add loading when process mails (#367) 2024-07-27 23:14:18 +08:00
Dream Hunter
84b4baa99e feat: add .github/workflows/pr_agent.yml (#366) 2024-07-27 23:06:54 +08:00
Dream Hunter
b57d46244a feat: add loading when process mails (#364) 2024-07-27 22:30:38 +08:00
Dream Hunter
5faae8796d feat: add ADMIN_USER_ROLE for user access admin panel (#363) 2024-07-27 22:04:18 +08:00
666-eth
a0805bc0ce Docs: Update new-address-api.md (#360) 2024-07-23 13:47:37 +08:00
99 changed files with 6431 additions and 3855 deletions

25
.github/workflows/pr_agent.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Codium PR Agent
on:
pull_request:
types: [opened, reopened, ready_for_review]
jobs:
pr_agent_job:
if: ${{ github.event.sender.type != 'Bot' }}
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
name: Run pr agent on every pull request, respond to user comments
steps:
- name: PR Agent action step
id: pragent
uses: Codium-ai/pr-agent@main
env:
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false"
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
CONFIG.MODEL: "gpt-4o"
CONFIG.MODEL_TURBO: "gpt-4o"
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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,73 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG
# main(v0.8.1)
- feat: |Doc| 更新 UI 安装的文档
- feat: |UI| 对用户隐藏邮箱账号的 ID
- feat: |UI| 增加邮件详情页的 `转发` 按钮
## v0.8.0
- feat: |UI| 随机生成地址时不超过最大长度
- feat: |UI| 邮件时间显示浏览器时区,可在设置中切换显示为 UTC 时间
- feat: 支持转移邮件到其他用户
## v0.7.6
- feat: 支持提前设置 bot info, 降低 telegram 回调延迟 (#441)
- feat: 增加 telegram mini app 的 build 压缩包
- feat: 增加是否启用垃圾邮件检查 `ENABLE_CHECK_JUNK_MAIL` 配置
## v0.7.5
- fix: 修复 `name` 的校验检查
## v0.7.4
- feat: UI 列表页面增加最小宽度
- fix: 修复 `name` 的校验检查
- fix: 修复 `DEFAULT_DOMAINS` 配置为空不生效的问题
## v0.7.3
- feat: worker 增加 `ADDRESS_CHECK_REGEX`, address name 的正则表达式, 只用于检查,符合条件将通过检查
- fix: UI 修复登录页面 tab 激活图标错位
- fix: UI 修复 admin 页面刷新弹框输入密码的问题
- feat: support `Oath2` 登录, 可以通过 `Github` `Authentik` 等第三方登录, 详情查看 [OAuth2 第三方登录](https://temp-mail-docs.awsl.uk/zh/guide/feature/user-oauth2.html)
## v0.7.2
### Breaking Changes
`webhook` 的结构增加了 `enabled` 字段,已经配置了的需要重新在页面开启并保存。
### Changes
- fix: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 加载失败的问题
- feat: worker 增加 `# ADDRESS_REGEX = "[^a-z.0-9]"` 配置, 替换非法符号的正则表达式,如果不设置,默认为 [^a-z0-9], 需谨慎使用, 有些符号可能导致无法收件
- feat: worker 优化 webhook 逻辑, 支持 admin 配置全局 webhook, 添加 `message pusher` 集成示例
## v0.7.1
- fix: 修复用户角色加载失败的问题
- feat: admin 账号设置增加来源邮件地址黑名单配置
## v0.7.0
### Breaking Changes
DB changes: 增加用户 `passkey` 表, 需要执行 `db/2024-08-10-patch.sql` 更新 `D1` 数据库
### Changes
- Docs: Update new-address-api.md (#360)
- feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363)
- feat: worker 增加 `DISABLE_SHOW_GITHUB` 配置, 用于配置是否显示 github 链接
- feat: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 用于配置可以无限发送邮件的角色
- feat: 用户增加 `passkey` 登录方式, 用于用户登录, 无需输入密码
- feat: worker 增加 `DISABLE_ADMIN_PASSWORD_CHECK` 配置, 用于配置是否禁用 admin 控制台密码检查, 若你的网站只可私人访问,可通过此禁用检查
## v0.6.1
- pages github actions && 修复清理邮件天数为 0 不生效 by @tqjason (#355)

14
db/2024-08-10-patch.sql Normal file
View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS user_passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
passkey_name TEXT NOT NULL,
passkey_id TEXT NOT NULL,
passkey TEXT NOT NULL,
counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);

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
@@ -88,3 +88,18 @@ CREATE TABLE IF NOT EXISTS user_roles (
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE TABLE IF NOT EXISTS user_passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
passkey_name TEXT NOT NULL,
passkey_id TEXT NOT NULL,
passkey TEXT NOT NULL,
counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);

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.6.1",
"version": "0.8.1",
"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",
@@ -17,32 +19,33 @@
"deploy:actions": "npm run build && wrangler pages deploy ./dist"
},
"dependencies": {
"@unhead/vue": "^1.9.15",
"@simplewebauthn/browser": "^10.0.0",
"@unhead/vue": "^1.11.11",
"@vicons/material": "^0.12.0",
"@vueuse/core": "^10.11.0",
"@vueuse/core": "^10.11.1",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.2",
"axios": "^1.7.7",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.1.8",
"naive-ui": "^2.38.2",
"postal-mime": "^2.2.5",
"naive-ui": "^2.40.1",
"postal-mime": "^2.3.2",
"vooks": "^0.2.12",
"vue": "^3.4.31",
"vue": "^3.5.13",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.0"
"vue-i18n": "^9.14.1",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vicons/fa": "^0.12.0",
"@vitejs/plugin-vue": "^5.0.5",
"unplugin-auto-import": "^0.17.6",
"unplugin-vue-components": "^0.27.2",
"vite": "^5.3.3",
"@vitejs/plugin-vue": "^5.2.0",
"unplugin-auto-import": "^0.18.5",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.11",
"vite-plugin-pwa": "^0.19.8",
"vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-top-level-await": "^1.4.4",
"vite-plugin-wasm": "^3.3.0",
"workbox-window": "^7.1.0",
"wrangler": "^3.63.1"
"workbox-window": "^7.3.0",
"wrangler": "^3.89.0"
}
}

3301
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import { useGlobalState } from './store'
import { useIsMobile } from './utils/composables'
import Header from './views/Header.vue';
import Footer from './views/Footer.vue';
import { api } from './api'
const {
isDark, loading, useSideMargin, telegramApp, isTelegram
@@ -19,6 +19,13 @@ const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
onMounted(async () => {
try {
await api.getUserSettings();
} catch (error) {
console.error(error);
}
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null

View File

@@ -22,6 +22,7 @@ const apiFetch = async (path, options = {}) => {
data: options.body || null,
headers: {
'x-user-token': userJwt.value,
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`,
@@ -55,6 +56,9 @@ const getOpenSettings = async (message) => {
try {
const res = await api.fetch("/open_api/settings");
const domainLabels = res["domainLabels"] || [];
if (res["domains"]?.length < 1) {
message.error("No domains found, please check your worker settings");
}
Object.assign(openSettings.value, {
...res,
title: res["title"] || "",
@@ -92,6 +96,8 @@ const getOpenSettings = async (message) => {
}
} catch (error) {
message.error(error.message || "error");
} finally {
openSettings.value.fetched = true;
}
}
@@ -118,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;
}
}
@@ -127,7 +135,7 @@ const getUserSettings = async (message) => {
const res = await api.fetch("/user_api/settings")
Object.assign(userSettings.value, res)
} catch (error) {
message.error(error.message || "error");
message?.error(error.message || "error");
} finally {
userSettings.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()
@@ -14,42 +15,42 @@ const props = defineProps({
enableUserDeleteEmail: {
type: Boolean,
default: false,
requried: false
required: false
},
showEMailTo: {
type: Boolean,
default: true,
requried: false
required: false
},
fetchMailData: {
type: Function,
default: () => { },
requried: true
required: true
},
deleteMail: {
type: Function,
default: () => { },
requried: false
required: false
},
showReply: {
type: Boolean,
default: false,
requried: false
required: false
},
showSaveS3: {
type: Boolean,
default: false,
requried: false
required: false
},
saveToS3: {
type: Function,
default: (mail_id, filename, blob) => { },
requried: false
required: false
},
})
const {
isDark, mailboxSplitSize, indexTab, loading,
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
useIframeShowMail, sendMailModel, preferShowTextMail
} = useGlobalState()
const autoRefresh = ref(false)
@@ -85,6 +86,7 @@ const { t } = useI18n({
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete mail?',
reply: 'Reply',
forwardMail: 'Forward',
showTextMail: 'Show Text Mail',
showHtmlMail: 'Show Html Mail',
saveToS3: 'Save to S3',
@@ -104,6 +106,7 @@ const { t } = useI18n({
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
reply: '回复',
forwardMail: '转发',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件',
saveToS3: '保存到S3',
@@ -147,6 +150,7 @@ const refresh = async () => {
const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value
);
loading.value = true;
data.value = await Promise.all(results.map(async (item) => {
item.checked = false;
return await processItem(item);
@@ -161,6 +165,8 @@ const refresh = async () => {
} catch (error) {
message.error(error.message || "error");
console.error(error);
} finally {
loading.value = false;
}
};
@@ -211,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;
}
@@ -372,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 }}
@@ -394,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 }}
@@ -425,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>
@@ -468,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 }}
@@ -490,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 }}
@@ -519,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()
@@ -12,7 +13,7 @@ const props = defineProps({
enableUserDeleteEmail: {
type: Boolean,
default: false,
requried: false
required: false
},
showEMailFrom: {
type: Boolean,
@@ -21,16 +22,16 @@ const props = defineProps({
fetchMailData: {
type: Function,
default: () => { },
requried: true
required: true
},
deleteMail: {
type: Function,
default: () => { },
requried: false
required: false
},
})
const { isDark, mailboxSplitSize, loading } = useGlobalState()
const { isDark, mailboxSplitSize, loading, useUTCDate } = useGlobalState()
const data = ref([])
const count = ref(0)
@@ -251,7 +252,7 @@ onMounted(async () => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ `${row.created_at} UTC` }}
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
@@ -273,7 +274,7 @@ onMounted(async () => {
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ `${curMail.created_at} UTC` }}
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.address }}
@@ -320,7 +321,7 @@ onMounted(async () => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ `${row.created_at} UTC` }}
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
@@ -342,7 +343,7 @@ onMounted(async () => {
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ `${curMail.created_at} UTC` }}
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.address }}

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
fetchData: {
type: Function,
default: () => { },
required: true
},
saveSettings: {
type: Function,
default: (webhookSettings: WebhookSettings) => { },
required: true
},
testSettings: {
type: Function,
default: (webhookSettings: WebhookSettings) => { },
required: true
},
})
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
successTip: 'Success',
test: 'Test',
save: 'Save',
notEnabled: 'Webhook is not enabled for you',
urlMissing: 'URL is required',
enable: 'Enable',
messagePusherDemo: 'Fill with Message Pusher Demo',
messagePusherDoc: 'Message Pusher Doc',
fillInDemoTip: 'Please modify the URL and other settings to your own',
},
zh: {
successTip: '成功',
test: '测试',
save: '保存',
notEnabled: 'Webhook 未开启,请联系管理员开启',
urlMissing: 'URL 不能为空',
enable: '启用',
messagePusherDemo: '填入MessagePusher示例',
messagePusherDoc: 'MessagePusher文档',
fillInDemoTip: '请修改URL和其他设置为您自己的配置',
}
}
});
class WebhookSettings {
enabled: boolean = false
url: string = ''
method: string = 'POST'
headers: string = JSON.stringify({}, null, 2)
body: string = JSON.stringify({}, null, 2)
}
const messagePusherDocLink = "https://github.com/songquanpeng/message-pusher";
const messagePusherDemo = {
enabled: true,
url: 'https://msgpusher.com/push/username',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"token": "token",
"title": "${subject}",
"description": "${subject}",
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
}, null, 2),
} as WebhookSettings;
const fillMessagePuhserDemo = () => {
Object.assign(webhookSettings.value, messagePusherDemo)
message.success(t('fillInDemoTip'))
}
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
const enableWebhook = ref(false)
const fetchData = async () => {
try {
const res = await props.fetchData()
Object.assign(webhookSettings.value, res)
enableWebhook.value = true
} catch (error) {
message.error((error as Error).message || "error");
}
}
const saveSettings = async () => {
if (!webhookSettings.value.url) {
message.error(t('urlMissing'))
return
}
try {
await props.saveSettings(webhookSettings.value)
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
const testSettings = async () => {
if (!webhookSettings.value.url) {
message.error(t('urlMissing'))
return
}
try {
await props.testSettings(webhookSettings.value)
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
<n-flex justify="end">
<n-button tag="a" :href="messagePusherDocLink" target="_blank" secondary>
{{ t('messagePusherDoc') }}
</n-button>
<n-button @click="fillMessagePuhserDemo" secondary>
{{ t('messagePusherDemo') }}
</n-button>
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
{{ t('test') }}
</n-button>
<n-button @click="saveSettings" type="primary">
{{ t('save') }}
</n-button>
</n-flex>
<n-form-item-row :label="t('enable')">
<n-switch v-model:value="webhookSettings.enabled" :round="false" />
</n-form-item-row>
<div v-if="webhookSettings.enabled">
<n-form-item-row label="URL">
<n-input v-model:value="webhookSettings.url" />
</n-form-item-row>
<n-form-item-row label="METHOD">
<n-select v-model:value="webhookSettings.method" tag :options='[
{ label: "POST", value: "POST" }
]' />
</n-form-item-row>
<n-form-item-row label="HEADERS">
<n-input v-model:value="webhookSettings.headers" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-form-item-row label="BODY">
<n-input v-model:value="webhookSettings.body" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
</div>
</n-card>
<n-result v-else status="404" :title="t('notEnabled')" />
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>

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 { ref } from "vue";
import { createGlobalState, useStorage, useDark, useToggle, useLocalStorage } from '@vueuse/core'
import { computed, ref } from "vue";
import {
createGlobalState, useStorage, useDark, useToggle,
useLocalStorage, useSessionStorage
} from '@vueuse/core'
export const useGlobalState = createGlobalState(
() => {
@@ -8,9 +11,11 @@ export const useGlobalState = createGlobalState(
const loading = ref(false);
const announcement = useLocalStorage('announcement', '');
const openSettings = ref({
fetched: false,
title: '',
announcement: '',
prefix: '',
addressRegex: '',
needAuth: false,
adminContact: '',
enableUserCreateEmail: false,
@@ -25,6 +30,8 @@ export const useGlobalState = createGlobalState(
cfTurnstileSiteKey: '',
enableWebhook: false,
isS3Enabled: false,
showGithub: true,
disableAdminPasswordCheck: false,
})
const settings = ref({
fetched: false,
@@ -38,7 +45,7 @@ export const useGlobalState = createGlobalState(
name: '',
}
});
const sendMailModel = useStorage('sendMailModel', {
const sendMailModel = useSessionStorage('sendMailModel', {
fromName: "",
toName: "",
toMail: "",
@@ -52,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} */
@@ -74,11 +85,22 @@ export const useGlobalState = createGlobalState(
user_email: '',
/** @type {number} */
user_id: 0,
/** @type {boolean} */
is_admin: false,
/** @type {string | null} */
access_token: null,
/** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
user_role: null,
});
const showAdminPage = computed(() =>
!!adminAuth.value
|| userSettings.value.is_admin
|| openSettings.value.disableAdminPasswordCheck
);
const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
return {
isDark,
toggleDark,
@@ -106,8 +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,8 +1,9 @@
<script setup>
import { onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { api } from '../api'
import SenderAccess from './admin/SenderAccess.vue'
import Statistics from "./admin/Statistics.vue"
@@ -12,6 +13,7 @@ import CreateAccount from './admin/CreateAccount.vue';
import AccountSettings from './admin/AccountSettings.vue';
import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue';
import UserOauth2Settings from './admin/UserOauth2Settings.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import About from './common/About.vue';
@@ -19,14 +21,18 @@ import Maintenance from './admin/Maintenance.vue';
import Appearance from './common/Appearance.vue';
import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
import MailWebhook from './admin/MailWebhook.vue';
import WorkerConfig from './admin/WorkerConfig.vue';
const {
adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
adminAuth, showAdminAuth, adminTab, loading,
globalTabplacement, showAdminPage, userSettings
} = useGlobalState()
const message = useMessage()
const authFunc = async () => {
try {
adminAuth.value = tmpAdminAuth.value;
location.reload()
} catch (error) {
message.error(error.message || "error");
@@ -39,70 +45,92 @@ const { t } = useI18n({
accessHeader: 'Admin Password',
accessTip: 'Please enter the admin password',
mails: 'Emails',
qucickSetup: 'Quick Setup',
account: 'Account',
account_create: 'Create Account',
account_settings: 'Account Settings',
user: 'User',
user_management: 'User Management',
user_settings: 'User Settings',
userOauth2Settings: 'Oauth2 Settings',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
telegram: 'Telegram Bot',
webhook: 'Webhook',
webhookSettings: 'Webhook Settings',
statistics: 'Statistics',
maintenance: 'Maintenance',
workerconfig: 'Worker Config',
appearance: 'Appearance',
about: 'About',
ok: 'OK',
mailWebhook: 'Mail Webhook',
},
zh: {
accessHeader: 'Admin 密码',
accessTip: '请输入 Admin 密码',
mails: '邮件',
qucickSetup: '快速设置',
account: '账号',
account_create: '创建账号',
account_settings: '账号设置',
user: '用户',
user_management: '用户管理',
user_settings: '用户设置',
userOauth2Settings: 'Oauth2 设置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
telegram: '电报机器人',
webhook: 'Webhook',
webhookSettings: 'Webhook 设置',
statistics: '统计',
maintenance: '维护',
workerconfig: 'Worker 配置',
appearance: '外观',
about: '关于',
ok: '确定',
mailWebhook: '邮件 Webhook',
}
}
});
const showAdminPasswordModal = computed(() => !showAdminPage.value || showAdminAuth.value)
const tmpAdminAuth = ref('')
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
})
</script>
<template>
<div>
<n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
:title="t('accessHeader')">
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
preset="dialog" :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="adminAuth" type="textarea" :autosize="{ minRows: 3 }" />
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" />
<template #action>
<n-button @click="authFunc" type="primary" :loading="loading">
{{ t('ok') }}
</n-button>
</template>
</n-modal>
<n-tabs type="card" v-model:value="adminTab" :placement="globalTabplacement">
<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>
@@ -115,34 +143,40 @@ onMounted(async () => {
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="webhook" :tab="t('webhook')">
<n-tab-pane name="webhook" :tab="t('webhookSettings')">
<Webhook />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="user" :tab="t('user')">
<n-tabs type="bar" animated>
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="user_management" :tab="t('user_management')">
<UserManagement />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettings />
</n-tab-pane>
<n-tab-pane name="userOauth2Settings" :tab="t('userOauth2Settings')">
<UserOauth2Settings />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
<n-tabs type="bar" animated>
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="mails" :tab="t('mails')">
<Mails />
</n-tab-pane>
<n-tab-pane name="unknow" :tab="t('unknow')">
<MailsUnknow />
</n-tab-pane>
<n-tab-pane name="sendBox" :tab="t('sendBox')">
<SendBox />
</n-tab-pane>
<n-tab-pane name="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>
@@ -150,7 +184,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

@@ -17,8 +17,8 @@ import { getRouterPathWithLang } from '../utils'
const message = useMessage()
const {
toggleDark, isDark, isTelegram,
showAuth, adminAuth, auth, loading, openSettings
toggleDark, isDark, isTelegram, showAdminPage,
showAuth, auth, loading, openSettings, userSettings
} = useGlobalState()
const route = useRoute()
const router = useRouter()
@@ -134,7 +134,7 @@ const menuOptions = computed(() => [
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
}
),
show: !!adminAuth.value,
show: showAdminPage.value,
key: "admin"
},
{
@@ -192,6 +192,7 @@ const menuOptions = computed(() => [
icon: () => h(NIcon, { component: GithubAlt })
}
),
show: openSettings.value?.showGithub,
key: "github"
}
]);
@@ -223,6 +224,8 @@ const logoClick = async () => {
onMounted(async () => {
await api.getOpenSettings(message);
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
});
</script>
@@ -257,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: {
@@ -29,6 +31,8 @@ const { t } = useI18n({
about: 'About',
s3Attachment: 'S3 Attachment',
saveToS3Success: 'save to s3 success',
webhookSettings: 'Webhook Settings',
query: 'Query',
},
zh: {
mailbox: '收件箱',
@@ -39,11 +43,18 @@ const { t } = useI18n({
about: '关于',
s3Attachment: 'S3附件',
saveToS3Success: '保存到s3成功',
webhookSettings: 'Webhook 设置',
query: '查询',
}
}
});
const fetchMailData = async (limit, offset) => {
if (mailIdQuery.value > 0) {
const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`);
if (singleMail) return { results: [singleMail], count: 1 };
return { results: [], count: 0 };
}
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
};
@@ -78,6 +89,30 @@ const saveToS3 = async (mail_id, filename, blob) => {
message.error(error.message || "save to s3 error");
}
}
const mailBoxKey = ref("")
const mailIdQuery = ref("")
const showMailIdQuery = ref(false)
const queryMail = () => {
mailBoxKey.value = Date.now();
}
watch(route, () => {
if (!route.query.mail_id) {
showMailIdQuery.value = false;
mailIdQuery.value = "";
queryMail();
}
})
onMounted(() => {
if (route.query.mail_id) {
showMailIdQuery.value = true;
mailIdQuery.value = route.query.mail_id;
queryMail();
}
})
</script>
<template>
@@ -85,9 +120,17 @@ const saveToS3 = async (mail_id, filename, blob) => {
<AddressBar />
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
<n-tab-pane name="mailbox" :tab="t('mailbox')">
<MailBox :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled" :saveToS3="saveToS3"
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
:deleteMail="deleteMail" />
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
<n-input-group>
<n-input v-model:value="mailIdQuery" />
<n-button @click="queryMail" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
</div>
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled"
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
</n-tab-pane>
<n-tab-pane name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
@@ -102,7 +145,7 @@ const saveToS3 = async (mail_id, filename, blob) => {
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
<AutoReply />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhook')">
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhookSettings')">
<Webhook />
</n-tab-pane>
<n-tab-pane v-if="openSettings.isS3Enabled" name="s3_attachment" :tab="t('s3Attachment')">

View File

@@ -9,8 +9,8 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
adminAuth, showAdminAuth, loading,
adminTab, adminMailTabAddress, adminSendBoxTabAddress
loading, adminTab,
adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
@@ -252,10 +252,6 @@ watch([page, pageSize], async () => {
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script>
@@ -290,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>
@@ -307,4 +305,8 @@ onMounted(async () => {
margin-top: 10px;
margin-bottom: 10px;
}
.n-data-table {
min-width: 1000px;
}
</style>

View File

@@ -11,20 +11,24 @@ const message = useMessage()
const { t } = useI18n({
messages: {
en: {
tip: 'You can manually input the following multiple select input',
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',
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
fromBlockList: 'Block Keywords for receive email',
},
zh: {
tip: '您可以手动输入以下多选输入框',
save: '保存',
successTip: '保存成功',
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
send_address_block_list: '发送邮件地址屏蔽关键词',
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
fromBlockList: '接收邮件地址屏蔽关键词',
}
}
});
@@ -32,6 +36,7 @@ const { t } = useI18n({
const addressBlockList = ref([])
const sendAddressBlockList = ref([])
const verifiedAddressList = ref([])
const fromBlockList = ref([])
const fetchData = async () => {
try {
@@ -39,6 +44,7 @@ const fetchData = async () => {
addressBlockList.value = res.blockList || []
sendAddressBlockList.value = res.sendBlockList || []
verifiedAddressList.value = res.verifiedAddressList || []
fromBlockList.value = res.fromBlockList || []
} catch (error) {
message.error(error.message || "error");
}
@@ -51,7 +57,8 @@ const save = async () => {
body: JSON.stringify({
blockList: addressBlockList.value || [],
sendBlockList: sendAddressBlockList.value || [],
verifiedAddressList: verifiedAddressList.value || []
verifiedAddressList: verifiedAddressList.value || [],
fromBlockList: fromBlockList.value || [],
})
})
message.success(t('successTip'))
@@ -69,6 +76,9 @@ onMounted(async () => {
<template>
<div class="center">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert :show-icon="false" style="margin-bottom: 10px;">
{{ t("tip") }}
</n-alert>
<n-form-item-row :label="t('address_block_list')">
<n-select v-model:value="addressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" />
@@ -81,6 +91,9 @@ onMounted(async () => {
<n-select v-model:value="verifiedAddressList" filterable multiple tag
:placeholder="t('verified_address_list')" />
</n-form-item-row>
<n-form-item-row :label="t('fromBlockList')">
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')" />
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
// @ts-ignore
import { api } from '../../api'
// @ts-ignore
import WebhookComponent from '../../components/WebhookComponent.vue'
const fetchData = async () => {
return await api.fetch(`/admin/mail_webhook/settings`)
}
const saveSettings = async (webhookSettings: any) => {
await api.fetch(`/admin/mail_webhook/settings`, {
method: 'POST',
body: JSON.stringify(webhookSettings),
})
}
const testSettings = async (webhookSettings: any) => {
await api.fetch(`/admin/mail_webhook/test`, {
method: 'POST',
body: JSON.stringify(webhookSettings),
})
}
</script>
<template>
<WebhookComponent :fetchData="fetchData" :saveSettings="saveSettings" :testSettings="testSettings" />
</template>

View File

@@ -6,10 +6,7 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
import MailBox from '../../components/MailBox.vue';
const {
adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const { adminMailTabAddress } = useGlobalState()
const { t } = useI18n({
messages: {
@@ -48,13 +45,6 @@ const fetchMailData = async (limit, offset) => {
const deleteMail = async (curMailId) => {
await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' });
};
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script>
<template>

View File

@@ -1,12 +1,7 @@
<script setup>
import { onMounted } from 'vue';
import { useGlobalState } from '../../store'
import { api } from '../../api'
import MailBox from '../../components/MailBox.vue';
const { adminAuth, showAdminAuth } = useGlobalState()
const fetchMailUnknowData = async (limit, offset) => {
return await api.fetch(
`/admin/mails_unknow`
@@ -18,17 +13,10 @@ const fetchMailUnknowData = async (limit, offset) => {
const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script>
<template>
<div v-if="adminAuth" style="margin-top: 10px;">
<div style="margin-top: 10px;">
<MailBox :enableUserDeleteEmail="true" :fetchMailData="fetchMailUnknowData" :deleteMail="deleteMail" />
</div>
</template>

View File

@@ -1,12 +1,10 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { CleaningServicesFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { adminAuth, showAdminAuth } = useGlobalState()
const message = useMessage()
const cleanupModel = ref({
enableMailsAutoCleanup: false,
@@ -80,10 +78,6 @@ const save = async () => {
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData();
})
</script>

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

@@ -4,10 +4,8 @@ import { useI18n } from 'vue-i18n'
import { User, UserCheck, MailBulk } from '@vicons/fa'
import { SendOutlined } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { adminAuth } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
@@ -60,9 +58,6 @@ const fetchStatistics = async () => {
}
onMounted(async () => {
if (!adminAuth.value) {
return;
}
await fetchStatistics()
})
</script>

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,253 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
import constant from '../../constant'
import { UserOauth2Settings } from '../../models';
const { loading } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
save: 'Save',
successTip: 'Save Success',
enable: 'Enable',
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
mailAllowList: 'Mail Address Allow List',
addOauth2: 'Add Oauth2',
name: 'Name',
oauth2Type: 'Oauth2 Type',
tip: 'Third-party login will automatically use the user\'s email to register an account (the same email will be regarded as the same account), this account is the same as the registered account, and you can also set the password through the forget password',
},
zh: {
save: '保存',
successTip: '保存成功',
enable: '启用',
enableMailAllowList: '启用邮件地址白名单(可手动输入)',
mailAllowList: '邮件地址白名单',
addOauth2: '添加 Oauth2',
name: '名称',
oauth2Type: 'Oauth2 类型',
tip: '第三方登录会自动使用用户邮箱注册账号(邮箱相同将视为同一账号), 此账号和注册的账号相同, 也可以通过忘记密码设置密码',
}
}
});
const mailAllowOptions = constant.COMMOM_MAIL.map((item) => {
return { label: item, value: item }
})
const userOauth2Settings = ref([] as UserOauth2Settings[])
const showAddOauth2 = ref(false)
const newOauth2Name = ref('')
const newOauth2Type = ref('custom')
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/user_oauth2_settings`)
Object.assign(userOauth2Settings.value, res)
} catch (error) {
message.error((error as Error).message || "error");
}
}
const save = async () => {
try {
await api.fetch(`/admin/user_oauth2_settings`, {
method: 'POST',
body: JSON.stringify(userOauth2Settings.value)
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
const addNewOauth2 = () => {
const authorizationURL = () => {
switch (newOauth2Type.value) {
case 'github':
return 'https://github.com/login/oauth/authorize'
case 'authentik':
return 'https://youdomain/application/o/authorize/'
default:
return ''
}
}
const accessTokenURL = () => {
switch (newOauth2Type.value) {
case 'github':
return 'https://github.com/login/oauth/access_token'
case 'authentik':
return 'https://youdomain/application/o/token/'
default:
return ''
}
}
const accessTokenFormat = () => {
switch (newOauth2Type.value) {
case 'github':
return 'json'
case 'authentik':
return 'urlencoded'
default:
return ''
}
}
const userInfoURL = () => {
switch (newOauth2Type.value) {
case 'github':
return 'https://api.github.com/user'
case 'authentik':
return 'https://youdomain/application/o/userinfo/'
default:
return ''
}
}
const userEmailKey = () => {
switch (newOauth2Type.value) {
case 'github':
return 'email'
case 'authentik':
return 'email'
default:
return ''
}
}
const scope = () => {
switch (newOauth2Type.value) {
case 'github':
return 'user:email'
case 'authentik':
return 'email openid'
default:
return ''
}
}
userOauth2Settings.value.push({
name: newOauth2Name.value,
clientID: '',
clientSecret: '',
authorizationURL: authorizationURL(),
accessTokenURL: accessTokenURL(),
accessTokenFormat: accessTokenFormat(),
userInfoURL: userInfoURL(),
userEmailKey: userEmailKey(),
redirectURL: `${window.location.origin}/user/oauth2/callback`,
logoutURL: '',
scope: scope(),
enableMailAllowList: false,
mailAllowList: constant.COMMOM_MAIL
} as UserOauth2Settings)
newOauth2Name.value = ''
showAddOauth2.value = false
}
const accessTokenFormatOptions = [
{ label: 'json', value: 'json' },
{ label: 'urlencoded', value: 'urlencoded' },
]
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-modal v-model:show="showAddOauth2" preset="dialog" :title="t('addOauth2')">
<n-form>
<n-form-item-row :label="t('name')" required>
<n-input v-model:value="newOauth2Name" />
</n-form-item-row>
<n-form-item-row :label="t('oauth2Type')" required>
<n-radio-group v-model:value="newOauth2Type">
<n-radio-button value="github" label="Github" />
<n-radio-button value="authentik" label="Authentik" />
<n-radio-button value="custom" label="Custom" />
</n-radio-group>
</n-form-item-row>
</n-form>
<template #action>
<n-button :loading="loading" @click="addNewOauth2" size="small" tertiary type="primary">
{{ t('addOauth2') }}
</n-button>
</template>
</n-modal>
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert :show-icon="false" type="warning" closable style="margin-bottom: 10px;">
{{ t("tip") }}
</n-alert>
<n-flex justify="end">
<n-button @click="showAddOauth2 = true" secondary :loading="loading">
{{ t('addOauth2') }}
</n-button>
<n-button @click="save" type="primary" :loading="loading">
{{ t('save') }}
</n-button>
</n-flex>
<n-collapse default-expanded-names="1" accordion>
<n-collapse-item v-for="(item, index) in userOauth2Settings" :key="index" :title="item.name">
<n-form :model="item">
<n-form-item-row :label="t('name')" required>
<n-input v-model:value="item.name" />
</n-form-item-row>
<n-form-item-row label="Client ID" required>
<n-input v-model:value="item.clientID" />
</n-form-item-row>
<n-form-item-row label="Client Secret" required>
<n-input v-model:value="item.clientSecret" type="password" show-password-on="click" />
</n-form-item-row>
<n-form-item-row label="Authorization URL" required>
<n-input v-model:value="item.authorizationURL" />
</n-form-item-row>
<n-form-item-row label="Access Token URL" required>
<n-input v-model:value="item.accessTokenURL" />
</n-form-item-row>
<n-form-item-row label="Access Token accessTokenFormat" required>
<n-select v-model:value="item.accessTokenFormat" :options="accessTokenFormatOptions" />
</n-form-item-row>
<n-form-item-row label="User Info URL" required>
<n-input v-model:value="item.userInfoURL" />
</n-form-item-row>
<n-form-item-row label="User Email Key" required>
<n-input v-model:value="item.userEmailKey" />
</n-form-item-row>
<n-form-item-row label="Redirect URL" required>
<n-input v-model:value="item.redirectURL" />
</n-form-item-row>
<n-form-item-row label="Scope" required>
<n-input v-model:value="item.scope" />
</n-form-item-row>
<n-form-item-row :label="t('enableMailAllowList')">
<n-input-group>
<n-checkbox v-model:checked="item.enableMailAllowList" style="width: 20%;">
{{ t('enable') }}
</n-checkbox>
<n-select v-model:value="item.mailAllowList" v-if="item.enableMailAllowList" filterable
multiple tag style="width: 80%;" :options="mailAllowOptions"
:placeholder="t('mailAllowList')" />
</n-input-group>
</n-form-item-row>
</n-form>
</n-collapse-item>
</n-collapse>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
</style>

View File

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

@@ -15,11 +15,13 @@ const { t } = useI18n({
successTip: 'Success',
webhookAllowList: 'Webhook Allow List(Enter the address that is allowed to use webhook)',
save: 'Save',
notEnabled: 'Webhook is not enabled',
},
zh: {
successTip: '成功',
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

@@ -15,7 +15,7 @@ const props = defineProps({
bindUserAddress: {
type: Function,
default: async () => { await api.bindUserAddress(); },
requried: true
required: true
},
newAddressPath: {
type: Function,
@@ -29,7 +29,7 @@ const props = defineProps({
}),
});
},
requried: true
required: true
},
})
@@ -72,7 +72,7 @@ const { locale, t } = useI18n({
login: 'Login',
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
getNewEmail: 'Create New Email',
getNewEmailTip1: 'Please input the email you want to use. only allow a-z and 0-9',
getNewEmailTip1: 'Please input the email you want to use. only allow: ',
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
credential: 'Email Address Credential',
@@ -87,7 +87,7 @@ const { locale, t } = useI18n({
login: '登录',
pleaseGetNewEmail: '请"登录"或点击 "注册新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '创建新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 a-z, 0-9',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许: ',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
credential: '邮箱地址凭据',
@@ -101,6 +101,18 @@ const { locale, t } = useI18n({
}
});
const addressRegex = computed(() => {
try {
if (openSettings.value.addressRegex) {
return new RegExp(openSettings.value.addressRegex, 'g');
}
} catch (error) {
console.error(error);
message.error(`Invalid addressRegex: ${openSettings.value.addressRegex}`);
}
return /[^a-z0-9]/g;
});
const generateNameLoading = ref(false);
const generateName = async () => {
try {
@@ -110,8 +122,12 @@ const generateName = async () => {
.split('@')[0]
.replace(/\s+/g, '.')
.replace(/\.{2,}/g, '.')
.replace(/[^a-z0-9]/g, '')
.replace(addressRegex.value, '')
.toLowerCase();
// support maxAddressLen
if (emailName.value.length > openSettings.value.maxAddressLen) {
emailName.value = emailName.value.slice(0, openSettings.value.maxAddressLen);
}
} catch (error) {
message.error(error.message || "error");
} finally {
@@ -181,7 +197,7 @@ onMounted(async () => {
<n-alert v-if="userSettings.user_email" :show-icon="false" :bordered="false" closable>
<span>{{ t('bindUserInfo') }}</span>
</n-alert>
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tabs v-if="openSettings.fetched" v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tab-pane name="signin" :tab="t('login')">
<n-form>
<n-form-item-row :label="t('credential')" required>
@@ -206,7 +222,7 @@ onMounted(async () => {
<n-spin :show="generateNameLoading">
<n-form>
<span>
<p>{{ t("getNewEmailTip1") }}</p>
<p>{{ t("getNewEmailTip1") + addressRegex.source }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>

View File

@@ -13,7 +13,7 @@ const isPreview = ref(false)
const editorRef = shallowRef()
const { settings, sendMailModel, indexTab } = useGlobalState()
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
const { t } = useI18n({
locale: 'zh',
@@ -136,6 +136,8 @@ const handleCreated = (editor) => {
}
onMounted(async () => {
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
await api.getSettings();
})
</script>
@@ -149,16 +151,15 @@ onMounted(async () => {
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
}}</n-button>
</n-alert>
<br />
<AdminContact />
</div>
<div v-else>
<n-alert type="info" :show-icon="false" :bordered="false">
<n-alert type="info" :show-icon="false" :bordered="false" closable>
{{ t('send_balance') }}: {{ settings.send_balance }}
</n-alert>
<div class="right">
<n-flex justify="end">
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
</div>
</n-flex>
<div class="left">
<n-form :model="sendMailModel">
<n-form-item :label="t('fromName')" label-placement="top">
@@ -230,9 +231,7 @@ onMounted(async () => {
justify-content: left;
}
.right {
text-align: right;
place-items: right;
justify-content: right;
.n-alert {
margin-bottom: 10px;
}
</style>

View File

@@ -1,129 +1,28 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
const { settings } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
successTip: 'Success',
test: 'Test',
save: 'Save',
notEnabled: 'Webhook is not enabled for you',
urlMissing: 'URL is required',
},
zh: {
successTip: '成功',
test: '测试',
save: '保存',
notEnabled: 'Webhook 未开启,请联系管理员开启',
urlMissing: 'URL 不能为空',
}
}
});
class WebhookSettings {
url: string = ''
method: string = 'POST'
headers: string = JSON.stringify({}, null, 2)
body: string = JSON.stringify({}, null, 2)
}
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
const enableWebhook = ref(false)
import WebhookComponent from '../../components/WebhookComponent.vue'
const fetchData = async () => {
try {
const res = await api.fetch(`/api/webhook/settings`)
Object.assign(webhookSettings.value, res)
enableWebhook.value = true
} catch (error) {
message.error((error as Error).message || "error");
}
return await api.fetch(`/api/webhook/settings`)
}
const saveSettings = async () => {
if (!webhookSettings.value.url) {
message.error(t('urlMissing'))
return
}
try {
await api.fetch(`/api/webhook/settings`, {
method: 'POST',
body: JSON.stringify(webhookSettings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
const saveSettings = async (webhookSettings: any) => {
await api.fetch(`/api/webhook/settings`, {
method: 'POST',
body: JSON.stringify(webhookSettings),
})
}
const testSettings = async () => {
if (!webhookSettings.value.url) {
message.error(t('urlMissing'))
return
}
try {
await api.fetch(`/api/webhook/test`, {
method: 'POST',
body: JSON.stringify(webhookSettings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
const testSettings = async (webhookSettings: any) => {
await api.fetch(`/api/webhook/test`, {
method: 'POST',
body: JSON.stringify(webhookSettings),
})
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center" v-if="settings.address">
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
<n-form-item-row label="URL">
<n-input v-model:value="webhookSettings.url" />
</n-form-item-row>
<n-form-item-row label="METHOD">
<n-select v-model:value="webhookSettings.method" tag :options='[
{ label: "POST", value: "POST" }
]' />
</n-form-item-row>
<n-form-item-row label="HEADERS">
<n-input v-model:value="webhookSettings.headers" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-form-item-row label="BODY">
<n-input v-model:value="webhookSettings.body" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-button @click="testSettings" secondary block strong>
{{ t('test') }}
</n-button>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</n-card>
<n-result v-else status="404" :title="t('notEnabled')" />
</div>
<WebhookComponent :fetchData="fetchData" :saveSettings="saveSettings" :testSettings="testSettings" />
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>

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 } = useGlobalState()
const { telegramApp, loading, useUTCDate } = useGlobalState()
const route = useRoute()
const curMail = ref({});
@@ -26,12 +27,16 @@ const fetchMailData = async () => {
mailId: route.query.mail_id
})
});
loading.value = true;
return await processItem(res);
}
catch (error) {
console.error(error);
return {};
}
finally {
loading.value = false;
}
};
onMounted(async () => {
@@ -46,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

@@ -31,7 +31,8 @@ const { t } = useI18n({
onMounted(async () => {
await api.getUserOpenSettings(message);
await api.getUserSettings(message);
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
});
</script>

View File

@@ -1,18 +1,21 @@
<script setup>
import { useMessage } from 'naive-ui'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref } from "vue";
import { onMounted, ref } from "vue";
import { useI18n } from 'vue-i18n'
import { KeyFilled } from '@vicons/material'
import { api } from '../../api';
import { useGlobalState } from '../../store'
import { hashPassword } from '../../utils';
import { startAuthentication } from '@simplewebauthn/browser';
import Turnstile from '../../components/Turnstile.vue';
const { userJwt, userTab, userOpenSettings, openSettings } = useGlobalState()
const {
userJwt, userOpenSettings, openSettings,
userOauth2SessionState, userOauth2SessionClientID
} = useGlobalState()
const message = useMessage();
const router = useRouter();
const { t } = useI18n({
messages: {
@@ -33,6 +36,8 @@ const { t } = useI18n({
pleaseInputCode: 'Please input code',
pleaseCompleteTurnstile: 'Please complete turnstile',
pleaseLogin: 'Please login',
loginWithPasskey: 'Login with Passkey',
loginWith: 'Login with {provider}',
},
zh: {
login: '登录',
@@ -51,6 +56,8 @@ const { t } = useI18n({
pleaseInputCode: '请输入验证码',
pleaseCompleteTurnstile: '请完成人机验证',
pleaseLogin: '请登录',
loginWithPasskey: '使用 Passkey 登录',
loginWith: '使用 {provider} 登录',
}
}
});
@@ -156,6 +163,45 @@ const emailSignup = async () => {
}
};
const passkeyLogin = async () => {
try {
const options = await api.fetch(`/user_api/passkey/authenticate_request`, {
method: 'POST',
body: JSON.stringify({
domain: location.hostname,
})
})
const credential = await startAuthentication(options)
// Send the result to the server and return the promise.
const res = await api.fetch(`/user_api/passkey/authenticate_response`, {
method: 'POST',
body: JSON.stringify({
origin: location.origin,
domain: location.hostname,
credential
})
})
userJwt.value = res.jwt;
location.reload();
} catch (e) {
console.error(e)
message.error(e.message)
}
};
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 () => {
});
@@ -163,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>
@@ -178,6 +224,17 @@ onMounted(async () => {
<n-button @click="showModal = true" type="info" quaternary size="tiny">
{{ t('forgotPassword') }}
</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')">
@@ -244,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

@@ -1,16 +1,22 @@
<script setup>
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { startRegistration } from '@simplewebauthn/browser';
import { NButton, NPopconfirm } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { userJwt, userSettings, } = useGlobalState()
const router = useRouter()
const message = useMessage()
const showLogout = ref(false)
const showCreatePasskey = ref(false)
const passkeyName = ref('')
const showPasskeyList = ref(false)
const showRenamePasskey = ref(false)
const currentPasskeyId = ref(null)
const currentPasskeyName = ref('')
const { t } = useI18n({
messages: {
@@ -18,11 +24,35 @@ const { t } = useI18n({
logout: 'Logout',
logoutConfirm: 'Are you sure you want to logout?',
passordTip: 'The server will only receive the hash value of the password, and will not receive the plaintext password, so it cannot view or retrieve your password. If the administrator enables email verification, you can reset the password in incognito mode',
createPasskey: 'Create Passkey',
showPasskeyList: 'Show Passkey List',
passkeyCreated: 'Passkey created successfully',
passkeyNamePlaceholder: 'Please enter the passkey name or leave it empty to generate a random one',
renamePasskey: 'Rename Passkey',
deletePasskey: 'Delete Passkey',
passkey_name: 'Passkey Name',
created_at: 'Created At',
updated_at: 'Updated At',
actions: 'Actions',
renamePasskey: 'Rename Passkey',
renamePasskeyNamePlaceholder: 'Please enter the new passkey name',
},
zh: {
logout: '退出登录',
logoutConfirm: '确定要退出登录吗?',
passordTip: '服务器只会接收到密码的哈希值,不会接收到明文密码,因此无法查看或者找回您的密码, 如果管理员启用了邮件验证您可以在无痕模式重置密码',
createPasskey: '创建 Passkey',
showPasskeyList: '查看 Passkey 列表',
passkeyCreated: 'Passkey 创建成功',
passkeyNamePlaceholder: '请输入 Passkey 名称或者留空自动生成',
renamePasskey: '重命名 Passkey',
deletePasskey: '删除 Passkey',
passkey_name: 'Passkey 名称',
created_at: '创建时间',
updated_at: '更新时间',
actions: '操作',
renamePasskey: '重命名 Passkey',
renamePasskeyNamePlaceholder: '请输入新的 Passkey 名称',
}
}
});
@@ -33,17 +63,144 @@ const logout = async () => {
location.reload()
}
const fetchData = async () => {
const createPasskey = async () => {
try {
const options = await api.fetch(`/user_api/passkey/register_request`, {
method: 'POST',
body: JSON.stringify({
domain: location.hostname,
})
})
const credential = await startRegistration(options)
// Send the result to the server and return the promise.
await api.fetch(`/user_api/passkey/register_response`, {
method: 'POST',
body: JSON.stringify({
origin: location.origin,
passkey_name: passkeyName.value || (
(window.navigator.userAgentData?.platform || "Unknown")
+ ": " + Math.random().toString(36).substring(7)
),
credential
})
})
message.success(t('passkeyCreated'));
} catch (e) {
console.error(e)
message.error(e.message)
} finally {
passkeyName.value = ''
showCreatePasskey.value = false
}
}
onMounted(async () => {
await fetchData()
})
const passkeyColumns = [
{
title: "Passkey ID",
key: "passkey_id"
},
{
title: t('passkey_name'),
key: "passkey_name"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('updated_at'),
key: "updated_at"
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
[
h(NButton,
{
tertiary: true,
type: "primary",
onClick: () => {
showRenamePasskey.value = true;
currentPasskeyId.value = row.passkey_id;
}
},
{ default: () => t('renamePasskey') }
),
h(NPopconfirm,
{
onPositiveClick: async () => {
try {
await api.fetch(`/user_api/passkey/${row.passkey_id}`, {
method: 'DELETE'
})
await fetchPasskeyList()
} catch (e) {
console.error(e)
message.error(e.message)
}
}
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "error",
},
{ default: () => t('deletePasskey') }
),
default: () => `${t('deletePasskey')}?`
}
),
]
])
}
}
]
const passkeyData = ref([])
const fetchPasskeyList = async () => {
try {
const data = await api.fetch(`/user_api/passkey`)
passkeyData.value = data
} catch (e) {
console.error(e)
message.error(e.message)
}
}
const renamePasskey = async () => {
try {
await api.fetch(`/user_api/passkey/rename`, {
method: 'POST',
body: JSON.stringify({
passkey_name: currentPasskeyName.value,
passkey_id: currentPasskeyId.value
})
})
await fetchPasskeyList()
} catch (e) {
console.error(e)
message.error(e.message)
} finally {
currentPasskeyName.value = ''
showRenamePasskey.value = false
}
}
</script>
<template>
<div class="center" v-if="userSettings.user_email">
<n-card :bordered="false" embedded>
<n-button @click="showPasskeyList = true; fetchPasskeyList();" secondary block strong>
{{ t('showPasskeyList') }}
</n-button>
<n-button @click="showCreatePasskey = true" type="primary" secondary block strong>
{{ t('createPasskey') }}
</n-button>
<n-alert :show-icon="false" :bordered="false">
<span>
{{ t('passordTip') }}
@@ -53,6 +210,25 @@ onMounted(async () => {
{{ t('logout') }}
</n-button>
</n-card>
<n-modal v-model:show="showCreatePasskey" preset="dialog" :title="t('createPasskey')">
<n-input v-model:value="passkeyName" :placeholder="t('passkeyNamePlaceholder')" />
<template #action>
<n-button :loading="loading" @click="createPasskey" size="small" tertiary type="primary">
{{ t('createPasskey') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showRenamePasskey" preset="dialog" :title="t('renamePasskey')">
<n-input v-model:value="currentPasskeyName" :placeholder="t('renamePasskeyNamePlaceholder')" />
<template #action>
<n-button :loading="loading" @click="renamePasskey" size="small" tertiary type="primary">
{{ t('renamePasskey') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showPasskeyList" preset="card" :title="t('showPasskeyList')">
<n-data-table :columns="passkeyColumns" :data="passkeyData" :bordered="false" embedded />
</n-modal>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
@@ -78,5 +254,6 @@ onMounted(async () => {
.n-button {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

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 @@
{
"name": "temp-email-pages",
"version": "1.0.0",
"version": "0.8.1",
"description": "",
"main": "index.js",
"scripts": {
@@ -11,6 +11,6 @@
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^3.62.0"
"wrangler": "^3.89.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' },
@@ -135,12 +135,14 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ text: '配置 Telegram Bot', link: 'feature/telegram' },
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
{ text: '配置 webhook', link: 'feature/webhook' },
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
{ text: 'Oauth2 第三方登录', link: 'feature/user-oauth2' },
]
},
{
text: '功能简介',
collapsed: true,
collapsed: false,
items: [
{ text: 'Admin 控制台', link: 'feature/admin' },
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },

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,12 +78,18 @@ node_compat = true
PREFIX = "tmp" # The mailbox name prefix to be processed
# (min, max) length of the adderss, if not set, the default is (1, 30)
# ANNOUNCEMENT = "Custom Announcement"
# address check REGEX, if not set, will not check
# ADDRESS_CHECK_REGEX = "^(?!.*admin).*"
# address name replace REGEX, if not set, the default is [^a-z0-9]
# ADDRESS_REGEX = "[^a-z0-9]"
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# If you want your site to be private, uncomment below and change your password
# PASSWORDS = ["123", "456"]
# admin console password, if not configured, access to the console is not allowed
# ADMIN_PASSWORDS = ["123", "456"]
# warning: no password or user check for admin portal
# DISABLE_ADMIN_PASSWORD_CHECK = false
# admin contact information. If not configured, it will not be displayed. Any string can be configured.
# ADMIN_CONTACT = "xx@xx.xxx"
DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users
@@ -91,6 +97,7 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all your domain name
# For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification)
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix
# USER_ROLES = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },
@@ -108,15 +115,23 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true
# Footer text
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # Disable Show GitHub link
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # the role which can send emails without limit
# Turnstile verification configuration
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# telegram bot
# TG_MAX_ACCOUNTS = 5
# TG_MAX_ADDRESS = 5
# telegram bot info, predefined bot info can reduce latency of the webhook
# TG_BOT_INFO = "{}"
# global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
# Frontend URL
# FRONTEND_URL = "https://xxxx.xxx"
# Enable check junk mail
# ENABLE_CHECK_JUNK_MAIL = false
[[d1_databases]]
binding = "DB"

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

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.

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,7 +25,9 @@ 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 },
@@ -46,12 +48,18 @@ node_compat = true
PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串
# (min, max) adderss的长度如果不设置默认为(1, 30)
# ANNOUNCEMENT = "Custom Announcement" # 自定义公告
# address name 的正则表达式, 只用于检查,符合条件将通过检查
# ADDRESS_CHECK_REGEX = "^(?!.*admin).*"
# address name 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 [^a-z0-9], 需谨慎使用, 有些符号可能导致无法收件
# ADDRESS_REGEX = "[^a-z0-9]"
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# 如果你想要你的网站私有,取消下面的注释,并修改密码
# PASSWORDS = ["123", "456"]
# admin 控制台密码, 不配置则不允许访问控制台
# ADMIN_PASSWORDS = ["123", "456"]
# 警告: 管理员控制台没有密码或用户检查
# DISABLE_ADMIN_PASSWORD_CHECK = false
# admin 联系方式,不配置则不显示,可配置任意字符串
# ADMIN_CONTACT = "xx@xx.xxx"
# DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 默认用户可用的域名(未登录或未分配角色的用户)
@@ -60,6 +68,8 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名, 支持多个域名
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# 新用户默认角色, 仅在启用邮件验证时有效
# USER_DEFAULT_ROLE = "vip"
# admin 角色配置, 如果用户角色等于 ADMIN_USER_ROLE 则可以访问 admin 控制台
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# 用户角色配置, 如果 domains 为空将使用 default_domains
# 如果 prefix 为 null 将使用默认前缀, 如果 prefix 为空字符串将不使用前缀
# USER_ROLES = [
@@ -78,15 +88,23 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true
# 前端界面页脚文本
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # 是否显示 GitHub 链接
# 默认发送邮件余额,如果不设置,将为 0
# DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # 可以无限发送邮件的角色
# Turnstile 人机验证配置
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# telegram bot 最多绑定邮箱数量
# TG_MAX_ACCOUNTS = 5
# TG_MAX_ADDRESS = 5
# telegram BOT_INFO预定义的 BOT_INFO 可以降低 webhook 的延迟
# TG_BOT_INFO = "{}"
# 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
# 前端地址,用于发送 webhook 的邮件 url
# FRONTEND_URL = "https://xxxx.xxx"
# 是否启用垃圾邮件检查
# ENABLE_CHECK_JUNK_MAIL = false
# D1 数据库的名称和 ID 可以在 cloudflare 控制台查看
[[d1_databases]]

View File

@@ -1,7 +1,5 @@
# Admin 用户相关
默认不允许用户注册,可通过
## 用户管理页面
![admin-user-management](/feature/admin-user-management.png)

View File

@@ -1,7 +1,15 @@
# Admin 控制台
部署前端应用之后,访问 `/admin` 路径即可进入管理控制台。
> [!NOTE]
> 需要配置 `ADMIN_PASSWORDS` 或者 `ADMIN_USER_ROLE` 才可以访问 admin 控制台
> admin 角色配置, 如果用户角色等于 ADMIN_USER_ROLE 则可以访问 admin 控制台
需要在后端配置 `admin 控制台密码`, 不配置则不允许访问控制台。
部署前端应用之后,点击 左上角 logo 5 次 或者访问 `/admin` 路径即可进入管理控制台。
需要在后端配置 `ADMIN_PASSWORDS` 或者当前用户角色为 `ADMIN_USER_ROLE`, 则不允许访问控制台。
![admin](/feature/admin.png)
## 如果你的网站只可私人访问,可通过此禁用检查
`DISABLE_ADMIN_PASSWORD_CHECK = true`

View File

@@ -23,3 +23,70 @@ res = requests.post(
# 返回值 {"jwt": "<Jwt>"}
print(res.json())
```
# 批量创建随机用户名邮箱地址 API 示例
## 通过 admin API 批量新建邮箱地址
这是一个 `python` 的例子,使用 `requests` 库发送邮件。
```python
import requests
import random
import string
from concurrent.futures import ThreadPoolExecutor, as_completed
def generate_random_name():
# 生成5位英文字符
letters1 = ''.join(random.choices(string.ascii_lowercase, k=5))
# 生成1-3个数字
numbers = ''.join(random.choices(string.digits, k=random.randint(1, 3)))
# 生成1-3个英文字符
letters2 = ''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))
# 组合成最终名称
return letters1 + numbers + letters2
def fetch_email_data(name):
try:
res = requests.post(
"https://<worker 域名>",
json={
"enablePrefix": True,
"name": name,
"domain": "<邮箱域名>",
},
headers={
'x-admin-auth': "<你的网站admin密码>",
"Content-Type": "application/json"
}
)
if res.status_code == 200:
response_data = res.json()
email = response_data.get("address", "无地址")
jwt = response_data.get("jwt", "无jwt")
return f"{email}----{jwt}\n"
else:
print(f"请求失败,状态码: {res.status_code}")
return None
except requests.RequestException as e:
print(f"请求出现错误: {e}")
return None
def generate_and_save_emails(num_emails):
with ThreadPoolExecutor(max_workers=30) as executor, open('email.txt', 'a') as file:
futures = [executor.submit(fetch_email_data, generate_random_name()) for _ in range(num_emails)]
for future in as_completed(futures):
result = future.result()
if result:
file.write(result)
# 生成10个邮箱并追加到现有文件
generate_and_save_emails(10)
```

View File

@@ -1,10 +1,12 @@
# 配置 Telegram Bot
## Telegram Bot 配置
> [!NOTE]
> 如果要使用 Telegram Bot, 请先绑定 `KV`
>
> 如果不需要 Telegram Bot, 可跳过此步骤
## Telegram Bot 配置
请先创建一个 Telegram Bot然后获取 `token`,然后执行下面的命令,将 `token` 添加到 secrets 中
你也可以在 Cloudflare 的 UI 界面中添加 `secrets`
@@ -23,6 +25,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

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

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

@@ -6,3 +6,26 @@
打开 [cloudflare控制台](https://dash.cloudflare.com/)
请查看通过 [命令行部署](/zh/guide/cli/pre-requisite) 或者 [用户界面部署](/zh/guide/ui/d1)
## 升级流程
首先确认当前的版本,然后访问 [Release 页面](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/) 和 [CHANGELOG 页面](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/CHANGELOG.md) 中找到当前的版本
> [!WARNING] 注意
> 需要注意 `Breaking Changes` 是必须进行 `数据库 sql 执行` 或者 `变量配置` 的
然后查看从当前版本往后的所有更改,需要注意 `Breaking Changes` 是必须进行 `数据库 sql 执行` 或者 `变量配置` 的, 其他的功能更新按需配置即可
然后参考下面的文档使用 `CLI` 或者 `UI` 覆盖部署之前的 `worker``pages` 即可
CLI 部署
- [命令行更新 d1](/zh/guide/cli/d1)
- [命令行部署 worker](/zh/guide/cli/worker)
- [命令行部署 pages](/zh/guide/cli/worker)
UI 部署
- [用户界面更新 d1](/zh/guide/ui/d1)
- [用户界面部署 worker](/zh/guide/ui/worker)
- [用户界面部署 pages](/zh/guide/ui/pages)

View File

@@ -8,6 +8,10 @@
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
::: warning 注意
下面输入的是 `db/schema.sql` 的内容
:::
打开 `Console` 标签页,输入 `db/schema.sql` 的内容,点击 `Execute` 执行
![d1](/ui_install/d1-exec.png)

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,7 +26,7 @@
![worker2](/ui_install/worker-2.png)
![worker-upload](/ui_install/worker-upload.png)
5. 点击 `Settings` -> `Trggers`, 这里可以添加自己的域名,你也可以使用自动生成的 `*.workers.dev` 的域名。记录下这个域名,后面部署前端会用到。
6. 点击 `Settings` -> `Trggers`, 这里可以添加自己的域名,你也可以使用自动生成的 `*.workers.dev` 的域名。记录下这个域名,后面部署前端会用到。
> [!NOTE]
> 打开 `worker` 的 `url`,如果显示 `OK` 说明部署成功
@@ -31,22 +35,33 @@
![worker3](/ui_install/worker-3.png)
6. 点击 `Settings` -> `Variables`, 如图所示添加变量,参考 [修改 wrangler.toml 配置文件](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件) 中的 `vars` 部分
7. 点击 `Settings` -> `Variables`, 如图所示添加变量,参考 [修改 wrangler.toml 配置文件](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件) 中的 `[vars]` 部分
> [!NOTE]
> 注意字符串格式的变量的最外层的引号是不需要的
>
> - 对于 `USER_ROLES` 请配置为此格式 `[{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"vip","prefix":"vip"},{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"admin","prefix":""}]`
![worker-var](/ui_install/worker-var.png)
7. 点击 `Settings` -> `Variables`, 下拉找到 `D1 Database`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 D1 数据库,点击 `Deploy`
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. 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `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 配置
10. Telegram Bot 配置
> [!NOTE]
> 如果不需要 Telegram Bot, 可跳过此步骤

View File

@@ -1,12 +1,12 @@
{
"name": "temp-mail-docs",
"private": true,
"version": "0.2.6",
"version": "0.8.1",
"type": "module",
"devDependencies": {
"@types/node": "^20.14.10",
"vitepress": "^1.2.3",
"wrangler": "^3.63.1"
"@types/node": "^22.9.1",
"vitepress": "^1.5.0",
"wrangler": "^3.89.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.1",
"private": true,
"type": "module",
"scripts": {
@@ -11,20 +11,22 @@
"build": "wrangler deploy --dry-run --outdir dist --minify"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240620.0",
"@cloudflare/workers-types": "^4.20241112.0",
"@eslint/js": "8.56.0",
"@simplewebauthn/types": "^10.0.0",
"eslint": "8.56.0",
"globals": "^15.8.0",
"typescript-eslint": "^7.15.0",
"wrangler": "^3.63.1"
"globals": "^15.12.0",
"typescript-eslint": "^7.18.0",
"wrangler": "^3.89.0"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.609.0",
"@aws-sdk/s3-request-presigner": "^3.609.0",
"hono": "^4.4.12",
"@aws-sdk/client-s3": "^3.698.0",
"@aws-sdk/s3-request-presigner": "^3.698.0",
"@simplewebauthn/server": "^10.0.1",
"hono": "^4.6.11",
"mimetext": "^3.0.24",
"postal-mime": "^2.2.5",
"resend": "^3.4.0",
"postal-mime": "^2.3.2",
"resend": "^3.5.0",
"telegraf": "4.16.3"
},
"pnpm": {

2200
worker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,9 @@ import { CONSTANTS } from '../constants'
import cleanup_api from './cleanup_api'
import admin_user_api from './admin_user_api'
import webhook_settings from './webhook_settings'
import mail_webhook_settings from './mail_webhook_settings'
import oauth2_settings from './oauth2_settings'
import worker_config from './worker_config'
export const api = new Hono<HonoCustomType>()
@@ -40,7 +43,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)
@@ -246,10 +255,12 @@ api.get('/admin/account_settings', async (c) => {
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
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') : [];
return c.json({
blockList: blockList || [],
sendBlockList: sendBlockList || [],
verifiedAddressList: verifiedAddressList || []
verifiedAddressList: verifiedAddressList || [],
fromBlockList: fromBlockList || []
})
} catch (error) {
console.error(error);
@@ -259,7 +270,7 @@ 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 } = await c.req.json();
const { blockList, sendBlockList, verifiedAddressList, fromBlockList } = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text("Invalid blockList or sendBlockList", 400)
}
@@ -278,14 +289,23 @@ api.post('/admin/account_settings', async (c) => {
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
JSON.stringify(verifiedAddressList)
)
if (fromBlockList?.length > 0 && !c.env.KV) {
return c.text("Please enable KV to use fromBlockList", 400)
}
if (fromBlockList) {
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList || []))
}
return c.json({
success: true
})
})
// cleanup
api.post('/admin/cleanup', cleanup_api.cleanup)
api.get('/admin/auto_cleanup', cleanup_api.getCleanup)
api.post('/admin/auto_cleanup', cleanup_api.saveCleanup)
// user settings
api.get('/admin/user_settings', admin_user_api.getSetting)
api.post('/admin/user_settings', admin_user_api.saveSetting)
api.get('/admin/users', admin_user_api.getUsers)
@@ -294,5 +314,19 @@ api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
api.get('/admin/user_roles', async (c) => c.json(getUserRoles(c)))
api.post('/admin/user_roles', admin_user_api.updateUserRoles)
// user oauth2 settings
api.get('/admin/user_oauth2_settings', oauth2_settings.getUserOauth2Settings)
api.post('/admin/user_oauth2_settings', oauth2_settings.saveUserOauth2Settings)
// webhook settings
api.get("/admin/webhook/settings", webhook_settings.getWebhookSettings);
api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings);
// mail webhook settings
api.get("/admin/mail_webhook/settings", mail_webhook_settings.getWebhookSettings);
api.post("/admin/mail_webhook/settings", mail_webhook_settings.saveWebhookSettings);
api.post("/admin/mail_webhook/test", mail_webhook_settings.testWebhookSettings);
// worker config
api.get("/admin/worker/configs", worker_config.getConfig);

View File

@@ -0,0 +1,50 @@
import { Context } from "hono";
import { HonoCustomType } from "../types";
import { CONSTANTS } from "../constants";
import { WebhookSettings } from "../models";
import { commonParseMail, sendWebhook } from "../common";
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.env.KV.get<WebhookSettings>(
CONSTANTS.WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY, "json"
) || new WebhookSettings();
return c.json(settings);
}
async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.req.json<WebhookSettings>();
await c.env.KV.put(
CONSTANTS.WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY,
JSON.stringify(settings));
return c.json({ success: true })
}
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.req.json<WebhookSettings>();
// random raw email
const { id: mailId, raw } = await c.env.DB.prepare(
`SELECT id, raw FROM raw_mails ORDER BY RANDOM() LIMIT 1`
).first<{ id: string, raw: string }>() || {};
const parsedEmail = await commonParseMail(raw);
const res = await sendWebhook(settings, {
id: mailId || "0",
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
from: parsedEmail?.sender || "test@test.com",
to: "admin@test.com",
subject: parsedEmail?.subject || "test subject",
raw: raw || "test raw email",
parsedText: parsedEmail?.text || "test parsed text",
parsedHtml: parsedEmail?.html || "test parsed html"
});
if (!res.success) {
return c.text(res.message || "send webhook error", 400);
}
return c.json({ success: true });
}
export default {
getWebhookSettings,
saveWebhookSettings,
testWebhookSettings,
}

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,47 @@
import { Context } from 'hono';
import { HonoCustomType } from '../types';
import { getAdminPasswords, getBooleanValue, getDefaultDomains, getDomains, getIntValue, getPasswords, getStringArray, getStringValue, getUserRoles } from '../utils';
import { CONSTANTS } from '../constants';
import { isS3Enabled } from '../mails_api/s3_attachment';
export default {
getConfig: async (c: Context<HonoCustomType>) => {
return c.json({
"TITLE": c.env.TITLE,
"HAS_PASSWORD": getPasswords(c).length,
"HAS_ADMIN_PASSWORDS": getAdminPasswords(c).length,
"ANNOUNCEMENT": getStringValue(c.env.ANNOUNCEMENT),
"PREFIX": c.env.PREFIX,
"ADDRESS_CHECK_REGEX": getStringValue(c.env.ADDRESS_CHECK_REGEX),
"ADDRESS_REGEX": getStringValue(c.env.ADDRESS_REGEX),
"MIN_ADDRESS_LEN": getIntValue(c.env.MIN_ADDRESS_LEN, 1),
"MAX_ADDRESS_LEN": getIntValue(c.env.MAX_ADDRESS_LEN, 30),
"FORWARD_ADDRESS_LIST": getStringArray(c.env.FORWARD_ADDRESS_LIST),
"DEFAULT_DOMAINS": getDefaultDomains(c),
"DOMAINS": getDomains(c),
"DOMAIN_LABELS": getStringArray(c.env.DOMAIN_LABELS),
"HAS_JWT_SECRET": !!getStringValue(c.env.JWT_SECRET),
"ADMIN_USER_ROLE": getStringValue(c.env.ADMIN_USER_ROLE),
"USER_DEFAULT_ROLE": getStringValue(c.env.USER_DEFAULT_ROLE),
"USER_ROLES": getUserRoles(c),
"NO_LIMIT_SEND_ROLE": getStringValue(c.env.NO_LIMIT_SEND_ROLE),
"ADMIN_CONTACT": c.env.ADMIN_CONTACT,
"ENABLE_USER_CREATE_EMAIL": getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
"ENABLE_USER_DELETE_EMAIL": getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
"ENABLE_AUTO_REPLY": getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"COPYRIGHT": c.env.COPYRIGHT,
"ENABLE_WEBHOOK": getBooleanValue(c.env.ENABLE_WEBHOOK),
"S3_ENABLED": isS3Enabled(c),
"VERSION": CONSTANTS.VERSION,
"DISABLE_SHOW_GITHUB": !getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"DISABLE_ADMIN_PASSWORD_CHECK": getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
"ENABLE_CHECK_JUNK_MAIL": getBooleanValue(c.env.ENABLE_CHECK_JUNK_MAIL),
})
}
}

View File

@@ -19,6 +19,7 @@ api.get('/open_api/settings', async (c) => {
"title": c.env.TITLE,
"announcement": getStringValue(c.env.ANNOUNCEMENT),
"prefix": c.env.PREFIX,
"addressRegex": getStringValue(c.env.ADDRESS_REGEX),
"minAddressLen": getIntValue(c.env.MIN_ADDRESS_LEN, 1),
"maxAddressLen": getIntValue(c.env.MAX_ADDRESS_LEN, 30),
"defaultDomains": getDefaultDomains(c),
@@ -35,6 +36,8 @@ api.get('/open_api/settings', async (c) => {
"enableWebhook": getBooleanValue(c.env.ENABLE_WEBHOOK),
"isS3Enabled": isS3Enabled(c),
"version": CONSTANTS.VERSION,
"showGithub": !getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"disableAdminPasswordCheck": getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK)
});
})

View File

@@ -1,20 +1,72 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains } from './utils';
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting } from './utils';
import { HonoCustomType, UserRole } from './types';
import { unbindTelegramByAddress } from './telegram_api/common';
import { CONSTANTS } from './constants';
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
const checkNameRegex = (c: Context<HonoCustomType>, name: string) => {
let error = null;
try {
const regexStr = getStringValue(c.env.ADDRESS_CHECK_REGEX);
if (!regexStr) return;
const regex = new RegExp(regexStr);
if (!regex.test(name)) {
error = new Error(`Name not match regex: /${regexStr}/`);
}
}
catch (e) {
console.error("Failed to check address regex", e);
}
if (error) {
throw error;
}
}
const getNameRegex = (c: Context<HonoCustomType>): RegExp => {
try {
const regex = getStringValue(c.env.ADDRESS_REGEX);
if (!regex) {
return DEFAULT_NAME_REGEX;
}
return new RegExp(regex, 'g');
}
catch (e) {
console.error("Failed to get address regex", e);
}
return DEFAULT_NAME_REGEX;
}
export const newAddress = async (
c: Context<HonoCustomType>,
name: string, domain: string | undefined | null,
enablePrefix: boolean,
checkLengthByConfig: boolean = true,
addressPrefix: string | undefined | null = null,
checkAllowDomains: boolean = true
{
name,
domain,
enablePrefix,
checkLengthByConfig = true,
addressPrefix = null,
checkAllowDomains = true,
enableCheckNameRegex = true,
}: {
name: string, domain: string | undefined | null,
enablePrefix: boolean,
checkLengthByConfig?: boolean,
addressPrefix?: string | undefined | null,
checkAllowDomains?: boolean,
enableCheckNameRegex?: boolean,
}
): Promise<{ address: string, jwt: string }> => {
// remove special characters
name = name.replace(/[^a-z0-9]/g, '')
name = name.replace(getNameRegex(c), '')
// check name
if (enableCheckNameRegex) {
await checkNameBlockList(c, name);
checkNameRegex(c, name);
}
// name min length min 1
const minAddressLength = Math.max(
checkLengthByConfig ? getIntValue(c.env.MIN_ADDRESS_LEN, 1) : 1,
@@ -78,6 +130,22 @@ export const newAddress = async (
}
}
const checkNameBlockList = async (
c: Context<HonoCustomType>, name: string
): Promise<void> => {
// check name block list
const blockList = [] as string[];
try {
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
blockList.push(...(value || []));
} catch (error) {
console.error(error);
}
if (blockList.some((item) => name.includes(item))) {
throw new Error(`Name[${name}]is blocked`);
}
}
export const cleanup = async (
c: Context<HonoCustomType>,
cleanType: string | undefined | null,
@@ -192,7 +260,8 @@ export const commonParseMail = async (raw_mail: string | undefined | null): Prom
sender: string,
subject: string,
text: string,
html: string
html: string,
headers?: Record<string, string>[]
} | undefined> => {
if (!raw_mail) {
return undefined;
@@ -219,6 +288,7 @@ export const commonParseMail = async (raw_mail: string | undefined | null): Prom
subject: parsedEmail.subject || "",
text: parsedEmail.text || "",
html: parsedEmail.html || "",
headers: parsedEmail.headers || [],
};
}
catch (e) {
@@ -257,3 +327,84 @@ export const getAllowDomains = async (c: Context<HonoCustomType>): Promise<strin
const user_role = await commonGetUserRole(c, user.user_id);
return user_role?.domains || getDefaultDomains(c);;
}
export async function sendWebhook(settings: WebhookSettings, formatMap: WebhookMail): Promise<{ success: boolean, message?: string }> {
// send webhook
let body = settings.body;
for (const key of Object.keys(formatMap)) {
/* eslint-disable no-useless-escape */
body = body.replace(
new RegExp(`\\$\\{${key}\\}`, "g"),
JSON.stringify(
formatMap[key as keyof WebhookMail]
).replace(/^"(.*)"$/, '\$1')
);
/* eslint-enable no-useless-escape */
}
console.log("send webhook", settings.url, settings.method, settings.headers, body);
const response = await fetch(settings.url, {
method: settings.method,
headers: JSON.parse(settings.headers),
body: body
});
if (!response.ok) {
console.log("send webhook error", response.status, response.statusText);
return { success: false, message: `send webhook error: ${response.status} ${response.statusText}` };
}
return { success: true }
}
export async function triggerWebhook(
c: Context<HonoCustomType>,
address: string,
raw_mail: string,
message_id: string | null
): Promise<void> {
if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) {
return
}
const webhookList: WebhookSettings[] = []
// admin mail webhook
const adminMailWebhookSettings = await c.env.KV.get<WebhookSettings>(CONSTANTS.WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY, "json");
if (adminMailWebhookSettings?.enabled) {
webhookList.push(adminMailWebhookSettings)
}
// user mail webhook
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (adminSettings?.allowList.includes(address)) {
const settings = await c.env.KV.get<WebhookSettings>(
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json"
);
if (settings?.enabled) {
webhookList.push(settings)
}
}
// no webhook
if (webhookList.length === 0) {
return
}
const mailId = await c.env.DB.prepare(
`SELECT id FROM raw_mails where address = ? and message_id = ?`
).bind(address, message_id).first<string>("id");
const parsedEmail = await commonParseMail(raw_mail);
const webhookMail = {
id: mailId || "",
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
from: parsedEmail?.sender || "",
to: address,
subject: parsedEmail?.subject || "",
raw: raw_mail,
parsedText: parsedEmail?.text || "",
parsedHtml: parsedEmail?.html || ""
}
for (const settings of webhookList) {
const res = await sendWebhook(settings, webhookMail);
if (!res.success) {
console.error(res.message);
}
}
}

View File

@@ -1,11 +1,12 @@
export const CONSTANTS = {
VERSION: 'v0.6.1',
VERSION: 'v0.8.1',
// 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',
// KV
@@ -13,4 +14,6 @@ export const CONSTANTS = {
TG_KV_SETTINGS_KEY: "temp-mail-telegram-settings",
WEBHOOK_KV_SETTINGS_KEY: "temp-mail-webhook-settings",
WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings",
EMAIL_KV_BLACK_LIST: "temp-mail-email-black-list",
WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY: "temp-mail-webhook-admin-mail-settings",
}

View File

@@ -0,0 +1,16 @@
import { CONSTANTS } from "../constants";
import { Bindings } from "../types";
export const isBlocked = async (from: string, env: Bindings): Promise<boolean> => {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => from.includes(word))) {
return true;
}
if (!env.KV) {
return false;
}
const blockList = await env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') || [];
if (blockList.some(word => from.includes(word))) {
return true;
}
return false;
}

View File

@@ -0,0 +1,44 @@
import { Bindings } from "../types";
import { getBooleanValue } from "../utils";
import { commonParseMail } from "../common";
export const check_if_junk_mail = async (
env: Bindings, address: string,
raw_mail: string, message_id: string | null
): Promise<boolean> => {
if (!getBooleanValue(env.ENABLE_CHECK_JUNK_MAIL)) {
return false;
}
const parsedEmail = await commonParseMail(raw_mail);
if (!parsedEmail?.headers) return false;
const headers = parsedEmail.headers;
for (const header of headers) {
if (!header["key"]) continue;
if (!header["value"]) continue;
// check spf
if (header["key"].toLowerCase() == "received-spf"
&&
!header["value"].toLowerCase().includes("pass")
) {
return true;
}
// check dkim and dmarc
if (header["key"].toLowerCase() == "authentication-results") {
if (header["value"].toLowerCase().includes("dkim=")
&&
!header["value"].toLowerCase().includes("dkim=pass")
) {
return true;
}
if (header["value"].toLowerCase().includes("dmarc=")
&&
!header["value"].toLowerCase().includes("dmarc=pass")
) {
return true;
}
}
}
return false;
}

View File

@@ -4,16 +4,31 @@ import { getEnvStringList } from "../utils";
import { sendMailToTelegram } from "../telegram_api";
import { Bindings, HonoCustomType } from "../types";
import { auto_reply } from "./auto_reply";
import { trigerWebhook } from "../mails_api/webhook_settings";
import { isBlocked } from "./black_list";
import { triggerWebhook } from "../common";
import { check_if_junk_mail } from "./check_junk";
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
message.setReject("Missing from address");
if (await isBlocked(message.from, env)) {
message.setReject("Reject from address");
console.log(`Reject message from ${message.from} to ${message.to}`);
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(
@@ -47,9 +62,9 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
// send webhook
try {
await trigerWebhook(
await triggerWebhook(
{ env: env } as Context<HonoCustomType>,
message.to, rawEmail
message.to, rawEmail, message_id
);
} catch (error) {
console.log("send webhook error", error);

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { HonoCustomType } from "../types";
import { getBooleanValue, getJsonSetting, checkCfTurnstile } from '../utils';
import { getBooleanValue, getJsonSetting, checkCfTurnstile, getStringValue } from '../utils';
import { newAddress, handleListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains } from '../common'
import { CONSTANTS } from '../constants'
import auto_reply from './auto_reply'
@@ -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)
@@ -49,6 +58,7 @@ api.delete('/api/mails/:id', async (c) => {
api.get('/api/settings', async (c) => {
const { address, address_id } = c.get("jwtPayload")
const user_role = c.get("userRolePayload")
if (address_id && address_id > 0) {
try {
const db_address_id = await c.env.DB.prepare(
@@ -82,7 +92,8 @@ api.get('/api/settings', async (c) => {
} catch (e) {
console.warn("Failed to update address")
}
const balance = await c.env.DB.prepare(
const is_no_limit_send_balance = user_role && user_role === getStringValue(c.env.NO_LIMIT_SEND_ROLE);
const balance = is_no_limit_send_balance ? 99999 : await c.env.DB.prepare(
`SELECT balance FROM address_sender where address = ? and enabled = 1`
).bind(address).first("balance");
return c.json({
@@ -119,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

@@ -4,7 +4,7 @@ import { createMimeMessage } from 'mimetext';
import { Resend } from 'resend';
import { CONSTANTS } from '../constants'
import { getJsonSetting, getDomains, getIntValue, getBooleanValue } from '../utils';
import { getJsonSetting, getDomains, getIntValue, getBooleanValue, getStringValue } from '../utils';
import { GeoData } from '../models'
import { handleListQuery } from '../common'
import { HonoCustomType } from '../types';
@@ -105,13 +105,17 @@ export const sendMail = async (
if (!domains.includes(mailDomain)) {
throw new Error("Invalid domain")
}
// check permission
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
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) {
// check permission
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
where address = ? and enabled = 1`
).bind(address).first<number>("balance");
if (!balance || balance <= 0) {
throw new Error("No balance")
).bind(address).first<number>("balance");
if (!balance || balance <= 0) {
throw new Error("No balance")
}
}
const {
from_name, to_mail, to_name,
@@ -154,7 +158,7 @@ export const sendMail = async (
throw new Error("Please enable resend or verified address list")
}
// update balance
if (!sendByVerifiedAddressList) {
if (!sendByVerifiedAddressList && !is_no_limit_send_balance) {
try {
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET balance = balance - 1 where address = ?`

View File

@@ -1,35 +1,12 @@
import { Context } from "hono";
import { HonoCustomType } from "../types";
import { CONSTANTS } from "../constants";
import { AdminWebhookSettings, WebhookMail } from "../models";
import { AdminWebhookSettings, WebhookSettings } from "../models";
import { getBooleanValue } from "../utils";
import { commonParseMail } from "../common";
class WebhookSettings {
url: string = ''
method: string = 'POST'
headers: string = JSON.stringify({
"Content-Type": "application/json"
}, null, 2)
body: string = JSON.stringify({
"from": "${from}",
"to": "${to}",
"subject": "${subject}",
"raw": "${raw}",
"parsedText": "${parsedText}",
"parsedHtml": "${parsedHtml}",
}, null, 2)
}
import { commonParseMail, sendWebhook } from "../common";
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
if (!c.env.KV) {
return c.text("KV is not available", 400);
}
if (!getBooleanValue(c.env.ENABLE_WEBHOOK)) {
return c.text("Webhook is disabled", 403);
}
const { address } = c.get("jwtPayload")
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (!adminSettings?.allowList.includes(address)) {
@@ -55,73 +32,18 @@ async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response
return c.json({ success: true })
}
async function sendWebhook(settings: WebhookSettings, formatMap: WebhookMail): Promise<{ success: boolean, message?: string }> {
// send webhook
let body = settings.body;
for (const key of Object.keys(formatMap)) {
/* eslint-disable no-useless-escape */
body = body.replace(
new RegExp(`\\$\\{${key}\\}`, "g"),
JSON.stringify(
formatMap[key as keyof WebhookMail]
).replace(/^"(.*)"$/, '\$1')
);
/* eslint-enable no-useless-escape */
}
const response = await fetch(settings.url, {
method: settings.method,
headers: JSON.parse(settings.headers),
body: body
});
if (!response.ok) {
console.log("send webhook error", response.status, response.statusText);
return { success: false, message: `send webhook error: ${response.status} ${response.statusText}` };
}
return { success: true }
}
export async function trigerWebhook(
c: Context<HonoCustomType>,
address: string,
raw_mail: string
): Promise<void> {
if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) {
return
}
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (!adminSettings?.allowList.includes(address)) {
return;
}
const settings = await c.env.KV.get<WebhookSettings>(
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json"
);
if (!settings) {
return;
}
const parsedEmail = await commonParseMail(raw_mail);
const res = await sendWebhook(settings, {
from: parsedEmail?.sender || "",
to: address,
subject: parsedEmail?.subject || "",
raw: raw_mail,
parsedText: parsedEmail?.text || "",
parsedHtml: parsedEmail?.html || ""
});
if (!res.success) {
console.log(res.message);
}
}
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.req.json<WebhookSettings>();
const { address } = c.get("jwtPayload");
// random raw email
const raw = await c.env.DB.prepare(
`SELECT raw FROM raw_mails WHERE address = ? ORDER BY RANDOM() LIMIT 1`
).bind(address).first<string>("raw");
const { id: mailId, raw } = await c.env.DB.prepare(
`SELECT id, raw FROM raw_mails WHERE address = ? ORDER BY RANDOM() LIMIT 1`
).bind(address).first<{ id: string, raw: string }>() || {};
const parsedEmail = await commonParseMail(raw);
const res = await sendWebhook(settings, {
id: mailId || "0",
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
from: parsedEmail?.sender || "test@test.com",
to: address,
subject: parsedEmail?.subject || "test subject",

View File

@@ -1,3 +1,18 @@
import type {
AuthenticatorTransportFuture,
CredentialDeviceType,
Base64URLString,
} from '@simplewebauthn/types';
export type Passkey = {
id: Base64URLString;
publicKey: string;
counter: number;
deviceType: CredentialDeviceType;
backedUp: boolean;
transports?: AuthenticatorTransportFuture[];
};
export class AdminWebhookSettings {
allowList: string[];
@@ -7,6 +22,8 @@ export class AdminWebhookSettings {
}
export type WebhookMail = {
id: string;
url?: string;
from: string;
to: string;
subject: string;
@@ -104,3 +121,38 @@ export class UserInfo {
this.userEmail = userEmail;
}
}
export class WebhookSettings {
enabled: boolean = false
url: string = ''
method: string = 'POST'
headers: string = JSON.stringify({
"Content-Type": "application/json"
}, null, 2)
body: string = JSON.stringify({
"id": "${id}",
"url": "${url}",
"from": "${from}",
"to": "${to}",
"subject": "${subject}",
"raw": "${raw}",
"parsedText": "${parsedText}",
"parsedHtml": "${parsedHtml}",
}, null, 2)
}
export type UserOauth2Settings = {
name: string;
clientID: string;
clientSecret: string;
authorizationURL: string;
accessTokenURL: string;
accessTokenFormat: string;
userInfoURL: string;
redirectURL: string;
logoutURL?: string;
userEmailKey: string;
scope: string;
enableMailAllowList?: boolean | undefined;
mailAllowList?: string[] | undefined;
}

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

15
worker/src/types.d.ts vendored
View File

@@ -15,15 +15,19 @@ export type Bindings = {
TITLE: string | undefined
ANNOUNCEMENT: string | undefined | null
PREFIX: string | undefined
ADDRESS_CHECK_REGEX: string | undefined
ADDRESS_REGEX: string | undefined
MIN_ADDRESS_LEN: string | number | undefined
MAX_ADDRESS_LEN: string | number | undefined
DEFAULT_DOMAINS: string | string[] | undefined
DOMAINS: string | string[] | undefined
ADMIN_USER_ROLE: string | undefined
USER_DEFAULT_ROLE: string | UserRole | undefined
USER_ROLES: string | UserRole[] | undefined
DOMAIN_LABELS: string | string[] | undefined
PASSWORDS: string | string[] | undefined
ADMIN_PASSWORDS: string | string[] | undefined
DISABLE_ADMIN_PASSWORD_CHECK: string | boolean | undefined
JWT_SECRET: string
BLACK_LIST: string | undefined
ENABLE_AUTO_REPLY: string | boolean | undefined
@@ -32,10 +36,14 @@ export type Bindings = {
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
ENABLE_INDEX_ABOUT: string | boolean | undefined
DEFAULT_SEND_BALANCE: number | string | undefined
NO_LIMIT_SEND_ROLE: string | undefined | null
ADMIN_CONTACT: string | undefined
COPYRIGHT: string | undefined
DISABLE_SHOW_GITHUB: string | boolean | undefined
FORWARD_ADDRESS_LIST: string | string[] | undefined
ENABLE_CHECK_JUNK_MAIL: string | boolean | undefined
// s3 config
S3_ENDPOINT: string | undefined
S3_ACCESS_KEY_ID: string | undefined
@@ -54,6 +62,10 @@ export type Bindings = {
// telegram config
TELEGRAM_BOT_TOKEN: string
TG_MAX_ADDRESS: number | undefined
TG_BOT_INFO: string | object | undefined
// webhook config
FRONTEND_URL: string | undefined
}
type JwtPayload = {
@@ -70,7 +82,8 @@ type UserPayload = {
type Variables = {
userPayload: UserPayload,
jwtPayload: JwtPayload
userRolePayload: string | undefined | null,
jwtPayload: JwtPayload,
}
type HonoCustomType = {

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

@@ -4,15 +4,36 @@ import { HonoCustomType } from '../types';
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>();
// settings api
api.get('/user_api/open_settings', settings.openSettings);
api.get('/user_api/settings', settings.settings);
// user api
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);
api.post('/user_api/passkey/rename', passkey.renamePassKey);
api.delete('/user_api/passkey/:passkey_id', passkey.deletePassKey);
api.post('/user_api/passkey/register_request', passkey.registerRequest);
api.post('/user_api/passkey/register_response', passkey.registerResponse);
api.post('/user_api/passkey/authenticate_request', passkey.authenticateRequest);
api.post('/user_api/passkey/authenticate_response', passkey.authenticateResponse);

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,
// 30 days expire in seconds
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
return c.json({
jwt: jwt
})
}
}

View File

@@ -0,0 +1,204 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { HonoCustomType } from '../types';
import { Passkey } from '../models';
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
export default {
getPassKeys: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { results } = await c.env.DB.prepare(
`SELECT passkey_name, passkey_id, created_at, updated_at FROM user_passkeys WHERE user_id = ?`
).bind(user.user_id).all<Record<string, string>>();
return c.json(results);
},
renamePassKey: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { passkey_id, passkey_name } = await c.req.json();
if (!passkey_name || passkey_name.length > 255) {
return c.text("Invalid passkey name", 400);
}
const { success } = await c.env.DB.prepare(
`UPDATE user_passkeys SET passkey_name = ? WHERE user_id = ? AND passkey_id = ?`
).bind(passkey_name, user.user_id, passkey_id).run();
return c.json({ success });
},
deletePassKey: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { passkey_id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM user_passkeys WHERE user_id = ? AND passkey_id = ?`
).bind(user.user_id, passkey_id).run();
return c.json({ success });
},
registerRequest: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { domain } = await c.req.json();
const { results } = await c.env.DB.prepare(
`SELECT passkey FROM user_passkeys WHERE user_id = ?`
).bind(user.user_id).all<Record<string, string>>();
const excludeCredentials = results
.map((record: any) => JSON.parse(record.passkey) as Passkey)
.map((passkey: Passkey) => ({
id: passkey.id,
transports: passkey.transports,
}));
// create challenge with 1 hour expiration
const challenge = await Jwt.sign({
user_email: user.user_email,
user_id: user.user_id,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
// Use SimpleWebAuthn's handy function to create registration options.
const options = await generateRegistrationOptions({
rpName: c.env.TITLE || "Temp Mail",
rpID: domain,
userID: new TextEncoder().encode(user.user_id.toString()),
userName: user.user_email,
userDisplayName: user.user_email,
attestationType: 'none',
excludeCredentials: excludeCredentials,
challenge: challenge,
});
return c.json(options);
},
registerResponse: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { credential, origin, passkey_name } = await c.req.json();
// Verify the registration response
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: async (challenge: string) => {
const payload = await Jwt.verify(atob(challenge), c.env.JWT_SECRET, "HS256");
if (!payload || !payload.iat) return false;
// check iad is not older than 5 minutes
if (Math.floor(Date.now() / 1000) - payload.iat > 300) return false;
if (payload.user_id !== user.user_id) return false;
return true;
},
expectedOrigin: origin,
requireUserVerification: true,
});
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return c.text("Registration failed", 400);
}
const {
credentialID, credentialPublicKey,
counter, credentialDeviceType, credentialBackedUp,
} = registrationInfo;
// Base64URL encode ArrayBuffers.
const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);
const newPasskey: Passkey = {
id: credentialID,
publicKey: base64PublicKey,
counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: credential?.response?.transports,
};
// Store the credential ID in the database
const { success } = await c.env.DB.prepare(
`INSERT INTO user_passkeys (user_id, passkey_name, passkey_id, passkey, counter) VALUES (?, ?, ?, ?, ?)`
).bind(user.user_id, passkey_name, credentialID, JSON.stringify(newPasskey), counter).run();
return c.json({ success });
},
authenticateRequest: async (c: Context<HonoCustomType>) => {
const { domain } = await c.req.json();
const challenge = await Jwt.sign({
domain,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
const options: PublicKeyCredentialRequestOptionsJSON = await generateAuthenticationOptions({
rpID: domain,
challenge: challenge,
allowCredentials: [],
});
return c.json(options);
},
authenticateResponse: async (c: Context<HonoCustomType>) => {
const { domain, credential, origin } = await c.req.json();
const passkey_id = credential?.id;
if (!passkey_id) {
return c.text("Invalid request", 400);
}
const { user_id, counter, passkey } = await c.env.DB.prepare(
`SELECT user_id, counter, passkey FROM user_passkeys WHERE passkey_id = ?`
).bind(passkey_id).first<{
counter: number; passkey: string; user_id: number;
}>() || {};
if (!passkey) {
return c.text("Passkey not found", 404);
}
const passkeyData = JSON.parse(passkey) as Passkey;
// Verify the registration response
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: async (challenge: string) => {
const payload = await Jwt.verify(atob(challenge), c.env.JWT_SECRET, "HS256");
if (!payload || !payload.iat) return false;
// check iad is not older than 5 minutes
if (Math.floor(Date.now() / 1000) - payload.iat > 300) return false;
return true;
},
expectedOrigin: origin,
expectedRPID: domain,
authenticator: {
credentialID: passkeyData.id,
credentialPublicKey: isoBase64URL.toBuffer(passkeyData.publicKey),
counter: counter || passkeyData.counter,
transports: passkeyData.transports,
},
});
const { verified, authenticationInfo } = verification;
if (!verified) {
return c.text("Authentication failed", 400);
}
if (authenticationInfo) {
const { newCounter } = authenticationInfo;
// Update the counter in the database
await c.env.DB.prepare(
`UPDATE user_passkeys SET counter = ? WHERE passkey_id = ?`
).bind(newCounter, passkey_id).run();
}
// update passkey updated_at
await c.env.DB.prepare(
`UPDATE user_passkeys SET updated_at = datetime('now') WHERE passkey_id = ?`
).bind(passkey_id).run();
// return jwt
const { user_email } = await c.env.DB.prepare(
`SELECT user_email FROM users WHERE id = ?`
).bind(user_id).first<{ user_email: string }>() || {};
if (!user_email) {
return c.text("User not found", 404);
}
// create jwt
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,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
return c.json({
jwt: jwt
})
},
}

View File

@@ -1,18 +1,32 @@
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";
import { Jwt } from "hono/utils/jwt";
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>) => {
@@ -25,8 +39,23 @@ export default {
return c.text("User not found", 400);
}
const user_role = await commonGetUserRole(c, db_user_id);
const is_admin = (
c.env.ADMIN_USER_ROLE
&&
c.env.ADMIN_USER_ROLE === user_role?.role
);
const access_token = user_role?.role ? await Jwt.sign({
user_email: user.user_email,
user_id: user.user_id,
user_role: user_role.role,
iat: Math.floor(Date.now() / 1000),
// 1 hour
exp: Math.floor(Date.now() / 1000) + 3600,
}, c.env.JWT_SECRET, "HS256") : null;
return c.json({
...user,
is_admin: is_admin,
access_token: access_token,
user_role: user_role
});
},

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);
}
@@ -59,7 +78,6 @@ export const getBooleanValue = (
if (typeof value === "string") {
return value === "true";
}
console.error(`Failed to parse boolean value: ${value}`);
return false;
}
@@ -99,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()
});
@@ -75,6 +83,26 @@ const checkUserPayload = async (
}
}
const checkoutUserRolePayload = async (
c: Context<HonoCustomType>
): Promise<void> => {
try {
const token = c.req.raw.headers.get("x-user-access-token");
if (!token) return;
const payload = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
// check expired
if (!payload.exp) return;
// exp is in seconds
if (payload.exp < Math.floor(Date.now() / 1000)) {
return;
}
if (typeof payload?.user_role !== "string") return;
c.set("userRolePayload", payload.user_role);
} catch (e) {
console.error(e);
}
}
// api auth
app.use('/api/*', async (c, next) => {
// check header x-custom-auth
@@ -90,6 +118,11 @@ app.use('/api/*', async (c, next) => {
await next();
return;
}
if (c.req.path.startsWith("/api/settings")
|| c.req.path.startsWith("/api/send_mail")
) {
await checkoutUserRolePayload(c);
}
return jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
});
// user_api auth
@@ -99,6 +132,8 @@ app.use('/user_api/*', async (c, next) => {
|| c.req.path.startsWith("/user_api/register")
|| 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;
@@ -127,6 +162,7 @@ app.use('/user_api/*', async (c, next) => {
});
// admin auth
app.use('/admin/*', async (c, next) => {
// check header x-admin-auth
const adminPasswords = getAdminPasswords(c);
if (adminPasswords && adminPasswords.length > 0) {
@@ -136,6 +172,33 @@ app.use('/admin/*', async (c, next) => {
return;
}
}
// check if user is admin
const access_token = c.req.raw.headers.get("x-user-access-token");
if (c.env.ADMIN_USER_ROLE && access_token) {
try {
const payload = await Jwt.verify(access_token, c.env.JWT_SECRET, "HS256");
// check expired
if (!payload.exp) return c.text("Invalid Token", 401);
// exp is in seconds
if (payload.exp < Math.floor(Date.now() / 1000)) {
return c.text("Token Expired", 401)
}
if (payload.user_role !== c.env.ADMIN_USER_ROLE) {
return c.text("Need Admin Role", 401)
}
await next();
return;
} catch (e) {
console.error(e);
}
}
// disable admin api check
if (getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK)) {
await next();
return;
}
return c.text("Need Admin Password", 401)
});

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,6 +19,10 @@ node_compat = true
# TITLE = "Custom Title" # custom title
# ANNOUNCEMENT = "Custom Announcement"
PREFIX = "tmp"
# address check REGEX, if not set, will not check
# ADDRESS_CHECK_REGEX = "^(?!.*admin).*"
# address name replace REGEX, if not set, the default is [^a-z0-9]
# ADDRESS_REGEX = "[^a-z0-9]"
# (min, max) length of the adderss, if not set, the default is (1, 30)
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
@@ -26,6 +30,8 @@ PREFIX = "tmp"
# PASSWORDS = ["123", "456"]
# For admin panel
# ADMIN_PASSWORDS = ["123", "456"]
# warning: no password or user check for admin portal
# DISABLE_ADMIN_PASSWORD_CHECK = false
# ADMIN CONTACT, CAN BE ANY STRING
# ADMIN_CONTACT = "xx@xx.xxx"
DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users
@@ -33,6 +39,7 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all domain names
# For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification)
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix
# USER_ROLES = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },
@@ -50,15 +57,23 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true
# Footer text
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # the role which can send emails without limit
# Turnstile verification
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# telegram bot
# TG_MAX_ACCOUNTS = 5
# TG_MAX_ADDRESS = 5
# telegram bot info, predefined bot info can reduce latency of the webhook
# TG_BOT_INFO = "{}"
# global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
# Frontend URL
# FRONTEND_URL = "https://xxxx.xxx"
# Enable check junk mail
# ENABLE_CHECK_JUNK_MAIL = false
[[d1_databases]]
binding = "DB"