Compare commits

...

43 Commits

Author SHA1 Message Date
Dream Hunter
98cd6d9fcc feat: docs (#218) 2024-05-09 20:23:52 +08:00
Dream Hunter
45783c7494 feat: UI move AdminContact to common (#217) 2024-05-09 20:14:29 +08:00
Dream Hunter
9bfded4d1d feat: UI check openSettings in Login page (#215) 2024-05-09 19:06:00 +08:00
Dream Hunter
b7308587c6 feat: admin search mailbox && fix generateName multi dot && user jwt exp in 30 days && UI globalTabplacement && useSideMargin (#214)
* fix: generateName multi dot && user jwt exp in 30 days

* feat: support admin search mailbox

* fix: DELETE mail bug(should be raw_mails)

* feat: UI add globalTabplacement

* feat: UI add useSideMargin option
2024-05-09 18:43:09 +08:00
Dream Hunter
1fa56dfe98 feat: add UserLogin (#209) 2024-05-08 23:14:44 +08:00
Dream Hunter
55b2603913 feat: add Deploy to Cloudflare Workers docs (#206) 2024-05-05 20:12:45 +08:00
Dream Hunter
7738210b93 feat: add Deploy to Cloudflare Workers button (#205) 2024-05-05 20:07:17 +08:00
Dream Hunter
9d84eb0634 feat: update docs (#204)
* feat: update docs

* feat: update docs
2024-05-05 17:45:02 +08:00
Dream Hunter
66a6d40499 Update CHANGELOG.md 2024-05-05 00:03:56 +08:00
Dream Hunter
41bed8b1db feat: add /external/api/send_mail for body verify (#202) 2024-05-04 23:52:06 +08:00
Dream Hunter
869bf99340 fix: delete account loading (#201) 2024-05-04 23:18:30 +08:00
Dream Hunter
f63c4ebd9c feat: add CF Turnstile when new address (#200) 2024-05-04 23:14:23 +08:00
Dream Hunter
26969bebb8 feat: update sendbox UI (#199) 2024-05-04 18:58:34 +08:00
Dream Hunter
1d191a091a Update README.md 2024-05-04 18:39:28 +08:00
Dream Hunter
4d6c4e2d10 feat: add sendBlockList (#198) 2024-05-04 18:37:28 +08:00
Dream Hunter
7f456078ea feat: quote content when reply message (#197) 2024-05-04 18:18:12 +08:00
Dream Hunter
68c18a6153 fix: admin/delete_address (#196) 2024-05-04 17:56:14 +08:00
Dream Hunter
2d01639ecd fix: admin/delete_address (#195) 2024-05-04 17:52:59 +08:00
Dream Hunter
53b7cfccde Update CHANGELOG.md 2024-05-04 00:16:15 +08:00
Dream Hunter
854e4665b9 fix: showPassword (#193) 2024-05-04 00:09:46 +08:00
Dream Hunter
91a01784db fix: showPassword (#192) 2024-05-04 00:01:55 +08:00
Dream Hunter
55ae4d9d8e fix: delete account (#191) 2024-05-03 23:57:16 +08:00
Dream Hunter
b97ce79fe4 fix: delete account (#190) 2024-05-03 23:54:55 +08:00
Dream Hunter
51ad37e951 feat: add cron auto clean up (#189) 2024-05-03 22:05:44 +08:00
Dream Hunter
6e02e9b20b feat: UI: MailBox add reply button (#187) 2024-05-02 20:32:15 +08:00
Dream Hunter
042736b67f Update send-mail-api.md 2024-05-02 15:58:30 +08:00
Dream Hunter
bce503e433 Update CHANGELOG.md 2024-05-02 15:33:52 +08:00
Dream Hunter
83b9bc9d5f feat: add address_block_list for new address (#185) 2024-05-02 15:23:37 +08:00
Dream Hunter
e81142f5ef feat: UI: add user page: useIframeShowMail && mailboxSplitSize (#184) 2024-05-01 18:09:21 +08:00
Dream Hunter
32ce446a27 feat: add COPYRIGHT (#183) 2024-04-30 22:58:28 +08:00
Dream Hunter
64bcf7d019 fix: some browser do not support wasm (#182) 2024-04-30 21:32:35 +08:00
Dream Hunter
c015b57d73 fix: smtp_proxy_server hostname && add docker image for linux/arm64 (#180) 2024-04-30 14:38:06 +08:00
Dream Hunter
0366699fec fix: UI mailbox 100vh to 80vh (#179) 2024-04-29 23:40:58 +08:00
Dream Hunter
9c9f4565b1 fix: cf ui var is string (#178) 2024-04-29 22:05:58 +08:00
Dream Hunter
8a02509ffa Update smtp_proxy_server.yml 2024-04-29 21:51:19 +08:00
Dream Hunter
8b210388ee Update smtp_proxy_server.yml 2024-04-29 21:49:30 +08:00
Dream Hunter
a456bfda7c feat: add SMTP proxy server (#177) 2024-04-29 21:47:15 +08:00
Dream Hunter
23d1709ca1 feat: support admin create address && add ENABLE_USER_CREATE_EMAIL config (#175) 2024-04-29 00:09:53 +08:00
Dream Hunter
6ce7e2e7f6 Update CHANGELOG.md 2024-04-28 16:05:04 +08:00
Dream Hunter
c0e870ce54 feat: all mail use MailBox Component (#173) 2024-04-28 15:58:26 +08:00
Dream Hunter
90e80fee53 feat: admin page add account mail count && sendbox default all && send access suppory filter (#172) 2024-04-28 14:02:33 +08:00
Dream Hunter
4fd7f776f6 feat: remove PREFIX logic in db (#171) 2024-04-28 13:20:09 +08:00
Dream Hunter
c73c86e86c Update CHANGELOG.md 2024-04-27 23:19:55 +08:00
94 changed files with 6009 additions and 2352 deletions

View File

@@ -4,8 +4,6 @@ on:
push:
paths:
- "vitepress-docs/**"
branches:
- main
tags:
- "*"
workflow_dispatch:

48
.github/workflows/smtp_proxy_server.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: SMTP Proxy Server Docker Image CI
on:
push:
paths:
- "smtp_proxy_server/**"
tags:
- "*"
branches:
- main
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: smtp_proxy_server
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: ./smtp_proxy_server
file: ./smtp_proxy_server/dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:latest

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
dist/
test/
.vscode/

View File

@@ -1,9 +1,123 @@
# CHANGE LOG
## main branch
### DB Changes
新增 user 相关表,用于存储用户信息
- `db/2024-05-08-patch.sql`
### config changs
```toml
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
```
### function changs
- 增加用户注册功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证
- 增加默认以文本显示邮件文本和HTML邮箱显示方式切换按钮
- 修复 `BUG` 随机生成的邮箱名字不合法 #211
- `admin` 邮件页面支持邮件内容搜索 #210
- 修复删除地址时邮件未删除的BUG #213
- UI 增加全局标签页位置配置, 侧边距配置
## v0.3.3
- 修复 Admin 删除邮件报错
- UI: 回复邮件按钮, 引用原始邮件文本 #186
- 添加发送邮件地址黑名单
- 添加 `CF Turnstile` 人机验证配置
- 添加 `/external/api/send_mail` 发送邮件 api, 使用 body 验证 #194
## v0.3.2
## What's Changed
- UI: 添加回复邮件按钮
- 添加定时清理功能,可在 admin 页面配置(需要在配置文件启用定时任务)
- 修复删除账户无反应的问题
* feat: UI: MailBox add reply button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/187
* feat: add cron auto clean up by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/189
* fix: delete account by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/190
## v0.3.1
### DB Changes
新增 `settings` 表,用于存储通用配置信息
- `db/2024-05-01-patch.sql`
### Changes
- `ENABLE_USER_CREATE_EMAIL` 是否允许用户创建邮件
- 允许 admin 创建无前缀的邮件
- 添加 `SMTP proxy server`,支持 SMTP 发送邮件
- 修复某些情况浏览器无法加载 `wasm` 时使用 js 解析邮件
- 页脚添加 `COPYRIGHT`
- UI 允许用户切换邮件展示模式 `v-html` / `iframe`
- 添加 `admin` 账户配置页面,支持配置用户注册名称黑名单
* feat: support admin create address && add ENABLE_USER_CREATE_EMAIL co… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/175
* feat: add SMTP proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/177
* fix: cf ui var is string by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/178
* fix: UI mailbox 100vh to 80vh by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/179
* fix: smtp_proxy_server hostname && add docker image for linux/arm64 by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/180
* fix: some browser do not support wasm by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/182
* feat: add COPYRIGHT by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/183
* feat: UI: add user page: useIframeShowMail && mailboxSplitSize by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/184
* feat: add address_block_list for new address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/185
## v0.3.0
### Breaking Changes
`address` 表的前缀将从代码中迁移到 db 中,请将下面 sql 中的 `tmp` 替换为你的前缀,然后执行。
如果你的数据很重要,请先备份数据库。
**注意替换前缀**
```sql
update
address
set
name = 'tmp' || name;
```
### Changes
- `address` 表的前缀将从代码中迁移到 db 中
- `admin` 账户页面添加收发邮件数量
- `admin` 发件页面默认显示全部
- `admin` 发件权限页面支持搜索地址
- `admin` 邮件页面使用左右分栏 UI
* feat: remove PREFIX logic in db by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/171
* feat: admin page add account mail count && sendbox default all && sen… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/172
* feat: all mail use MailBox Component by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/173
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/0.2.10...v0.3.0
## v0.2.10
- `ENABLE_USER_DELETE_EMAIL` 是否允许用户删除账户和邮件
- `ENABLE_AUTO_REPLY` 是否启用自动回复
- fetchAddressError 提示改进
- 自动刷新显示倒计时
* feat: docs update by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/165
* feat: add ENABLE_USER_DELETE_EMAIL && ENABLE_AUTO_REPLY && modify fetchAddressError i18n && UI: show autoRefreshInterval by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/169
## v0.2.9
- 添加富文本编辑器
- admin 联系方式,不配置则不显示,可配置任意字符串 `ADMIN_CONTACT = "xx@xx.xxx"`
- admin 联系方式,不配置则不显示,可配置任意字符串 `ADMIN_CONTACT = "xx@xx.xxx"`
- 默认发送邮件余额,如果不设置,将为 0 `DEFAULT_SEND_BALANCE = 1`
## v0.2.8

View File

@@ -1,8 +1,14 @@
# 使用 cloudflare 免费服务,搭建临时邮箱
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
## [查看部署文档](https://temp-mail-docs.awsl.uk)
## [English Docs](https://temp-mail-docs.awsl.uk/en/)
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/dreamhunter2333/cloudflare_temp_email)
[Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/github-action.html)
[English Docs](https://temp-mail-docs.awsl.uk/en/)
## [CHANGELOG](CHANGELOG.md)
@@ -21,23 +27,29 @@
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
- [查看部署文档](#查看部署文档)
- [English Docs](#english-docs)
- [CHANGELOG](#changelog)
- [在线演示](#在线演示)
- [功能/TODO](#功能todo)
- [Reference](#reference)
## 功能/TODO
- [x] Cloudflare D1 作为数据库
- [x] 使用 Cloudflare Pages 部署前端
- [x] 使用 Cloudflare Workers 部署后端
- [x] email 转发使用 Cloudflare Email Routing
- [x] 使用 password 重新登录之前的邮箱
- [x] 获取自定义名字的邮箱
- [x] 使用 `password` 重新登录之前的邮箱
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
- [x] 支持多语言
- [x] 增加访问密码,可作为私人站点
- [x] 增加自动回复功能
- [x] 增加查看附件功能
- [x] 使用 rust wasm 解析邮件
- [x] 增加查看 `附件` 功能
- [x] 使用 `rust wasm` 解析邮件
- [x] 支持发送邮件
- [x] 支持 DKIM
- [x] 支持 `DKIM`
- [x] `admin` 后台创建无前缀邮箱
- [x] 添加 `SMTP proxy server`,支持 SMTP 发送邮件
- [x] 添加完整的用户注册登录功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证切换不同邮箱
## Reference
- Cloudflare D1 作为数据库
- 使用 Cloudflare Pages 部署前端
- 使用 Cloudflare Workers 部署后端
- email 转发使用 Cloudflare Email Routing

6
db/2024-05-01-patch.sql Normal file
View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

21
db/2024-05-08-patch.sql Normal file
View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
user_email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
user_info TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
CREATE TABLE IF NOT EXISTS users_address (
id INTEGER PRIMARY KEY,
user_id INTEGER,
address_id INTEGER UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);

View File

@@ -70,3 +70,32 @@ CREATE TABLE IF NOT EXISTS sendbox (
);
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
user_email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
user_info TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
CREATE TABLE IF NOT EXISTS users_address (
id INTEGER PRIMARY KEY,
user_id INTEGER,
address_id INTEGER UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);

View File

@@ -14,6 +14,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/logo.png" sizes="any">
<link rel="apple-touch-icon" href="/logo.png">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
</head>
<body>

View File

@@ -18,24 +18,24 @@
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.6.8",
"mail-parser-wasm": "^0.1.6",
"naive-ui": "^2.38.1",
"naive-ui": "^2.38.2",
"postal-mime": "^2.2.5",
"vooks": "^0.2.12",
"vue": "^3.4.25",
"vue": "^3.4.26",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^9.13.1",
"vue-router": "^4.3.2"
},
"devDependencies": {
"@vicons/fa": "^0.12.0",
"@vitejs/plugin-vue": "^4.6.2",
"@vitejs/plugin-vue": "^5.0.4",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.2.10",
"unplugin-vue-components": "^0.27.0",
"vite": "^5.2.11",
"vite-plugin-pwa": "^0.19.8",
"vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-wasm": "^3.3.0",
"workbox-window": "^7.1.0",
"wrangler": "^3.52.0"
"wrangler": "^3.53.1"
}
}

1545
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,18 @@
<script setup>
import { darkTheme, NGlobalStyle, zhCN } from 'naive-ui'
import { computed, onMounted } from 'vue'
import { useDark, useToggle } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from './store'
import { useIsMobile } from './utils/composables'
import Header from './views/Header.vue';
import Footer from './views/Footer.vue';
const { localeCache, isDark, loading } = useGlobalState()
const { localeCache, isDark, loading, useSideMargin } = useGlobalState()
const theme = computed(() => isDark.value ? darkTheme : null)
const localeConfig = computed(() => localeCache.value == 'zh' ? zhCN : null)
const isMobile = useIsMobile()
const showSideMargin = computed(() => !isMobile.value && !useSideMargin.value);
const { locale } = useI18n({
useScope: 'global',
@@ -38,16 +40,19 @@ onMounted(async () => {
<n-spin description="loading..." :show="loading">
<n-message-provider>
<n-grid x-gap="12" :cols="12">
<n-gi v-if="!isMobile" span="1"></n-gi>
<n-gi :span="isMobile ? 12 : 10">
<n-gi v-if="!showSideMargin" span="1"></n-gi>
<n-gi :span="showSideMargin ? 12 : 10">
<div class="main">
<n-space vertical>
<Header />
<router-view></router-view>
<n-layout style="min-height: 80vh;">
<Header />
<router-view></router-view>
</n-layout>
<Footer />
</n-space>
</div>
</n-gi>
<n-gi v-if="!isMobile" span="1"></n-gi>
<n-gi v-if="!showSideMargin" span="1"></n-gi>
</n-grid>
<n-back-top />
</n-message-provider>

View File

@@ -2,12 +2,15 @@ import { useGlobalState } from '../store'
import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const { loading, auth, jwt, settings, openSettings } = useGlobalState();
const { showAuth, adminAuth, showAdminAuth } = useGlobalState();
const {
loading, auth, jwt, settings, openSettings,
userOpenSettings, userSettings,
showAuth, adminAuth, showAdminAuth, userJwt
} = useGlobalState();
const instance = axios.create({
baseURL: API_BASE,
timeout: 10000
timeout: 30000
});
const apiFetch = async (path, options = {}) => {
@@ -17,6 +20,7 @@ const apiFetch = async (path, options = {}) => {
method: options.method || 'GET',
data: options.body || null,
headers: {
'x-user-token': userJwt.value,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`,
@@ -25,7 +29,7 @@ const apiFetch = async (path, options = {}) => {
});
if (response.status === 401 && openSettings.value.auth) {
showAuth.value = true;
throw new Error("Unauthorized, you password is wrong")
throw new Error("Unauthorized, you access password is wrong")
}
if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true;
@@ -49,7 +53,7 @@ const apiFetch = async (path, options = {}) => {
const getOpenSettings = async (message) => {
try {
const res = await api.fetch("/open_api/settings");
openSettings.value = {
Object.assign(openSettings.value, {
prefix: res["prefix"] || "",
needAuth: res["needAuth"] || false,
domains: res["domains"].map((domain) => {
@@ -59,9 +63,12 @@ const getOpenSettings = async (message) => {
}
}),
adminContact: res["adminContact"] || "",
enableUserCreateEmail: res["enableUserCreateEmail"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableAutoReply: res["enableAutoReply"] || false,
};
copyright: res["copyright"] || openSettings.value.copyright,
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
});
if (openSettings.value.needAuth) {
showAuth.value = true;
}
@@ -87,10 +94,32 @@ const getSettings = async () => {
}
}
const adminShowPassword = async (id) => {
const getUserOpenSettings = async (message) => {
try {
const { password } = await apiFetch(`/admin/show_password/${id}`);
return password;
const res = await api.fetch(`/user_api/open_settings`);
Object.assign(userOpenSettings.value, res);
} catch (error) {
message.error(error.message || "fetch settings failed");
}
}
const getUserSettings = async (message) => {
try {
if (!userJwt.value) return;
const res = await api.fetch("/user_api/settings")
Object.assign(userSettings.value, res)
} catch (error) {
message.error(error.message || "error");
} finally {
userSettings.value.fetched = true;
}
}
const adminShowAddressCredential = async (id) => {
try {
const { jwt: addressCredential } = await apiFetch(`/admin/show_password/${id}`);
return addressCredential;
} catch (error) {
throw error;
}
@@ -106,10 +135,24 @@ const adminDeleteAddress = async (id) => {
}
}
const bindUserAddress = async () => {
if (!userJwt.value) return;
try {
await apiFetch(`/user_api/bind_address`, {
method: 'POST',
});
} catch (error) {
throw error;
}
}
export const api = {
fetch: apiFetch,
getSettings: getSettings,
getOpenSettings: getOpenSettings,
adminShowPassword: adminShowPassword,
adminDeleteAddress: adminDeleteAddress,
getSettings,
getOpenSettings,
getUserOpenSettings,
getUserSettings,
adminShowAddressCredential,
adminDeleteAddress,
bindUserAddress,
}

View File

@@ -0,0 +1,438 @@
<script setup>
import { watch, onMounted, ref, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { CloudDownloadRound, ReplyFilled } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
const message = useMessage()
const isMobile = useIsMobile()
const router = useRouter()
const props = defineProps({
enableUserDeleteEmail: {
type: Boolean,
default: false,
requried: false
},
showEMailTo: {
type: Boolean,
default: true,
requried: false
},
fetchMailData: {
type: Function,
default: () => { },
requried: true
},
deleteMail: {
type: Function,
default: () => { },
requried: false
},
showReply: {
type: Boolean,
default: false,
requried: false
}
})
const {
localeCache, isDark, mailboxSplitSize, indexTab,
useIframeShowMail, sendMailModel, preferShowTextMail
} = useGlobalState()
const autoRefresh = ref(false)
const autoRefreshInterval = ref(30)
const data = ref([])
const timer = ref(null)
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
const curMail = ref(null);
const showTextMail = ref(preferShowTextMail.value)
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'Success',
autoRefresh: 'Auto Refresh',
refreshAfter: 'Refresh After {msg} Seconds',
refresh: 'Refresh',
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
pleaseSelectMail: "Please select a mail to view.",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete this mail?',
reply: 'Reply',
showTextMail: 'Show Text Mail',
showHtmlMail: 'Show Html Mail'
},
zh: {
success: '成功',
autoRefresh: '自动刷新',
refreshAfter: '{msg}秒后刷新',
refresh: '刷新',
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择一封邮件查看。",
delete: '删除',
deleteMailTip: '确定要删除这封邮件吗?',
reply: '回复',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件'
}
}
});
const setupAutoRefresh = async (autoRefresh) => {
// auto refresh every 30 seconds
autoRefreshInterval.value = 30;
if (autoRefresh) {
timer.value = setInterval(async () => {
autoRefreshInterval.value--;
if (autoRefreshInterval.value <= 0) {
autoRefreshInterval.value = 30;
await refresh();
}
}, 1000)
} else {
clearInterval(timer.value)
timer.value = null
}
}
watch(autoRefresh, async (autoRefresh, old) => {
setupAutoRefresh(autoRefresh)
})
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
await refresh();
}
})
const refresh = async () => {
try {
const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value
);
data.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (totalCount > 0) {
count.value = totalCount;
}
if (!isMobile.value && !curMail.value && data.value.length > 0) {
curMail.value = data.value[0];
}
} catch (error) {
message.error(error.message || "error");
console.error(error);
}
};
const clickRow = async (row) => {
curMail.value = row;
};
const getAttachments = (attachments) => {
curAttachments.value = attachments;
showAttachments.value = true;
};
const mailItemClass = (row) => {
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
};
const deleteMail = async () => {
try {
await props.deleteMail(curMail.value.id);
message.success(t("success"));
curMail.value = null;
await refresh();
} catch (error) {
message.error(error.message || "error");
}
};
const replyMail = async () => {
const emailRegex = /(.+?) <(.+?)>/;
let toMail = curMail.value.originalSource;
let toName = ""
const match = emailRegex.exec(curMail.value.source);
if (match) {
toName = match[1];
toMail = match[2];
}
Object.assign(sendMailModel.value, {
toName: toName,
toMail: toMail,
subject: `${t('reply')}: ${curMail.value.subject}`,
contentType: 'rich',
content: curMail.value.text ? `<p><br></p><blockquote>${curMail.value.text}</blockquote><p><br></p>` : '',
});
indexTab.value = 'sendmail';
};
const onSpiltSizeChange = (size) => {
mailboxSplitSize.value = size;
}
onMounted(async () => {
await refresh();
});
onBeforeUnmount(() => {
clearInterval(timer.value)
})
</script>
<template>
<div>
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25"
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
<template #1>
<div class="center">
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small" :round="false">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail.raw)">
<template #icon>
<n-icon :component="CloudDownloadRound" />
</template>
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
</n-button>
</n-space>
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
style="margin-top: 10px;width: 100%; height: 100%;">
</iframe>
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
<n-card class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
<div class="left" v-else>
<div class="center">
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small" :round="false">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
<n-drawer v-model:show="curMail" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 80vh;">
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
<n-card style="overflow: auto;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail)">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
</n-space>
<div v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
</n-drawer-content>
</n-drawer>
</div>
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("attachments") }}</div>
</template>
<n-list hoverable clickable>
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
<n-thing class="center" :title="row.filename">
<template #description>
<n-space>
<n-tag type="info">
Size: {{ row.size }}
</n-tag>
</n-space>
</template>
</n-thing>
<template #suffix>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
:href="row.url">
<n-icon :component="CloudDownloadRound" />
</n-button>
</template>
</n-list-item>
</n-list>
</n-modal>
</div>
</template>
<style scoped>
.left {
text-align: left;
}
.center {
text-align: center;
}
.overlay {
width: 100%;
height: 100%;
z-index: 1000;
}
.overlay-dark-backgroud {
background-color: rgba(255, 255, 255, 0.1);
}
.overlay-light-backgroud {
background-color: rgba(0, 0, 0, 0.1);
}
.mail-item {
height: 100%;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup>
import { ref, watch, defineModel, onMounted } from "vue";
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
const { localeCache, openSettings, isDark } = useGlobalState()
const cfToken = defineModel('value')
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
refresh: 'Refresh'
},
zh: {
refresh: '刷新'
}
}
});
const cfTurnstileId = ref("")
const turnstileLoading = ref(false)
const checkCfTurnstile = async (remove) => {
if (!openSettings.value.cfTurnstileSiteKey) return;
turnstileLoading.value = true;
try {
let container = document.getElementById("cf-turnstile");
let count = 100;
while (!container && count-- > 0) {
container = document.getElementById("cf-turnstile");
await new Promise(r => setTimeout(r, 10));
}
count = 100;
while (!window.turnstile && count-- > 0) {
await new Promise(r => setTimeout(r, 10));
}
if (remove && cfTurnstileId.value) {
window.turnstile.remove(cfTurnstileId.value);
}
cfTurnstileId.value = window.turnstile.render(
"#cf-turnstile",
{
sitekey: openSettings.value.cfTurnstileSiteKey,
language: localeCache.value == 'zh' ? 'zh-CN' : 'en-US',
theme: isDark.value ? 'dark' : 'light',
callback: function (token) {
cfToken.value = token;
},
}
);
} finally {
turnstileLoading.value = false;
}
}
watch(isDark, async (isDark) => {
checkCfTurnstile(true)
}, { immediate: true })
onMounted(() => {
cfToken.value = "";
checkCfTurnstile(true);
})
</script>
<template>
<div v-if="openSettings.cfTurnstileSiteKey" class="center">
<n-spin description="loading..." :show="turnstileLoading">
<n-form-item-row>
<div id="cf-turnstile"></div>
<n-button text @click="checkCfTurnstile(true)">
{{ t('refresh') }}
</n-button>
</n-form-item-row>
</n-spin>
</div>
</template>
<style scoped>
.center {
display: flex;
}
.n-button {
margin-left: 10px;
}
</style>

View File

@@ -1,9 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue'
import Settings from '../views/Settings.vue'
import SendMail from '../views/send/SendMail.vue'
import UserLogin from '../views/user/UserLogin.vue'
import User from '../views/User.vue'
import SendMail from '../views/index/SendMail.vue'
import Admin from '../views/Admin.vue'
import SendBox from '../views/send/SendBox.vue'
const router = createRouter({
history: createWebHistory(),
@@ -13,16 +13,8 @@ const router = createRouter({
component: Index
},
{
path: '/settings',
component: Settings
},
{
path: '/send',
component: SendMail
},
{
path: '/sendbox',
component: SendBox
path: '/user',
component: User
},
{
path: '/admin',

View File

@@ -1,6 +1,5 @@
import { ref } from "vue";
import { createGlobalState, useStorage } from '@vueuse/core'
import { useDark, useToggle } from '@vueuse/core'
import { createGlobalState, useStorage, useDark, useToggle } from '@vueuse/core'
export const useGlobalState = createGlobalState(
() => {
@@ -11,12 +10,12 @@ export const useGlobalState = createGlobalState(
prefix: '',
needAuth: false,
adminContact: '',
enableUserCreateEmail: false,
enableUserDeleteEmail: false,
enableAutoReply: false,
domains: [{
label: 'test.com',
value: 'test.com'
}]
domains: [],
copyright: 'Dream Hunter',
cfTurnstileSiteKey: '',
})
const settings = ref({
fetched: false,
@@ -30,35 +29,72 @@ export const useGlobalState = createGlobalState(
source_prefix: '',
name: '',
}
})
});
const sendMailModel = useStorage('sendMailModel', {
fromName: "",
toName: "",
toMail: "",
subject: "",
contentType: 'text',
content: "",
});
const showAuth = ref(false);
const showAddressCredential = ref(false);
const showAdminAuth = ref(false);
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', '');
const localeCache = useStorage('locale', 'zh');
const themeSwitch = useStorage('themeSwitch', false);
const showLogin = ref(false);
const adminTab = ref("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 globalTabplacement = useStorage('globalTabplacement', 'top');
const useSideMargin = useStorage('useSideMargin', true);
const userOpenSettings = ref({
enable: false,
enableMailVerify: false,
});
const userSettings = ref({
/** @type {boolean} */
fetched: false,
/** @type {string} */
user_email: '',
/** @type {number} */
user_id: 0,
});
return {
isDark,
toggleDark,
loading,
settings,
sendMailModel,
openSettings,
showAuth,
showAddressCredential,
auth,
jwt,
localeCache,
themeSwitch,
adminAuth,
showAdminAuth,
showLogin,
adminTab,
adminMailTabAddress,
adminSendBoxTabAddress,
mailboxSplitSize,
useIframeShowMail,
preferShowTextMail,
userJwt,
userTab,
indexTab,
userOpenSettings,
userSettings,
globalTabplacement,
useSideMargin,
}
},
)

View File

@@ -1,5 +1,4 @@
import PostalMime from 'postal-mime';
import { parse_message } from 'mail-parser-wasm'
function humanFileSize(size) {
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
@@ -8,11 +7,14 @@ function humanFileSize(size) {
export async function processItem(item) {
// Try to parse the email using mail-parser-wasm
item.originalSource = item.source;
try {
const { parse_message } = await import('mail-parser-wasm');
const parsedEmail = parse_message(item.raw);
item.source = parsedEmail.sender || item.source;
item.subject = parsedEmail.subject || '';
item.message = parsedEmail.body_html || parsedEmail.text || '';
item.text = parsedEmail.text || '';
item.attachments = parsedEmail.attachments?.map((a_item) => {
const blob_url = URL.createObjectURL(
new Blob(
@@ -45,6 +47,7 @@ export async function processItem(item) {
}
item.subject = parsedEmail.subject || 'No Subject';
item.message = parsedEmail.html || parsedEmail.text || item.raw;
item.text = parsedEmail.text || '';
item.attachments = parsedEmail.attachments?.map((a_item) => {
const blob_url = URL.createObjectURL(
new Blob(
@@ -67,6 +70,7 @@ export async function processItem(item) {
item.subject = 'No Subject';
item.message = item.raw;
}
return item;
}
export function getDownloadEmlUrl(raw) {

View File

@@ -0,0 +1,6 @@
export const hashPassword = async (password) => {
// user crypto to hash password
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
const hashArray = Array.from(new Uint8Array(digest));
return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
}

View File

@@ -8,12 +8,17 @@ import SenderAccess from './admin/SenderAccess.vue'
import Statistics from "./admin/Statistics.vue"
import SendBox from './admin/SendBox.vue';
import Account from './admin/Account.vue';
import CreateAccount from './admin/CreateAccount.vue';
import AccountSettings from './admin/AccountSettings.vue';
import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import Maintenance from './admin/Maintenance.vue';
import Appearance from './common/Appearance.vue';
const {
localeCache, adminAuth, showAdminAuth, adminTab, loading
localeCache, adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
} = useGlobalState()
const message = useMessage()
@@ -33,10 +38,16 @@ const { t } = useI18n({
accessTip: 'Please enter the admin password',
mails: 'Emails',
account: 'Account',
account_create: 'Create Account',
account_settings: 'Account Settings',
user_management: 'User Management',
user_settings: 'User Settings',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
statistics: 'Statistics',
maintenance: 'Maintenance',
appearance: 'Appearance',
ok: 'OK',
},
zh: {
@@ -44,10 +55,16 @@ const { t } = useI18n({
accessTip: '请输入 Admin 密码',
mails: '邮件',
account: '账号',
account_create: '创建账号',
account_settings: '账号设置',
user_management: '用户管理',
user_settings: '用户设置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
statistics: '统计',
maintenance: '维护',
appearance: '外观',
ok: '确定',
}
}
@@ -64,10 +81,7 @@ onMounted(async () => {
<template>
<div>
<n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
title="Dialog">
<template #header>
<div>{{ t('accessHeader') }}</div>
</template>
:title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="adminAuth" type="textarea" :autosize="{ minRows: 3 }" />
<template #action>
@@ -76,11 +90,22 @@ onMounted(async () => {
</n-button>
</template>
</n-modal>
<Statistics />
<n-tabs type="card" v-model:value="adminTab">
<n-tabs type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="account" :tab="t('account')">
<Account />
</n-tab-pane>
<n-tab-pane name="account_create" :tab="t('account_create')">
<CreateAccount />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings />
</n-tab-pane>
<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="mails" :tab="t('mails')">
<Mails />
</n-tab-pane>
@@ -93,9 +118,15 @@ onMounted(async () => {
<n-tab-pane name="sendBox" :tab="t('sendBox')">
<SendBox />
</n-tab-pane>
<n-tab-pane name="statistics" :tab="t('statistics')">
<Statistics />
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<Maintenance />
</n-tab-pane>
<n-tab-pane name="appearance" :tab="t('appearance')">
<Appearance />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
const { localeCache, openSettings } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
copyright: "Copyright"
},
zh: {
copyright: "版权所有"
}
}
});
</script>
<template>
<div>
<n-divider class="footer-divider" />
<div style="text-align: center; padding: 20px">
<n-text depth="3">
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }} {{ openSettings.copyright }}
</n-text>
</div>
</div>
</template>
<style scoped>
.footer-divider {
margin: 0;
padding: 0 var(--x-padding);
}
</style>

View File

@@ -1,51 +1,32 @@
<script setup>
import useClipboard from 'vue-clipboard3'
import { ref, h, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useIsMobile } from '../utils/composables'
import { DarkModeFilled, LightModeFilled, MenuFilled, AdminPanelSettingsFilled, SendFilled } from '@vicons/material'
import { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
import AdminContact from './admin/AdminContact.vue'
import {
DarkModeFilled, LightModeFilled, MenuFilled,
AdminPanelSettingsFilled
} from '@vicons/material'
import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
const { toClipboard } = useClipboard()
const message = useMessage()
const {
jwt, localeCache, toggleDark, isDark,
localeCache, toggleDark, isDark, openSettings,
showAuth, adminAuth, auth, loading
} = useGlobalState()
const { showLogin, openSettings, settings } = useGlobalState()
const route = useRoute()
const router = useRouter()
const isMobile = useIsMobile()
const isAdminRoute = computed(() => route.path.includes('admin'))
const showMobileMenu = ref(false)
const showNewEmail = ref(false)
const showLogout = ref(false)
const showDelteAccount = ref(false)
const password = ref('')
const showPassword = ref(false)
const emailName = ref("")
const emailDomain = ref("")
const login = async () => {
try {
jwt.value = password.value;
await api.getSettings()
location.reload()
} catch (error) {
message.error(error.message || "error");
}
}
const logout = () => {
jwt.value = '';
location.reload()
}
const menuValue = computed(() => {
if (route.path.includes("user")) return "user";
if (route.path.includes("admin")) return "admin";
return "home";
});
const authFunc = async () => {
try {
@@ -67,90 +48,41 @@ const { t } = useI18n({
title: 'Cloudflare Temp Email',
dark: 'Dark',
light: 'Light',
login: 'Login',
logout: 'Logout',
logoutConfirm: 'Are you sure to logout?',
delteAccount: "Delete Account",
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
accessHeader: 'Access Password',
accessTip: 'Please enter the correct password',
settings: 'Settings',
accessTip: 'Please enter the correct access password',
home: 'Home',
menu: 'Menu',
user: 'User',
sendbox: 'Send Box',
sendMail: 'Send Mail',
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
getNewEmail: 'Get New Email',
getNewEmailTip1: 'Please input the email you want to use. only allow ., a-z, A-Z and 0-9',
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
yourAddress: 'Your email address is',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
cancel: 'Cancel',
ok: 'OK',
copy: 'Copy',
copied: 'Copied',
showPassword: 'Show Password',
fetchAddressError: 'Login password is invalid or account not exist, it may be network connection issue, please try again later.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
generateName: 'Generate Fake Name',
},
zh: {
title: 'Cloudflare 临时邮件',
dark: '暗色',
light: '亮色',
login: '登录',
logout: '登出',
logoutConfirm: '确定要登出吗?',
delteAccount: "删除账户",
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
accessHeader: '访问密码',
accessTip: '请输入站点访问密码',
settings: '设置',
home: '主页',
menu: '菜单',
user: '用户',
sendbox: '发件箱',
sendMail: '发送邮件',
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '获取新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
yourAddress: '你的邮箱地址是',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
cancel: '取消',
ok: '确定',
copy: '复制',
copied: '已复制',
showPassword: '查看密码',
fetchAddressError: '登录密码无效或账号不存在,也可能是网络连接异常,请稍后再尝试。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
generateName: '生成随机名字',
}
}
});
const showUserMenu = computed(() => !!settings.value.address)
const menuOptions = computed(() => [
{
label: () => h(
NButton,
label: () => h(NButton,
{
text: true,
size: "small",
type: menuValue.value == "home" ? "primary" : "default",
style: "width: 100%",
onClick: () => { router.push('/'); showMobileMenu.value = false; }
onClick: async () => { await router.push('/'); showMobileMenu.value = false; }
},
{
default: () => t('home'),
icon: () => h(NIcon, { component: Home })
}
),
}),
key: "home"
},
{
@@ -159,8 +91,26 @@ const menuOptions = computed(() => [
{
text: true,
size: "small",
type: menuValue.value == "user" ? "primary" : "default",
style: "width: 100%",
onClick: () => { router.push('/admin'); showMobileMenu.value = false; }
onClick: async () => { await router.push("/user"); showMobileMenu.value = false; }
},
{
default: () => t('user'),
icon: () => h(NIcon, { component: User }),
}
),
key: "user",
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
type: menuValue.value == "admin" ? "primary" : "default",
style: "width: 100%",
onClick: async () => { await router.push('/admin'); showMobileMenu.value = false; }
},
{
default: () => "Admin",
@@ -170,91 +120,6 @@ const menuOptions = computed(() => [
show: !!adminAuth.value,
key: "admin"
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
style: "width: 100%",
},
{
default: () => t('user'),
icon: () => h(NIcon, { component: User }),
}
),
show: showUserMenu.value,
key: "user",
children: [
{
label: () => h(
NButton,
{
text: true,
size: "small",
style: "width: 100%",
onClick: () => { router.push('/sendbox'); showMobileMenu.value = false; }
},
{ default: () => t('sendbox') }
),
key: "sendbox"
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
style: "width: 100%",
onClick: () => { showPassword.value = true; showMobileMenu.value = false; }
},
{ default: () => t('showPassword') }
),
key: "showPassword"
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
style: "width: 100%",
onClick: () => { router.push('/settings'); showMobileMenu.value = false; }
},
{ default: () => t('settings') }
),
show: openSettings.value.enableAutoReply,
key: "settings"
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
style: "width: 100%",
onClick: () => { showLogout.value = true; showMobileMenu.value = false; }
},
{ default: () => t('logout') }
),
key: "logout"
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
style: "width: 100%",
onClick: () => { showDelteAccount.value = true; showMobileMenu.value = false; }
},
{ default: () => t('delteAccount') }
),
show: openSettings.value.enableUserDeleteEmail,
key: "delte_account"
}
]
},
{
label: () => h(
NButton,
@@ -314,64 +179,8 @@ const menuOptions = computed(() => [
}
]);
const copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
}
}
const generateNameLoading = ref(false);
const generateName = async () => {
try {
generateNameLoading.value = true;
const { faker } = await import('https://esm.sh/@faker-js/faker');
emailName.value = faker.person
.fullName()
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9.]/g, '')
.toLowerCase();
} catch (error) {
message.error(error.message || "error");
} finally {
generateNameLoading.value = false;
}
};
const newEmail = async () => {
try {
const res = await api.fetch(
`/api/new_address`
+ `?name=${emailName.value || ''}`
+ `&domain=${emailDomain.value || ''}`
);
jwt.value = res["jwt"];
await api.getSettings();
showNewEmail.value = false;
showPassword.value = true;
} catch (error) {
message.error(error.message || "error");
}
};
const deleteAccount = async () => {
try {
await api.fetch(`/api/delete_address`, {
method: 'DELETE'
});
jwt.value = '';
location.reload()
} catch (error) {
message.error(error.message || "error");
}
};
onMounted(async () => {
await api.getOpenSettings(message);
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
await api.getSettings();
});
</script>
@@ -386,7 +195,7 @@ onMounted(async () => {
</template>
<template #extra>
<n-space>
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" />
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" responsive />
<n-button v-else :text="true" @click="showMobileMenu = !showMobileMenu" style="margin-right: 10px;">
<template #icon>
<n-icon :component="MenuFilled" />
@@ -401,138 +210,10 @@ onMounted(async () => {
<n-menu :options="menuOptions" />
</n-drawer-content>
</n-drawer>
<div v-if="!isAdminRoute">
<n-card v-if="!settings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<div v-else-if="settings.address">
<n-alert v-if="settings.has_v1_mails" type="warning" show-icon closable>
<span>
<n-button tag="a" target="_blank" tertiary type="info" size="small"
href="https://mail-v1.awsl.uk">
<b>{{ t('mailV1Alert') }} </b>
</n-button>
</span>
</n-alert>
<n-alert type="info" show-icon>
<span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
<n-button style="margin-left: 10px" @click="router.push('/send')" size="small" tertiary round
type="primary">
<n-icon :component="SendFilled" /> {{ t('sendMail') }}
</n-button>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary round type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</span>
</n-alert>
</div>
<n-card v-else>
<n-result status="info" :description="t('pleaseGetNewEmail')">
<template #footer>
<n-alert v-if="jwt" type="warning" show-icon>
<span>{{ t('fetchAddressError') }}</span>
</n-alert>
<n-button @click="showLogin = true" tertiary round type="primary">
{{ t('login') }}
</n-button>
<n-button @click="showNewEmail = true" tertiary round type="primary">
{{ t('getNewEmail') }}
</n-button>
</template>
</n-result>
</n-card>
</div>
<n-modal v-model:show="showNewEmail" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('getNewEmail') }}</div>
</template>
<n-spin :show="generateNameLoading">
<span>
<p>{{ t("getNewEmailTip1") }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>
<n-button @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
</n-spin>
<template #action>
<n-button @click="showNewEmail = false">
{{ t('cancel') }}
</n-button>
<n-button @click="newEmail" type="primary" :loading="loading">
{{ t('ok') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showPassword" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("password") }}</div>
</template>
<span>
<p>{{ t("passwordTip") }}</p>
</span>
<n-card>
<b>{{ jwt }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showLogin" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('login') }}</div>
</template>
<AdminContact />
<n-input v-model:value="password" type="textarea" :autosize="{
minRows: 3
}" />
<template #action>
<n-button @click="login" :loading="loading" size="small" tertiary round type="primary">
{{ t('login') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showLogout" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('logout') }}</div>
</template>
<p>{{ t('logoutConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="logout" size="small" tertiary round type="primary">
{{ t('logout') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('delteAccount') }}</div>
</template>
<p>{{ t('delteAccountConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary round type="error">
{{ t('delteAccount') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
title="Dialog">
<template #header>
<div>{{ t('accessHeader') }}</div>
</template>
: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="textarea" :autosize="{ minRows: 3 }" />
<template #action>
<n-button :loading="loading" @click="authFunc" type="primary">
{{ t('ok') }}
@@ -549,11 +230,6 @@ onMounted(async () => {
justify-content: space-between;
}
.mobile-menu-button {
width: 100%;
text-align: left;
}
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
@@ -563,4 +239,16 @@ onMounted(async () => {
.n-card {
margin-top: 10px;
}
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
.n-form .n-button {
margin-top: 10px;
}
</style>

View File

@@ -1,11 +1,67 @@
<script setup>
import MailBox from './MailBox.vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
const { settings } = useGlobalState()
import { api } from '../api'
import AddressBar from './index/AddressBar.vue';
import MailBox from '../components/MailBox.vue';
import AutoReply from './index/AutoReply.vue';
import SendBox from './index/SendBox.vue';
import SendMail from './index/SendMail.vue';
import AccountSettings from './index/AccountSettings.vue';
const { localeCache, settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
mailbox: 'Mail Box',
sendbox: 'Send Box',
sendmail: 'Send Mail',
auto_reply: 'Auto Reply',
accountSettings: 'Account Settings',
},
zh: {
mailbox: '收件箱',
sendbox: '发件箱',
sendmail: '发送邮件',
auto_reply: '自动回复',
accountSettings: '账户设置',
}
}
});
const fetchMailData = async (limit, offset) => {
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
};
const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
</script>
<template>
<div>
<MailBox v-if="settings.address" />
<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" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
</n-tab-pane>
<n-tab-pane name="sendbox" :tab="t('sendbox')">
<SendBox />
</n-tab-pane>
<n-tab-pane name="sendmail" :tab="t('sendmail')">
<SendMail />
</n-tab-pane>
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
<AutoReply />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

@@ -1,337 +0,0 @@
<script setup>
import { watch, onMounted, ref } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { api } from '../api'
import { CloudDownloadRound } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
const message = useMessage()
const isMobile = useIsMobile()
const { settings, openSettings, themeSwitch } = useGlobalState()
const autoRefresh = ref(false)
const autoRefreshInterval = ref(30)
const data = ref([])
const timer = ref(null)
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
const curMail = ref(null);
const { t } = useI18n({
locale: 'zh',
messages: {
en: {
autoRefresh: 'Auto Refresh',
refreshAfter: 'Refresh After {msg} Seconds',
refresh: 'Refresh',
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
pleaseSelectMail: "Please select a mail to view.",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete this mail?'
},
zh: {
autoRefresh: '自动刷新',
refreshAfter: '{msg}秒后刷新',
refresh: '刷新',
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择一封邮件查看。",
delete: '删除',
deleteMailTip: '确定要删除这封邮件吗?'
}
}
});
const setupAutoRefresh = async (autoRefresh) => {
// auto refresh every 30 seconds
autoRefreshInterval.value = 30;
if (autoRefresh) {
timer.value = setInterval(async () => {
autoRefreshInterval.value--;
if (autoRefreshInterval.value <= 0) {
autoRefreshInterval.value = 30;
await refresh();
}
}, 1000)
} else {
clearInterval(timer.value)
timer.value = null
}
}
watch(autoRefresh, async (autoRefresh, old) => {
setupAutoRefresh(autoRefresh)
})
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
await refresh();
}
})
const refresh = async () => {
try {
const { results, count: totalCount } = await api.fetch(
`/api/mails`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
);
data.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (totalCount > 0) {
count.value = totalCount;
}
if (!isMobile.value && !curMail.value && data.value.length > 0) {
curMail.value = data.value[0];
}
} catch (error) {
message.error(error.message || "error");
console.error(error);
}
};
const clickRow = async (row) => {
curMail.value = row;
};
const getAttachments = (attachments) => {
curAttachments.value = attachments;
showAttachments.value = true;
};
const mailItemClass = (row) => {
return curMail.value && row.id == curMail.value.id ? (themeSwitch.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
};
const deleteMail = async () => {
try {
await api.fetch(`/api/mails/${curMail.value.id}`, {
method: 'DELETE'
});
message.success(t("success"));
curMail.value = null;
await refresh();
} catch (error) {
message.error(error.message || "error");
}
};
onMounted(async () => {
await refresh();
});
</script>
<template>
<div>
<n-layout v-if="settings.address">
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25" :default-size="0.3">
<template #1>
<div class="center">
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small" :round="false">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<div style="word-break: break-all; font-size: small;">
FROM: {{ row.source }}
</div>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-popconfirm v-if="openSettings.enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail.raw)">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') }}
</n-button>
</n-space>
<div v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
<n-card class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
<div class="left" v-else>
<div class="center">
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small">
<template #checked>
{{ t('autoRefresh') }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template></n-switch>
<n-button @click="refresh" size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
<div id="drawer-target" style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<div style="word-break: break-all; font-size: small;">
FROM: {{ row.source }}
</div>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
<n-drawer v-model:show="curMail" width="100%" :trap-focus="false" :block-scroll="false" to="#drawer-target">
<n-drawer-content :title="curMail.subject" closable>
<n-card style="overflow: auto;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-popconfirm @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail)">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') }}
</n-button>
</n-space>
<div v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
</n-drawer-content>
</n-drawer>
</div>
</n-layout>
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("attachments") }}</div>
</template>
<n-list hoverable clickable>
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
<n-thing class="center" :title="row.filename">
<template #description>
<n-space>
<n-tag type="info">
Size: {{ row.size }}
</n-tag>
</n-space>
</template>
</n-thing>
<template #suffix>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
:href="row.url">
<n-icon :component="CloudDownloadRound" />
</n-button>
</template>
</n-list-item>
</n-list>
<template #action>
</template>
</n-modal>
</div>
</template>
<style scoped>
.left {
text-align: left;
}
.center {
text-align: center;
}
.overlay {
width: 100%;
height: 100%;
z-index: 1000;
}
.overlay-dark-backgroud {
background-color: rgba(255, 255, 255, 0.1);
}
.overlay-light-backgroud {
background-color: rgba(0, 0, 0, 0.1);
}
.mail-item {
height: 100%;
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import AddressMangement from './user/AddressManagement.vue';
import UserSettingsPage from './user/UserSettings.vue';
import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue';
const {
localeCache, userTab, globalTabplacement, userSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address_management: 'Address Management',
user_settings: 'User Settings',
bind_address: 'Bind Mail Address',
},
zh: {
address_management: '地址管理',
user_settings: '用户设置',
bind_address: '绑定邮箱地址',
}
}
});
</script>
<template>
<div>
<UserBar />
<n-tabs v-if="userSettings.user_email" type="card" v-model:value="userTab" :placement="globalTabplacement">
<n-tab-pane name="address_management" :tab="t('address_management')">
<AddressMangement />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettingsPage />
</n-tab-pane>
<n-tab-pane name="bind_address" :tab="t('bind_address')">
<BindAddress />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

@@ -1,10 +1,11 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { NBadge } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { NMenu } from 'naive-ui';
import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
@@ -19,9 +20,12 @@ const { t } = useI18n({
en: {
name: 'Name',
created_at: 'Created At',
showPass: 'Show Passwrod',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
updated_at: 'Update At',
mail_count: 'Mail Count',
send_count: 'Send Count',
showCredential: 'Show Mail Address Credential',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
delete: 'Delete',
deleteTip: 'Are you sure to delete this email?',
delteAccount: 'Delete Account',
@@ -35,9 +39,12 @@ const { t } = useI18n({
zh: {
name: '名称',
created_at: '创建时间',
showPass: '显示密码',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
updated_at: '更新时间',
mail_count: '邮件数量',
send_count: '发送数量',
showCredential: '查看邮箱地址凭证',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
delteAccount: '删除邮箱',
@@ -51,8 +58,8 @@ const { t } = useI18n({
}
});
const showEmailPassword = ref(false)
const curEmailPassword = ref("")
const showEmailCredential = ref(false)
const curEmailCredential = ref("")
const curDeleteAddressId = ref(0);
const addressQuery = ref("")
@@ -61,16 +68,16 @@ const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showDelteAccount = ref(false)
const showDeleteAccount = ref(false)
const showPassword = async (id) => {
const showCredential = async (id) => {
try {
curEmailPassword.value = await api.adminShowPassword(id)
showEmailPassword.value = true
curEmailCredential.value = await api.adminShowAddressCredential(id)
showEmailCredential.value = true
} catch (error) {
message.error(error.message || "error");
showEmailPassword.value = false
curEmailPassword.value = ""
showEmailCredential.value = false
curEmailCredential.value = ""
}
}
@@ -81,7 +88,8 @@ const deleteEmail = async () => {
await fetchData()
} catch (error) {
message.error(error.message || "error");
showDelteAccount.value = false
} finally {
showDeleteAccount.value = false
}
}
@@ -116,6 +124,62 @@ const columns = [
title: t('created_at'),
key: "created_at"
},
{
title: t('updated_at'),
key: "updated_at"
},
{
title: t('mail_count'),
key: "mail_count",
render(row) {
return h(NButton,
{
text: true,
onClick: () => {
if (row.mail_count > 0) {
adminMailTabAddress.value = row.name;
adminTab.value = "mails";
}
}
},
{
icon: () => h(NBadge, {
value: row.mail_count,
'show-zero': true,
max: 99,
type: "success"
}),
default: () => row.mail_count > 0 ? t('viewMails') : ""
}
)
}
},
{
title: t('send_count'),
key: "send_count",
render(row) {
return h(NButton,
{
text: true,
onClick: () => {
if (row.send_count > 0) {
adminSendBoxTabAddress.value = row.name;
adminTab.value = "sendBox";
}
}
},
{
icon: () => h(NBadge, {
value: row.send_count,
'show-zero': true,
max: 99,
type: "success"
}),
default: () => row.send_count > 0 ? t('viewSendBox') : ""
}
)
}
},
{
title: t('actions'),
key: 'actions',
@@ -132,18 +196,16 @@ const columns = [
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
onClick: () => showPassword(row.id)
text: true,
onClick: () => showCredential(row.id)
},
{ default: () => t('showPass') }
{ default: () => t('showCredential') }
),
},
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
text: true,
onClick: () => {
adminMailTabAddress.value = row.name;
adminTab.value = "mails";
@@ -155,8 +217,7 @@ const columns = [
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
text: true,
onClick: () => {
adminSendBoxTabAddress.value = row.name;
adminTab.value = "sendBox";
@@ -168,11 +229,10 @@ const columns = [
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
text: true,
onClick: () => {
curDeleteAddressId.value = row.id;
showDelteAccount.value = true;
showDeleteAccount.value = true;
}
},
{ default: () => t('delete') }
@@ -202,30 +262,30 @@ onMounted(async () => {
<template>
<div>
<n-modal v-model:show="showEmailPassword" preset="dialog" title="Dialog">
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("password") }}</div>
<div>{{ t("addressCredential") }}</div>
</template>
<span>
<p>{{ t("passwordTip") }}</p>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card>
<b>{{ curEmailPassword }}</b>
<b>{{ curEmailCredential }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('delteAccount')">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary round type="error">
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
{{ t('delteAccount') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
<n-button @click="fetchData" type="primary" ghost>
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>

View File

@@ -0,0 +1,91 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
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',
},
zh: {
save: '保存',
successTip: '保存成功',
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
send_address_block_list: '发送邮件地址屏蔽关键词',
}
}
});
const addressBlockList = ref([])
const sendAddressBlockList = ref([])
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/account_settings`)
addressBlockList.value = res.blockList || []
sendAddressBlockList.value = res.sendBlockList || []
} catch (error) {
message.error(error.message || "error");
}
}
const save = async () => {
try {
await api.fetch(`/admin/account_settings`, {
method: 'POST',
body: JSON.stringify({
blockList: addressBlockList.value || [],
sendBlockList: sendAddressBlockList.value || []
})
})
message.success(t('successTip'))
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card style="max-width: 600px;">
<n-form-item-row :label="t('address_block_list')">
<n-select v-model:value="addressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" />
</n-form-item-row>
<n-form-item-row :label="t('send_address_block_list')">
<n-select v-model:value="sendAddressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" />
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

View File

@@ -0,0 +1,109 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const {
localeCache, loading, openSettings,
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
enablePrefix: 'If enable Prefix',
creatNewEmail: 'Get New Email',
fillInAllFields: 'Please fill in all fields',
successTip: 'Success Created',
addressCredential: 'Mail Address Credential',
},
zh: {
address: '地址',
enablePrefix: '是否启用前缀',
creatNewEmail: '创建新邮箱',
fillInAllFields: '请填写完整信息',
successTip: '创建成功',
addressCredential: '邮箱地址凭证',
}
}
});
const enablePrefix = ref(true)
const emailName = ref("")
const emailDomain = ref("")
const showReultModal = ref(false)
const result = ref("")
const newEmail = async () => {
if (!emailName.value || !emailDomain.value) {
message.error(t('fillInAllFields'))
return
}
try {
const res = await api.fetch(`/admin/new_address`, {
method: 'POST',
body: JSON.stringify({
enablePrefix: enablePrefix.value,
name: emailName.value,
domain: emailDomain.value,
})
})
result.value = res["jwt"];
message.success(t('successTip'))
showReultModal.value = true
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
if (openSettings.prefix) {
enablePrefix.value = true
}
emailDomain.value = openSettings.value.domains?.[0]?.value || ""
})
</script>
<template>
<div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
<p>{{ t('addressCredential') }}</p>
<n-card>
<b>{{ result }}</b>
</n-card>
</n-modal>
<n-card style="max-width: 600px;">
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
<n-checkbox v-model:checked="enablePrefix" />
</n-form-item-row>
<n-form-item-row :label="t('address')">
<n-input-group>
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
</n-form-item-row>
<n-button @click="newEmail" type="primary" block :loading="loading">
{{ t('creatNewEmail') }}
</n-button>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

View File

@@ -4,60 +4,49 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { processItem } from '../../utils/email-parser'
import MailBox from '../../components/MailBox.vue';
const {
localeCache, adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
mails: 'Emails',
itemCount: 'itemCount',
addressQueryTip: 'Leave blank to query all addresses',
keywordQueryTip: 'Leave blank to not query by keyword',
query: 'Query',
},
zh: {
mails: '邮件',
itemCount: '总数',
addressQueryTip: '留空查询所有地址',
keywordQueryTip: '留空不按关键字查询',
query: '查询',
}
}
});
const mailData = ref([])
const mailCount = ref(0)
const mailPage = ref(1)
const mailPageSize = ref(20)
const mailBoxKey = ref("")
const mailKeyword = ref("")
watch([mailPage, mailPageSize, adminMailTabAddress], async () => {
await fetchMailData()
})
watch([adminMailTabAddress, mailKeyword], () => {
adminMailTabAddress.value = adminMailTabAddress.value.trim();
mailKeyword.value = mailKeyword.value.trim();
});
const fetchMailData = async () => {
if (!adminMailTabAddress.value) {
return
}
try {
const { results, count } = await api.fetch(
`/admin/mails`
+ `?address=${adminMailTabAddress.value}`
+ `&limit=${mailPageSize.value}`
+ `&offset=${(mailPage.value - 1) * mailPageSize.value}`
);
mailData.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (count > 0) {
mailCount.value = count;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
const queryMail = () => {
mailBoxKey.value = Date.now();
}
const fetchMailData = async (limit, offset) => {
return await api.fetch(
`/admin/mails`
+ `?limit=${limit}`
+ `&offset=${offset}`
+ (adminMailTabAddress.value ? `&address=${adminMailTabAddress.value}` : '')
+ (mailKeyword.value ? `&keyword=${mailKeyword.value}` : '')
);
}
onMounted(async () => {
@@ -65,48 +54,18 @@ onMounted(async () => {
showAdminAuth.value = true;
return;
}
await fetchMailData()
})
</script>
<template>
<div>
<n-input-group>
<n-input v-model:value="adminMailTabAddress" />
<n-button @click="fetchMailData" type="primary" ghost>
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" />
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" />
<n-button @click="queryMail" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<n-list hoverable clickable>
<div style="display: inline-block; margin-bottom: 10px;">
<n-pagination v-model:page="mailPage" v-model:page-size="mailPageSize" :item-count="mailCount" simple>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-list-item v-for="row in mailData" v-bind:key="row.id">
<n-thing class="center" :title="row.subject">
<template #description>
<n-space>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
</n-space>
</template>
<div v-html="row.message"></div>
</n-thing>
</n-list-item>
</n-list>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="false" :fetchMailData="fetchMailData" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

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

View File

@@ -8,29 +8,43 @@ import { api } from '../../api'
const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
const message = useMessage()
const cleanMailsDays = ref(30)
const cleanUnknowMailsDays = ref(30)
const cleanAddressDays = ref(30)
const cleanSendBoxDays = ref(30)
const cleanupModel = ref({
enableMailsAutoCleanup: false,
cleanMailsDays: 30,
enableUnknowMailsAutoCleanup: false,
cleanUnknowMailsDays: 30,
enableAddressAutoCleanup: false,
cleanAddressDays: 30,
enableSendBoxAutoCleanup: false,
cleanSendBoxDays: 30,
})
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
tip: 'Please input the cleanup days',
mailBoxTip: "Clean up {day} days ago mailbox",
mailUnknowTip: "Clean up {day} days ago mails with unknow receiver",
addressUnActiveTip: "Clean up {day} days ago unactive address",
sendBoxTip: "Clean up {day} days ago sendbox",
mailBoxLabel: 'Clean up days for mailbox',
mailUnknowLabel: "Clean up days for unknow receiver",
addressUnActiveLabel: "Clean up days for unactive address",
sendBoxLabel: "Clean up days for sendbox",
cleanupNow: "Cleanup now",
autoCleanup: "Auto cleanup",
cleanupSuccess: "Cleanup success",
save: "Save",
cronTip: "Enable cron cleanup, need to configure [crons] in worker, please refer to the document",
},
zh: {
tip: '请输入清理天数',
mailBoxTip: "清理{day}天前的收件箱",
mailUnknowTip: "清理{day}天前的无收件人邮件",
addressUnActiveTip: "清理{day}天前的未活地址",
sendBoxTip: "清理{day}天前的发件箱",
mailBoxLabel: '收件箱清理天数',
mailUnknowLabel: "无收件人邮件清理天数",
addressUnActiveLabel: "未活地址清理天数",
sendBoxLabel: "发件箱清理天数",
autoCleanup: "自动清理",
cleanupSuccess: "清理成功",
cleanupNow: "立即清理",
save: "保存",
cronTip: "启用定时清理, 需在 worker 配置 [crons] 参数, 请参考文档",
}
}
});
@@ -47,11 +61,33 @@ const cleanup = async (cleanType, cleanDays) => {
}
}
const fetchData = async () => {
try {
const res = await api.fetch('/admin/auto_cleanup');
if (res) Object.assign(cleanupModel.value, res);
} catch (error) {
message.error(error.message || "error");
}
}
const save = async () => {
try {
await api.fetch('/admin/auto_cleanup', {
method: 'POST',
body: JSON.stringify(cleanupModel.value)
});
message.success(t('cleanupSuccess'));
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData();
})
</script>
@@ -59,42 +95,62 @@ onMounted(async () => {
<template>
<div class="center">
<n-card>
<div class="item">
<n-input-number v-model:value="cleanMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails', cleanMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('mailBoxTip', { day: cleanMailsDays }) }}
<n-alert :show-icon="false">
<span>{{ t('cronTip') }}</span>
</n-alert>
<n-form :model="cleanupModel">
<n-form-item-row :label="t('mailBoxLabel')">
<n-checkbox v-model:checked="cleanupModel.enableMailsAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails', cleanupModel.cleanMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('mailUnknowLabel')">
<n-checkbox v-model:checked="cleanupModel.enableUnknowMailsAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanUnknowMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails_unknow', cleanupModel.cleanUnknowMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('addressUnActiveLabel')">
<n-checkbox v-model:checked="cleanupModel.enableAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('address', cleanupModel.cleanAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('mailBoxLabel')">
<n-checkbox v-model:checked="cleanupModel.enableSendBoxAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanSendBoxDays" :placeholder="t('tip')" />
<n-button @click="cleanup('sendbox', cleanupModel.cleanSendBoxDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>
</div>
<div class="item">
<n-input-number v-model:value="cleanUnknowMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails_unknow', cleanUnknowMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('mailUnknowTip', { day: cleanUnknowMailsDays }) }}
</n-button>
</div>
<div class="item">
<n-input-number v-model:value="cleanAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('address', cleanAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('addressUnActiveTip', { day: cleanAddressDays }) }}
</n-button>
</div>
<div class="item">
<n-input-number v-model:value="cleanSendBoxDays" :placeholder="t('tip')" />
<n-button @click="cleanup('sendbox', cleanSendBoxDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('sendBoxTip', { day: cleanSendBoxDays }) }}
</n-button>
</div>
</n-form>
</n-card>
</div>
</template>
@@ -111,6 +167,10 @@ onMounted(async () => {
justify-content: center;
}
.n-alert {
margin-bottom: 20px;
}
.item {
display: flex;
margin: 10px;

View File

@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, settings, adminAuth, adminSendBoxTabAddress } = useGlobalState()
const { localeCache, adminAuth, adminSendBoxTabAddress, showAdminAuth } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
@@ -44,15 +44,12 @@ const curRow = ref({})
const showModal = ref(false)
const fetchData = async () => {
if (!adminSendBoxTabAddress.value) {
return
}
try {
const { results, count: addressCount } = await api.fetch(
`/admin/sendbox`
+ `?address=${adminSendBoxTabAddress.value}`
+ `&limit=${pageSize.value}`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
);
data.value = results.map((item) => {
try {
@@ -105,7 +102,7 @@ const columns = [
h(NButton,
{
type: 'success',
ghost: true,
tertiary: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
@@ -132,13 +129,13 @@ onMounted(async () => {
</script>
<template>
<div v-if="settings.address">
<n-modal v-model:show="showModal" preset="dialog">
<pre>{{ curRow.raw }}</pre>
<div>
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
</n-modal>
<n-input-group>
<n-input v-model:value="adminSendBoxTabAddress" />
<n-button @click="fetchData" type="primary" ghost>
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>

View File

@@ -14,6 +14,7 @@ const { t } = useI18n({
en: {
address: 'Address',
success: 'Success',
is_enabled: 'Is Enabled',
enable: 'Enable',
disable: 'Disable',
modify: 'Modify',
@@ -22,12 +23,13 @@ const { t } = useI18n({
itemCount: 'itemCount',
modalTip: 'Please input the sender balance',
balance: 'Balance',
refresh: 'Refresh',
query: 'Query',
ok: 'OK'
},
zh: {
address: '地址',
success: '成功',
is_enabled: '是否启用',
enable: '启用',
disable: '禁用',
modify: '修改',
@@ -36,7 +38,7 @@ const { t } = useI18n({
itemCount: '总数',
modalTip: '请输入发件额度',
balance: '余额',
refresh: '刷新',
query: '查询',
ok: '确定'
}
}
@@ -51,6 +53,7 @@ const showModal = ref(false)
const senderBalance = ref(0)
const senderEnabled = ref(false)
const addressQuery = ref('')
const updateData = async () => {
try {
@@ -77,6 +80,7 @@ const fetchData = async () => {
`/admin/address_sender`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (addressQuery.value ? `&address=${addressQuery.value}` : '')
);
data.value = results;
if (addressCount > 0) {
@@ -106,7 +110,7 @@ const columns = [
key: "balance"
},
{
title: "Enabled",
title: t('is_enabled'),
key: "enabled",
render(row) {
return h('div', [
@@ -122,7 +126,7 @@ const columns = [
h(NButton,
{
type: 'success',
ghost: true,
tertiary: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
@@ -161,22 +165,23 @@ onMounted(async () => {
<n-input-number v-model:value="senderBalance" :min="0" :max="1000" />
</n-form-item>
<template #action>
<n-button :loading="loading" @click="updateData()" size="small" tertiary round type="primary">
<n-button :loading="loading" @click="updateData()" size="small" tertiary type="primary">
{{ t('ok') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" />
<n-button @click="fetchData" type="primary" tertiary>
{{ 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="fetchData" type="primary" size="small" ghost>
{{ t('refresh') }}
</n-button>
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />

View File

@@ -14,14 +14,14 @@ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
userCount: 'User Count',
activeUser: '7 days Active User',
userCount: 'Account Count',
activeUser: '7 days Active Mail Account',
mailCount: 'Mail Count',
sendMailCount: 'Send Mail Count'
},
zh: {
userCount: '用户总数',
activeUser: '周活跃用户',
userCount: '地址总数',
activeUser: '周活跃邮箱地址',
mailCount: '邮件总数',
sendMailCount: '发送邮件总数'
}

View File

@@ -0,0 +1,289 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { NMenu, NButton, NBadge } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { hashPassword } from '../../utils';
const { localeCache, loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'Success',
user_email: 'User Email',
address_count: 'Address Count',
created_at: 'Created At',
actions: 'Actions',
query: 'Query',
itemCount: 'itemCount',
deleteUser: 'Delete User',
delete: 'Delete',
deleteUserTip: 'Are you sure you want to delete this user?',
resetPassword: 'Reset Password',
pleaseInput: 'Please input complete information',
createUser: 'Create User',
email: 'Email',
password: 'Password',
},
zh: {
success: '成功',
user_email: '用户邮箱',
address_count: '地址数量',
created_at: '创建时间',
actions: '操作',
query: '查询',
itemCount: '总数',
deleteUser: '删除用户',
delete: '删除',
deleteUserTip: '确定要删除此用户吗?',
resetPassword: '重置密码',
pleaseInput: '请输入完整信息',
createUser: '创建用户',
email: '邮箱',
password: '密码',
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const userQuery = ref('')
const showResetPassword = ref(false)
const newResetPassword = ref('')
const showDeleteUser = ref(false)
const curUserId = ref(0)
const showCreateUser = ref(false)
const user = ref({
email: "",
password: ""
})
const fetchData = async () => {
try {
const { results, count: userCount } = await api.fetch(
`/admin/users`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (userQuery.value ? `&query=${userQuery.value}` : '')
);
data.value = results;
if (userCount > 0) {
count.value = userCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const resetPassword = async () => {
if (!newResetPassword.value) {
message.error(t('pleaseInput'));
return;
}
try {
await api.fetch(`/admin/users/${curUserId.value}/reset_password`, {
method: "POST",
body: JSON.stringify({
password: await hashPassword(newResetPassword.value)
})
});
message.success(t('success'));
showResetPassword.value = false;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const createUser = async () => {
if (!user.value.email || !user.value.password) {
message.error(t('pleaseInput'));
return;
}
try {
await api.fetch(`/admin/users`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
password: await hashPassword(user.value.password)
})
});
message.success(t('success'));
await fetchData();
showCreateUser.value = false;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const deleteUser = async () => {
try {
await api.fetch(`/admin/users/${curUserId.value}`, {
method: "DELETE"
});
message.success(t('success'));
showDeleteUser.value = false;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('user_email'),
key: "user_email"
},
{
title: t('address_count'),
key: "address_count",
render(row) {
return h(NBadge, {
value: row.address_count,
'show-zero': true,
max: 99,
type: "success"
})
}
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
h(NMenu, {
mode: "horizontal",
options: [
{
label: t('actions'),
icon: () => h(MenuFilled),
key: "action",
children: [
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curUserId.value = row.id;
newResetPassword.value = '';
showResetPassword.value = true;
}
},
{ default: () => t('resetPassword') }
),
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curUserId.value = row.id;
user.value.email = '';
user.value.password = '';
showDeleteUser.value = true;
}
},
{ default: () => t('delete') }
)
}
]
}
]
})
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showCreateUser" preset="dialog" :title="t('createUser')">
<n-form>
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
</n-form>
<template #action>
<n-button :loading="loading" @click="createUser" size="small" tertiary type="primary">
{{ t('createUser') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="newResetPassword" type="password" show-password-on="click" />
</n-form-item-row>
<template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="primary">
{{ t('resetPassword') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showDeleteUser" preset="dialog" :title="t('deleteUser')">
<p>{{ t('deleteUserTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteUser" size="small" tertiary type="error">
{{ t('deleteUser') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="userQuery" />
<n-button @click="fetchData" type="primary" tertiary>
{{ 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>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,130 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
save: 'Save',
successTip: 'Save Success',
enable: 'Enable',
enableUserRegister: 'Allow User Register',
enableMailVerify: 'Enable Mail Verify (Send address must be an address in the system with a balance and can send mail normally)',
verifyMailSender: 'Verify Mail Sender',
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
mailAllowList: 'Mail Address Allow List',
maxAddressCount: 'Maximum number of email addresses that can be binded',
},
zh: {
save: '保存',
successTip: '保存成功',
enable: '启用',
enableUserRegister: "允许用户注册",
enableMailVerify: '启用邮件验证(发送地址必须是系统中能有余额且能正常发送邮件的地址)',
verifyMailSender: '验证邮件发送地址',
enableMailAllowList: '启用邮件地址白名单(可手动输入)',
mailAllowList: '邮件地址白名单',
maxAddressCount: '可绑定最大邮箱地址数量',
}
}
});
const commonMail = [
"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com",
"icloud.com", "yahoo.com", "foxmail.com"
]
const mailAllowOptions = commonMail.map((item) => {
return { label: item, value: item }
})
const userSettings = ref({
enable: false,
enableMailVerify: false,
verifyMailSender: "",
enableMailAllowList: false,
mailAllowList: commonMail,
maxAddressCount: 5,
});
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/user_settings`)
Object.assign(userSettings.value, res)
} catch (error) {
message.error(error.message || "error");
}
}
const save = async () => {
try {
await api.fetch(`/admin/user_settings`, {
method: 'POST',
body: JSON.stringify(userSettings.value)
})
message.success(t('successTip'))
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card style="max-width: 600px;">
<n-form :model="userSettings">
<n-form-item-row :label="t('enableUserRegister')">
<n-checkbox v-model:checked="userSettings.enable" />
</n-form-item-row>
<n-form-item-row :label="t('enableMailVerify')">
<n-input-group>
<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-group>
</n-form-item-row>
<n-form-item-row :label="t('enableMailAllowList')">
<n-input-group>
<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-input-group>
</n-form-item-row>
<n-form-item-row :label="t('maxAddressCount')">
<n-input-group>
<n-input-number v-model:value="userSettings.maxAddressCount"
:placeholder="t('maxAddressCount')" />
</n-input-group>
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>
</n-form>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -17,7 +17,7 @@ const { t } = useI18n({
</script>
<template>
<n-alert v-if="openSettings.adminContact" type="info" show-icon>
<n-alert v-if="openSettings.adminContact" :show-icon="false">
<span>{{ t('adminContact', { msg: openSettings.adminContact }) }}</span>
</n-alert>
</template>

View File

@@ -0,0 +1,82 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
const {
localeCache, mailboxSplitSize, useIframeShowMail, preferShowTextMail,
globalTabplacement, useSideMargin
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
mailboxSplitSize: 'Mailbox Split Size',
useIframeShowMail: 'Use iframe Show HTML Mail',
preferShowTextMail: 'Display text Mail by default',
useSideMargin: 'Turn on the side margins on the left and right sides of the page',
globalTabplacement: 'Global Tab Placement',
left: 'left',
top: 'top',
right: 'right',
bottom: 'bottom',
},
zh: {
mailboxSplitSize: '邮箱界面分栏大小',
preferShowTextMail: '默认以文本显示邮件',
useIframeShowMail: '使用iframe显示HTML邮件',
globalTabplacement: '全局选项卡位置',
useSideMargin: '开启页面左右两侧侧边距',
left: '左侧',
top: '顶部',
right: '右侧',
bottom: '底部',
}
}
});
</script>
<template>
<div class="center">
<n-card>
<n-form-item-row :label="t('mailboxSplitSize')">
<n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{
0.25: '0.25',
0.5: '0.5',
0.75: '0.75'
}" />
</n-form-item-row>
<n-form-item-row :label="t('preferShowTextMail')">
<n-switch v-model:value="preferShowTextMail" :round="false" />
</n-form-item-row>
<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('useSideMargin')">
<n-switch v-model:value="useSideMargin" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('globalTabplacement')">
<n-radio-group v-model:value="globalTabplacement">
<n-radio-button value="top" :label="t('top')" />
<n-radio-button value="left" :label="t('left')" />
<n-radio-button value="right" :label="t('right')" />
<n-radio-button value="bottom" :label="t('bottom')" />
</n-radio-group>
</n-form-item-row>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
justify-content: center;
}
.n-card {
max-width: 800px;
text-align: left;
}
</style>

View File

@@ -0,0 +1,212 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
import AdminContact from '../common/AdminContact.vue'
import Turnstile from '../../components/Turnstile.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const message = useMessage()
const router = useRouter()
const {
jwt, localeCache, loading, openSettings,
showAddressCredential, userSettings
} = useGlobalState()
const tabValue = ref('signin')
const credential = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const login = async () => {
if (!credential.value) {
message.error(t('credentialInput'));
return;
}
try {
jwt.value = credential.value;
await api.getSettings();
try {
await api.bindUserAddress();
} catch (error) {
message.error(`${t('bindUserAddressError')}: ${error.message}`);
}
await router.push("/");
} catch (error) {
message.error(error.message || "error");
}
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
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, A-Z and 0-9',
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
credential: 'Email Address Credential',
ok: 'OK',
generateName: 'Generate Fake Name',
help: 'Help',
credentialInput: 'Please input the Mail Address Credential',
bindUserInfo: 'Logged in user, login without binding email or create new email address will bind to current user',
bindUserAddressError: 'Error when bind email address to user',
},
zh: {
login: '登录',
pleaseGetNewEmail: '请"登录"或点击 "注册新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '创建新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
credential: '邮箱地址凭据',
ok: '确定',
generateName: '生成随机名字',
help: '帮助',
credentialInput: '请输入邮箱地址凭据',
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
bindUserAddressError: '绑定邮箱地址到用户时错误',
}
}
});
const generateNameLoading = ref(false);
const generateName = async () => {
try {
generateNameLoading.value = true;
const { faker } = await import('https://esm.sh/@faker-js/faker');
emailName.value = faker.internet.email()
.split('@')[0]
.replace(/\s+/g, '.')
.replace(/\.{2,}/g, '.')
.replace(/[^a-zA-Z0-9.]/g, '')
.toLowerCase();
} catch (error) {
message.error(error.message || "error");
} finally {
generateNameLoading.value = false;
}
};
const newEmail = async () => {
try {
const res = await api.fetch(`/api/new_address`, {
method: "POST",
body: JSON.stringify({
name: emailName.value,
domain: emailDomain.value,
cf_token: cfToken.value,
}),
});
jwt.value = res["jwt"];
await api.getSettings();
await router.push("/");
showAddressCredential.value = true;
try {
await api.bindUserAddress();
} catch (error) {
message.error(`${t('bindUserAddressError')}: ${error.message}`);
}
} catch (error) {
message.error(error.message || "error");
}
};
onMounted(async () => {
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
await api.getOpenSettings();
}
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0]?.value : "";
});
</script>
<template>
<div>
<n-alert v-if="userSettings.user_email" :show-icon="false" closable>
<span>{{ t('bindUserInfo') }}</span>
</n-alert>
<n-tabs 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>
<n-input v-model:value="credential" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-button @click="login" :loading="loading" type="primary" block secondary strong>
<template #icon>
<n-icon :component="EmailOutlined" />
</template>
{{ t('login') }}
</n-button>
<n-button v-if="openSettings.enableUserCreateEmail" @click="tabValue = 'register'" block secondary
strong>
<template #icon>
<n-icon :component="NewLabelOutlined" />
</template>
{{ t('getNewEmail') }}
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableUserCreateEmail" name="register" :tab="t('getNewEmail')">
<n-spin :show="generateNameLoading">
<n-form>
<span>
<p>{{ t("getNewEmailTip1") }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>
<n-button @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
<Turnstile v-model:value="cfToken" />
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
<template #icon>
<n-icon :component="NewLabelOutlined" />
</template>
{{ t('getNewEmail') }}
</n-button>
</n-form>
</n-spin>
</n-tab-pane>
<n-tab-pane name="help" :tab="t('help')">
<n-alert :show-icon="false">
<span>{{ t('pleaseGetNewEmail') }}</span>
</n-alert>
<AdminContact />
</n-tab-pane>
</n-tabs>
</div>
</template>
<style scoped>
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.n-form .n-button {
margin-top: 10px;
}
.n-form {
text-align: left;
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import Appearance from '../common/Appearance.vue'
const {
jwt, localeCache, settings, showAddressCredential, loading
} = useGlobalState()
const router = useRouter()
const message = useMessage()
const showLogout = ref(false)
const showDelteAccount = ref(false)
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
logout: "Logout",
delteAccount: "Delete Account",
showAddressCredential: 'Show Address Credential',
logoutConfirm: 'Are you sure to logout?',
delteAccount: "Delete Account",
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
},
zh: {
logout: '退出登录',
delteAccount: "删除账户",
showAddressCredential: '查看邮箱地址凭证',
logoutConfirm: '确定要退出登录吗?',
delteAccount: "删除账户",
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
}
}
});
const logout = async () => {
jwt.value = '';
await router.push('/')
location.reload()
}
const deleteAccount = async () => {
try {
await api.fetch(`/api/delete_address`, {
method: 'DELETE'
});
jwt.value = '';
await router.push('/')
location.reload()
} catch (error) {
message.error(error.message || "error");
}
};
</script>
<template>
<div class="center" v-if="settings.address">
<n-card>
<Appearance />
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }}
</n-button>
<n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }}
</n-button>
<n-button @click="showDelteAccount = true" type="error" secondary block strong>
{{ t('delteAccount') }}
</n-button>
</n-card>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="logout" size="small" tertiary type="primary">
{{ t('logout') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
<p>{{ t('delteAccountConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
{{ t('delteAccount') }}
</n-button>
</template>
</n-modal>
</div>
</template>
<style scoped>
.center {
display: flex;
justify-content: center;
}
.n-card {
max-width: 800px;
text-align: left;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,128 @@
<script setup>
import useClipboard from 'vue-clipboard3'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Copy, User } from '@vicons/fa'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import Login from '../common/Login.vue'
const { toClipboard } = useClipboard()
const message = useMessage()
const router = useRouter()
const {
jwt, localeCache, settings, showAddressCredential, openSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
yourAddress: 'Your email address is',
ok: 'OK',
copy: 'Copy',
copied: 'Copied',
fetchAddressError: 'Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
userLogin: 'User Login',
},
zh: {
yourAddress: '你的邮箱地址是',
ok: '确定',
copy: '复制',
copied: '已复制',
fetchAddressError: '邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
userLogin: '用户登录',
}
}
});
const copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
}
}
onMounted(async () => {
await api.getSettings();
});
</script>
<template>
<div>
<n-card v-if="!settings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<div v-else-if="settings.address">
<n-alert v-if="settings.has_v1_mails" type="warning" :show-icon="false" closable>
<span>
<n-button tag="a" target="_blank" tertiary type="info" size="small" href="https://mail-v1.awsl.uk">
<b>{{ t('mailV1Alert') }} </b>
</n-button>
</span>
</n-alert>
<n-alert type="info" :show-icon="false">
<span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</span>
</n-alert>
</div>
<div v-else class="center">
<n-card style="max-width: 600px;">
<n-alert v-if="jwt" type="warning" :show-icon="false" closable>
<span>{{ t('fetchAddressError') }}</span>
</n-alert>
<Login />
<n-divider />
<n-button @click="router.push('/user')" type="primary" block secondary strong>
<template #icon>
<n-icon :component="User" />
</template>
{{ t('userLogin') }}
</n-button>
</n-card>
</div>
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card>
<b>{{ jwt }}</b>
</n-card>
</n-modal>
</div>
</template>
<style scoped>
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.n-card {
margin-top: 10px;
}
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

View File

@@ -2,8 +2,8 @@
import { useI18n } from 'vue-i18n'
import { onMounted, ref } from 'vue'
import { useGlobalState } from '../store'
import { api } from '../api'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const message = useMessage()
const sourcePrefix = ref("")
@@ -41,17 +41,22 @@ const { t } = useI18n({
}
});
const getSettings = async () => {
sourcePrefix.value = settings.value.auto_reply.source_prefix || ""
enableAutoReply.value = settings.value.auto_reply.enabled || false
name.value = settings.value.auto_reply.name || ""
autoReplyMessage.value = settings.value.auto_reply.message || ""
subject.value = settings.value.auto_reply.subject || ""
const fetchData = async () => {
try {
const res = await api.fetch("/api/auto_reply")
sourcePrefix.value = res.source_prefix || ""
enableAutoReply.value = res.enabled || false
name.value = res.name || ""
autoReplyMessage.value = res.message || ""
subject.value = res.subject || ""
} catch (error) {
message.error(error.message || "error");
}
}
const saveSettings = async () => {
const saveData = async () => {
try {
await api.fetch("/api/settings", {
await api.fetch("/api/auto_reply", {
method: "POST",
body: JSON.stringify({
auto_reply: {
@@ -70,7 +75,7 @@ const saveSettings = async () => {
}
onMounted(async () => {
await getSettings()
await fetchData()
})
</script>
@@ -78,7 +83,7 @@ onMounted(async () => {
<div class="center">
<n-card v-if="settings.address" :title='t("settings")'>
<div class="right">
<n-button type="primary" @click="saveSettings">{{ t('save') }}</n-button>
<n-button type="primary" @click="saveData">{{ t('save') }}</n-button>
</div>
<div class="left">
<n-form-item :label="t('enableAutoReply')" label-placement="left">

View File

@@ -128,8 +128,8 @@ onMounted(async () => {
<template>
<div v-if="settings.address">
<n-modal v-model:show="showModal" preset="dialog">
<pre>{{ curRow.raw }}</pre>
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
</n-modal>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
@@ -138,7 +138,7 @@ onMounted(async () => {
{{ t('itemCount') }}: {{ itemCount }}
</template>
<template #suffix>
<n-button @click="fetchData" type="primary" size="small" ghost>
<n-button @click="fetchData" type="primary" size="small" tertiary>
{{ t('refresh') }}
</n-button>
</template>

View File

@@ -1,30 +1,19 @@
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { DomEditor } from '@wangeditor/editor'
import { useI18n } from 'vue-i18n'
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
import { useStorage } from '@vueuse/core'
import AdminContact from '../admin/AdminContact.vue'
import AdminContact from '../common/AdminContact.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import router from '../../router'
const message = useMessage()
const isPreview = ref(false)
const editorRef = shallowRef()
const mailModel = useStorage('mailModelCache', {
fromName: "",
toName: "",
toMail: "",
subject: "",
contentType: 'text',
content: "",
})
const { settings } = useGlobalState()
const { settings, sendMailModel, indexTab } = useGlobalState()
const { t } = useI18n({
locale: 'zh',
@@ -81,15 +70,15 @@ const send = async () => {
method: 'POST',
body:
JSON.stringify({
from_name: mailModel.value.fromName,
to_name: mailModel.value.toName,
to_mail: mailModel.value.toMail,
subject: mailModel.value.subject,
is_html: mailModel.value.contentType != 'text',
content: mailModel.value.content,
from_name: sendMailModel.value.fromName,
to_name: sendMailModel.value.toName,
to_mail: sendMailModel.value.toMail,
subject: sendMailModel.value.subject,
is_html: sendMailModel.value.contentType != 'text',
content: sendMailModel.value.content,
})
})
mailModel.value = {
sendMailModel.value = {
fromName: "",
toName: "",
toMail: "",
@@ -101,7 +90,7 @@ const send = async () => {
message.error(error.message || "error");
} finally {
message.success(t("successSend"));
router.push('/sendbox');
indexTab.value = 'sendbox'
}
}
@@ -155,58 +144,59 @@ onMounted(async () => {
<div class="center" v-if="settings.address">
<n-card>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<n-alert type="warning" show-icon>
<n-alert type="warning" :show-icon="false">
{{ t('requestAccessTip') }}
<n-button type="primary" ghost @click="requestAccess">{{ t('requestAccess') }}</n-button>
<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>
<n-alert type="info" :show-icon="false">
{{ t('send_balance') }}: {{ settings.send_balance }}
</n-alert>
<div class="right">
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
</div>
<div class="left">
<n-form :model="mailModel">
<n-form :model="sendMailModel">
<n-form-item :label="t('fromName')" label-placement="top">
<n-input-group>
<n-input v-model:value="mailModel.fromName" />
<n-input v-model:value="sendMailModel.fromName" />
<n-input :value="settings.address" disabled />
</n-input-group>
</n-form-item>
<n-form-item :label="t('toName')" label-placement="top">
<n-input-group>
<n-input v-model:value="mailModel.toName" />
<n-input v-model:value="mailModel.toMail" />
<n-input v-model:value="sendMailModel.toName" />
<n-input v-model:value="sendMailModel.toMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('subject')" label-placement="top">
<n-input v-model:value="mailModel.subject" />
<n-input v-model:value="sendMailModel.subject" />
</n-form-item>
<n-form-item :label="t('options')" label-placement="top">
<n-radio-group v-model:value="mailModel.contentType">
<n-radio-group v-model:value="sendMailModel.contentType">
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
:label="option.label" />
</n-radio-group>
<n-button v-if="mailModel.contentType != 'text'" @click="isPreview = !isPreview"
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
style="margin-left: 10px;">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<n-card v-if="isPreview">
<div v-html="mailModel.content" />
<div v-html="sendMailModel.content" />
</n-card>
<div v-else-if="mailModel.contentType == 'rich'" style="border: 1px solid #ccc">
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
:editor="editorRef" mode="default" />
<Editor style="height: 500px; overflow-y: hidden;" v-model="mailModel.content"
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
</div>
<n-input v-else type="textarea" v-model:value="mailModel.content" :autosize="{
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
minRows: 3
}" />
</n-form-item>

View File

@@ -0,0 +1,170 @@
<script setup>
import { ref, h, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router';
import { NBadge, NPopconfirm, NButton } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, jwt } = useGlobalState()
const message = useMessage()
const router = useRouter()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'success',
name: 'Name',
mail_count: 'Mail Count',
send_count: 'Send Count',
actions: 'Actions',
changeMailAddress: 'Change Mail Address',
unbindAddress: 'Unbind Address',
unbindAddressTip: 'Before unbinding, please switch to this email address and save the email address credential.',
},
zh: {
success: '成功',
name: '名称',
mail_count: '邮件数量',
send_count: '发送数量',
actions: '操作',
changeMailAddress: '切换邮箱地址',
unbindAddress: '解绑地址',
unbindAddressTip: '解绑前请切换到此邮箱地址并保存邮箱地址凭证。',
}
}
});
const data = ref([])
const changeMailAddress = async (address_id) => {
try {
const res = await api.fetch(`/user_api/bind_address_jwt/${address_id}`);
message.success(t('changeMailAddress') + " " + t('success'));
if (!res.jwt) {
message.error("jwt not found");
return;
}
jwt.value = res.jwt;
await router.push('/');
location.reload();
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const unbindAddress = async (address_id) => {
try {
const res = await api.fetch(`/user_api/unbind_address`, {
method: 'POST',
body: JSON.stringify({ address_id })
});
message.success(t('unbindAddress') + " " + t('success'));
await fetchData();
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/user_api/bind_address`
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('name'),
key: "name"
},
{
title: t('mail_count'),
key: "mail_count",
render(row) {
return h(NBadge, {
value: row.mail_count,
'show-zero': true,
max: 99,
type: "success"
})
}
},
{
title: t('send_count'),
key: "send_count",
render(row) {
return h(NBadge, {
value: row.send_count,
'show-zero': true,
max: 99,
type: "success"
})
}
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
h(NPopconfirm,
{
onPositiveClick: () => changeMailAddress(row.id)
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "primary",
},
{ default: () => t('changeMailAddress') }
),
default: () => `${t('changeMailAddress')}?`
}
),
h(NPopconfirm,
{
onPositiveClick: () => unbindAddress(row.id)
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "error",
},
{ default: () => t('unbindAddress') }
),
default: () => t('unbindAddressTip')
}
),
])
}
}
]
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import Login from '../common/Login.vue'
const { userJwt, localeCache, userSettings, } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
logout: 'Logout',
},
zh: {
logout: '退出登录',
}
}
});
const fetchData = async () => {
}
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div class="center" v-if="userSettings.user_email">
<n-card style="max-width: 600px;">
<Login />
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,76 @@
<script setup>
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import UserLogin from './UserLogin.vue'
const message = useMessage()
const router = useRouter()
const {
localeCache, userSettings, userJwt, userOpenSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
currentUser: 'Current Login User',
fetchUserSettingsError: 'Login password is invalid or account not exist, it may be network connection issue, please try again later.',
},
zh: {
currentUser: '当前登录用户',
fetchUserSettingsError: '登录信息已过期或账号不存在,也可能是网络连接异常,请稍后再尝试。',
}
}
});
onMounted(async () => {
await api.getUserOpenSettings(message);
await api.getUserSettings(message);
});
</script>
<template>
<div>
<n-card v-if="!userSettings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<div v-else-if="userSettings.user_email">
<n-alert type="success" :show-icon="false">
<span>
<b>{{ t('currentUser') }} <b>{{ userSettings.user_email }}</b></b>
</span>
</n-alert>
</div>
<div v-else class="center">
<n-card style="max-width: 600px;">
<n-alert v-if="userJwt" type="warning" :show-icon="false" closable>
<span>{{ t('fetchUserSettingsError') }}</span>
</n-alert>
<UserLogin />
</n-card>
</div>
</div>
</template>
<style scoped>
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

View File

@@ -0,0 +1,248 @@
<script setup>
import { useMessage } from 'naive-ui'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref } from "vue";
import { useI18n } from 'vue-i18n'
import { api } from '../../api';
import { useGlobalState } from '../../store'
import { hashPassword } from '../../utils';
import Turnstile from '../../components/Turnstile.vue';
const { userJwt, localeCache, userTab, userOpenSettings } = useGlobalState()
const message = useMessage();
const router = useRouter();
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
login: 'Login',
register: 'Register',
email: 'Email',
password: 'Password',
verifyCode: 'Verification Code',
verifyCodeSent: 'Verification Code Sent, expires in {timeout} seconds',
waitforVerifyCode: 'Wait for {timeout} seconds',
sendVerificationCode: 'Send Verification Code',
forgotPassword: 'Forgot Password',
cannotForgotPassword: 'Mail verification is disabled or register is disabled, cannot reset password, please contact administrator',
resetPassword: 'Reset Password',
pleaseInput: 'Please input email and password',
pleaseInputEmail: 'Please input email',
pleaseInputCode: 'Please input code',
pleaseCompleteTurnstile: 'Please complete turnstile',
pleaseLogin: 'Please login',
},
zh: {
login: '登录',
register: '注册',
email: '邮箱',
password: '密码',
verifyCode: '验证码',
sendVerificationCode: '发送验证码',
verifyCodeSent: '验证码已发送, {timeout} 秒后失效',
waitforVerifyCode: '等待{timeout}秒',
forgotPassword: '忘记密码',
cannotForgotPassword: '未开启邮箱验证或未开启注册功能,无法重置密码,请联系管理员',
resetPassword: '重置密码',
pleaseInput: '请输入邮箱和密码',
pleaseInputEmail: '请输入邮箱',
pleaseInputCode: '请输入验证码',
pleaseCompleteTurnstile: '请完成人机验证',
pleaseLogin: '请登录',
}
}
});
const tabValue = ref("signin");
const showModal = ref(false);
const user = ref({
email: "",
password: "",
code: ""
});
const cfToken = ref("")
const emailLogin = async () => {
if (!user.value.email || !user.value.password) {
message.error(t('pleaseInput'));
return;
}
try {
const res = await api.fetch(`/user_api/login`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
// hash password
password: await hashPassword(user.value.password)
})
});
userJwt.value = res.jwt;
location.reload();
} catch (error) {
message.error(error.message || "login failed");
}
};
const verifyCodeExpire = ref(0);
const verifyCodeTimeout = ref(0);
const getVerifyCodeTimeout = () => {
if (!verifyCodeExpire.value || verifyCodeExpire.value < new Date().getTime()) return 0;
return Math.round((verifyCodeExpire.value - new Date().getTime()) / 1000);
};
const sendVerificationCode = async () => {
if (!user.value.email) {
message.error(t('pleaseInputEmail'));
return;
}
if (!cfToken.value && userOpenSettings.value.enableMailVerify) {
message.error(t('pleaseCompleteTurnstile'));
return;
}
try {
const res = await api.fetch(`/user_api/verify_code`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
cf_token: cfToken.value
})
});
if (res && res.expirationTtl) {
message.success(t('verifyCodeSent', { timeout: res.expirationTtl }));
verifyCodeExpire.value = new Date().getTime() + res.expirationTtl * 1000;
const intervalId = setInterval(() => {
verifyCodeTimeout.value = getVerifyCodeTimeout();
if (verifyCodeTimeout.value <= 0) {
clearInterval(intervalId);
verifyCodeTimeout.value = 0;
}
}, 1000);
}
} catch (error) {
message.error(error.message || "send verification code failed");
}
};
const emailSignup = async () => {
if (!user.value.email || !user.value.password) {
message.error(t('pleaseInput'));
return;
}
if (!user.value.code && userOpenSettings.value.enableMailVerify) {
message.error(t('pleaseInputCode'));
return;
}
try {
const res = await api.fetch(`/user_api/register`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
// hash password
password: await hashPassword(user.value.password),
code: user.value.code
}),
message: message
});
if (res) {
tabValue.value = "signin";
message.success(t('pleaseLogin'));
}
showModal.value = false;
} catch (error) {
message.error(error.message || "register failed");
}
};
onMounted(async () => {
});
</script>
<template>
<div class="center">
<n-tabs 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('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<n-button @click="emailLogin" type="primary" block secondary strong>
{{ t('login') }}
</n-button>
<n-button @click="showModal = true" type="info" quaternary size="tiny">
{{ t('forgotPassword') }}
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="userOpenSettings.enable" name="signup" :tab="t('register')">
<n-form>
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile v-if="userOpenSettings.enableMailVerify" v-model:value="cfToken" />
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
<n-input-group>
<n-input v-model:value="user.code" />
<n-button @click="sendVerificationCode" style="margin-bottom: 0" type="primary" ghost
:disabled="verifyCodeTimeout > 0">
{{ verifyCodeTimeout > 0 ? t('waitforVerifyCode', { timeout: verifyCodeTimeout })
: t('sendVerificationCode') }}
</n-button>
</n-input-group>
</n-form-item-row>
</n-form>
<n-button @click="emailSignup" type="primary" block secondary strong>
{{ t('register') }}
</n-button>
</n-tab-pane>
</n-tabs>
<n-modal v-model:show="showModal" style="max-width: 600px;" preset="card" :title="t('forgotPassword')">
<n-form v-if="userOpenSettings.enable && userOpenSettings.enableMailVerify">
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile v-model:value="cfToken" />
<n-form-item-row :label="t('verifyCode')" required>
<n-input-group>
<n-input v-model:value="user.code" />
<n-button @click="sendVerificationCode" style="margin-bottom: 0" type="primary" ghost
:disabled="verifyCodeTimeout > 0">
{{ verifyCodeTimeout > 0 ? t('waitforVerifyCode', { timeout: verifyCodeTimeout })
: t('sendVerificationCode') }}
</n-button>
</n-input-group>
</n-form-item-row>
<n-button @click="emailSignup" type="primary" block secondary strong>
{{ t('resetPassword') }}
</n-button>
</n-form>
<n-alert v-else :show-icon="false">
<span>
{{ t('cannotForgotPassword') }}
</span>
</n-alert>
</n-modal>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { userJwt, localeCache, userSettings, } = useGlobalState()
const router = useRouter()
const message = useMessage()
const showLogout = ref(false)
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
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',
},
zh: {
logout: '退出登录',
logoutConfirm: '确定要退出登录吗?',
passordTip: '服务器只会接收到密码的哈希值,不会接收到明文密码,因此无法查看或者找回您的密码, 如果管理员启用了邮件验证您可以在无痕模式重置密码',
}
}
});
const logout = async () => {
userJwt.value = '';
location.reload()
}
const fetchData = async () => {
}
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div class="center" v-if="userSettings.user_email">
<n-card>
<n-alert :show-icon="false">
<span>
{{ t('passordTip') }}
</span>
</n-alert>
<n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }}
</n-button>
</n-card>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="logout" size="small" tertiary type="primary">
{{ t('logout') }}
</n-button>
</template>
</n-modal>
</div>
</template>
<style scoped>
.center {
display: flex;
justify-content: center;
}
.n-card {
max-width: 800px;
text-align: left;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,2 @@
proxy_url=https://temp-email-api.xxx.xxx
port=8025

161
smtp_proxy_server/.gitignore vendored Normal file
View File

@@ -0,0 +1,161 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
test*

View File

@@ -0,0 +1,12 @@
services:
smtp_proxy_server:
image: ghcr.io/dreamhunter2333/cloudflare_temp_email/smtp_proxy_server:latest
# build:
# context: .
# dockerfile: dockerfile
container_name: "smtp_proxy_server"
ports:
- "8025:8025"
environment:
- proxy_url=https://temp-email-api.xxx.xxx
- port=8025

View File

@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt /requirements.txt
RUN python3 -m pip install -r /requirements.txt
COPY . /app
ENTRYPOINT [ "python3", "server.py" ]

View File

@@ -0,0 +1,3 @@
aiosmtpd==1.4.5
pydantic-settings==2.2.1
requests==2.31.0

147
smtp_proxy_server/server.py Normal file
View File

@@ -0,0 +1,147 @@
import asyncio
import logging
import email
import requests
from pydantic_settings import BaseSettings
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import SMTP, Session, Envelope, AuthResult, LoginPassword
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
class Settings(BaseSettings):
proxy_url: str = "http://localhost:8787"
port: int = 8025
class Config:
env_file = ".env"
class CustomSMTPHandler:
def authenticator(self, server, session, envelope, mechanism, auth_data):
fail_nothandled = AuthResult(success=False, handled=False)
if mechanism not in ("LOGIN", "PLAIN"):
_logger.warning(f"Unsupported mechanism {mechanism}")
return fail_nothandled
if not isinstance(auth_data, LoginPassword):
_logger.warning(f"Invalid auth data {auth_data}")
return fail_nothandled
return AuthResult(success=True, auth_data=auth_data)
async def handle_DATA(self, server: SMTP, session: Session, envelope: Envelope) -> str:
_logger.info(
f"handle_DATA from {envelope.mail_from} to {envelope.rcpt_tos}"
)
if not isinstance(session.auth_data, LoginPassword):
return '530 Authentication required'
if len(envelope.rcpt_tos) != 1:
return '500 Only one recipient allowed'
# Only one recipient allowed
to_mail = envelope.rcpt_tos[0]
# Parse email
msg = email.message_from_string(envelope.content)
content_list = []
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
payload = part.get_payload(decode=True)
if content_type not in ["text/plain", "text/html"]:
_logger.warning(f"Skipping {content_type}")
continue
if not payload:
continue
content_list.append({
"type": content_type,
"value": payload.decode()
})
elif msg.get_content_type() in ["text/plain", "text/html"] and msg.get_payload(decode=True):
content_list.append({
"type": msg.get_content_type(),
"value": msg.get_payload(decode=True).decode()
})
if not content_list:
return '500 Invalid content'
body = max(
content_list,
key=lambda x: (x["type"] == "text/html", len(x["value"]))
)
from_name, _ = email.utils.parseaddr(
str(email.header.make_header(
email.header.decode_header(msg['From'])
))
)
to_mail_map = {}
for to in str(email.header.make_header(
email.header.decode_header(msg['To'])
)).split(","):
tmp_to_name, tmp_to_mail = email.utils.parseaddr(to)
to_mail_map[tmp_to_mail] = tmp_to_name
_logger.info(f"Parsed mail from {from_name} to {to_mail_map}")
# Send mail
send_body = {
"token": session.auth_data.password.decode(),
"from_name": from_name,
"to_name": to_mail_map.get(to_mail),
"to_mail": to_mail,
"subject": str(email.header.make_header(
email.header.decode_header(msg['Subject'])
)),
"is_html": body["type"] == "text/html",
"content": body["value"],
}
_logger.info(f"Send mail {send_body}")
try:
res = requests.post(
f"{settings.proxy_url}/external/api/send_mail",
json=send_body, headers={
"Content-Type": "application/json"
}
)
if res.status_code != 200:
_logger.error(
"Failed to send mail "
f"code=[{res.status_code}] text=[{res.text}]"
)
return f'500 Internal server error code=[{res.status_code}] text=[{res.text}]'
except Exception as e:
_logger.error(e)
return '500 Internal server error'
return '250 OK'
settings = Settings()
handler = CustomSMTPHandler()
server = Controller(
handler,
hostname="",
port=settings.port,
auth_require_tls=False,
decode_data=True,
authenticator=handler.authenticator,
auth_exclude_mechanism=["DONT"]
)
async def start():
_logger.info(f"Starting server settings[{settings}]")
server.start()
if __name__ == "__main__":
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task = loop.create_task(start())
try:
loop.run_forever()
except KeyboardInterrupt:
_logger.info("Got KeyboardInterrupt, stopping")
server.stop()

View File

@@ -126,11 +126,22 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ text: '开发中', link: 'github-action' },
]
},
{
text: '附加功能',
collapsed: false,
items: [
{ text: '配置 SMTP 代理服务', link: 'feature/config-smtp-proxy' },
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
{ text: '查看邮件 API', link: 'feature/mail-api' },
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
]
},
{
text: '功能简介',
collapsed: false,
items: [
{ text: 'Admin 控制台', link: 'feature/admin' },
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },
]
},
{ text: '参考', base: "/", link: 'reference' }

View File

@@ -38,6 +38,8 @@ wrangler d1 execute dev --file=db/schema.sql
# schema update, if you have initialized the database before this date, you can execute this command to update
# wrangler d1 execute dev --file=db/2024-01-13-patch.sql
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql
# create a namespace, and copy the output to wrangler.toml in the next step
wrangler kv:namespace create DEV
```
![d1](/readme_assets/d1.png)
@@ -56,12 +58,16 @@ pnpm run deploy
`wrangler.toml`
```bash
```toml
name = "cloudflare_temp_email"
main = "src/worker.js"
compatibility_date = "2023-08-14"
node_compat = true
# enable cron if you want set auto clean up
# [triggers]
# crons = [ "0 0 * * *" ]
[vars]
PREFIX = "tmp" # The mailbox name prefix to be processed
# If you want your site to be private, uncomment below and change your password
@@ -73,12 +79,19 @@ PREFIX = "tmp" # The mailbox name prefix to be processed
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # your domain name
JWT_SECRET = "xxx" # Key used to generate jwt
BLACK_LIST = "" # Blacklist, used to filter senders, comma separated
# Allow users to create email addresses
ENABLE_USER_CREATE_EMAIL = true
# Allow users to delete messages
ENABLE_USER_DELETE_EMAIL = true
# Allow automatic replies to emails
ENABLE_AUTO_REPLY = false
# Footer text
# COPYRIGHT = "Dream Hunter"
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# Turnstile verification configuration
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# dkim config
# DKIM_SELECTOR = "mailchannels" # Refer to the DKIM section mailchannels._domainkey for mailchannels
# DKIM_PRIVATE_KEY = "" # Refer to the contents of priv_key.txt in the DKIM section
@@ -88,6 +101,11 @@ binding = "DB"
database_name = "xxx" # D1 database name
database_id = "xxx" # D1 database ID
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
# Create a new address current limiting configuration
# [[unsafe.bindings]]
# name = "RATE_LIMITER"

View File

@@ -23,6 +23,6 @@ features:
details: 支持 password 登录邮箱,使用访问密码可作为私人站点,支持附件功能
- title: 使用 rust wasm 解析邮件
details: 使用 rust wasm 解析邮件支持邮件各种RFC标准支持附件, 速度极快
- title: 支持发送邮件
details: 支持通过域名邮箱发送 txt 或者 html 邮件,支持 DKIM 签名
- title: 支持发送邮件(UI/API/SMTP)
details: 支持通过域名邮箱发送 txt 或者 html 邮件,支持 DKIM 签名, UI/API/SMTP 发送邮件
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -5,9 +5,11 @@
## 初始化数据库
```bash
cd worker
cp wrangler.toml.template wrangler.toml
# 创建 D1 并执行 schema.sql
wrangler d1 create dev
wrangler d1 execute dev --file=db/schema.sql
wrangler d1 execute dev --file=../db/schema.sql
```
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
@@ -22,6 +24,7 @@ wrangler d1 execute dev --file=db/schema.sql
找到需要执行的 `patch` 文件, 执行, 例如:
```bash
wrangler d1 execute dev --file=db/2024-01-13-patch.sql
wrangler d1 execute dev --file=db/2024-04-03-patch.sql
cd worker
wrangler d1 execute dev --file=../db/2024-01-13-patch.sql
wrangler d1 execute dev --file=../db/2024-04-03-patch.sql
```

View File

@@ -8,6 +8,17 @@ pnpm install
cp wrangler.toml.template wrangler.toml
```
## 创建 KV 缓存
> [!NOTE]
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
通过命令行创建 KV 缓存,或者在 Cloudflare 控制台创建,然后复制对应配置到 `wrangler.toml` 文件中
```bash
wrangler kv:namespace create DEV
```
## 修改 `wrangler.toml` 配置文件
```toml
@@ -20,6 +31,10 @@ compatibility_date = "2023-12-01"
# ]
node_compat = true
# 如果你想要使用定时任务清理邮件,取消下面的注释,并修改 cron 表达式
# [triggers]
# crons = [ "0 0 * * *" ]
[vars]
PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串
# 如果你想要你的网站私有,取消下面的注释,并修改密码
@@ -31,12 +46,19 @@ PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名, 支持多个域名
JWT_SECRET = "xxx" # 用于生成 jwt 的密钥, jwt 用于给用户登录以及鉴权
BLACK_LIST = "" # 黑名单,用于过滤发件人,逗号分隔
# 是否允许用户创建邮件, 不配置则不允许
ENABLE_USER_CREATE_EMAIL = true
# 允许用户删除邮件, 不配置则不允许
ENABLE_USER_DELETE_EMAIL = true
# 允许自动回复邮件
ENABLE_AUTO_REPLY = false
# 前端界面页脚文本
# COPYRIGHT = "Dream Hunter"
# 默认发送邮件余额,如果不设置,将为 0
# DEFAULT_SEND_BALANCE = 1
# Turnstile 人机验证配置
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# dkim config
# DKIM_SELECTOR = "mailchannels" # 参考 DKIM 部分 mailchannels._domainkey 的 mailchannels
# DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容
@@ -47,6 +69,11 @@ binding = "DB"
database_name = "xxx" # D1 数据库名称
database_id = "xxx" # D1 数据库 ID
# kv config 用于用户注册发送邮件验证码,如果不启用用户注册或不启用注册验证,可以不配置
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
# 新建地址限流配置 /api/new_address
# [[unsafe.bindings]]
# name = "RATE_LIMITER"

View File

@@ -0,0 +1,13 @@
# Admin 用户相关
默认不允许用户注册,可通过
## 用户管理页面
![admin-user-management](/feature/admin-user-management.png)
## 用户设置
此处开启用户登录,以及验证等配置
![admin-user-page](/feature/admin-user-page.png)

View File

@@ -0,0 +1,41 @@
# 搭建 SMTP 代理服务
## 为什么需要 SMTP 代理服务
SMTP 的应用场景更加广泛
## 如何搭建 SMTP 代理服务
### Local Run
```bash
cd smtp_proxy_server/
# 复制配置文件, 并修改配置文件
# 你的 worker 地址proxy_url=https://temp-email-api.xxx.xxx
# 你的 SMTP 服务端口port=8025
cp .env.example .env
python3 -m venv venv
./venv/bin/python3 -m pip install -r requirements.txt
./venv/bin/python3 server.py
```
### Docker Run
```bash
cd smtp_proxy_server/
docker-compose up -d
```
修改 docker-compose.yaml 中的环境变量, 注意选择合适的 `tag`
```yaml
services:
smtp_proxy_server:
image: ghcr.io/dreamhunter2333/cloudflare_temp_email/smtp_proxy_server:latest
container_name: "smtp_proxy_server"
ports:
- "8025:8025"
environment:
- proxy_url=https://temp-email-api.xxx.xxx
- port=8025
```

View File

@@ -0,0 +1,18 @@
# 查看邮件 API
## 通过 HTTP API 查看邮件
这是一个 `python` 的例子,使用 `requests` 库查看邮件。
```python
limit = 10
offset = 0
res = requests.post(
f"http://localhost:8787/api/mails?limit={limit}&offset={offset}`;",
json=send_body, headers={
"Authorization": f"Bearer {你的JWT密码}",
# "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码
"Content-Type": "application/json"
}
)
```

View File

@@ -0,0 +1,67 @@
# 发送邮件 API
## 通过 HTTP API 发送邮件
这是一个 `python` 的例子,使用 `requests` 库发送邮件。
```python
send_body = {
"from_name": "发件人名字",
"to_name": "收件人名字",
"to_mail": "收件人地址",
"subject": "邮件主题",
"is_html": False, # 根据内容设置是否为 HTML
"content": "<邮件内容html 或者 文本>",
}
res = requests.post(
"http://localhost:8787/api/send_mail",
json=send_body, headers={
"Authorization": f"Bearer {你的JWT密码}",
# "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码
"Content-Type": "application/json"
}
)
# 使用 body 验证
send_body = {
"token": "<你的JWT密码>",
"from_name": "发件人名字",
"to_name": "收件人名字",
"to_mail": "收件人地址",
"subject": "邮件主题",
"is_html": False, # 根据内容设置是否为 HTML
"content": "<邮件内容html 或者 文本>",
}
res = requests.post(
"http://localhost:8787/external/api/send_mail",
json=send_body, headers={
"Content-Type": "application/json"
}
)
```
## 通过 SMTP 发送邮件
请先参考 [配置 SMTP 代理](/zh/guide/feature/config-smtp-proxy.html)。
这是一个 `python` 的例子,使用 `smtplib` 库发送邮件。
`JWT令牌密码`: 即为邮箱登录密码,可以在 UI 界面中查看密码菜单中查看。
```python
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
with smtplib.SMTP('localhost', 8025) as smtp:
smtp.login("jwt", "此处填写你的JWT令牌密码")
message = MIMEMultipart()
message['From'] = "Me <me@awsl.uk>"
message['To'] = "Admin <admin@awsl.uk>"
message['Subject'] = "测试主题"
message.attach(MIMEText("测试内容", 'html'))
smtp.sendmail("me@awsl.uk", "admin@awsl.uk", message.as_string())
```

View File

@@ -0,0 +1,5 @@
# 配置子域名邮箱
参考
- [配置子域名邮箱](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/164#issuecomment-2082612710)

View File

@@ -1,5 +1,21 @@
# 通过 Github Actions 部署
::: warning
开发中...
有问题请通过 `Github Issues` 反馈,感谢。
:::
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/dreamhunter2333/cloudflare_temp_email)
1. 点击按钮 fork 本仓库 或者直接 fork 本仓库
2. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production``Deploy Frontend`,点击 `enable workflow` 启用 `workflow`
3. 然后在仓库页面 `Settings` -> `Secrets and variables` -> `Actions` -> `Repository secrets`, 添加以下 `secrets`:
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare 账户 ID, [参考文档](https://developers.cloudflare.com/workers/wrangler/ci-cd/#cloudflare-account-id)
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token, [参考文档](https://developers.cloudflare.com/workers/wrangler/ci-cd/#api-token)
- `BACKEND_TOML`: 后端配置文件,[参考此处](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件)
- `FRONTEND_ENV`: 前端配置文件,请复制 `frontend/.env.example` 的内容,[并参考此处修改](/zh/guide/cli/pages.html)
- `FRONTEND_NAME`: 你在 Cloudflare Pages 创建的项目名称,可通过 [用户界面](https://temp-mail-docs.awsl.uk/zh/guide/ui/pages.html) 或者 [命令行](https://temp-mail-docs.awsl.uk/zh/guide/cli/pages.html) 创建
1. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production``Deploy Frontend`,点击 `Run workflow` 选择分支手动部署

View File

@@ -38,3 +38,10 @@
7. 点击 `Settings` -> `Variables`, 下拉找到 `D1 Database`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 D1 数据库,点击 `Deploy`
![worker-d1](/ui_install/worker-d1.png)
8. 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤,点击 `Workers & Pages` -> `KV` -> `Create Namespace`, 如图,点击 `Create Namespace`,然后在 `Settings` -> `Variables`, 下拉找到 `KV`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 `KV` 缓存,点击 `Deploy`
> [!NOTE]
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
![worker-kv](/ui_install/worker-kv.png)
![worker-kv-bind](/ui_install/worker-kv-bind.png)

View File

@@ -10,10 +10,10 @@
"build": "wrangler deploy src/worker.js --dry-run --outdir dist --minify"
},
"devDependencies": {
"wrangler": "^3.52.0"
"wrangler": "^3.53.1"
},
"dependencies": {
"hono": "^4.2.7",
"hono": "^4.3.0",
"mimetext": "^3.0.24"
}
}

60
worker/pnpm-lock.yaml generated
View File

@@ -9,24 +9,24 @@ importers:
.:
dependencies:
hono:
specifier: ^4.2.7
version: 4.2.7
specifier: ^4.3.0
version: 4.3.0
mimetext:
specifier: ^3.0.24
version: 3.0.24
devDependencies:
wrangler:
specifier: ^3.52.0
version: 3.52.0
specifier: ^3.53.1
version: 3.53.1
packages:
'@babel/runtime-corejs3@7.24.4':
resolution: {integrity: sha512-VOQOexSilscN24VEY810G/PqtpFvx/z6UqDIjIWbDe2368HhDLkYN5TYwaEz/+eRCUkhJ2WaNLLmQAlxzfWj4w==}
'@babel/runtime-corejs3@7.24.5':
resolution: {integrity: sha512-GWO0mgzNMLWaSYM4z4NVIuY0Cd1fl8cPnuetuddu5w/qGuvt5Y7oUi/kvvQGK9xgOkFJDQX2heIvTRn/OQ1XTg==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.24.4':
resolution: {integrity: sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==}
'@babel/runtime@7.24.5':
resolution: {integrity: sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==}
engines: {node: '>=6.9.0'}
'@cloudflare/kv-asset-handler@0.3.2':
@@ -226,8 +226,8 @@ packages:
'@types/node-forge@1.3.11':
resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==}
'@types/node@20.12.7':
resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==}
'@types/node@20.12.8':
resolution: {integrity: sha512-NU0rJLJnshZWdE/097cdCBbyW1h4hEg0xpovcoAQYHl8dnEyp/NAOiE45pvc+Bd1Dt+2r94v2eGFpQJ4R7g+2w==}
acorn-walk@8.3.2:
resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
@@ -324,8 +324,8 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hono@4.2.7:
resolution: {integrity: sha512-k1xHi86tJnRIVvqhFMBDGFKJ8r5O+bEsT4P59ZK59r0F300Xd910/r237inVfuT/VmE86RQQffX4OYNda6dLXw==}
hono@4.3.0:
resolution: {integrity: sha512-rf9142VLQNMVBj+BjVLISgDWDxnJGUIuX39dvqcdySwr2gTsPfsqW1twWDUjfwQNWm9hEn40MpDu9RFGUN+e8A==}
engines: {node: '>=16.0.0'}
is-binary-path@2.1.0:
@@ -474,8 +474,8 @@ packages:
engines: {node: '>=16'}
hasBin: true
wrangler@3.52.0:
resolution: {integrity: sha512-HR06jTym+yr7+CI3Ggld3nfp1OM9vSC7h4B8vwWHwhi5K0sYg8p44rxV514Gmsv9dkFHegkRP70SM3sjuuxxpQ==}
wrangler@3.53.1:
resolution: {integrity: sha512-bdMRQdHYdvowIwOhEMFkARIZUh56aDw7HLUZ/2JreBjj760osXE4Fc4L1TCkfRRBWgB6/LKF5LA4OcvORMYmHg==}
engines: {node: '>=16.17.0'}
hasBin: true
peerDependencies:
@@ -484,8 +484,8 @@ packages:
'@cloudflare/workers-types':
optional: true
ws@8.16.0:
resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==}
ws@8.17.0:
resolution: {integrity: sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -502,17 +502,17 @@ packages:
youch@3.3.3:
resolution: {integrity: sha512-qSFXUk3UZBLfggAW3dJKg0BMblG5biqSF8M34E06o5CSsZtH92u9Hqmj2RzGiHDi64fhe83+4tENFP2DB6t6ZA==}
zod@3.23.4:
resolution: {integrity: sha512-/AtWOKbBgjzEYYQRNfoGKHObgfAZag6qUJX1VbHo2PRBgS+wfWagEY2mizjfyAPcGesrJOcx/wcl0L9WnVrHFw==}
zod@3.23.6:
resolution: {integrity: sha512-RTHJlZhsRbuA8Hmp/iNL7jnfc4nZishjsanDAfEY1QpDQZCahUp3xDzl+zfweE9BklxMUcgBgS1b7Lvie/ZVwA==}
snapshots:
'@babel/runtime-corejs3@7.24.4':
'@babel/runtime-corejs3@7.24.5':
dependencies:
core-js-pure: 3.37.0
regenerator-runtime: 0.14.1
'@babel/runtime@7.24.4':
'@babel/runtime@7.24.5':
dependencies:
regenerator-runtime: 0.14.1
@@ -628,9 +628,9 @@ snapshots:
'@types/node-forge@1.3.11':
dependencies:
'@types/node': 20.12.7
'@types/node': 20.12.8
'@types/node@20.12.7':
'@types/node@20.12.8':
dependencies:
undici-types: 5.26.5
@@ -739,7 +739,7 @@ snapshots:
dependencies:
function-bind: 1.1.2
hono@4.2.7: {}
hono@4.3.0: {}
is-binary-path@2.1.0:
dependencies:
@@ -773,8 +773,8 @@ snapshots:
mimetext@3.0.24:
dependencies:
'@babel/runtime': 7.24.4
'@babel/runtime-corejs3': 7.24.4
'@babel/runtime': 7.24.5
'@babel/runtime-corejs3': 7.24.5
js-base64: 3.7.7
mime-types: 2.1.35
@@ -789,9 +789,9 @@ snapshots:
stoppable: 1.1.0
undici: 5.28.4
workerd: 1.20240419.0
ws: 8.16.0
ws: 8.17.0
youch: 3.3.3
zod: 3.23.4
zod: 3.23.6
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -881,7 +881,7 @@ snapshots:
'@cloudflare/workerd-linux-arm64': 1.20240419.0
'@cloudflare/workerd-windows-64': 1.20240419.0
wrangler@3.52.0:
wrangler@3.53.1:
dependencies:
'@cloudflare/kv-asset-handler': 0.3.2
'@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19)
@@ -904,7 +904,7 @@ snapshots:
- supports-color
- utf-8-validate
ws@8.16.0: {}
ws@8.17.0: {}
xxhash-wasm@1.0.2: {}
@@ -914,4 +914,4 @@ snapshots:
mustache: 4.2.0
stacktracey: 2.1.8
zod@3.23.4: {}
zod@3.23.6: {}

View File

@@ -1,256 +0,0 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { getSendbox } from './send_mail_api'
import { sendAdminInternalMail } from './utils'
const api = new Hono()
api.get('/admin/address', async (c) => {
const { limit, offset, query } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (query) {
const { results } = await c.env.DB.prepare(
`SELECT * FROM address where concat('${c.env.PREFIX}', name) like ? order by id desc limit ? offset ? `
).bind(`%${query}%`, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where concat('${c.env.PREFIX}', name) like ?`
).bind(`%${query}%`).first();
count = addressCount;
}
return c.json({
results: results.map((r) => {
r.name = c.env.PREFIX + r.name;
return r;
}),
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM address order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address`
).first();
count = addressCount;
}
return c.json({
results: results.map((r) => {
r.name = c.env.PREFIX + r.name;
return r;
}),
count: count
})
})
api.delete('/admin/delete_address/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE id = ? `
).bind(id).run();
if (!success) {
return c.text("Failed to delete address", 500)
}
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM mails WHERE address IN
(select concat('${c.env.PREFIX}', name) from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text("Failed to delete mails", 500)
}
const { success: sendAccess } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE address_id = ? `
).bind(id).run();
return c.json({
success: success && mailSuccess && sendAccess
})
})
api.get('/admin/show_password/:id', async (c) => {
const { id } = c.req.param();
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(id).first("name");
// compute address
const emailAddress = c.env.PREFIX + name
const jwt = await Jwt.sign({
address: emailAddress,
address_id: id
}, c.env.JWT_SECRET)
return c.json({
password: jwt
})
})
api.get('/admin/mails', async (c) => {
const { address, limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM raw_mails where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM raw_mails where address = ? `
).bind(address).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
});
api.get('/admin/mails_unknow', async (c) => {
const { limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(`
SELECT * FROM raw_mails
where address NOT IN(select concat('${c.env.PREFIX}', name) from address)
order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM raw_mails
where address NOT IN
(select concat('${c.env.PREFIX}', name) from address)`
).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
});
api.get('/admin/address_sender', async (c) => {
const { limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM address_sender order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address_sender`
).first();
count = addressCount;
}
return c.json({
results: results,
count: count
})
})
api.post('/admin/address_sender', async (c) => {
let { address, address_id, balance, enabled } = await c.req.json();
if (!address_id) {
return c.text("Invalid address_id", 400)
}
enabled = enabled ? 1 : 0;
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
).bind(enabled, balance, address_id).run();
if (!success) {
return c.text("Failed to update address sender", 500)
}
await sendAdminInternalMail(
c, address, "Account Send Access Updated",
`You send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}`
);
return c.json({
success: success
})
})
api.get('/admin/sendbox', async (c) => {
const { address, limit, offset } = c.req.query();
return getSendbox(c, address, limit, offset);
})
api.get('/admin/statistics', async (c) => {
const { count: mailCountV1 } = await c.env.DB.prepare(`
SELECT count(*) as count FROM mails`
).first();
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM raw_mails`
).first();
const { count: addressCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM address`
).first();
const { count: activeUserCount7days } = await c.env.DB.prepare(`
SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first();
const { count: sendMailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM sendbox`
).first();
return c.json({
mailCount: (mailCountV1 || 0) + (mailCount || 0),
userCount: addressCount,
activeUserCount7days: activeUserCount7days,
sendMailCount: sendMailCount
})
});
api.post('/admin/cleanup', async (c) => {
const { cleanType, cleanDays } = await c.req.json();
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
return c.text("Invalid cleanType or cleanDays", 400)
}
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
switch (cleanType) {
case "mails":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "mails_unknow":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE address NOT IN
(select concat('${c.env.PREFIX}', name) from address) AND created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "address":
await c.env.DB.prepare(`
DELETE FROM address WHERE updated_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "sendbox":
await c.env.DB.prepare(`
DELETE FROM sendbox WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
default:
return c.text("Invalid cleanType", 400)
}
return c.json({
success: true
})
})
export { api }

View File

@@ -0,0 +1,135 @@
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting, checkUserPassword, getDomains } from '../utils';
import { UserSettings, GeoData, UserInfo } from "../models";
export default {
getSetting: async (c) => {
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
return c.json(settings)
},
saveSetting: async (c) => {
const value = await c.req.json();
const settings = new UserSettings(value);
if (settings.enableMailVerify && !c.env.KV) {
return c.text("Please enable KV first if you want to enable mail verify", 403)
}
if (settings.enableMailVerify) {
const mailDomain = settings.verifyMailSender.split("@")[1];
const domains = getDomains(c);
if (!domains.includes(mailDomain)) {
return c.text(`VerifyMailSender(${settings.verifyMailSender}) domain must in ${JSON.stringify(domains, null, 2)}`, 400)
}
}
if (settings.maxAddressCount < 0) {
return c.text("Invalid maxAddressCount", 400)
}
await saveSetting(c, CONSTANTS.USER_SETTINGS_KEY, JSON.stringify(settings));
return c.json({ success: true })
},
getUsers: async (c) => {
const { limit, offset, query } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (query) {
const { results } = await c.env.DB.prepare(
`SELECT u.id, u.user_email, u.created_at, u.updated_at,`
+ ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count`
+ ` FROM users u`
+ ` where u.user_email like ?`
+ ` order by u.id desc limit ? offset ?`
).bind(`%${query}%`, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: userCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM users where user_email like ?`
).bind(`%${query}%`).first();
count = userCount;
}
return c.json({
results: results,
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT u.id, u.user_email, u.created_at, u.updated_at,`
+ ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count`
+ ` FROM users u`
+ ` order by u.id desc limit ? offset ?`
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: userCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM users`
).first();
count = userCount;
}
return c.json({
results: results,
count: count
})
},
createUser: async (c) => {
const { email, password } = await c.req.json();
if (!email || !password) {
return c.text("Invalid email or password", 400)
}
// geo data
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
const geoData = new GeoData(reqIp, c.req.raw.cf);
const userInfo = new UserInfo(geoData);
try {
checkUserPassword(password);
const { success } = await c.env.DB.prepare(
`INSERT INTO users (user_email, password, user_info)`
+ ` VALUES (?, ?, ?)`
).bind(
email, password, JSON.stringify(userInfo)
).run();
if (!success) {
return c.text("Failed to register", 500)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("User already exists", 400)
}
return c.text(`Failed to register: ${e.message}`, 500)
}
return c.json({ success: true })
},
deleteUser: async (c) => {
const { user_id } = c.req.param();
if (!user_id) return c.text("Invalid user_id", 400);
const { success } = await c.env.DB.prepare(
`DELETE FROM users WHERE id = ?`
).bind(user_id).run();
const { success: addressSuccess } = await c.env.DB.prepare(
`DELETE FROM users_address WHERE user_id = ?`
).bind(user_id).run();
if (!success || !addressSuccess) {
return c.text("Failed to delete user", 500)
}
return c.json({ success: true })
},
resetPassword: async (c) => {
const { user_id } = c.req.param();
const { password } = await c.req.json();
if (!user_id) return c.text("Invalid user_id", 400);
try {
checkUserPassword(password);
const { success } = await c.env.DB.prepare(
`UPDATE users SET password = ? WHERE id = ?`
).bind(password, user_id).run();
if (!success) {
return c.text("Failed to reset password", 500)
}
} catch (e) {
return c.text(`Failed to reset password: ${e.message}`, 500)
}
return c.json({ success: true });
},
}

View File

@@ -0,0 +1,28 @@
import { cleanup } from '../common';
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting } from '../utils';
import { CleanupSettings } from '../models';
export default {
cleanup: async (c) => {
const { cleanType, cleanDays } = await c.req.json();
try {
await cleanup(c, cleanType, cleanDays);
} catch (error) {
console.error(error);
return c.text(`Failed to cleanup ${error.message}`, 500)
}
return c.json({ success: true })
},
getCleanup: async (c) => {
const value = await getJsonSetting(c, CONSTANTS.AUTO_CLEANUP_KEY);
const cleanupSetting = new CleanupSettings(value);
return c.json(cleanupSetting)
},
saveCleanup: async (c) => {
const value = await c.req.json();
const cleanupSetting = new CleanupSettings(value);
await saveSetting(c, CONSTANTS.AUTO_CLEANUP_KEY, JSON.stringify(cleanupSetting));
return c.json({ success: true })
}
}

View File

@@ -0,0 +1,291 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { sendAdminInternalMail, getJsonSetting, saveSetting } from '../utils'
import { newAddress, handleListQuery } from '../common'
import { CONSTANTS } from '../constants'
import cleanup_api from './cleanup_api'
import admin_user_api from './admin_user_api'
const api = new Hono()
api.get('/admin/address', async (c) => {
const { limit, offset, query } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (query) {
const { results } = await c.env.DB.prepare(
`SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`
+ ` where name like ?`
+ ` order by id desc limit ? offset ?`
).bind(`%${query}%`, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where name like ?`
).bind(`%${query}%`).first();
count = addressCount;
}
return c.json({
results: results,
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`
+ ` order by id desc limit ? offset ?`
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address`
).first();
count = addressCount;
}
return c.json({
results: results,
count: count
})
})
api.post('/admin/new_address', async (c) => {
let { name, domain, enablePrefix } = await c.req.json();
if (!name) {
return c.text("Please provide a name", 400)
}
return newAddress(c, name, domain, enablePrefix);
})
api.delete('/admin/delete_address/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE id = ? `
).bind(id).run();
if (!success) {
return c.text("Failed to delete address", 500)
}
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text("Failed to delete mails", 500)
}
const { success: sendAccess } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
const { success: usersAddressSuccess } = await c.env.DB.prepare(
`DELETE FROM users_address WHERE address_id = ?`
).bind(id).run();
return c.json({
success: success && mailSuccess && sendAccess && usersAddressSuccess
})
})
api.get('/admin/show_password/:id', async (c) => {
const { id } = c.req.param();
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(id).first("name");
const jwt = await Jwt.sign({
address: name,
address_id: id
}, c.env.JWT_SECRET)
return c.json({
jwt: jwt
})
})
api.get('/admin/mails', async (c) => {
const { address, limit, offset, keyword } = c.req.query();
if (address && keyword) {
return await handleListQuery(c,
`SELECT * FROM raw_mails where address = ? and raw like ? `,
`SELECT count(*) as count FROM raw_mails where address = ? and raw like ? `,
[address, `%${keyword}%`], limit, offset
);
} else if (keyword) {
return await handleListQuery(c,
`SELECT * FROM raw_mails where raw like ? `,
`SELECT count(*) as count FROM raw_mails where raw like ? `,
[`%${keyword}%`], limit, offset
);
} else if (address) {
return await handleListQuery(c,
`SELECT * FROM raw_mails where address = ? `,
`SELECT count(*) as count FROM raw_mails where address = ? `,
[address], limit, offset
);
} else {
return await handleListQuery(c,
`SELECT * FROM raw_mails `,
`SELECT count(*) as count FROM raw_mails `,
[], limit, offset
);
}
});
api.get('/admin/mails_unknow', async (c) => {
const { limit, offset } = c.req.query();
return await handleListQuery(c,
`SELECT * FROM raw_mails where address NOT IN (select name from address) `,
`SELECT count(*) as count FROM raw_mails`
+ ` where address NOT IN (select name from address) `,
[], limit, offset
);
});
api.get('/admin/address_sender', async (c) => {
const { address, limit, offset } = c.req.query();
if (address) {
return await handleListQuery(c,
`SELECT * FROM address_sender where address = ? `,
`SELECT count(*) as count FROM address_sender where address = ? `,
[address], limit, offset
);
}
return await handleListQuery(c,
`SELECT * FROM address_sender `,
`SELECT count(*) as count FROM address_sender `,
[], limit, offset
);
})
api.post('/admin/address_sender', async (c) => {
let { address, address_id, balance, enabled } = await c.req.json();
if (!address_id) {
return c.text("Invalid address_id", 400)
}
enabled = enabled ? 1 : 0;
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
).bind(enabled, balance, address_id).run();
if (!success) {
return c.text("Failed to update address sender", 500)
}
await sendAdminInternalMail(
c, address, "Account Send Access Updated",
`You send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}`
);
return c.json({
success: success
})
})
api.get('/admin/sendbox', async (c) => {
const { address, limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (!address) {
const { results } = await c.env.DB.prepare(
`SELECT * FROM sendbox order by id desc limit ? offset ?`
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox`
).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM sendbox where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox where address = ?`
).bind(address).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
})
api.get('/admin/statistics', async (c) => {
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM raw_mails`
).first();
const { count: addressCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM address`
).first();
const { count: activeUserCount7days } = await c.env.DB.prepare(`
SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first();
const { count: sendMailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM sendbox`
).first();
return c.json({
mailCount: mailCount,
userCount: addressCount,
activeUserCount7days: activeUserCount7days,
sendMailCount: sendMailCount
})
});
api.get('/admin/account_settings', async (c) => {
try {
/** @type {Array<string>|undefined|null} */
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
/** @type {Array<string>|undefined|null} */
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
return c.json({
blockList: blockList || [],
sendBlockList: sendBlockList || []
})
} catch (error) {
console.error(error);
return c.json({})
}
})
api.post('/admin/account_settings', async (c) => {
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const { blockList, sendBlockList } = await c.req.json();
if (!blockList || !sendBlockList) {
return c.text("Invalid blockList or sendBlockList", 400)
}
await saveSetting(
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
JSON.stringify(blockList)
);
await saveSetting(
c, CONSTANTS.SEND_BLOCK_LIST_KEY,
JSON.stringify(sendBlockList)
);
return c.json({
success: true
})
})
api.post('/admin/cleanup', cleanup_api.cleanup)
api.get('/admin/auto_cleanup', cleanup_api.getCleanup)
api.post('/admin/auto_cleanup', cleanup_api.saveCleanup)
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)
api.delete('/admin/users/:user_id', admin_user_api.deleteUser)
api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
export { api }

117
worker/src/common.js Normal file
View File

@@ -0,0 +1,117 @@
import { Jwt } from 'hono/utils/jwt'
import { getDomains, getStringValue } from './utils';
export const newAddress = async (c, name, domain, enablePrefix) => {
// remove special characters
name = name.replace(/[^a-zA-Z0-9.]/g, '')
// check name length
if (name.length < 0) {
return c.text("Name too short", 400)
}
if (name.length > 100) {
return c.text("Name too long (max 100)", 400)
}
// check domain, generate random domain
const domains = getDomains(c);
if (!domain || !domains.includes(domain)) {
domain = domains[Math.floor(Math.random() * domains.length)];
}
// create address
if (enablePrefix) {
name = getStringValue(c.env.PREFIX) + name + "@" + domain;
} else {
name = name + "@" + domain;
}
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO address(name) VALUES(?)`
).bind(name).run();
if (!success) {
return c.text("Failed to create address", 500)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("Address already exists, please retry a new address", 400)
}
return c.text("Failed to create address", 500)
}
let address_id = 0;
try {
address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(name).first("id");
} catch (error) {
console.log(error);
}
// create jwt
const jwt = await Jwt.sign({
address: name,
address_id: address_id
}, c.env.JWT_SECRET)
return c.json({
jwt: jwt
})
}
export const cleanup = async (c, cleanType, cleanDays) => {
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
throw new Error("Invalid cleanType or cleanDays")
}
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
switch (cleanType) {
case "mails":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "mails_unknow":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE address NOT IN
(select name from address) AND created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "address":
await c.env.DB.prepare(`
DELETE FROM address WHERE updated_at < datetime('now', '-${cleanDays} day')`
+ ` AND id NOT IN (SELECT address_id FROM users_address)`
).run();
break;
case "sendbox":
await c.env.DB.prepare(`
DELETE FROM sendbox WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
default:
throw new Error("Invalid cleanType")
}
return true;
}
/**
*
* @param {*} c context
* @param {*} query @type {string} query
* @param {*} countQuery @type {string} countQuery
* @param {*} limit @type {number} limit
* @param {*} offset @type {number} offset
* @returns {Promise} Promise
*/
export const handleListQuery = async (
c, query, countQuery, params, limit, offset
) => {
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const resultsQuery = `${query} order by id desc limit ? offset ?`;
const { results } = await c.env.DB.prepare(resultsQuery).bind(
...params, limit, offset
).all();
const count = offset == 0 ? await c.env.DB.prepare(
countQuery
).bind(...params).first("count") : 0;
return c.json({ results, count });
}

6
worker/src/constants.js Normal file
View File

@@ -0,0 +1,6 @@
export const CONSTANTS = {
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
SEND_BLOCK_LIST_KEY: 'send_block_list',
AUTO_CLEANUP_KEY: 'auto_cleanup',
USER_SETTINGS_KEY: 'user_settings',
}

View File

@@ -37,15 +37,14 @@ api.get('/admin/v1/mails_unknow', async (c) => {
}
const { results } = await c.env.DB.prepare(`
SELECT id, source, subject, message FROM mails
where address NOT IN(select concat('${c.env.PREFIX}', name) from address)
where address NOT IN(select name from address)
order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM mails
where address NOT IN
(select concat('${c.env.PREFIX}', name) from address)`
where address NOT IN (select name from address)`
).first();
count = mailCount;
}

View File

@@ -1,5 +1,5 @@
import { createMimeMessage } from "mimetext";
import { EmailMessage } from "cloudflare:email";
import { getBooleanValue } from "./utils";
async function email(message, env, ctx) {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
@@ -7,54 +7,49 @@ async function email(message, env, ctx) {
console.log(`Reject message from ${message.from} to ${message.to}`);
return;
}
if (!env.PREFIX || (message.to && message.to.startsWith(env.PREFIX))) {
const rawEmail = await new Response(message.raw).text();
const message_id = message.headers.get("Message-ID");
// save email
const { success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, rawEmail, message_id
).run();
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.log(`Failed save message from ${message.from} to ${message.to}`);
}
const rawEmail = await new Response(message.raw).text();
const message_id = message.headers.get("Message-ID");
// save email
const { success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, rawEmail, message_id
).run();
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.log(`Failed save message from ${message.from} to ${message.to}`);
}
// auto reply email
if (env.ENABLE_AUTO_REPLY) {
try {
const results = await env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
).bind(message.to).first();
if (results && results.source_prefix && message.from.startsWith(results.source_prefix)) {
const msg = createMimeMessage();
msg.setHeader("In-Reply-To", message.headers.get("Message-ID"));
msg.setSender({
name: results.name || results.address,
addr: results.address
});
msg.setRecipient(message.from);
msg.setSubject(results.subject || "Auto-reply");
msg.addMessage({
contentType: 'text/plain',
data: results.message || "This is an auto-reply message, please reconact later."
});
const replyMessage = new EmailMessage(
message.to,
message.from,
msg.asRaw()
);
await message.reply(replyMessage);
}
} catch (error) {
console.log("reply email error", error);
// auto reply email
if (getBooleanValue(env.ENABLE_AUTO_REPLY)) {
try {
const results = await env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
).bind(message.to).first();
if (results && results.source_prefix && message.from.startsWith(results.source_prefix)) {
const msg = createMimeMessage();
msg.setHeader("In-Reply-To", message.headers.get("Message-ID"));
msg.setSender({
name: results.name || results.address,
addr: results.address
});
msg.setRecipient(message.from);
msg.setSubject(results.subject || "Auto-reply");
msg.addMessage({
contentType: 'text/plain',
data: results.message || "This is an auto-reply message, please reconact later."
});
const { EmailMessage } = await import('cloudflare:email');
const replyMessage = new EmailMessage(
message.to,
message.from,
msg.asRaw()
);
await message.reply(replyMessage);
}
} catch (error) {
console.log("reply email error", error);
}
} else {
message.setReject(`Unknown address ${message.to}`);
console.log(`Unknown address ${message.to}`);
}
}

View File

@@ -0,0 +1,52 @@
import { getBooleanValue } from "../utils";
export default {
getAutoReply: async (c) => {
if (!getBooleanValue(c.env.ENABLE_AUTO_REPLY)) {
return c.text("Auto reply is disabled", 403)
}
const { address } = c.get("jwtPayload")
const results = await c.env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? `
).bind(address).first();
if (!results) {
return c.json({});
}
return c.json({
subject: results.subject,
message: results.message,
enabled: results.enabled == 1,
source_prefix: results.source_prefix,
name: results.name,
})
},
saveAutoReply: async (c) => {
if (!getBooleanValue(c.env.ENABLE_AUTO_REPLY)) {
return c.text("Auto reply is disabled", 403)
}
const { address } = c.get("jwtPayload");
const { auto_reply } = await c.req.json();
const { name, subject, source_prefix, message, enabled } = auto_reply;
if ((!subject || !message) && enabled) {
return c.text("Invalid subject or message", 400)
}
else if (subject.length > 255 || message.length > 255) {
return c.text("Subject or message too long", 400)
}
const { success } = await c.env.DB.prepare(
`INSERT OR REPLACE INTO auto_reply_mails`
+ ` (name, address, source_prefix, subject, message, enabled)`
+ ` VALUES (?, ?, ?, ?, ?, ?)`
).bind(
name || '', address, source_prefix || '',
subject || '', message || '', enabled ? 1 : 0
).run();
if (!success) {
return c.text("Failed to auto_reply settings", 500)
}
return c.json({
success: success
})
}
}

View File

@@ -0,0 +1,10 @@
import { Hono } from 'hono'
import auto_reply from './auto_reply'
const api = new Hono()
api.get('/api/auto_reply', auto_reply.getAutoReply)
api.post('/api/auto_reply', auto_reply.saveAutoReply)
export { api }

View File

@@ -1,4 +1,8 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { CONSTANTS } from '../constants'
import { getJsonSetting, getDomains } from '../utils';
import { GeoData } from '../models'
const api = new Hono()
@@ -26,34 +30,43 @@ api.post('/api/requset_send_mail_access', async (c) => {
return c.json({ status: "ok" })
})
api.post('/api/send_mail', async (c) => {
const { address } = c.get("jwtPayload")
export const sendMail = async (c, address, reqJson) => {
if (!address) {
throw new Error("No address")
}
// check domain
const mailDomain = address.split("@")[1];
const domains = getDomains(c);
if (!domains.includes(mailDomain)) {
throw new Error("Invalid domain")
}
// check permission
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
where address = ? and enabled = 1`
).bind(address).first("balance");
if (!balance || balance <= 0) {
return c.text("No balance", 400);
throw new Error("No balance")
}
let {
from_name, to_mail, to_name,
subject, content, is_html
} = await c.req.json();
if (!address) {
return c.text("No address", 400)
}
} = reqJson;
if (!to_mail) {
return c.text("Invalid to mail", 400)
throw new Error("Invalid to mail")
}
// check SEND_BLOCK_LIST_KEY
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
if (sendBlockList && sendBlockList.some((item) => to_mail.includes(item))) {
throw new Error("to_mail address is blocked")
}
from_name = from_name || address;
to_name = to_name || to_mail;
if (!subject) {
return c.text("Invalid subject", 400)
throw new Error("Invalid subject")
}
if (!content) {
return c.text("Invalid content", 400)
throw new Error("Invalid content")
}
let dmikBody = {}
if (c.env.DKIM_SELECTOR && c.env.DKIM_PRIVATE_KEY && address.includes("@")) {
@@ -94,7 +107,7 @@ api.post('/api/send_mail', async (c) => {
const respText = await resp.text();
console.log(resp.status + " " + resp.statusText + ": " + respText);
if (resp.status >= 300) {
return c.text("Failed to send mail", 500)
throw new Error(`Mailchannels error: ${resp.status} ${respText}`);
}
// update balance
try {
@@ -113,6 +126,9 @@ api.post('/api/send_mail', async (c) => {
if (body?.personalizations?.[0]?.dkim_private_key) {
delete body.personalizations[0].dkim_private_key;
}
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
const geoData = new GeoData(reqIp, c.req.raw.cf);
body.geoData = geoData;
const { success: success2 } = await c.env.DB.prepare(
`INSERT INTO sendbox (address, raw) VALUES (?, ?)`
).bind(address, JSON.stringify(body)).run();
@@ -122,7 +138,33 @@ api.post('/api/send_mail', async (c) => {
} catch (e) {
console.warn(`Failed to save to sendbox for ${address}`);
}
return c.json({ status: "ok" });
}
api.post('/api/send_mail', async (c) => {
const { address } = c.get("jwtPayload")
const reqJson = await c.req.json();
try {
await sendMail(c, address, reqJson);
} catch (e) {
console.error("Failed to send mail", e);
return c.text(`Failed to send mail ${e.message}`, 400)
}
return c.json({ status: "ok" })
})
api.post('/external/api/send_mail', async (c) => {
const { token } = await c.req.json();
try {
const { address } = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
if (!address) {
return c.text("No address", 400)
}
const reqJson = await c.req.json();
return await sendMail(c, address, reqJson);
} catch (e) {
console.error("Failed to send mail", e);
return c.text(`Failed to send mail ${e.message}`, 400)
}
})
const getSendbox = async (c, address, limit, offset) => {

View File

@@ -0,0 +1,92 @@
export class UserSettings {
/** @param {UserSettings|undefined|null} data */
constructor(data) {
if (data === null) {
return;
}
const {
enable, enableMailVerify, verifyMailSender,
enableMailAllowList, mailAllowList, maxAddressCount
} = data || {};
/** @type {boolean|undefined} */
this.enable = enable;
/** @type {boolean|undefined} */
this.enableMailVerify = enableMailVerify;
/** @type {string|undefined} */
this.verifyMailSender = verifyMailSender;
/** @type {boolean|undefined} */
this.enableMailAllowList = enableMailAllowList;
/** @type {Array<string>|undefined} */
this.mailAllowList = mailAllowList;
/** @type {number|undefined} */
this.maxAddressCount = maxAddressCount || 5;
}
}
export class CleanupSettings {
/** @param {CleanupSettings|undefined|null} data */
constructor(data) {
const {
enableMailsAutoCleanup, cleanMailsDays,
enableUnknowMailsAutoCleanup, cleanUnknowMailsDays,
enableAddressAutoCleanup, cleanAddressDays,
enableSendBoxAutoCleanup, cleanSendBoxDays
} = data || {};
/** @type {boolean|undefined} */
this.enableMailsAutoCleanup = enableMailsAutoCleanup;
/** @type {number|undefined} */
this.cleanMailsDays = cleanMailsDays;
/** @type {boolean|undefined} */
this.enableUnknowMailsAutoCleanup = enableUnknowMailsAutoCleanup;
/** @type {number|undefined} */
this.cleanUnknowMailsDays = cleanUnknowMailsDays;
/** @type {boolean|undefined} */
this.enableAddressAutoCleanup = enableAddressAutoCleanup;
/** @type {number|undefined} */
this.cleanAddressDays = cleanAddressDays;
/** @type {boolean|undefined} */
this.enableSendBoxAutoCleanup = enableSendBoxAutoCleanup;
/** @type {number|undefined} */
this.cleanSendBoxDays = cleanSendBoxDays;
}
}
export class GeoData {
/** @param {string} ip @param {GeoData|undefined|null} data */
constructor(ip, data) {
const {
country, city, timezone, postalCode, region,
latitude, longitude, regionCode, asOrganization
} = data || {};
/** @type {string} */
this.ip = ip;
/** @type {string|undefined} */
this.country = country;
/** @type {string|undefined} */
this.city = city;
/** @type {string|undefined} */
this.timezone = timezone;
/** @type {string|undefined} */
this.postalCode = postalCode;
/** @type {string|undefined} */
this.region = region;
/** @type {number|undefined} */
this.latitude = latitude;
/** @type {number|undefined} */
this.longitude = longitude;
/** @type {string|undefined} */
this.regionCode = regionCode;
/** @type {string|undefined} */
this.asOrganization = asOrganization;
}
}
export class UserInfo {
/** @param {GeoData} geoData @param {string} userEmail */
constructor(geoData, userEmail) {
/** @type {geoData} */
this.geoData = geoData;
/** @type {string} */
this.userEmail = userEmail;
}
}

View File

@@ -1,7 +1,11 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { getDomains, getPasswords } from './utils';
import {
getDomains, getPasswords, getBooleanValue, getJsonSetting,
checkCfTurnstile
} from './utils';
import { newAddress } from './common'
import { CONSTANTS } from './constants'
const api = new Hono()
@@ -18,7 +22,7 @@ api.get('/api/mails', async (c) => {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT id, source, raw, created_at FROM raw_mails where address = ? order by id desc limit ? offset ?`
`SELECT * FROM raw_mails where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
@@ -34,7 +38,7 @@ api.get('/api/mails', async (c) => {
})
api.delete('/api/mails/:id', async (c) => {
if (c.env.ENABLE_USER_DELETE_EMAIL) {
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text("User delete email is disabled", 403)
}
const { address } = c.get("jwtPayload")
@@ -61,43 +65,26 @@ api.get('/api/settings', async (c) => {
return c.text("Invalid address", 400)
}
}
if (address.startsWith(c.env.PREFIX)) {
// check address id
try {
if (!address_id) {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(address.substring(c.env.PREFIX.length)).first("id");
if (!db_address_id) {
return c.text("Invalid address", 400)
}
// check address id
try {
if (!address_id) {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(address).first("id");
if (!db_address_id) {
return c.text("Invalid address", 400)
}
} catch (error) {
return c.text("Invalid address", 400)
}
// update address updated_at
try {
c.env.DB.prepare(
`UPDATE address SET updated_at = datetime('now') where name = ?`
).bind(address.substring(c.env.PREFIX.length)).run();
} catch (e) {
console.warn("Failed to update address")
}
} catch (error) {
return c.text("Invalid address", 400)
}
let auto_reply = {};
if (c.env.ENABLE_AUTO_REPLY) {
const results = await c.env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? `
).bind(address).first();
if (results) {
auto_reply = {
subject: results.subject,
message: results.message,
enabled: results.enabled == 1,
source_prefix: results.source_prefix,
name: results.name,
}
}
// update address updated_at
try {
c.env.DB.prepare(
`UPDATE address SET updated_at = datetime('now') where name = ?`
).bind(address).run();
} catch (e) {
console.warn("Failed to update address")
}
const { count: mailCountV1 } = await c.env.DB.prepare(
`SELECT count(*) as count FROM mails where address = ?`
@@ -107,41 +94,12 @@ api.get('/api/settings', async (c) => {
where address = ? and enabled = 1`
).bind(address).first("balance");
return c.json({
auto_reply: auto_reply,
address: address,
has_v1_mails: mailCountV1 && mailCountV1 > 0,
send_balance: balance || 0,
});
})
api.post('/api/settings', async (c) => {
const { address } = c.get("jwtPayload")
if (!c.env.ENABLE_AUTO_REPLY) {
return c.text("Auto reply is disabled", 403)
}
const { auto_reply } = await c.req.json();
const { name, subject, source_prefix, message, enabled } = auto_reply;
if ((!subject || !message) && enabled) {
return c.text("Invalid subject or message", 400)
}
else if (subject.length > 255 || message.length > 255) {
return c.text("Subject or message too long", 400)
}
const { success } = await c.env.DB.prepare(
`INSERT OR REPLACE INTO
auto_reply_mails
(name, address, source_prefix, subject, message, enabled)
VALUES
(?, ?, ?, ?, ?, ?)`
).bind(name || '', address, source_prefix || '', subject || '', message || '', enabled ? 1 : 0).run();
if (!success) {
return c.text("Failed to save settings", 500)
}
return c.json({
success: success
})
})
api.get('/open_api/settings', async (c) => {
// check header x-custom-auth
let needAuth = false;
@@ -155,73 +113,48 @@ api.get('/open_api/settings', async (c) => {
"domains": getDomains(c),
"needAuth": needAuth,
"adminContact": c.env.ADMIN_CONTACT,
"enableUserDeleteEmail": c.env.ENABLE_USER_DELETE_EMAIL,
"enableAutoReply": c.env.ENABLE_AUTO_REPLY,
"enableUserCreateEmail": getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
"enableUserDeleteEmail": getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
"enableAutoReply": getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"copyright": c.env.COPYRIGHT,
"cfTurnstileSiteKey": c.env.CF_TURNSTILE_SITE_KEY,
});
})
api.get('/api/new_address', async (c) => {
let { name, domain } = c.req.query();
api.post('/api/new_address', async (c) => {
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
return c.text("New address is disabled", 403)
}
let { name, domain, cf_token } = await c.req.json();
// check cf turnstile
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text("Failed to check cf turnstile", 500)
}
// if no name, generate random name
if (!name) {
name = Math.random().toString(36).substring(2, 15);
}
// remove special characters
name = name.replace(/[^a-zA-Z0-9.]/g, '')
// check name length
if (name.length < 0) {
return c.text("Name too short", 400)
}
if (name.length > 100) {
return c.text("Name too long (max 100)", 400)
}
// check domain, generate random domain
const domains = getDomains(c);
if (!domain || !domains.includes(domain)) {
domain = domains[Math.floor(Math.random() * domains.length)];
}
// create address
const emailAddress = c.env.PREFIX + name + "@" + domain
// check name block list
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO address(name) VALUES(?)`
).bind(name + "@" + domain).run();
if (!success) {
return c.text("Failed to create address", 500)
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const blockList = value || [];
if (blockList.some((item) => name.includes(item))) {
return c.text(`Name [${name}] is blocked`, 400)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("Please retry a new address", 400)
}
return c.text("Failed to create address", 500)
}
let address_id = 0;
try {
address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(name + "@" + domain).first("id");
} catch (error) {
console.log(error);
console.error(error);
}
// create jwt
const jwt = await Jwt.sign({
address: emailAddress,
address_id: address_id
}, c.env.JWT_SECRET)
return c.json({
jwt: jwt
})
return newAddress(c, name, domain, true);
})
api.delete('/api/delete_address', async (c) => {
if (c.env.ENABLE_USER_DELETE_EMAIL) {
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text("User delete email is disabled", 403)
}
const { address } = c.get("jwtPayload")
const { address, address_id } = c.get("jwtPayload")
let name = address;
if (address.startsWith(c.env.PREFIX)) {
name = address.substring(c.env.PREFIX.length);
}
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE name = ? `
).bind(name).run();
@@ -229,7 +162,7 @@ api.delete('/api/delete_address', async (c) => {
return c.text("Failed to delete address", 500)
}
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM mails WHERE address = ? `
`DELETE FROM raw_mails WHERE address = ? `
).bind(address).run();
if (!mailSuccess) {
return c.text("Failed to delete mails", 500)
@@ -237,8 +170,11 @@ api.delete('/api/delete_address', async (c) => {
const { success: sendAccess } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE address = ? `
).bind(address).run();
const { success: addressSuccess } = await c.env.DB.prepare(
`DELETE FROM users_address WHERE address_id = ?`
).bind(address_id).run();
return c.json({
success: success && mailSuccess && sendAccess
success: success && mailSuccess && sendAccess && addressSuccess
})
})

42
worker/src/scheduled.js Normal file
View File

@@ -0,0 +1,42 @@
import { cleanup } from './common'
import { CONSTANTS } from './constants'
import { getJsonSetting } from './utils';
import { CleanupSettings } from './models';
export async function scheduled(event, env, ctx) {
console.log("Scheduled event: ", event);
const value = await getJsonSetting(
{ env: env, },
CONSTANTS.AUTO_CLEANUP_KEY
);
const autoCleanupSetting = new CleanupSettings(value);
console.log("autoCleanupSetting:", JSON.stringify(autoCleanupSetting));
if (autoCleanupSetting.enableMailsAutoCleanup && autoCleanupSetting.cleanMailsDays > 0) {
await cleanup(
{ env: env, },
"mails",
autoCleanupSetting.cleanMailsDays
);
}
if (autoCleanupSetting.enableUnknowMailsAutoCleanup && autoCleanupSetting.cleanUnknowMailsDays > 0) {
await cleanup(
{ env: env, },
"mails_unknow",
autoCleanupSetting.cleanUnknowMailsDays
);
}
if (autoCleanupSetting.enableAddressAutoCleanup && autoCleanupSetting.cleanAddressDays > 0) {
await cleanup(
{ env: env, },
"address",
autoCleanupSetting.cleanAddressDays
);
}
if (autoCleanupSetting.enableSendBoxAutoCleanup && autoCleanupSetting.cleanSendBoxDays > 0) {
await cleanup(
{ env: env, },
"sendbox",
autoCleanupSetting.cleanSendBoxDays
);
}
}

View File

@@ -0,0 +1,139 @@
import { Jwt } from 'hono/utils/jwt'
import { UserSettings } from "../models";
import { getJsonSetting } from "../utils"
import { CONSTANTS } from "../constants";
export default {
bind: async (c) => {
const { user_id } = c.get("userPayload");
const { address_id } = c.get("jwtPayload");
if (!address_id || !user_id) {
return c.text("No address or user token", 400)
}
// check if address exists
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ?`
).bind(address_id).first("id");
if (!db_address_id) {
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 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.json({ success: true })
// check if 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(user_id).first();
if (count >= settings.maxAddressCount) {
return c.text("Max address count reached", 400)
}
}
// bind
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO users_address (user_id, address_id) VALUES (?, ?)`
).bind(user_id, address_id).run();
if (!success) {
return c.text("Failed to bind", 500)
}
} catch (e) {
if (e.message && e.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 })
},
unbind: async (c) => {
const { user_id } = c.get("userPayload");
const { address_id } = await c.req.json();
if (!address_id || !user_id) {
return c.text("Invalid address or user token", 400)
}
// check if address exists
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ?`
).bind(address_id).first("id");
if (!db_address_id) {
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)
}
// unbind
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("Invalid address token", 400)
}
return c.json({ success: true })
},
getBindedAddresses: async (c) => {
const { user_id } = c.get("userPayload");
if (!user_id) {
return c.text("No user token", 400)
}
// select binded address
const { results } = await c.env.DB.prepare(
`SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a `
+ ` JOIN users_address ua `
+ ` ON ua.address_id = a.id `
+ ` WHERE ua.user_id = ?`
+ ` ORDER BY a.id DESC`
).bind(user_id).all();
return c.json({
results: results,
})
},
getBindedAddressJwt: async (c) => {
const { address_id } = c.req.param();
// check binded
const { user_id } = c.get("userPayload");
if (!address_id || !user_id) {
return c.text("Invalid address or user token", 400)
}
// check users_address if address binded
const db_user_id = await c.env.DB.prepare(
`SELECT user_id FROM users_address WHERE address_id = ? and user_id = ?`
).bind(address_id, user_id).first("user_id");
if (!db_user_id) {
return c.text("Address not binded", 400)
}
// generate jwt
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(address_id).first("name");
const jwt = await Jwt.sign({
address: name,
address_id: address_id
}, c.env.JWT_SECRET)
return c.json({
jwt: jwt
})
},
}

View File

@@ -0,0 +1,19 @@
import { Hono } from 'hono';
import settings from './settings';
import user from './user';
import bind_address from './bind_address';
const api = new Hono();
api.get('/user_api/open_settings', settings.openSettings);
api.get('/user_api/settings', settings.settings);
api.post('/user_api/login', user.login);
api.post('/user_api/verify_code', user.verifyCode);
api.post('/user_api/register', user.register);
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);
export { api }

View File

@@ -0,0 +1,25 @@
import { UserSettings } from "../models";
import { getJsonSetting } from "../utils"
import { CONSTANTS } from "../constants";
export default {
openSettings: async (c) => {
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
return c.json({
enable: settings.enable,
enableMailVerify: settings.enableMailVerify,
})
},
settings: async (c) => {
const user = c.get("userPayload");
// check if user exists
const db_user_id = await c.env.DB.prepare(
`SELECT id FROM users where id = ?`
).bind(user.user_id).first("id");
if (!db_user_id) {
return c.text("User not found", 400);
}
return c.json(user);
},
}

144
worker/src/user_api/user.js Normal file
View File

@@ -0,0 +1,144 @@
import { Jwt } from 'hono/utils/jwt'
import { checkCfTurnstile, getJsonSetting, checkUserPassword } from "../utils"
import { CONSTANTS } from "../constants";
import { GeoData, UserInfo, UserSettings } from "../models";
import { sendMail } from "../mails_api/send_mail_api";
export default {
verifyCode: async (c) => {
const { email, cf_token } = await c.req.json();
// check cf turnstile
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text("Failed to check cf turnstile", 500)
}
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value)
// check mail domain allow list
const mailDomain = email.split("@")[1];
if (settings.enableMailAllowList
&& settings.mailAllowList
&& !settings.mailAllowList.includes(mailDomain)
) {
return c.text(`Mail domain must in ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400)
}
// check if code exists in KV
const tmpcode = await c.env.KV.get(`temp-mail:${email}`)
if (tmpcode) {
return c.text("Code already sent, please wait", 400)
}
// generate code 6 digits and convert to string
const code = Math.floor(100000 + Math.random() * 900000).toString();
// send code to email
try {
await sendMail(c, settings.verifyMailSender, {
to_mail: email,
subject: "Temp Mail Verify code",
content: `Your verify code is ${code}`,
})
} catch (e) {
return c.text(`Failed to send verify code: ${e.message}`, 500)
}
// save to KV
await c.env.KV.put(`temp-mail:${email}`, code, { expirationTtl: 300 });
return c.json({
success: true,
expirationTtl: 300
})
},
register: async (c) => {
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value)
// check enable
if (!settings.enable) {
return c.text("User registration is disabled");
}
// check request
const { email, password, code } = await c.req.json();
if (!email || !password) {
return c.text("Invalid email or password", 400)
}
checkUserPassword(password);
if (settings.enableMailVerify && !code) {
return c.text("Need verify code", 400)
}
// check mail domain allow list
const mailDomain = email.split("@")[1];
if (settings.enableMailAllowList
&& !settings.mailAllowList.includes(mailDomain)
) {
return c.text(`Mail domain must in ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400)
}
// check code
if (settings.enableMailVerify) {
const verifyCode = await c.env.KV.get(`temp-mail:${email}`)
if (verifyCode != code) {
return c.text("Invalid verify code", 400)
}
}
// geo data
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
const geoData = new GeoData(reqIp, c.req.raw.cf);
const userInfo = new UserInfo(geoData);
// if not enable mail verify, do not on conflict update
if (!settings.enableMailVerify) {
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO users (user_email, password, user_info)`
+ ` VALUES (?, ?, ?)`
).bind(
email, password, JSON.stringify(userInfo)
).run();
if (!success) {
return c.text("Failed to register", 500)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("User already exists, please login", 400)
}
return c.text(`Failed to register: ${e.message}`, 500)
}
return c.json({ success: true })
}
// if enable mail verify, on conflict update
const { success } = await c.env.DB.prepare(
`INSERT INTO users (user_email, password, user_info)`
+ ` VALUES (?, ?, ?)`
+ ` ON CONFLICT(user_email) DO UPDATE SET password = ?, user_info = ?, updated_at = datetime('now')`
).bind(
email, password, JSON.stringify(userInfo),
password, JSON.stringify(userInfo)
).run();
if (!success) {
return c.text("Failed to register", 500)
}
return c.json({ success: true })
},
login: async (c) => {
const { email, password } = await c.req.json();
if (!email || !password) return c.text("Invalid email or password", 400);
const { id: user_id, password: dbPassword } = await c.env.DB.prepare(
`SELECT id, password FROM users where user_email = ?`
).bind(email).first() || {};
if (!dbPassword) {
return c.text("User not found", 400)
}
// TODO: need check password use random salt
if (dbPassword != password) {
return c.text("Invalid password", 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)
return c.json({
jwt: jwt
})
},
}

View File

@@ -1,5 +1,56 @@
import { createMimeMessage } from "mimetext";
export const getJsonSetting = async (c, key) => {
const value = await getSetting(c, key);
if (!value) {
return null;
}
try {
return JSON.parse(value);
} catch (e) {
console.error(`GetJsonSetting: Failed to parse ${key}`, e);
}
return null;
}
export const getSetting = async (c, key) => {
try {
const value = await c.env.DB.prepare(
`SELECT value FROM settings where key = ?`
).bind(key).first("value");
return value;
} catch (error) {
console.error(`GetSetting: Failed to get ${key}`, error);
}
return null;
}
export const saveSetting = async (c, key, value) => {
await c.env.DB.prepare(
`INSERT or REPLACE INTO settings (key, value) VALUES (?, ?)`
+ ` ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')`
).bind(key, value, value).run();
return true;
}
export const getStringValue = (value) => {
if (typeof value === "string") {
return value;
}
return "";
}
export const getBooleanValue = (value) => {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
return value === "true";
}
console.error("Invalid boolean value", value);
return false;
}
export const getDomains = (c) => {
if (!c.env.DOMAINS) {
return [];
@@ -78,3 +129,34 @@ export const sendAdminInternalMail = async (c, toMail, subject, text) => {
return false;
}
};
export const checkCfTurnstile = async (c, token) => {
if (!c.env.CF_TURNSTILE_SITE_KEY) {
return;
}
if (!token) {
throw new Error("Captcha token is required");
}
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
let formData = new FormData();
formData.append('secret', c.env.CF_TURNSTILE_SECRET_KEY);
formData.append('response', token);
formData.append('remoteip', reqIp);
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const result = await fetch(url, {
body: formData,
method: 'POST',
});
const captchaRes = await result.json();
if (!captchaRes.success) {
console.log("Captcha failed", captchaRes);
throw new Error("Captcha failed");
}
}
export const checkUserPassword = (password) => {
if (!password || password.length < 1 || password.length > 100) {
throw new Error("Invalid password")
}
return true;
}

View File

@@ -1,26 +1,31 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors';
import { jwt } from 'hono/jwt'
import { Jwt } from 'hono/utils/jwt'
import { api } from './router';
import { api as MailsApi } from './mails_api'
import { api as userApi } from './user_api';
import { api as adminApi } from './admin_api';
import { api as apiV1 } from './api_v1';
import { api as apiSendMail } from './send_mail_api'
import { api as apiV1 } from './deprecated';
import { api as apiSendMail } from './mails_api/send_mail_api'
import { email } from './email';
import { scheduled } from './scheduled';
import { getAdminPasswords, getPasswords } from './utils';
const app = new Hono()
//cors
app.use('/*', cors());
app.use('/api/*', async (c, next) => {
// check header x-custom-auth
const passwords = getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
if (!auth || !passwords.includes(auth)) {
return c.text("Need Password", 401)
}
}
if (c.req.path.startsWith("/api/new_address") || c.req.path.startsWith("/api/send_mail")) {
// rate limit
app.use('/*', async (c, next) => {
if (
c.req.path.startsWith("/api/new_address")
|| c.req.path.startsWith("/api/send_mail")
|| c.req.path.startsWith("/external/api/send_mail")
|| c.req.path.startsWith("/user_api/register")
|| c.req.path.startsWith("/user_api/verify_code")
) {
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
if (reqIp && c.env.RATE_LIMITER) {
const { success } = await c.env.RATE_LIMITER.limit(
@@ -31,13 +36,57 @@ app.use('/api/*', async (c, next) => {
}
}
}
await next()
});
// api auth
app.use('/api/*', async (c, next) => {
// check header x-custom-auth
const passwords = getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
if (!auth || !passwords.includes(auth)) {
return c.text("Need Password", 401)
}
}
if (c.req.path.startsWith("/api/new_address")) {
await next();
return;
}
return jwt({ secret: c.env.JWT_SECRET })(c, next);
return jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
});
// user_api auth
app.use('/user_api/*', async (c, next) => {
if (
c.req.path.startsWith("/user_api/open_settings")
|| c.req.path.startsWith("/user_api/register")
|| c.req.path.startsWith("/user_api/login")
|| c.req.path.startsWith("/user_api/verify_code")
) {
await next();
return;
}
try {
const token = c.req.raw.headers.get("x-user-token");
const payload = await Jwt.verify(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)
}
c.set("userPayload", payload);
} catch (e) {
console.error(e);
return c.text("Need User Token", 401)
}
if (c.req.path.startsWith('/user_api/bind_address')
&& c.req.method === 'POST'
) {
return jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
}
await next();
});
// admin auth
app.use('/admin/*', async (c, next) => {
// check header x-admin-auth
const adminPasswords = getAdminPasswords(c);
@@ -53,6 +102,8 @@ app.use('/admin/*', async (c, next) => {
app.route('/', api)
app.route('/', MailsApi)
app.route('/', userApi)
app.route('/', adminApi)
app.route('/', apiV1)
app.route('/', apiSendMail)
@@ -65,4 +116,5 @@ app.all('/*', async c => c.text("Not Found", 404))
export default {
fetch: app.fetch,
email: email,
scheduled: scheduled,
}

View File

@@ -7,6 +7,10 @@ node_compat = true
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
# ]
# enable cron if you want set auto clean up
# [triggers]
# crons = [ "0 0 * * *" ]
[vars]
PREFIX = "tmp"
# IF YOU WANT TO MAKE YOUR SITE PRIVATE, UNCOMMENT THE FOLLOWING LINES
@@ -18,12 +22,19 @@ PREFIX = "tmp"
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"]
JWT_SECRET = "xxx"
BLACK_LIST = ""
# Allow users to create email addresses
ENABLE_USER_CREATE_EMAIL = true
# Allow users to delete messages
ENABLE_USER_DELETE_EMAIL = true
# Allow automatic replies to emails
ENABLE_AUTO_REPLY = false
# Footer text
# COPYRIGHT = "Dream Hunter"
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# Turnstile verification
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# dkim config
# DKIM_SELECTOR = ""
# DKIM_PRIVATE_KEY = ""
@@ -33,6 +44,11 @@ binding = "DB"
database_name = "xxx"
database_id = "xxx"
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
# ratelimit config for /api/new_address
# [[unsafe.bindings]]
# name = "RATE_LIMITER"