Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76843a1aef | ||
|
|
59ad0264db | ||
|
|
da0b954a17 | ||
|
|
4e91689026 | ||
|
|
53d24b4f06 | ||
|
|
121c13704a | ||
|
|
43ed02392e | ||
|
|
4ea9527cb7 | ||
|
|
4bf5c97017 | ||
|
|
82ae8f07d5 | ||
|
|
679cb4c035 | ||
|
|
629a32c651 | ||
|
|
80a0cb65a5 | ||
|
|
a84b2ae469 | ||
|
|
22c35119c0 | ||
|
|
edba6b80d3 | ||
|
|
6ae3d43f9e | ||
|
|
40fc68e56c | ||
|
|
e68099edb3 | ||
|
|
fdf4259701 | ||
|
|
f6eaf6d0fd | ||
|
|
421a963a92 | ||
|
|
53c4db3bc9 | ||
|
|
f227bd75ca | ||
|
|
23b1d5e6be | ||
|
|
3a0ded3d01 | ||
|
|
db0e930bd1 | ||
|
|
1f898e8f65 | ||
|
|
421dc3b10a | ||
|
|
fbf889ab12 | ||
|
|
44bbf9c3da | ||
|
|
80242d5c24 | ||
|
|
09516c9cc8 | ||
|
|
84e7e2e9d8 | ||
|
|
65680c46fd | ||
|
|
ead8f9e2b8 | ||
|
|
7423a25fd6 | ||
|
|
5f45563c49 | ||
|
|
a45324a742 | ||
|
|
ac2c17e31c | ||
|
|
6189a91000 | ||
|
|
a46192b214 | ||
|
|
92e18ea683 | ||
|
|
baa70a863c | ||
|
|
d48b139aed | ||
|
|
c96e349fda | ||
|
|
984b734d4c | ||
|
|
a1c47d73e8 | ||
|
|
1b95707819 | ||
|
|
c14615d1ef | ||
|
|
8bf9adbd13 | ||
|
|
24f271a1ed | ||
|
|
f0bf6da963 | ||
|
|
522db4ee3a | ||
|
|
b60add170c | ||
|
|
154f536c61 | ||
|
|
e92a44b069 | ||
|
|
980b62f6ac | ||
|
|
af18ad3562 | ||
|
|
9ab766879e | ||
|
|
dafb8dda01 | ||
|
|
216f7272d8 | ||
|
|
24e37288e5 | ||
|
|
ee1b76cd1b | ||
|
|
5da6b44ad6 | ||
|
|
3ea917a572 | ||
|
|
5d2b49ff70 | ||
|
|
bfb896bae6 | ||
|
|
1d4979bc43 | ||
|
|
9f5076e3b5 | ||
|
|
d7c3cf27b0 | ||
|
|
31dbf1a9e6 | ||
|
|
8a47418c91 | ||
|
|
cbae3ffbdf | ||
|
|
d3235bdd17 | ||
|
|
ec3bd76666 | ||
|
|
5c11d16bdf | ||
|
|
7694278045 | ||
|
|
97b2be8d25 | ||
|
|
dc38535ea8 | ||
|
|
693e3aa9f1 | ||
|
|
6f83a440dc | ||
|
|
e723af4229 | ||
|
|
88a4b9dff5 | ||
|
|
69632c8cc6 | ||
|
|
9ef1edb341 | ||
|
|
dcd9e9c95f | ||
|
|
70923fee59 | ||
|
|
c61bda1828 | ||
|
|
0c4be2b723 | ||
|
|
9a089da3b7 | ||
|
|
0395992b5b | ||
|
|
025dc1fbdb | ||
|
|
56b42d29b3 | ||
|
|
a343a4a08f | ||
|
|
8c6cdd0279 | ||
|
|
3cf347ad7a | ||
|
|
6cd3dcb7ef | ||
|
|
4d00f0aeb7 | ||
|
|
37cad0f281 | ||
|
|
a4a1db22e8 | ||
|
|
7980e6a444 | ||
|
|
d5a5bf8c29 | ||
|
|
aded699314 | ||
|
|
57638aa8e1 | ||
|
|
fdf9e95d67 | ||
|
|
dad4a22356 | ||
|
|
083dc8daab | ||
|
|
1b441774a4 | ||
|
|
8ee140743a | ||
|
|
ad36be00a2 | ||
|
|
46cfd61b12 | ||
|
|
88da574dfb | ||
|
|
172f34dfe3 | ||
|
|
86fddf4a5d | ||
|
|
82bd308d56 | ||
|
|
457304eafb | ||
|
|
b21b63aea7 | ||
|
|
062c9f7dff | ||
|
|
8b74656c83 | ||
|
|
2c6803475b | ||
|
|
25a27ec038 | ||
|
|
b046f49ea2 | ||
|
|
2fd34b407e | ||
|
|
613582c13e | ||
|
|
4d31dbc9c4 | ||
|
|
c72230e812 | ||
|
|
2390143dc6 | ||
|
|
b4954e948b | ||
|
|
97a7950808 | ||
|
|
6f25991ff2 | ||
|
|
86a80900c1 | ||
|
|
562528e968 | ||
|
|
45994c24e3 | ||
|
|
ce09d4ac46 | ||
|
|
6219a1474e | ||
|
|
375613c219 | ||
|
|
344389cf0f | ||
|
|
fc0a5564f4 | ||
|
|
00fd14ad88 | ||
|
|
01cef2355d | ||
|
|
de0bb288ec | ||
|
|
799cc4277f | ||
|
|
0eae1bd9b8 | ||
|
|
8cd75868bc | ||
|
|
ff8ba81661 | ||
|
|
d3325f0fe3 | ||
|
|
ea8ad14049 | ||
|
|
6dc126d3b0 | ||
|
|
ab977ae64a | ||
|
|
181dcd87a3 | ||
|
|
6a11324ef9 | ||
|
|
46127a8d67 | ||
|
|
2437ff4599 | ||
|
|
658c1776d5 | ||
|
|
3a40cfc63b | ||
|
|
d03b7ca21d | ||
|
|
12833cd901 | ||
|
|
0e86b93e0c | ||
|
|
775d976f10 | ||
|
|
7267f03482 | ||
|
|
bbcff4908d | ||
|
|
249edb9e19 | ||
|
|
196fb4a6b5 | ||
|
|
bd28de5522 | ||
|
|
7762670c78 | ||
|
|
12e39c5f9c | ||
|
|
254325431b | ||
|
|
7e5a383bc6 | ||
|
|
f2c12216c8 | ||
|
|
1ecce5af21 | ||
|
|
ab6ea3847e | ||
|
|
5bc8dfdf00 | ||
|
|
b0daf8f5fd | ||
|
|
cfebeb1fda | ||
|
|
fc249dde6e | ||
|
|
3385635b26 | ||
|
|
e426ee7cbe | ||
|
|
e2aeaf3783 | ||
|
|
7a0711071d | ||
|
|
22b56781d5 | ||
|
|
52e5915d93 | ||
|
|
c72213b97d | ||
|
|
33141fe7dd | ||
|
|
bd244e48ec | ||
|
|
cd8767c1ce | ||
|
|
99bbdb3df1 | ||
|
|
2bef5ee0f6 | ||
|
|
136c0496b8 | ||
|
|
010e0451ab | ||
|
|
6af78c21ea | ||
|
|
9aa8d3a410 | ||
|
|
621859c454 | ||
|
|
f9956507fe | ||
|
|
7ec485907a | ||
|
|
bb6440b387 | ||
|
|
b6142483ed | ||
|
|
605829f180 | ||
|
|
ea309f9171 | ||
|
|
8054c0390c | ||
|
|
1c40dc8338 | ||
|
|
cdcd22962f | ||
|
|
9f9dc3d019 | ||
|
|
0692f8ec5e | ||
|
|
dac0383bab | ||
|
|
3190b47bb9 | ||
|
|
9b8598da57 | ||
|
|
462bb132e2 | ||
|
|
2341d14b2d | ||
|
|
de742d31e7 | ||
|
|
59c5be2623 | ||
|
|
aa02573217 | ||
|
|
ba2a56e1f0 | ||
|
|
741f839d09 | ||
|
|
ba3442ccb1 | ||
|
|
24ba4772e6 |
@@ -24,11 +24,16 @@ jobs:
|
||||
D1_DATABASE_ID: ${{ secrets.D1_DATABASE_ID || vars.D1_DATABASE_ID }}
|
||||
KV_NAMESPACE_ID: ${{ secrets.KV_NAMESPACE_ID || vars.KV_NAMESPACE_ID }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME || vars.R2_BUCKET_NAME }}
|
||||
PROJECT_LINK: ${{ secrets.PROJECT_LINK || vars.PROJECT_LINK }}
|
||||
AI_MODEL: ${{ secrets.AI_MODEL || vars.AI_MODEL || '@cf/meta/llama-3.1-8b-instruct' }}
|
||||
CLOUDFLARE_EMAIL: ${{ secrets.CF_EMAIL || vars.CF_EMAIL || false }}
|
||||
ANALYSIS_CACHE: ${{ secrets.ANALYSIS_CACHE || vars.ANALYSIS_CACHE || false }}
|
||||
LINUXDO_CLIENT_ID: ${{ secrets.LINUXDO_CLIENT_ID || vars.LINUXDO_CLIENT_ID }}
|
||||
LINUXDO_CLIENT_SECRET: ${{ secrets.LINUXDO_CLIENT_SECRET || vars.LINUXDO_CLIENT_SECRET }}
|
||||
LINUXDO_CALLBACK_URL: ${{ secrets.LINUXDO_CALLBACK_URL || vars.LINUXDO_CALLBACK_URL }}
|
||||
LINUXDO_SWITCH: ${{ secrets.LINUXDO_SWITCH || vars.LINUXDO_SWITCH }}
|
||||
|
||||
|
||||
outputs:
|
||||
worker_url: ${{ steps.deploy.outputs.worker_url }}
|
||||
|
||||
@@ -40,12 +45,12 @@ jobs:
|
||||
- name: ⚙ 设置 pnpm / Set up pnpm
|
||||
uses: pnpm/[email protected]
|
||||
with:
|
||||
version: latest
|
||||
version: 9
|
||||
|
||||
- name: ⚙ 设置 Node.js / Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "./mail-worker/pnpm-lock.yaml"
|
||||
|
||||
@@ -98,6 +103,10 @@ jobs:
|
||||
sed -i '/\[\[r2_buckets\]\]/,/^$/d' "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
if [ -z "$PROJECT_LINK" ]; then
|
||||
sed -i '/^project_link = /d' "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
if [ -z "$LINUXDO_CLIENT_ID" ] || [ -z "$LINUXDO_CLIENT_SECRET" ]; then
|
||||
sed -i '/^linuxdo_client_id = /,/^linuxdo_switch = /d' "$CONFIG_FILE"
|
||||
fi
|
||||
@@ -105,6 +114,14 @@ jobs:
|
||||
if [ -z "$CUSTOM_DOMAIN" ]; then
|
||||
sed -i '/\[\[routes\]\]/,/^$/d' "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
if [ "$(printf '%s' "$CLOUDFLARE_EMAIL" | tr '[:upper:]' '[:lower:]')" = "true" ]; then
|
||||
cat >> "$CONFIG_FILE" <<'EOF'
|
||||
|
||||
[[send_email]]
|
||||
name = "email"
|
||||
EOF
|
||||
fi
|
||||
|
||||
sed -i "s|\${NAME}|${NAME}|g" "$CONFIG_FILE"
|
||||
sed -i "s|\${CUSTOM_DOMAIN}|${CUSTOM_DOMAIN}|g" "$CONFIG_FILE"
|
||||
@@ -112,6 +129,9 @@ jobs:
|
||||
sed -i "s|\${ADMIN}|${ADMIN}|g" "$CONFIG_FILE"
|
||||
sed -i "s|\${JWT_SECRET}|${JWT_SECRET}|g" "$CONFIG_FILE"
|
||||
sed -i "s|\${R2_BUCKET_NAME}|${R2_BUCKET_NAME}|g" "$CONFIG_FILE"
|
||||
sed -i "s|\${PROJECT_LINK}|${PROJECT_LINK}|g" "$CONFIG_FILE"
|
||||
sed -i "s|\${ANALYSIS_CACHE}|${ANALYSIS_CACHE}|g" "$CONFIG_FILE"
|
||||
sed -i "s|\${AI_MODEL}|${AI_MODEL}|g" "$CONFIG_FILE"
|
||||
sed -i "s|\${LINUXDO_CLIENT_ID}|${LINUXDO_CLIENT_ID}|g" "$CONFIG_FILE"
|
||||
sed -i "s|\${LINUXDO_CLIENT_SECRET}|${LINUXDO_CLIENT_SECRET}|g" "$CONFIG_FILE"
|
||||
sed -i "s|\${LINUXDO_CALLBACK_URL}|${LINUXDO_CALLBACK_URL}|g" "$CONFIG_FILE"
|
||||
|
||||
+5
-3
@@ -23,8 +23,8 @@
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/14418" target="_blank" >
|
||||
<img src="https://trendshift.io/api/badge/repositories/14418" alt="trendshift" >
|
||||
<a href="https://trendshift.io/repositories/20459" target="_blank" >
|
||||
<img src="https://trendshift.io/api/badge/repositories/20459" alt="trendshift" >
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
@@ -57,6 +57,8 @@ With only one domain, you can create multiple different email addresses, similar
|
||||
|
||||
- **📡 Open API**: Supports batch user creation via API and multi-condition email queries
|
||||
|
||||
- **🔢 Verification Code Recognition**: Auto-detect codes via Workers AI
|
||||
|
||||
- **📈 Data Visualization**: Use ECharts to visualize system data, including user email growth.
|
||||
|
||||
- **🎨 Personalization**: Customize website title, login background, and transparency.
|
||||
@@ -131,7 +133,7 @@ cloud-mail
|
||||
|
||||
```
|
||||
|
||||
## Support
|
||||
## Sponsor
|
||||
|
||||
<a href="https://doc.skymail.ink/support.html">
|
||||
<img width="170px" src="./doc/images/support.png" alt="">
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/14418" target="_blank" >
|
||||
<img src="https://trendshift.io/api/badge/repositories/14418" alt="trendshift" >
|
||||
<a href="https://trendshift.io/repositories/20459" target="_blank" >
|
||||
<img src="https://trendshift.io/api/badge/repositories/20459" alt="trendshift" >
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
@@ -62,6 +62,8 @@
|
||||
|
||||
- **📡 开放API**:支持使用API批量生成用户,多条件查询邮件
|
||||
|
||||
- **🔢 验证码识别**:使用Workers AI,自动识别邮件验证码
|
||||
|
||||
- **📈 数据可视化**:使用ECharts对系统数据详情,用户邮件增长可视化显示
|
||||
|
||||
- **🎨 个性化设置**:可以自定义网站标题,登录背景,透明度
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
NODE_ENV = 'remote'
|
||||
VITE_APP_TITLE = '远程环境'
|
||||
VITE_BASE_URL = ''
|
||||
VITE_BASE_URL = 'https://skymail.ink/api'
|
||||
VITE_PWA_NAME = 'Cloud Mail'
|
||||
|
||||
+60
-43
@@ -43,77 +43,94 @@
|
||||
|
||||
.loading-hide {
|
||||
pointer-events: none;
|
||||
transition: all 200ms;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.circular {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
animation: loading-rotate 1s linear infinite;
|
||||
.loading-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.loading-path .dot1 {
|
||||
transform: translate(3.75px, 3.75px);
|
||||
fill: #409EFF;
|
||||
animation: custom-spin-move 1s infinite linear alternate;
|
||||
opacity: 0.3;
|
||||
.loading-progress {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(64, 158, 255, 0.18);
|
||||
}
|
||||
|
||||
.loading-path .dot2 {
|
||||
transform: translate(calc(100% - 3.75px), 3.75px);
|
||||
fill: #409EFF;
|
||||
animation: custom-spin-move 1s infinite linear alternate;
|
||||
opacity: 0.3;
|
||||
animation-delay: 0.4s;
|
||||
.loading-progress::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #409EFF;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
animation: loading-progress-grow 2.4s linear forwards;
|
||||
}
|
||||
|
||||
.loading-path .dot3 {
|
||||
transform: translate(3.75px, calc(100% - 3.75px));
|
||||
fill: #409EFF;
|
||||
animation: custom-spin-move 1s infinite linear alternate;
|
||||
opacity: 0.3;
|
||||
animation-delay: 1.2s;
|
||||
.loading-progress::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 24px;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgba(64, 158, 255, 0.45);
|
||||
filter: blur(4px);
|
||||
transform: translateX(-24px);
|
||||
animation: loading-progress-peg 2.4s linear forwards;
|
||||
}
|
||||
|
||||
.loading-path .dot4 {
|
||||
transform: translate(calc(100% - 3.75px), calc(100% - 3.75px));
|
||||
fill: #409EFF;
|
||||
animation: custom-spin-move 1s infinite linear alternate;
|
||||
opacity: 0.3;
|
||||
animation-delay: 0.8s;
|
||||
.loading-complete .loading-progress::before {
|
||||
animation: none;
|
||||
transform: scaleX(1);
|
||||
transition: transform 200ms linear;
|
||||
}
|
||||
|
||||
@keyframes loading-rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
.loading-complete .loading-progress::after {
|
||||
animation: none;
|
||||
transform: translateX(60px);
|
||||
transition: transform 200ms linear;
|
||||
}
|
||||
|
||||
@keyframes loading-progress-grow {
|
||||
0% {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
100% {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes custom-spin-move {
|
||||
to {
|
||||
opacity: 1;
|
||||
@keyframes loading-progress-peg {
|
||||
0% {
|
||||
transform: translateX(-24px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(60px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
<div id="loading-first" >
|
||||
<div class="loading-icon">
|
||||
<svg class="circular" viewBox="0 0 20 20">
|
||||
<g class="loading-path">
|
||||
<circle r="3.375" class="dot1" rx="0" ry="0"/>
|
||||
<circle r="3.375" class="dot2" rx="0" ry="0"/>
|
||||
<circle r="3.375" class="dot4" rx="0" ry="0"/>
|
||||
<circle r="3.375" class="dot3" rx="0" ry="0"/>
|
||||
</g>
|
||||
</svg>
|
||||
<img class="loading-image" src="/mail-pwa.png" alt="Cloud Mail">
|
||||
<div class="loading-progress" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@vueuse/components": "^14.1.0",
|
||||
"@vueuse/core": "^12.0.0",
|
||||
"axios": "^1.7.8",
|
||||
"axios": "1.15.2",
|
||||
"compressorjs": "^1.2.1",
|
||||
"date-time-format-timezone": "^1.0.22",
|
||||
"dayjs": "^1.11.13",
|
||||
|
||||
Generated
+28
-16
@@ -11,6 +11,9 @@ importers:
|
||||
'@iconify/vue':
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0([email protected])
|
||||
'@simplewebauthn/browser':
|
||||
specifier: ^13.3.0
|
||||
version: 13.3.0
|
||||
'@vueuse/components':
|
||||
specifier: ^14.1.0
|
||||
version: 14.1.0([email protected])
|
||||
@@ -18,8 +21,8 @@ importers:
|
||||
specifier: ^12.0.0
|
||||
version: 12.8.2
|
||||
axios:
|
||||
specifier: ^1.7.8
|
||||
version: 1.11.0
|
||||
specifier: 1.15.2
|
||||
version: 1.15.2([email protected])
|
||||
compressorjs:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
@@ -1061,6 +1064,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@simplewebauthn/[email protected]':
|
||||
resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==}
|
||||
|
||||
'@surma/[email protected]':
|
||||
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
||||
|
||||
@@ -1216,8 +1222,8 @@ packages:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]1.0:
|
||||
resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==}
|
||||
[email protected]5.2:
|
||||
resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==}
|
||||
@@ -1513,8 +1519,8 @@ packages:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||
[email protected].5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
[email protected]:
|
||||
@@ -1564,7 +1570,7 @@ packages:
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting [email protected]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
|
||||
@@ -2015,8 +2021,9 @@ packages:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
|
||||
@@ -2422,6 +2429,7 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LvyteQoXeQiuILbzqv13LbyBna/TEv2Ha+4ZWK2AwGHUzZ8+IBaZS0TJkCgn5izSPLcgZwXy9yyTrewCb2u/MA==}
|
||||
engines: {node: '>= 16'}
|
||||
deprecated: This version is NOT deprecated. Previous deprecation was a mistake.
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
@@ -3499,6 +3507,8 @@ snapshots:
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@simplewebauthn/[email protected]': {}
|
||||
|
||||
'@surma/[email protected]':
|
||||
dependencies:
|
||||
ejs: 3.1.10
|
||||
@@ -3703,11 +3713,11 @@ snapshots:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
[email protected]1.0:
|
||||
[email protected]5.2([email protected]):
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.4
|
||||
proxy-from-env: 1.1.0
|
||||
follow-redirects: 1.15.11([email protected])
|
||||
form-data: 4.0.5
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
@@ -4077,13 +4087,15 @@ snapshots:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
[email protected]: {}
|
||||
[email protected]([email protected]):
|
||||
optionalDependencies:
|
||||
debug: 4.4.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
[email protected].4:
|
||||
[email protected].5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
@@ -4565,7 +4577,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
@@ -82,9 +82,12 @@
|
||||
<div class="email-text">
|
||||
<span class="email-subject" :style="(item.unread === EmailUnreadEnum.UNREAD && showUnread) ? 'font-weight: bold' : ''">
|
||||
<div class="unread" v-if="!isMobile && (item.unread === EmailUnreadEnum.UNREAD && showUnread) "/>
|
||||
<slot name="subject" :email="item" >
|
||||
{{ item.subject || '\u200B' }}
|
||||
</slot>
|
||||
<span v-if="item.code" class="code-tag" @click.stop="copyCode(item.code)">[{{ t('codeLabel') }}{{ item.code }}]</span>
|
||||
<span class="subject-text">
|
||||
<slot name="subject" :email="item" >
|
||||
{{ item.subject || '\u200B' }}
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
<span class="email-content">{{ item.formatText || '\u200B' }}</span>
|
||||
</div>
|
||||
@@ -152,6 +155,14 @@
|
||||
>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="rightClickEmail.code" @click="copyCode(rightClickEmail.code)" >
|
||||
<template #default>
|
||||
<div class="right-dropdown-item">
|
||||
<Icon icon="fluent-color:clipboard-24" width="20" height="20" />
|
||||
<span>{{t('copyCode')}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="['email'].includes(props.type)" @click="emailRead(rightClickEmail.emailId)" >
|
||||
<template #default>
|
||||
<div class="right-dropdown-item">
|
||||
@@ -656,6 +667,24 @@ function handleSearch(type, value) {
|
||||
emit('right-search', type, value);
|
||||
}
|
||||
|
||||
async function copyCode(code) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
ElMessage({
|
||||
message: t('copySuccessMsg'),
|
||||
type: 'success',
|
||||
plain: true
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`${t('copyFailMsg')}:`, err);
|
||||
ElMessage({
|
||||
message: t('copyFailMsg'),
|
||||
type: 'error',
|
||||
plain: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
ElMessageBox.confirm(t('delEmailsConfirm'), {
|
||||
confirmButtonText: t('confirm'),
|
||||
@@ -1139,14 +1168,37 @@ function loadData() {
|
||||
}
|
||||
|
||||
.email-subject {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
@media (min-width: 1367px) {
|
||||
padding-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.code-tag {
|
||||
flex: 0 0 auto;
|
||||
max-width: 170px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subject-text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
+27
-1
@@ -104,6 +104,8 @@ const en = {
|
||||
validUntil: 'Valid Until',
|
||||
expired: 'Expired',
|
||||
copy: 'Copy',
|
||||
copyCode: 'Copy Code',
|
||||
codeLabel: 'Code: ',
|
||||
history: 'History',
|
||||
addRegKey: 'Add Invite Code',
|
||||
regKey: 'Invite Code',
|
||||
@@ -134,7 +136,21 @@ const en = {
|
||||
loginSwitch: 'Sign in',
|
||||
websiteSetting: 'Website',
|
||||
websiteReg: 'Sign Up',
|
||||
loginDomain: 'Sign-In Box Domain',
|
||||
loginDomain: 'Hide Sign-In Domain',
|
||||
passkeyLogin: 'Passkey',
|
||||
passkeySetting: 'Passkey',
|
||||
passkeyNotConfigured: 'No Passkey configured',
|
||||
passkeyConfigured: 'Passkey configured',
|
||||
passkeyCreatedAt: 'Created: {time}',
|
||||
passkeyCreate: 'Create Passkey',
|
||||
passkeyReplace: 'Replace',
|
||||
passkeyDelete: 'Delete Passkey',
|
||||
passkeyCurrentPassword: 'Enter your current password',
|
||||
passkeyUnsupported: 'Passkeys are not supported by this browser or device',
|
||||
passkeyLoginFailed: 'Passkey verification failed. Please try again',
|
||||
passkeyCreateSuccess: 'Passkey created successfully',
|
||||
passkeyDeleteSuccess: 'Passkey deleted successfully',
|
||||
passkeyDeleteConfirm: 'Delete the current Passkey?',
|
||||
multipleEmail: 'Multiple Email Address',
|
||||
multipleEmailDesc: 'Enable this feature to allow users to add multiple email',
|
||||
customization: 'Customization',
|
||||
@@ -147,6 +163,11 @@ const en = {
|
||||
autoRefreshDesc: 'Automatically fetch the latest emails from the server',
|
||||
sendEmail: 'Send Email',
|
||||
resendToken: 'Resend Token',
|
||||
cloudflareEmailSending: 'Cloudflare Email Sending',
|
||||
blackList: 'Blocklist',
|
||||
codeRecognition: 'Verification Code Recognition',
|
||||
codeRecognitionRules: 'Recognition Rules',
|
||||
codeRecognitionRulesDesc: 'Only recognize emails containing the following info. Empty means all.',
|
||||
oss: 'Object Storage',
|
||||
osDomain: 'Domain',
|
||||
ossDomainDesc: 'Leave empty if using KV storage.',
|
||||
@@ -311,6 +332,11 @@ const en = {
|
||||
character: '',
|
||||
mustNotContain: 'Must Not Contain',
|
||||
mustNotContainDesc: 'Separate with commas',
|
||||
blackSubjectDesc: 'Subject',
|
||||
blackContentDesc: 'Content',
|
||||
blackFromDesc: 'Sender or domain',
|
||||
senderRules: 'Sender address or domain',
|
||||
blackListDesc: 'Reject emails containing the following information',
|
||||
setSuccess: 'Settings saved successfully',
|
||||
details: 'Details',
|
||||
userDetails: 'User Details',
|
||||
|
||||
+28
-2
@@ -104,6 +104,8 @@ const zh = {
|
||||
validUntil: '有效至期',
|
||||
expired: '已过期',
|
||||
copy: '复制',
|
||||
copyCode: '复制验证码',
|
||||
codeLabel: '验证码:',
|
||||
history: '记录',
|
||||
addRegKey: '添加注册码',
|
||||
regKey: '注册码',
|
||||
@@ -134,7 +136,21 @@ const zh = {
|
||||
loginSwitch: '去登录',
|
||||
websiteSetting: '网站设置',
|
||||
websiteReg: '用户注册',
|
||||
loginDomain: '登录框域名',
|
||||
loginDomain: '隐藏登录域名',
|
||||
passkeyLogin: 'Passkey 登录',
|
||||
passkeySetting: 'Passkey',
|
||||
passkeyNotConfigured: '尚未创建 Passkey',
|
||||
passkeyConfigured: '已创建 Passkey',
|
||||
passkeyCreatedAt: '创建时间:{time}',
|
||||
passkeyCreate: '创建 Passkey',
|
||||
passkeyReplace: '重新创建',
|
||||
passkeyDelete: '删除 Passkey',
|
||||
passkeyCurrentPassword: '请输入当前密码',
|
||||
passkeyUnsupported: '当前浏览器或设备不支持 Passkey',
|
||||
passkeyLoginFailed: 'Passkey 验证失败,请重试',
|
||||
passkeyCreateSuccess: 'Passkey 创建成功',
|
||||
passkeyDeleteSuccess: 'Passkey 删除成功',
|
||||
passkeyDeleteConfirm: '确认删除当前 Passkey?',
|
||||
multipleEmail: '多号模式',
|
||||
multipleEmailDesc: '开启后账号栏出现一个用户可以添加多个邮箱',
|
||||
customization: '个性化设置',
|
||||
@@ -147,6 +163,11 @@ const zh = {
|
||||
autoRefreshDesc: '轮询请求服务器获取最新邮件',
|
||||
sendEmail: '邮件发送',
|
||||
resendToken: 'Resend Token',
|
||||
cloudflareEmailSending: 'Cloudflare 邮件发送',
|
||||
blackList: '邮件黑名单',
|
||||
codeRecognition: '识别验证码',
|
||||
codeRecognitionRules: '识别规则',
|
||||
codeRecognitionRulesDesc: '只识别包含以下信息的邮件,为空识别全部',
|
||||
oss: '对象存储',
|
||||
osDomain: '访问域名',
|
||||
ossDomainDesc: '如果是KV存储不要填',
|
||||
@@ -245,7 +266,7 @@ const zh = {
|
||||
serverBusyErrorMsg: '服务器繁忙,请稍后重试',
|
||||
reqFailErrorMsg: '请求失败,请稍后再试',
|
||||
message: '邮件详情',
|
||||
language: '网站语言',
|
||||
language: '系统语言',
|
||||
totalUserAccount: '{msg} 个',
|
||||
sendBanned: '已禁用',
|
||||
sendInternal: '仅站内',
|
||||
@@ -311,6 +332,11 @@ const zh = {
|
||||
character: '位',
|
||||
mustNotContain: '禁止包含',
|
||||
mustNotContainDesc: '输入多个值用,分开',
|
||||
blackSubjectDesc: '邮件标题',
|
||||
blackContentDesc: '邮件内容',
|
||||
blackFromDesc: '发件人邮箱或域名',
|
||||
senderRules: '发件人邮箱或域名',
|
||||
blackListDesc: '拒收包含以下信息的邮件',
|
||||
setSuccess: '设置成功',
|
||||
details: '详情',
|
||||
userDetails: '用户详情',
|
||||
|
||||
@@ -54,18 +54,4 @@ export async function init() {
|
||||
settingStore.domainList = setting.domainList;
|
||||
document.title = setting.title;
|
||||
}
|
||||
|
||||
removeLoading();
|
||||
}
|
||||
|
||||
function removeLoading() {
|
||||
if (window.innerWidth < 1025) {
|
||||
document.documentElement.style.setProperty('--loading-hide-transition', 'none')
|
||||
}
|
||||
const doc = document.getElementById('loading-first');
|
||||
doc.classList.add('loading-hide')
|
||||
setTimeout(() => {
|
||||
doc.remove()
|
||||
},1000)
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {Icon} from "@iconify/vue";
|
||||
import {nextTick, reactive, ref, watch} from "vue";
|
||||
import {computed, nextTick, reactive, ref, watch} from "vue";
|
||||
import {
|
||||
accountList,
|
||||
accountAdd,
|
||||
@@ -153,7 +153,7 @@ const settingStore = useSettingStore();
|
||||
const emailStore = useEmailStore();
|
||||
const showAdd = ref(false)
|
||||
const addLoading = ref(false);
|
||||
const domainList = settingStore.domainList
|
||||
const domainList = computed(() => settingStore.domainList)
|
||||
const accounts = reactive([])
|
||||
const noLoading = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -189,6 +189,12 @@ watch(() => accountStore.changeUserAccountName, () => {
|
||||
accounts[0].name = accountStore.changeUserAccountName
|
||||
})
|
||||
|
||||
watch(() => settingStore.domainList, (list) => {
|
||||
if (!addForm.suffix && list.length > 0) {
|
||||
addForm.suffix = list[0]
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
|
||||
const openSelect = () => {
|
||||
mySelect.value.toggleMenu()
|
||||
@@ -338,6 +344,7 @@ function changeAccount(account) {
|
||||
}
|
||||
|
||||
function add() {
|
||||
addForm.suffix = addForm.suffix || settingStore.domainList[0]
|
||||
showAdd.value = true
|
||||
setTimeout(() => {
|
||||
addRef.value.focus()
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
<div class="item-title" >{{ $t('recipient') }}</div>
|
||||
<el-select
|
||||
ref="mySelect"
|
||||
class="select"
|
||||
class="write-select"
|
||||
popper-class="write-select"
|
||||
:show-arrow="false"
|
||||
:no-match-text="' '"
|
||||
:no-data-text="' '"
|
||||
@@ -604,14 +605,14 @@ function close() {
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.el-select-dropdown__list {
|
||||
.write-select .el-select-dropdown__list {
|
||||
padding: 4px 4px !important;
|
||||
}
|
||||
.el-select-dropdown__item {
|
||||
.write-select .el-select-dropdown__item {
|
||||
padding: 0 10px 0 10px;
|
||||
}
|
||||
|
||||
.el-select-dropdown {
|
||||
.write-select .el-select-dropdown {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -768,7 +769,7 @@ function close() {
|
||||
color: var(--regular-text-color)
|
||||
}
|
||||
|
||||
.select {
|
||||
.write-select {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
left: 60px;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import http from '@/axios/index.js';
|
||||
|
||||
export function passkeyAuthOptions() {
|
||||
return http.post('/passkey/auth/options')
|
||||
}
|
||||
|
||||
export function passkeyAuthVerify(transactionId, response) {
|
||||
return http.post('/passkey/auth/verify', {transactionId, response})
|
||||
}
|
||||
|
||||
export function passkeyStatus() {
|
||||
return http.get('/my/passkey')
|
||||
}
|
||||
|
||||
export function passkeyRegOptions(password) {
|
||||
return http.post('/my/passkey/reg/options', {password})
|
||||
}
|
||||
|
||||
export function passkeyRegVerify(transactionId, response) {
|
||||
return http.post('/my/passkey/reg/verify', {transactionId, response})
|
||||
}
|
||||
|
||||
export function passkeyDelete(password) {
|
||||
return http.delete('/my/passkey', {data: {password}})
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export function roleAdd(params) {
|
||||
}
|
||||
|
||||
export function rolePermTree() {
|
||||
return http.get('/role/permTree')
|
||||
return http.get('/role/tree')
|
||||
}
|
||||
|
||||
export function roleRoleList() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import http from '@/axios/index.js';
|
||||
|
||||
export function settingSet(setting) {
|
||||
return http.put('/setting/set',setting)
|
||||
return http.put('/setting/set', setting)
|
||||
}
|
||||
|
||||
export function settingQuery() {
|
||||
@@ -19,3 +19,7 @@ export function setBackground(background) {
|
||||
export function deleteBackground() {
|
||||
return http.delete('/setting/deleteBackground')
|
||||
}
|
||||
|
||||
export function setBlackList(params) {
|
||||
return http.put('/setting/setBlacklist', params)
|
||||
}
|
||||
@@ -92,9 +92,11 @@ router.beforeEach((to, from, next) => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
NProgress.start()
|
||||
}, first ? 200 : 100)
|
||||
if (!first) {
|
||||
timer = setTimeout(() => {
|
||||
NProgress.start()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
@@ -149,7 +151,11 @@ function loadBackground(next) {
|
||||
router.afterEach((to) => {
|
||||
|
||||
clearTimeout(timer)
|
||||
NProgress.done();
|
||||
if (first) {
|
||||
removeLoading()
|
||||
} else {
|
||||
NProgress.done();
|
||||
}
|
||||
|
||||
const uiStore = useUiStore()
|
||||
if (to.meta.menu) {
|
||||
@@ -167,4 +173,13 @@ router.afterEach((to) => {
|
||||
first = false
|
||||
})
|
||||
|
||||
function removeLoading() {
|
||||
const doc = document.getElementById('loading-first');
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
doc.remove()
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
@@ -11,5 +11,8 @@ export const useSettingStore = defineStore('setting', {
|
||||
}),
|
||||
actions: {
|
||||
|
||||
}
|
||||
},
|
||||
persist: {
|
||||
pick: ['lang'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -221,7 +221,7 @@ const handleDelete = () => {
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
padding: 9px 15px;
|
||||
padding: 9px 15px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
<span class="form-desc" v-if="show === 'login'">{{ $t('loginTitle') }}</span>
|
||||
<span class="form-desc" v-else>{{ $t('regTitle') }}</span>
|
||||
<div v-show="show === 'login'">
|
||||
<el-input :class="settingStore.settings.loginDomain === 0 ? 'email-input' : ''" v-model="form.email"
|
||||
type="text" :placeholder="$t('emailAccount')" autocomplete="off">
|
||||
<template #append v-if="settingStore.settings.loginDomain === 0">
|
||||
<el-input ref="loginEmailInput" :class="!hideLoginDomain ? 'email-input' : ''" v-model="form.email"
|
||||
type="text" :placeholder="$t('emailAccount')" autocomplete="off" @keydown.tab="handleEmailTab">
|
||||
<template #append v-if="!hideLoginDomain">
|
||||
<div @click.stop="openSelect">
|
||||
<el-select
|
||||
v-if="show === 'login'"
|
||||
@@ -39,19 +39,26 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input v-model="form.password" :placeholder="$t('password')" type="password" autocomplete="off">
|
||||
<el-input ref="loginPasswordInput" v-model="form.password" :placeholder="$t('password')" type="password"
|
||||
autocomplete="off" @keydown.tab="handlePasswordTab" @keyup.enter="submit">
|
||||
</el-input>
|
||||
<el-button class="btn" type="primary" @click="submit" :loading="loginLoading"
|
||||
>{{ $t('loginBtn') }}
|
||||
</el-button>
|
||||
<div class="login-actions">
|
||||
<el-button class="btn" type="primary" @click="submit" :loading="loginLoading"
|
||||
>{{ $t('loginBtn') }}
|
||||
</el-button>
|
||||
<el-button v-if="settingStore.settings.passkey === 1" class="btn" @click="submitPasskey" :loading="passkeyLoading">
|
||||
<Icon icon="material-symbols:passkey-outline-rounded" width="18" height="18"/>
|
||||
{{ $t('passkeyLogin') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button class="btn" v-if="settingStore.settings.linuxdoSwitch" style="margin-top: 10px" @click="linuxDoLogin">
|
||||
<el-avatar src="/image/linuxdo.webp" :size="18" style="margin-right: 10px" />LinuxDo
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-show="show !== 'login'">
|
||||
<el-input class="email-input" v-model="registerForm.email" type="text" :placeholder="$t('emailAccount')"
|
||||
<el-input :class="!hideLoginDomain ? 'email-input' : ''" v-model="registerForm.email" type="text" :placeholder="$t('emailAccount')"
|
||||
autocomplete="off">
|
||||
<template #append>
|
||||
<template #append v-if="!hideLoginDomain">
|
||||
<div @click.stop="openSelect">
|
||||
<el-select
|
||||
v-if="show !== 'login'"
|
||||
@@ -108,8 +115,8 @@
|
||||
</div>
|
||||
<el-dialog class="bind-dialog" v-model="showBindForm" title="注册邮箱" >
|
||||
<div class="bind-container">
|
||||
<el-input v-model="bindForm.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
|
||||
<template #append>
|
||||
<el-input :class="!hideLoginDomain ? 'email-input' : ''" v-model="bindForm.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
|
||||
<template #append v-if="!hideLoginDomain">
|
||||
<div @click.stop="openSelect">
|
||||
<el-select
|
||||
ref="mySelect"
|
||||
@@ -140,7 +147,7 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<a class="github" href="https://github.com/maillab/cloud-mail">
|
||||
<a v-show="settingStore.settings.projectLink" class="github" href="https://github.com/maillab/cloud-mail">
|
||||
<Icon icon="mingcute:github-line" color="#1890ff" width="20" height="20" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -151,6 +158,7 @@ import router from "@/router";
|
||||
import {computed, nextTick, reactive, ref} from "vue";
|
||||
import {login} from "@/request/login.js";
|
||||
import {register} from "@/request/login.js";
|
||||
import {websiteConfig} from "@/request/setting.js";
|
||||
import {isEmail} from "@/utils/verify-utils.js";
|
||||
import {useSettingStore} from "@/store/setting.js";
|
||||
import {useAccountStore} from "@/store/account.js";
|
||||
@@ -162,6 +170,8 @@ import {loginUserInfo} from "@/request/my.js";
|
||||
import {permsToRouter} from "@/perm/perm.js";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {oauthBindUser, oauthLinuxDoLogin} from "@/request/ouath.js";
|
||||
import {browserSupportsWebAuthn, startAuthentication} from "@simplewebauthn/browser";
|
||||
import {passkeyAuthOptions, passkeyAuthVerify} from "@/request/passkey.js";
|
||||
|
||||
const {t} = useI18n();
|
||||
const accountStore = useAccountStore();
|
||||
@@ -169,10 +179,13 @@ const userStore = useUserStore();
|
||||
const uiStore = useUiStore();
|
||||
const settingStore = useSettingStore();
|
||||
const loginLoading = ref(false)
|
||||
const passkeyLoading = ref(false)
|
||||
const bindLoading = ref(false)
|
||||
const oauthLoading = ref(false);
|
||||
const showBindForm = ref(false);
|
||||
const show = ref('login')
|
||||
const loginEmailInput = ref()
|
||||
const loginPasswordInput = ref()
|
||||
|
||||
const bindForm = reactive({
|
||||
email: '',
|
||||
@@ -198,6 +211,18 @@ const registerLoading = ref(false)
|
||||
suffix.value = domainList[0]
|
||||
const verifyShow = ref(false)
|
||||
let verifyToken = ''
|
||||
|
||||
function handleEmailTab(event) {
|
||||
if (event.shiftKey) return
|
||||
event.preventDefault()
|
||||
loginPasswordInput.value?.focus()
|
||||
}
|
||||
|
||||
function handlePasswordTab(event) {
|
||||
if (!event.shiftKey) return
|
||||
event.preventDefault()
|
||||
loginEmailInput.value?.focus()
|
||||
}
|
||||
let turnstileId = null
|
||||
let botJsError = ref(false)
|
||||
let verifyErrorCount = 0
|
||||
@@ -236,6 +261,8 @@ const loginOpacity = computed(() => {
|
||||
return uiStore.dark ? `rgba(0, 0, 0, ${opacity})` : `rgba(255, 255, 255, ${opacity})`
|
||||
})
|
||||
|
||||
const hideLoginDomain = computed(() => settingStore.settings.loginDomain === 1)
|
||||
|
||||
const background = computed(() => {
|
||||
|
||||
return settingStore.settings.background ? {
|
||||
@@ -250,6 +277,14 @@ const openSelect = () => {
|
||||
mySelect.value.toggleMenu()
|
||||
}
|
||||
|
||||
const getFullEmail = (email) => {
|
||||
return hideLoginDomain.value ? email : email + suffix.value
|
||||
}
|
||||
|
||||
const getEmailName = (email) => {
|
||||
return email.split('@')[0]
|
||||
}
|
||||
|
||||
function linuxDoLogin() {
|
||||
const clientId = settingStore.settings.linuxdoClientId
|
||||
const redirectUri = encodeURIComponent(settingStore.settings.linuxdoCallbackUrl)
|
||||
@@ -305,7 +340,7 @@ function bind() {
|
||||
}
|
||||
|
||||
|
||||
if (bindForm.email.length < settingStore.settings.minEmailPrefix) {
|
||||
if (getEmailName(bindForm.email).length < settingStore.settings.minEmailPrefix) {
|
||||
ElMessage({
|
||||
message: t('minEmailPrefix', {msg: settingStore.settings.minEmailPrefix}),
|
||||
type: 'error',
|
||||
@@ -314,7 +349,7 @@ function bind() {
|
||||
return
|
||||
}
|
||||
|
||||
let email = bindForm.email + suffix.value;
|
||||
let email = getFullEmail(bindForm.email);
|
||||
|
||||
|
||||
if (!isEmail(email)) {
|
||||
@@ -340,7 +375,7 @@ function bind() {
|
||||
|
||||
}
|
||||
|
||||
const form = {email: bindForm.email + suffix.value, oauthUserId: bindForm.oauthUserId, code: bindForm.code}
|
||||
const form = {email, oauthUserId: bindForm.oauthUserId, code: bindForm.code}
|
||||
|
||||
bindLoading.value = true
|
||||
oauthBindUser(form).then(data => {
|
||||
@@ -361,7 +396,7 @@ const submit = () => {
|
||||
return
|
||||
}
|
||||
|
||||
let email = form.email + (settingStore.settings.loginDomain === 0 ? suffix.value : '');
|
||||
let email = getFullEmail(form.email);
|
||||
|
||||
if (!isEmail(email)) {
|
||||
ElMessage({
|
||||
@@ -389,8 +424,38 @@ const submit = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function submitPasskey() {
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
ElMessage({
|
||||
message: t('passkeyUnsupported'),
|
||||
type: 'error',
|
||||
plain: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
const {transactionId, options} = await passkeyAuthOptions()
|
||||
const response = await startAuthentication({optionsJSON: options})
|
||||
const data = await passkeyAuthVerify(transactionId, response)
|
||||
await saveToken(data.token)
|
||||
} catch (error) {
|
||||
if (!error?.code) {
|
||||
ElMessage({
|
||||
message: t('passkeyLoginFailed'),
|
||||
type: 'error',
|
||||
plain: true,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToken(token) {
|
||||
localStorage.setItem('token', token)
|
||||
refreshWebsiteConfig()
|
||||
const user = await loginUserInfo();
|
||||
accountStore.currentAccountId = user.account.accountId;
|
||||
accountStore.currentAccount = user.account;
|
||||
@@ -405,6 +470,19 @@ async function saveToken(token) {
|
||||
bindLoading.value = false;
|
||||
}
|
||||
|
||||
function refreshWebsiteConfig() {
|
||||
websiteConfig().then(setting => {
|
||||
settingStore.settings = setting
|
||||
settingStore.domainList = setting.domainList
|
||||
if (!suffix.value && setting.domainList.length > 0) {
|
||||
suffix.value = setting.domainList[0]
|
||||
}
|
||||
document.title = setting.title
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function submitRegister() {
|
||||
|
||||
@@ -419,7 +497,7 @@ function submitRegister() {
|
||||
|
||||
console.log(registerForm.email)
|
||||
|
||||
if (registerForm.email.length < settingStore.settings.minEmailPrefix) {
|
||||
if (getEmailName(registerForm.email).length < settingStore.settings.minEmailPrefix) {
|
||||
ElMessage({
|
||||
message: t('minEmailPrefix', {msg: settingStore.settings.minEmailPrefix}),
|
||||
type: 'error',
|
||||
@@ -428,7 +506,9 @@ function submitRegister() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEmail(registerForm.email + suffix.value)) {
|
||||
const email = getFullEmail(registerForm.email);
|
||||
|
||||
if (!isEmail(email)) {
|
||||
ElMessage({
|
||||
message: t('notEmailMsg'),
|
||||
type: 'error',
|
||||
@@ -507,7 +587,7 @@ function submitRegister() {
|
||||
registerLoading.value = true
|
||||
|
||||
const form = {
|
||||
email: registerForm.email + suffix.value,
|
||||
email,
|
||||
password: registerForm.password,
|
||||
token: verifyToken,
|
||||
code: registerForm.code
|
||||
@@ -610,6 +690,15 @@ function submitRegister() {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.form-desc {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
@@ -30,13 +30,44 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="passkey" v-if="settingStore.settings.passkey === 1">
|
||||
<div class="title">{{$t('passkeySetting')}}</div>
|
||||
<div class="passkey-status">
|
||||
<div>
|
||||
<div>{{ passkey.registered ? $t('passkeyConfigured') : $t('passkeyNotConfigured') }}</div>
|
||||
<div class="passkey-time" v-if="passkey.registered && passkey.createTime">
|
||||
{{$t('passkeyCreatedAt', {time: passkey.createTime})}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="passkey-actions">
|
||||
<el-button type="primary" :loading="passkeyLoading" @click="openPasskeyAction('register')">
|
||||
{{ passkey.registered ? $t('passkeyReplace') : $t('passkeyCreate') }}
|
||||
</el-button>
|
||||
<el-button v-if="passkey.registered" :loading="passkeyLoading" @click="openPasskeyAction('delete')">
|
||||
{{$t('passkeyDelete')}}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="language">
|
||||
<div class="title">{{$t('language')}}</div>
|
||||
<el-select
|
||||
:model-value="langSelect"
|
||||
class="language-select"
|
||||
placeholder="Select"
|
||||
@change="changeLang"
|
||||
>
|
||||
<el-option label="中文" value="zh" @pointerdown.prevent.stop="changeLang('zh')"/>
|
||||
<el-option label="English" value="en" @pointerdown.prevent.stop="changeLang('en')"/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="del-email" v-perm="'my:delete'">
|
||||
<div class="title">{{$t('deleteUser')}}</div>
|
||||
<div style="color: var(--regular-text-color);">
|
||||
{{$t('delAccountMsg')}}
|
||||
</div>
|
||||
<div>
|
||||
<el-button type="primary" @click="deleteConfirm">{{$t('deleteUserBtn')}}</el-button>
|
||||
<el-button type="danger" @click="deleteConfirm">{{$t('deleteUserBtn')}}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="pwdShow" :title="$t('changePassword')" width="340">
|
||||
@@ -46,6 +77,15 @@
|
||||
<el-button type="primary" :loading="setPwdLoading" @click="submitPwd">{{$t('save')}}</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="passkeyPwdShow"
|
||||
:title="passkeyAction === 'delete' ? $t('passkeyDelete') : $t('passkeySetting')" width="340">
|
||||
<div class="update-pwd">
|
||||
<div v-if="passkeyAction === 'delete'">{{$t('passkeyDeleteConfirm')}}</div>
|
||||
<el-input type="password" :placeholder="$t('passkeyCurrentPassword')" v-model="passkeyPassword"
|
||||
autocomplete="current-password" @keyup.enter="submitPasskeyAction"/>
|
||||
<el-button type="primary" :loading="passkeyLoading" @click="submitPasskeyAction">{{$t('confirm')}}</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
@@ -56,18 +96,97 @@ import router from "@/router/index.js";
|
||||
import {accountSetName} from "@/request/account.js";
|
||||
import {useAccountStore} from "@/store/account.js";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useSettingStore} from "@/store/setting.js";
|
||||
import {browserSupportsWebAuthn, startRegistration} from "@simplewebauthn/browser";
|
||||
import {
|
||||
passkeyDelete,
|
||||
passkeyRegOptions,
|
||||
passkeyRegVerify,
|
||||
passkeyStatus
|
||||
} from "@/request/passkey.js";
|
||||
|
||||
const { t } = useI18n()
|
||||
const accountStore = useAccountStore()
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore();
|
||||
const setPwdLoading = ref(false)
|
||||
const setNameShow = ref(false)
|
||||
const accountName = ref(null)
|
||||
const langSelect = ref(settingStore.lang)
|
||||
const passkey = reactive({registered: false, createTime: null})
|
||||
const passkeyLoading = ref(false)
|
||||
const passkeyPwdShow = ref(false)
|
||||
const passkeyPassword = ref('')
|
||||
const passkeyAction = ref('register')
|
||||
|
||||
defineOptions({
|
||||
name: 'setting'
|
||||
})
|
||||
|
||||
if (settingStore.settings.passkey === 1) {
|
||||
loadPasskeyStatus()
|
||||
}
|
||||
|
||||
async function loadPasskeyStatus() {
|
||||
try {
|
||||
const status = await passkeyStatus()
|
||||
passkey.registered = status.registered
|
||||
passkey.createTime = status.createTime
|
||||
} catch (_) {
|
||||
// The shared HTTP interceptor already displays the server error.
|
||||
}
|
||||
}
|
||||
|
||||
function openPasskeyAction(action) {
|
||||
if (action === 'register' && !browserSupportsWebAuthn()) {
|
||||
ElMessage({
|
||||
message: t('passkeyUnsupported'),
|
||||
type: 'error',
|
||||
plain: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
passkeyAction.value = action
|
||||
passkeyPassword.value = ''
|
||||
passkeyPwdShow.value = true
|
||||
}
|
||||
|
||||
async function submitPasskeyAction() {
|
||||
if (!passkeyPassword.value) {
|
||||
ElMessage({
|
||||
message: t('emptyPwdMsg'),
|
||||
type: 'error',
|
||||
plain: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
if (passkeyAction.value === 'delete') {
|
||||
await passkeyDelete(passkeyPassword.value)
|
||||
passkey.registered = false
|
||||
passkey.createTime = null
|
||||
ElMessage({message: t('passkeyDeleteSuccess'), type: 'success', plain: true})
|
||||
} else {
|
||||
const {transactionId, options} = await passkeyRegOptions(passkeyPassword.value)
|
||||
const response = await startRegistration({optionsJSON: options})
|
||||
const status = await passkeyRegVerify(transactionId, response)
|
||||
passkey.registered = status.registered
|
||||
passkey.createTime = status.createTime
|
||||
ElMessage({message: t('passkeyCreateSuccess'), type: 'success', plain: true})
|
||||
}
|
||||
passkeyPwdShow.value = false
|
||||
passkeyPassword.value = ''
|
||||
} catch (error) {
|
||||
if (!error?.code) {
|
||||
ElMessage({message: t('passkeyLoginFailed'), type: 'error', plain: true})
|
||||
}
|
||||
} finally {
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showSetName() {
|
||||
accountName.value = userStore.user.name
|
||||
setNameShow.value = true
|
||||
@@ -107,6 +226,17 @@ function setName() {
|
||||
})
|
||||
}
|
||||
|
||||
function changeLang(lang) {
|
||||
let setting = {}
|
||||
try {
|
||||
setting = JSON.parse(localStorage.getItem('setting') || '{}')
|
||||
} catch (e) {
|
||||
setting = {}
|
||||
}
|
||||
localStorage.setItem('setting', JSON.stringify({...setting, lang}))
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const pwdShow = ref(false)
|
||||
const form = reactive({
|
||||
password: '',
|
||||
@@ -249,6 +379,45 @@ function submitPwd() {
|
||||
}
|
||||
}
|
||||
|
||||
.language {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.language-select {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.passkey {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.passkey-status {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.passkey-time {
|
||||
color: var(--regular-text-color);
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.passkey-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
|
||||
.el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.del-email {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
|
||||
@@ -20,10 +20,17 @@
|
||||
<div class="setting-item">
|
||||
<div><span>{{ $t('loginDomain') }}</span></div>
|
||||
<div>
|
||||
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
|
||||
<el-switch @change="change" :before-change="beforeChange" :active-value="1" :inactive-value="0"
|
||||
v-model="setting.loginDomain"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div><span>{{ $t('passkeyLogin') }}</span></div>
|
||||
<div>
|
||||
<el-switch @change="change" :before-change="beforeChange" :active-value="1" :inactive-value="0"
|
||||
v-model="setting.passkey"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div><span>{{ $t('regKey') }}</span></div>
|
||||
<div>
|
||||
@@ -177,8 +184,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div><span>{{ $t('resendToken') }}</span></div>
|
||||
<div>
|
||||
<div><span>{{ setting.hasCfEmail ? $t('cloudflareEmailSending') : $t('resendToken') }}</span></div>
|
||||
<div v-if="setting.hasCfEmail">
|
||||
<span>{{ $t('enabled') }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-button class="opt-button" style="margin-top: 0" @click="openResendList" size="small"
|
||||
type="primary">
|
||||
<Icon icon="ic:round-list" width="18" height="18"/>
|
||||
@@ -189,6 +199,15 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div><span>{{ $t('blackList') }}</span></div>
|
||||
<div>
|
||||
<el-button class="opt-button" style="margin-top: 0" @click="openBlackListForm" size="small"
|
||||
type="primary">
|
||||
<Icon icon="fluent:settings-48-regular" width="16" height="16"/>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -352,6 +371,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="card-title">Workers AI</div>
|
||||
<div class="card-content">
|
||||
<div class="setting-item">
|
||||
<div><span>{{ $t('codeRecognition') }}</span></div>
|
||||
<div>
|
||||
<el-switch @change="changeField('aiCode', $event)" :before-change="beforeChange" :active-value="0" :inactive-value="1"
|
||||
v-model="setting.aiCode"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div><span>{{ $t('codeRecognitionRules') }}</span></div>
|
||||
<div class="forward">
|
||||
<el-button class="opt-button" size="small" type="primary" @click="openAiCodeFilter">
|
||||
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card about">
|
||||
<div class="card-title">{{ $t('about') }}</div>
|
||||
<div class="card-content">
|
||||
@@ -491,7 +531,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="forward-set-body">
|
||||
<el-input :placeholder="$t('tgBotToken')" v-model="tgBotToken"></el-input>
|
||||
<el-input :placeholder="setting.tgBotToken || $t('tgBotToken')" v-model="tgBotToken"></el-input>
|
||||
<el-input-tag tag-type="warning" :placeholder="$t('toBotTokenDesc')" v-model="tgChatId"
|
||||
@add-tag="addChatTag"></el-input-tag>
|
||||
<el-input tag-type="warning" :placeholder="$t('customDomainDesc')" v-model="customDomain" ></el-input>
|
||||
@@ -724,17 +764,55 @@
|
||||
</div>
|
||||
<div class="prefix-filter">
|
||||
<div style="margin-bottom: 10px;">{{ t('mustNotContain') }}</div>
|
||||
<el-input-tag style="margin-bottom: 10px;" v-model="emailPrefixFilter" :placeholder="t('mustNotContainDesc')" />
|
||||
<el-input-tag style="margin-bottom: 10px;" v-model="emailPrefixFilter" />
|
||||
</div>
|
||||
<el-button type="primary" style="width: 100%;" :loading="settingLoading" @click="saveEmailPrefix">{{ $t('save') }}</el-button>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="blackFormShow" class="forward-dialog" @closed="resetBlackList">
|
||||
<template #header>
|
||||
<div class="forward-head">
|
||||
<span class="forward-set-title">{{ $t('blackList') }}</span>
|
||||
<el-tooltip effect="dark" :content="$t('blackListDesc')">
|
||||
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-form>
|
||||
<el-form-item :label="t('blackFromDesc')" label-position="top">
|
||||
<el-input-tag v-model="blackListForm.blackFrom" @add-tag="banEmailAddTag" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('blackSubjectDesc')" label-position="top">
|
||||
<el-input-tag v-model="blackListForm.blackSubject"/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('blackContentDesc')" label-position="top">
|
||||
<el-input-tag v-model="blackListForm.blackContent"/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" style="width: 100%;" :loading="settingLoading" @click="saveBlackList">{{ $t('save') }}</el-button>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="aiCodeFilterShow" class="forward-dialog" @closed="resetAiCodeFilter">
|
||||
<template #header>
|
||||
<div class="forward-head">
|
||||
<span class="forward-set-title">{{ $t('codeRecognitionRules') }}</span>
|
||||
<el-tooltip effect="dark" :content="$t('codeRecognitionRulesDesc')">
|
||||
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-form>
|
||||
<el-form-item :label="t('senderRules')" label-position="top">
|
||||
<el-input-tag v-model="aiCodeFilter" @add-tag="aiCodeFilterAddTag"/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" style="width: 100%;" :loading="settingLoading" @click="saveAiCodeFilter">{{ $t('save') }}</el-button>
|
||||
</el-dialog>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, defineOptions, reactive, ref} from "vue";
|
||||
import {deleteBackground, setBackground, settingQuery, settingSet} from "@/request/setting.js";
|
||||
import {computed, defineOptions, nextTick, reactive, ref} from "vue";
|
||||
import {deleteBackground, setBackground, setBlackList, settingQuery, settingSet} from "@/request/setting.js";
|
||||
import {useSettingStore} from "@/store/setting.js";
|
||||
import {useUiStore} from "@/store/ui.js";
|
||||
import {useUserStore} from "@/store/user.js";
|
||||
@@ -743,7 +821,7 @@ import {Icon} from "@iconify/vue";
|
||||
import {cvtR2Url} from "@/utils/convert.js";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {debounce} from 'lodash-es'
|
||||
import {isEmail} from "@/utils/verify-utils.js";
|
||||
import {isDomain, isEmail} from "@/utils/verify-utils.js";
|
||||
import loading from "@/components/loading/index.vue";
|
||||
import {getTextWidth} from "@/utils/text.js";
|
||||
import {fileToBase64} from "@/utils/file-utils.js"
|
||||
@@ -754,17 +832,20 @@ defineOptions({
|
||||
name: 'sys-setting'
|
||||
})
|
||||
|
||||
const currentVersion = 'v2.9.0'
|
||||
const currentVersion = 'v3.0.0'
|
||||
const hasUpdate = ref(false)
|
||||
let getUpdateErrorCount = 1;
|
||||
const {t, locale} = useI18n();
|
||||
const firstLoading = ref(true)
|
||||
const settingReady = ref(false)
|
||||
const backgroundImage = ref('')
|
||||
const localUpShow = ref(false)
|
||||
const accountStore = useAccountStore();
|
||||
const userStore = useUserStore();
|
||||
const editTitleShow = ref(false)
|
||||
const resendTokenFormShow = ref(false)
|
||||
const blackFormShow = ref(false)
|
||||
const aiCodeFilterShow = ref(false)
|
||||
const r2DomainShow = ref(false)
|
||||
const turnstileShow = ref(false)
|
||||
const tgSettingShow = ref(false)
|
||||
@@ -827,6 +908,13 @@ const regKeyOptions = computed(() => [
|
||||
{label: t('optional'), value: 2},
|
||||
])
|
||||
|
||||
const blackListForm = ref({
|
||||
blackSubject: [],
|
||||
blackContent: [],
|
||||
blackFrom: []
|
||||
})
|
||||
const aiCodeFilter = ref([])
|
||||
|
||||
const authRefreshOptions = computed(() => [
|
||||
{label: t('disable'), value: 0},
|
||||
{label: '3s', value: 3},
|
||||
@@ -859,8 +947,10 @@ getSettings()
|
||||
getUpdate()
|
||||
|
||||
function getSettings() {
|
||||
settingReady.value = false
|
||||
settingQuery().then(settingData => {
|
||||
setting.value = settingData
|
||||
settingStore.settings.passkey = settingData.passkey
|
||||
settingStore.domainList = settingData.domainList;
|
||||
resendTokenForm.domain = setting.value.domainList[0]
|
||||
loginOpacity.value = setting.value.loginOpacity
|
||||
@@ -874,6 +964,11 @@ function getSettings() {
|
||||
resetNoticeForm()
|
||||
resetAddS3Form()
|
||||
resetEmailPrefix()
|
||||
resetBlackList()
|
||||
resetAiCodeFilter()
|
||||
nextTick(() => {
|
||||
settingReady.value = true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -968,7 +1063,7 @@ function closedSetBackground() {
|
||||
|
||||
function openTgSetting() {
|
||||
tgBotStatus.value = setting.value.tgBotStatus
|
||||
tgBotToken.value = setting.value.tgBotToken
|
||||
tgBotToken.value = ''
|
||||
customDomain.value = setting.value.customDomain
|
||||
tgMsgFrom.value = setting.value.tgMsgFrom
|
||||
tgMsgText.value = setting.value.tgMsgText
|
||||
@@ -1109,7 +1204,6 @@ function saveS3() {
|
||||
|
||||
function tgBotSave() {
|
||||
const form = {
|
||||
tgBotToken: tgBotToken.value,
|
||||
customDomain: customDomain.value,
|
||||
tgBotStatus: tgBotStatus.value,
|
||||
tgChatId: tgChatId.value + '',
|
||||
@@ -1117,6 +1211,7 @@ function tgBotSave() {
|
||||
tgMsgText: tgMsgText.value,
|
||||
tgMsgTo: tgMsgTo.value
|
||||
}
|
||||
if (tgBotToken.value) form.tgBotToken = tgBotToken.value
|
||||
editSetting(form)
|
||||
}
|
||||
|
||||
@@ -1138,6 +1233,7 @@ function ruleEmailSave() {
|
||||
}
|
||||
|
||||
function doOpacityChange() {
|
||||
if (!settingReady.value) return
|
||||
const form = {}
|
||||
form.loginOpacity = loginOpacity.value
|
||||
editSetting(form, true)
|
||||
@@ -1148,6 +1244,16 @@ function resetEmailPrefix() {
|
||||
emailPrefixFilter.value = setting.value.emailPrefixFilter
|
||||
}
|
||||
|
||||
function resetBlackList() {
|
||||
blackListForm.value.blackFrom = setting.value.blackFrom ? setting.value.blackFrom.split(',') : []
|
||||
blackListForm.value.blackContent = setting.value.blackContent ? setting.value.blackContent.split(',') : []
|
||||
blackListForm.value.blackSubject = setting.value.blackSubject ? setting.value.blackSubject.split(',') : []
|
||||
}
|
||||
|
||||
function resetAiCodeFilter() {
|
||||
aiCodeFilter.value = setting.value.aiCodeFilter ? setting.value.aiCodeFilter.split(',') : []
|
||||
}
|
||||
|
||||
function saveEmailPrefix() {
|
||||
const form = {}
|
||||
form.minEmailPrefix = minEmailPrefix.value
|
||||
@@ -1155,11 +1261,67 @@ function saveEmailPrefix() {
|
||||
editSetting(form, true)
|
||||
}
|
||||
|
||||
function saveAiCodeFilter() {
|
||||
editSetting({aiCodeFilter: aiCodeFilter.value + ''})
|
||||
}
|
||||
|
||||
const opacityChange = debounce(doOpacityChange, 1000, {
|
||||
leading: false,
|
||||
trailing: true
|
||||
})
|
||||
|
||||
function saveBlackList() {
|
||||
|
||||
let form = {
|
||||
blackContent: blackListForm.value.blackContent + '',
|
||||
blackSubject: blackListForm.value.blackSubject + '',
|
||||
blackFrom: blackListForm.value.blackFrom + ''
|
||||
}
|
||||
|
||||
settingLoading.value = true
|
||||
|
||||
setBlackList(form).then(() => {
|
||||
getSettings()
|
||||
ElMessage({
|
||||
message: t('setSuccess'),
|
||||
type: "success",
|
||||
plain: true
|
||||
})
|
||||
blackFormShow.value = false;
|
||||
}).finally(() => {
|
||||
settingLoading.value = false;
|
||||
})
|
||||
}
|
||||
|
||||
function banEmailAddTag(val) {
|
||||
const emails = Array.from(new Set(
|
||||
val.split(/[,,]/).map(item => item.trim()).filter(item => item)
|
||||
));
|
||||
|
||||
blackListForm.value.blackFrom.splice(blackListForm.value.blackFrom.length - 1, 1)
|
||||
|
||||
emails.forEach(email => {
|
||||
if ((isEmail(email) || isDomain(email)) && !blackListForm.value.blackFrom.includes(email)) {
|
||||
blackListForm.value.blackFrom.push(email)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function aiCodeFilterAddTag(val) {
|
||||
const emails = Array.from(new Set(
|
||||
val.split(/[,,]/).map(item => item.trim()).filter(item => item)
|
||||
));
|
||||
|
||||
aiCodeFilter.value.splice(aiCodeFilter.value.length - 1, 1)
|
||||
|
||||
emails.forEach(email => {
|
||||
if ((isEmail(email) || isDomain(email)) && !aiCodeFilter.value.includes(email)) {
|
||||
aiCodeFilter.value.push(email)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function delBackground() {
|
||||
ElMessageBox.confirm(t('delBackgroundConfirm'), {
|
||||
confirmButtonText: t('confirm'),
|
||||
@@ -1245,6 +1407,14 @@ function openResendForm() {
|
||||
resendTokenFormShow.value = true
|
||||
}
|
||||
|
||||
function openBlackListForm() {
|
||||
blackFormShow.value = true
|
||||
}
|
||||
|
||||
function openAiCodeFilter() {
|
||||
aiCodeFilterShow.value = true
|
||||
}
|
||||
|
||||
function saveResendToken() {
|
||||
const settingForm = {
|
||||
resendTokens: {}
|
||||
@@ -1267,21 +1437,29 @@ function cleanResendTokenForm() {
|
||||
}
|
||||
|
||||
function beforeChange() {
|
||||
if (settingLoading.value) return false
|
||||
if (!settingReady.value || settingLoading.value) return false
|
||||
backupSetting()
|
||||
return true
|
||||
}
|
||||
|
||||
function change(e) {
|
||||
if (!settingReady.value) return
|
||||
const settingForm = {...setting.value}
|
||||
delete settingForm.siteKey
|
||||
delete settingForm.secretKey
|
||||
delete settingForm.s3AccessKey
|
||||
delete settingForm.s3SecretKey
|
||||
delete settingForm.tgBotToken
|
||||
delete settingForm.resendTokens
|
||||
editSetting(settingForm, false)
|
||||
}
|
||||
|
||||
function changeField(key, value) {
|
||||
if (!settingReady.value) return
|
||||
setting.value[key] = value
|
||||
editSetting({[key]: value}, false)
|
||||
}
|
||||
|
||||
function saveTitle() {
|
||||
editSetting({title: editTitle.value})
|
||||
}
|
||||
@@ -1322,6 +1500,7 @@ function editSetting(settingForm, refreshStatus = true) {
|
||||
noticePopupShow.value = false
|
||||
addS3Show.value = false
|
||||
emailPrefixShow.value = false
|
||||
aiCodeFilterShow.value = false
|
||||
}).catch((e) => {
|
||||
loginOpacity.value = setting.value.loginOpacity
|
||||
setting.value = {...setting.value, ...JSON.parse(backup)}
|
||||
@@ -1382,7 +1561,7 @@ function editSetting(settingForm, refreshStatus = true) {
|
||||
.card-grid {
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
|
||||
padding: 20px;
|
||||
gap: 20px;
|
||||
@media (max-width: 500px) {
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="showAdd" :title="$t('addUser')">
|
||||
<el-dialog v-model="showAdd" :title="$t('addUser')" @closed="resetAddForm">
|
||||
<div class="container">
|
||||
<el-input v-model="addForm.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
|
||||
<template #append>
|
||||
@@ -743,13 +743,12 @@ function submit() {
|
||||
form.email = form.email + form.suffix
|
||||
userAdd(form).then(() => {
|
||||
addLoading.value = false
|
||||
showAdd.value = false
|
||||
addForm.email = ''
|
||||
ElMessage({
|
||||
message: t('addSuccessMsg'),
|
||||
type: "success",
|
||||
plain: true
|
||||
})
|
||||
resetAddForm()
|
||||
getUserList(false)
|
||||
}).finally(res => {
|
||||
addLoading.value = false
|
||||
|
||||
@@ -11,14 +11,15 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.7.5",
|
||||
"vitest": "~3.0.7",
|
||||
"wrangler": "^4.7.0"
|
||||
"wrangler": "^4.90.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.882.0",
|
||||
"@cloudflare/vite-plugin": "1.6.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"drizzle-orm": "^0.42.0",
|
||||
"hono": "^4.7.5",
|
||||
"hono": "4.12.16",
|
||||
"i18next": "^25.3.2",
|
||||
"linkedom": "^0.18.10",
|
||||
"postal-mime": "^2.4.3",
|
||||
|
||||
Generated
+802
-286
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import app from '../hono/hono';
|
||||
import result from '../model/result';
|
||||
import userContext from '../security/user-context';
|
||||
import passkeyService from '../service/passkey-service';
|
||||
|
||||
app.post('/passkey/auth/options', async (c) => {
|
||||
return c.json(result.ok(await passkeyService.authenticationOptions(c)));
|
||||
});
|
||||
|
||||
app.post('/passkey/auth/verify', async (c) => {
|
||||
const token = await passkeyService.verifyAuthentication(c, await c.req.json());
|
||||
return c.json(result.ok({ token }));
|
||||
});
|
||||
|
||||
app.get('/my/passkey', async (c) => {
|
||||
return c.json(result.ok(await passkeyService.status(c, userContext.getUserId(c))));
|
||||
});
|
||||
|
||||
app.post('/my/passkey/reg/options', async (c) => {
|
||||
const data = await passkeyService.registrationOptions(c, userContext.getUserId(c), await c.req.json());
|
||||
return c.json(result.ok(data));
|
||||
});
|
||||
|
||||
app.post('/my/passkey/reg/verify', async (c) => {
|
||||
const data = await passkeyService.verifyRegistration(c, userContext.getUserId(c), await c.req.json());
|
||||
return c.json(result.ok(data));
|
||||
});
|
||||
|
||||
app.delete('/my/passkey', async (c) => {
|
||||
await passkeyService.delete(c, userContext.getUserId(c), await c.req.json());
|
||||
return c.json(result.ok());
|
||||
});
|
||||
@@ -19,7 +19,7 @@ app.put('/role/set', async (c) => {
|
||||
return c.json(result.ok());
|
||||
});
|
||||
|
||||
app.get('/role/permTree', async (c) => {
|
||||
app.get('/role/tree', async (c) => {
|
||||
const tree = await permService.tree(c);
|
||||
return c.json(result.ok(tree));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import app from '../hono/hono';
|
||||
import result from '../model/result';
|
||||
import settingService from '../service/setting-service';
|
||||
import userContext from "../security/user-context";
|
||||
|
||||
app.put('/setting/set', async (c) => {
|
||||
await settingService.set(c, await c.req.json());
|
||||
@@ -27,3 +28,8 @@ app.delete('/setting/deleteBackground', async (c) => {
|
||||
return c.json(result.ok());
|
||||
});
|
||||
|
||||
app.put('/setting/setBlacklist', async (c) => {
|
||||
const setting = await settingService.setBlacklist(c, await c.req.json());
|
||||
return c.json(result.ok(setting));
|
||||
})
|
||||
|
||||
|
||||
@@ -128,6 +128,10 @@ export const settingConst = {
|
||||
OPEN: 0,
|
||||
CLOSE: 1
|
||||
},
|
||||
aiCode: {
|
||||
OPEN: 0,
|
||||
CLOSE: 1
|
||||
},
|
||||
authRefresh: {
|
||||
OPEN: 1,
|
||||
CLOSE: 0
|
||||
|
||||
@@ -2,6 +2,7 @@ const KvConst = {
|
||||
AUTH_INFO: 'auth-uid:',
|
||||
SETTING: 'setting:',
|
||||
SEND_DAY_COUNT: 'send_day_count:',
|
||||
ANALYSIS_ECHARTS: 'analysis_echarts:',
|
||||
PUBLIC_KEY: "public_key:"
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import emailUtils from '../utils/email-utils';
|
||||
import roleService from '../service/role-service';
|
||||
import userService from '../service/user-service';
|
||||
import telegramService from '../service/telegram-service';
|
||||
import aiService from '../service/ai-service';
|
||||
|
||||
export async function email(message, env, ctx) {
|
||||
|
||||
@@ -24,7 +25,12 @@ export async function email(message, env, ctx) {
|
||||
ruleEmail,
|
||||
ruleType,
|
||||
r2Domain,
|
||||
noRecipient
|
||||
noRecipient,
|
||||
blackSubject,
|
||||
blackContent,
|
||||
blackFrom,
|
||||
aiCode,
|
||||
aiCodeFilter
|
||||
} = await settingService.query({ env });
|
||||
|
||||
if (receive === settingConst.receive.CLOSE) {
|
||||
@@ -32,7 +38,6 @@ export async function email(message, env, ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const reader = message.raw.getReader();
|
||||
let content = '';
|
||||
|
||||
@@ -44,6 +49,14 @@ export async function email(message, env, ctx) {
|
||||
|
||||
const email = await PostalMime.parse(content);
|
||||
|
||||
|
||||
const blockFlag = checkBlock(blackSubject, blackContent, blackFrom, email);
|
||||
|
||||
if (blockFlag) {
|
||||
message.setReject('Message rejected');
|
||||
return;
|
||||
}
|
||||
|
||||
const account = await accountService.selectByEmailIncludeDel({ env: env }, message.to);
|
||||
|
||||
if (!account && noRecipient === settingConst.noRecipient.CLOSE) {
|
||||
@@ -79,6 +92,7 @@ export async function email(message, env, ctx) {
|
||||
}
|
||||
|
||||
const toName = email.to.find(item => item.address === message.to)?.name || '';
|
||||
const code = await aiService.extractCode({ env }, email, { aiCode, aiCodeFilter });
|
||||
|
||||
const params = {
|
||||
toEmail: message.to,
|
||||
@@ -86,6 +100,7 @@ export async function email(message, env, ctx) {
|
||||
sendEmail: email.from.address,
|
||||
name: email.from.name || emailUtils.getName(email.from.address),
|
||||
subject: email.subject,
|
||||
code,
|
||||
content: email.html,
|
||||
text: email.text,
|
||||
cc: email.cc ? JSON.stringify(email.cc) : '[]',
|
||||
@@ -169,3 +184,31 @@ export async function email(message, env, ctx) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function checkBlock(blackSubjectStr, blackContentStr, blackFromStr, email) {
|
||||
|
||||
const blackFromList = blackFromStr ? blackFromStr.split(',') : []
|
||||
const blackContentList = blackContentStr ? blackContentStr.split(',') : []
|
||||
const blackSubjectList = blackSubjectStr ? blackSubjectStr.split(',') : []
|
||||
|
||||
for (const blackSubject of blackSubjectList) {
|
||||
if (email.subject?.includes(blackSubject)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for (const blackContent of blackContentList) {
|
||||
if (email.html?.includes(blackContent) || email.text?.includes(blackContent)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for (const blackFrom of blackFromList) {
|
||||
if (email.from.address === blackFrom || emailUtils.getDomain(email.from.address) === blackFrom) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const email = sqliteTable('email', {
|
||||
accountId: integer('account_id').notNull(),
|
||||
userId: integer('user_id').notNull(),
|
||||
subject: text('subject'),
|
||||
code: text('code').default('').notNull(),
|
||||
text: text('text'),
|
||||
content: text('content'),
|
||||
cc: text('cc').default('[]'),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const passkeyCredential = sqliteTable('passkey_credential', {
|
||||
passkeyId: integer('passkey_id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().unique(),
|
||||
userHandle: text('user_handle').notNull().unique(),
|
||||
credentialId: text('credential_id').notNull().unique(),
|
||||
publicKey: text('public_key').notNull(),
|
||||
counter: integer('counter').default(0).notNull(),
|
||||
transports: text('transports').default('[]').notNull(),
|
||||
createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
});
|
||||
|
||||
export default passkeyCredential;
|
||||
@@ -35,6 +35,7 @@ export const setting = sqliteTable('setting', {
|
||||
notice: integer('notice').default(0).notNull(),
|
||||
noRecipient: integer('no_recipient').default(1).notNull(),
|
||||
loginDomain: integer('login_domain').default(0).notNull(),
|
||||
passkey: integer('passkey').default(0).notNull(),
|
||||
bucket: text('bucket').default('').notNull(),
|
||||
region: text('region').default('').notNull(),
|
||||
endpoint: text('endpoint').default('').notNull(),
|
||||
@@ -46,6 +47,11 @@ export const setting = sqliteTable('setting', {
|
||||
tgMsgTo: text('tg_msg_to').default('show').notNull(),
|
||||
tgMsgText: text('tg_msg_text').default('hide').notNull(),
|
||||
minEmailPrefix: integer('min_email_prefix').default(0).notNull(),
|
||||
emailPrefixFilter: text('email_prefix_filter').default('').notNull()
|
||||
emailPrefixFilter: text('email_prefix_filter').default('').notNull(),
|
||||
blackSubject: text('black_subject').default('').notNull(),
|
||||
blackContent: text('black_content').default('').notNull(),
|
||||
blackFrom: text('black_from').default('').notNull(),
|
||||
aiCode: integer('ai_code').default(1).notNull(),
|
||||
aiCodeFilter: text('ai_code_filter').default('').notNull()
|
||||
});
|
||||
export default setting
|
||||
|
||||
@@ -20,4 +20,5 @@ import '../api/reg-key-api'
|
||||
import '../api/public-api'
|
||||
import '../api/telegram-api'
|
||||
import '../api/oauth-api'
|
||||
import '../api/passkey-api'
|
||||
export default app;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
const en = {
|
||||
passkeyDisabled: 'Passkey sign-in is disabled',
|
||||
passkeyChallengeInvalid: 'The Passkey request expired. Please try again',
|
||||
passkeyVerifyFailed: 'Passkey verification failed',
|
||||
passkeyNotFound: 'No matching Passkey was found',
|
||||
passkeyOriginInvalid: 'Passkeys are unavailable on this site',
|
||||
IncorrectPwd: 'Incorrect password',
|
||||
addAccountDisabled: 'Add Email Address feature is disabled',
|
||||
regDisabled: 'Sign up is disabled',
|
||||
@@ -21,6 +26,7 @@ const en = {
|
||||
totalSendLack: 'Not enough total remaining sends',
|
||||
senderAccountNotExist: 'Sender email does not exist',
|
||||
noResendToken: 'Resend API token not configured',
|
||||
noSendProvider: 'Email sending service is not configured',
|
||||
sendEmailNotCurUser: 'Sender email does not belong to current user',
|
||||
notExistEmailReply: 'Mail does not exist and cannot be replied to',
|
||||
imageAttLimit: 'The maximum number of image attachments is 10',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
const zh = {
|
||||
passkeyDisabled: 'Passkey 登录未启用',
|
||||
passkeyChallengeInvalid: 'Passkey 请求已过期,请重试',
|
||||
passkeyVerifyFailed: 'Passkey 验证失败',
|
||||
passkeyNotFound: '未找到可用的 Passkey',
|
||||
passkeyOriginInvalid: '当前站点无法使用 Passkey',
|
||||
IncorrectPwd: '密码输入错误',
|
||||
addAccountDisabled: '添加邮箱功能已关闭',
|
||||
regDisabled: '注册功能已关闭',
|
||||
@@ -21,6 +26,7 @@ const zh = {
|
||||
totalSendLack: '剩余发送次数不足',
|
||||
senderAccountNotExist: '发件人邮箱不存在',
|
||||
noResendToken: 'Resend未配置,只能给站内邮箱发件',
|
||||
noSendProvider: '发信服务未配置,只能给站内邮箱发件',
|
||||
sendEmailNotCurUser: '发件人邮箱非当前用户所有',
|
||||
notExistEmailReply: '邮件不存在无法回复',
|
||||
imageAttLimit: '图片不能超过10个',
|
||||
|
||||
@@ -5,6 +5,7 @@ import verifyRecordService from './service/verify-record-service';
|
||||
import emailService from './service/email-service';
|
||||
import kvObjService from './service/kv-obj-service';
|
||||
import oauthService from "./service/oauth-service";
|
||||
import analysisService from './service/analysis-service';
|
||||
export default {
|
||||
async fetch(req, env, ctx) {
|
||||
|
||||
@@ -24,9 +25,15 @@ export default {
|
||||
},
|
||||
email: email,
|
||||
async scheduled(c, env, ctx) {
|
||||
if (c.cron === '*/30 * * * *') {
|
||||
await analysisService.refreshEchartsCache({ env })
|
||||
return;
|
||||
}
|
||||
|
||||
await verifyRecordService.clearRecord({ env })
|
||||
await userService.resetDaySendCount({ env })
|
||||
await emailService.completeReceiveAll({ env })
|
||||
await oauthService.clearNoBindOathUser({ env })
|
||||
await analysisService.refreshEchartsCache({ env })
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,10 +28,71 @@ const dbInit = {
|
||||
await this.v2_7DB(c);
|
||||
await this.v2_8DB(c);
|
||||
await this.v2_9DB(c);
|
||||
await this.v3_0DB(c);
|
||||
await this.v3_1DB(c);
|
||||
await settingService.refresh(c);
|
||||
return c.text('success');
|
||||
},
|
||||
|
||||
async v3_1DB(c) {
|
||||
try {
|
||||
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN passkey INTEGER NOT NULL DEFAULT 0;`).run();
|
||||
} catch (e) {
|
||||
console.warn(`跳过字段:${e.message}`);
|
||||
}
|
||||
|
||||
await c.env.db.batch([
|
||||
c.env.db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS passkey_credential (
|
||||
passkey_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE,
|
||||
user_handle TEXT NOT NULL UNIQUE,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT NOT NULL DEFAULT '[]',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`),
|
||||
c.env.db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS passkey_challenge (
|
||||
challenge_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
challenge TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
user_handle TEXT NOT NULL DEFAULT '',
|
||||
origin TEXT NOT NULL,
|
||||
rp_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
`),
|
||||
c.env.db.prepare(`CREATE INDEX IF NOT EXISTS idx_passkey_challenge_expires ON passkey_challenge(expires_at)`),
|
||||
]);
|
||||
},
|
||||
|
||||
async v3_0DB(c) {
|
||||
try {
|
||||
await c.env.db.batch([
|
||||
await c.env.db.prepare(`ALTER TABLE email ADD COLUMN code TEXT NOT NULL DEFAULT '';`),
|
||||
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN ai_code INTEGER NOT NULL DEFAULT 1;`),
|
||||
await c.env.db.prepare(`ALTER TABLE setting ADD COLUMN ai_code_filter TEXT NOT NULL DEFAULT '';`)
|
||||
]);
|
||||
} catch (e) {
|
||||
console.warn(`跳过字段:${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await c.env.db.batch([
|
||||
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN black_subject TEXT NOT NULL DEFAULT '';`),
|
||||
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN black_content TEXT NOT NULL DEFAULT '';`),
|
||||
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN black_from TEXT NOT NULL DEFAULT '';`)
|
||||
]);
|
||||
} catch (e) {
|
||||
console.warn(`跳过字段:${e.message}`);
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
async v2_9DB(c) {
|
||||
try {
|
||||
await c.env.db.prepare(`UPDATE setting SET auto_refresh = 5 WHERE auto_refresh = 1;`).run();
|
||||
|
||||
@@ -18,7 +18,8 @@ const exclude = [
|
||||
'/public/genToken',
|
||||
'/telegram',
|
||||
'/test',
|
||||
'/oauth'
|
||||
'/oauth',
|
||||
'/passkey/auth/'
|
||||
];
|
||||
|
||||
const requirePerms = [
|
||||
@@ -43,11 +44,13 @@ const requirePerms = [
|
||||
'/setting/deleteBackground',
|
||||
'/setting/set',
|
||||
'/setting/query',
|
||||
'/setting/setBlacklist',
|
||||
'/user/delete',
|
||||
'/user/setPwd',
|
||||
'/user/setStatus',
|
||||
'/user/setType',
|
||||
'/user/list',
|
||||
'/user/restore',
|
||||
'/user/resetSendCount',
|
||||
'/user/add',
|
||||
'/user/deleteAccount',
|
||||
@@ -74,13 +77,13 @@ const premKey = {
|
||||
'user:add': ['/user/add'],
|
||||
'user:reset-send': ['/user/resetSendCount'],
|
||||
'user:set-pwd': ['/user/setPwd'],
|
||||
'user:set-status': ['/user/setStatus'],
|
||||
'user:set-status': ['/user/setStatus', '/user/restore'],
|
||||
'user:set-type': ['/user/setType'],
|
||||
'user:delete': ['/user/delete','/user/deleteAccount'],
|
||||
'all-email:query': ['/allEmail/list','/allEmail/latest'],
|
||||
'all-email:delete': ['/allEmail/delete','/allEmail/batchDelete'],
|
||||
'setting:query': ['/setting/query'],
|
||||
'setting:set': ['/setting/set', '/setting/setBackground','/setting/deleteBackground'],
|
||||
'setting:set': ['/setting/set', '/setting/setBackground','/setting/deleteBackground','/setting/setBlacklist'],
|
||||
'analysis:query': ['/analysis/echarts'],
|
||||
'reg-key:add': ['/regKey/add'],
|
||||
'reg-key:query': ['/regKey/list','/regKey/history'],
|
||||
|
||||
@@ -12,8 +12,8 @@ const userContext = {
|
||||
|
||||
async getToken(c) {
|
||||
const jwt = c.req.header(constant.TOKEN_HEADER);
|
||||
const { token } = JwtUtils.verifyToken(c,jwt);
|
||||
return token;
|
||||
const result = await JwtUtils.verifyToken(c,jwt);
|
||||
return result?.token;
|
||||
},
|
||||
};
|
||||
export default userContext;
|
||||
|
||||
@@ -259,7 +259,6 @@ const accountService = {
|
||||
|
||||
async setAsTop(c, params, userId) {
|
||||
const { accountId } = params;
|
||||
console.log(accountId);
|
||||
const userRow = await userService.selectById(c, userId);
|
||||
const mainAccountRow = await accountService.selectByEmailIncludeDel(c, userRow.email);
|
||||
let mainSort = mainAccountRow.sort === 0 ? 2 : mainAccountRow.sort + 1;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import emailUtils from '../utils/email-utils';
|
||||
import { settingConst } from '../const/entity-const';
|
||||
|
||||
const aiService = {
|
||||
async extractCode(c, email, options = {}) {
|
||||
if (!this.shouldExtractCode(options.aiCode, options.aiCodeFilter, email)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const ai = c.env.ai;
|
||||
|
||||
try {
|
||||
const subject = email.subject || '';
|
||||
const text = emailUtils.formatText(email.text || '');
|
||||
const htmlText = emailUtils.htmlToText(email.html || '');
|
||||
const body = (htmlText || text).slice(0, 6000);
|
||||
|
||||
if (!subject && !body) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const result = await ai.run(c.env.ai_model || '@cf/meta/llama-3.1-8b-instruct', {
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You extract verification codes from emails. Return only JSON like {"code":"12345678"} or {"code":""}. The code must be 8 characters or fewer and must not contain spaces. If the code is longer than 8 characters or contains spaces, return {"code":""}. Do not explain.'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Subject: ${subject}\n\n${body}`
|
||||
}
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 32
|
||||
});
|
||||
|
||||
const content = typeof result === 'string' ? result : result?.response || '';
|
||||
const json = JSON.parse(content);
|
||||
if (typeof json.code !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (json.code.length > 8 || /\s/.test(json.code)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return json.code;
|
||||
} catch (e) {
|
||||
console.error('验证码提取失败: ', e);
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
shouldExtractCode(aiCode, aiCodeFilterStr, email) {
|
||||
if (aiCode !== settingConst.aiCode.OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filterList = aiCodeFilterStr ? aiCodeFilterStr.split(',').map(item => item.trim().toLowerCase()).filter(Boolean) : [];
|
||||
|
||||
if (filterList.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const fromEmail = (email.from?.address || '').trim().toLowerCase();
|
||||
const fromDomain = emailUtils.getDomain(fromEmail).toLowerCase();
|
||||
|
||||
return filterList.some(item => item === fromEmail || item === fromDomain);
|
||||
}
|
||||
};
|
||||
|
||||
export default aiService;
|
||||
@@ -9,7 +9,38 @@ import { toUtc } from '../utils/date-uitil';
|
||||
const analysisService = {
|
||||
|
||||
async echarts(c, params) {
|
||||
if (!this.analysisCacheEnabled(c)) {
|
||||
return await this.queryEcharts(c, params);
|
||||
}
|
||||
|
||||
const cacheKey = this.echartsCacheKey(params);
|
||||
const cache = await c.env.kv.get(cacheKey, { type: 'json' });
|
||||
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
return await this.refreshEchartsCacheByKey(c, cacheKey);
|
||||
},
|
||||
|
||||
async refreshEchartsCacheByKey(c, cacheKey) {
|
||||
const params = this.echartsParamsByCacheKey(cacheKey);
|
||||
const data = await this.queryEcharts(c, params);
|
||||
await c.env.kv.put(cacheKey, JSON.stringify(data));
|
||||
return data;
|
||||
},
|
||||
|
||||
async refreshEchartsCache(c) {
|
||||
if (!this.analysisCacheEnabled(c)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { keys } = await c.env.kv.list({ prefix: kvConst.ANALYSIS_ECHARTS });
|
||||
|
||||
await Promise.all(keys.map(key => this.refreshEchartsCacheByKey(c, key.name)));
|
||||
},
|
||||
|
||||
async queryEcharts(c, params) {
|
||||
|
||||
const { timeZone } = params;
|
||||
|
||||
@@ -84,6 +115,20 @@ const analysisService = {
|
||||
return {date: day,total}
|
||||
})
|
||||
|
||||
},
|
||||
|
||||
echartsCacheKey(params = {}) {
|
||||
return kvConst.ANALYSIS_ECHARTS + encodeURIComponent(params.timeZone || 'UTC');
|
||||
},
|
||||
|
||||
echartsParamsByCacheKey(cacheKey) {
|
||||
return {
|
||||
timeZone: decodeURIComponent(cacheKey.replace(kvConst.ANALYSIS_ECHARTS, ''))
|
||||
};
|
||||
},
|
||||
|
||||
analysisCacheEnabled(c) {
|
||||
return c.env.analysis_cache === true || c.env.analysis_cache === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,13 +91,10 @@ const attService = {
|
||||
|
||||
if (src.startsWith(domainUtils.toOssDomain(r2Domain))) {
|
||||
attData.key = src.replace(domainUtils.toOssDomain(r2Domain) + '/','');
|
||||
attData.path = src;
|
||||
}
|
||||
|
||||
if (src.startsWith('attachments/')) {
|
||||
const origin = new URL(c.req.url).origin;
|
||||
attData.key = src;
|
||||
attData.path = origin + '/' + src;
|
||||
}
|
||||
|
||||
attData.contentId = cid;
|
||||
@@ -117,22 +114,34 @@ const attService = {
|
||||
}
|
||||
|
||||
//查询已有内嵌url图片信息
|
||||
const keys = [...new Set(imageDataList.filter(item => item.path).map(item => item.key))];
|
||||
const keys = [...new Set(imageDataList.filter(item => !item.content).map(item => item.key))];
|
||||
const dbImageList = await this.selectOneByKeys(c, keys);
|
||||
|
||||
//设置给当前附件
|
||||
imageDataList.forEach(image => {
|
||||
dbImageList.forEach(dbImage => {
|
||||
if (image.path && (image.key === dbImage.key)) {
|
||||
image.size = dbImage.size;
|
||||
image.filename = dbImage.filename;
|
||||
image.mimeType = dbImage.mimeType;
|
||||
image.contentType = dbImage.mimeType;
|
||||
}
|
||||
})
|
||||
})
|
||||
await Promise.all(imageDataList.map(async image => {
|
||||
if (image.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
imageDataList = imageDataList.filter(image => !image.path || image.size);
|
||||
const dbImage = dbImageList.find(dbImage => image.key === dbImage.key);
|
||||
if (!dbImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
image.size = dbImage.size;
|
||||
image.filename = dbImage.filename;
|
||||
image.mimeType = dbImage.mimeType;
|
||||
image.contentType = dbImage.mimeType;
|
||||
|
||||
const obj = await r2Service.getObj(c, image.key);
|
||||
if (!obj) {
|
||||
return;
|
||||
}
|
||||
|
||||
image.content = obj instanceof ArrayBuffer ? obj : await obj.arrayBuffer();
|
||||
}))
|
||||
|
||||
imageDataList = imageDataList.filter(image => image.content);
|
||||
|
||||
return { imageDataList, html: document.toString() };
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import settingService from './setting-service';
|
||||
import accountService from './account-service';
|
||||
import BizError from '../error/biz-error';
|
||||
import emailUtils from '../utils/email-utils';
|
||||
import fileUtils from '../utils/file-utils';
|
||||
import { Resend } from 'resend';
|
||||
import attService from './att-service';
|
||||
import { parseHTML } from 'linkedom';
|
||||
@@ -160,7 +161,7 @@ const emailService = {
|
||||
text, //邮件纯文本
|
||||
content, //邮件内容
|
||||
subject, //邮件标题
|
||||
attachments //附件
|
||||
attachments = [] //附件
|
||||
} = params;
|
||||
|
||||
const { resendTokens, r2Domain, send, domainList } = await settingService.query(c);
|
||||
@@ -230,10 +231,11 @@ const emailService = {
|
||||
|
||||
const domain = emailUtils.getDomain(accountRow.email);
|
||||
const resendToken = resendTokens[domain];
|
||||
const useCloudflareEmail = !!c.env.email;
|
||||
|
||||
//如果接收方存在站外邮箱,又没有resend token
|
||||
if (!resendToken && !allInternal) {
|
||||
throw new BizError(t('noResendToken'));
|
||||
//如果接收方存在站外邮箱,又没有发信服务
|
||||
if (!useCloudflareEmail && !resendToken && !allInternal) {
|
||||
throw new BizError(t('noSendProvider'));
|
||||
}
|
||||
|
||||
//没有发件人名字自动截取
|
||||
@@ -256,34 +258,40 @@ const emailService = {
|
||||
|
||||
}
|
||||
|
||||
let resendResult = {};
|
||||
let sendResult = {};
|
||||
|
||||
//存在站外时邮箱全部由resend发送
|
||||
//存在站外邮箱时,如果配置了 Cloudflare Email Service 就优先使用,否则使用 Resend
|
||||
if (!allInternal) {
|
||||
|
||||
const resend = new Resend(resendToken);
|
||||
|
||||
const sendForm = {
|
||||
from: `${name} <${accountRow.email}>`,
|
||||
to: [...receiveEmail],
|
||||
subject: subject,
|
||||
text: text,
|
||||
html: html,
|
||||
attachments: [...imageDataList, ...attachments]
|
||||
};
|
||||
|
||||
if (sendType === 'reply') {
|
||||
sendForm.headers = {
|
||||
'in-reply-to': emailRow.messageId,
|
||||
'references': emailRow.messageId
|
||||
};
|
||||
if (useCloudflareEmail) {
|
||||
sendResult = await this.sendByCloudflareEmail(c, {
|
||||
name,
|
||||
accountEmail: accountRow.email,
|
||||
receiveEmail,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
attachments: [...imageDataList, ...attachments],
|
||||
sendType,
|
||||
messageId: emailRow.messageId
|
||||
});
|
||||
} else {
|
||||
sendResult = await this.sendByResend(resendToken, {
|
||||
name,
|
||||
accountEmail: accountRow.email,
|
||||
receiveEmail,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
attachments: [...imageDataList, ...attachments],
|
||||
sendType,
|
||||
messageId: emailRow.messageId
|
||||
});
|
||||
}
|
||||
|
||||
resendResult = await resend.emails.send(sendForm);
|
||||
|
||||
}
|
||||
|
||||
const { data, error } = resendResult;
|
||||
const { data, error } = sendResult;
|
||||
|
||||
|
||||
if (error) {
|
||||
@@ -303,7 +311,7 @@ const emailService = {
|
||||
emailData.content = html;
|
||||
emailData.text = text;
|
||||
emailData.accountId = accountId;
|
||||
emailData.status = emailConst.status.SENT;
|
||||
emailData.status = useCloudflareEmail ? emailConst.status.DELIVERED : emailConst.status.SENT;
|
||||
emailData.type = emailConst.type.SEND;
|
||||
emailData.userId = userId;
|
||||
emailData.resendEmailId = data?.id;
|
||||
@@ -367,6 +375,171 @@ const emailService = {
|
||||
return [ emailResult ];
|
||||
},
|
||||
|
||||
async sendByCloudflareEmail(c, params) {
|
||||
const sendForm = {
|
||||
from: { email: params.accountEmail, name: params.name },
|
||||
to: [...params.receiveEmail],
|
||||
subject: params.subject
|
||||
};
|
||||
|
||||
if (params.text) {
|
||||
sendForm.text = params.text;
|
||||
}
|
||||
|
||||
if (params.html) {
|
||||
sendForm.html = params.html;
|
||||
}
|
||||
|
||||
const attachments = await this.toCloudflareAttachments(params.attachments);
|
||||
if (attachments.length > 0) {
|
||||
sendForm.attachments = attachments;
|
||||
}
|
||||
|
||||
if (params.sendType === 'reply' && params.messageId) {
|
||||
sendForm.headers = {
|
||||
'in-reply-to': params.messageId,
|
||||
'references': params.messageId
|
||||
};
|
||||
}
|
||||
|
||||
const result = await c.env.email.send(sendForm);
|
||||
|
||||
return {
|
||||
data: {
|
||||
id: result.messageId
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async sendByResend(resendToken, params) {
|
||||
const resend = new Resend(resendToken);
|
||||
|
||||
const sendForm = {
|
||||
from: `${params.name} <${params.accountEmail}>`,
|
||||
to: [...params.receiveEmail],
|
||||
subject: params.subject,
|
||||
text: params.text,
|
||||
html: params.html,
|
||||
attachments: await this.toResendAttachments(params.attachments)
|
||||
};
|
||||
|
||||
if (params.sendType === 'reply') {
|
||||
sendForm.headers = {
|
||||
'in-reply-to': params.messageId,
|
||||
'references': params.messageId
|
||||
};
|
||||
}
|
||||
|
||||
return await resend.emails.send(sendForm);
|
||||
},
|
||||
|
||||
async toCloudflareAttachments(attachments) {
|
||||
const arrayBufferAttachments = await this.toArrayBufferAttachments(attachments);
|
||||
|
||||
return arrayBufferAttachments.map(attachment => {
|
||||
const item = {
|
||||
content: attachment.content,
|
||||
filename: attachment.filename,
|
||||
type: attachment.mimeType || attachment.contentType || attachment.type || 'application/octet-stream',
|
||||
disposition: attachment.contentId ? 'inline' : 'attachment'
|
||||
};
|
||||
|
||||
if (attachment.contentId) {
|
||||
item.contentId = attachment.contentId.replace(/^<|>$/g, '');
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
},
|
||||
|
||||
async toResendAttachments(attachments = []) {
|
||||
const result = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const content = await this.toAttachmentBase64(attachment);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({
|
||||
...attachment,
|
||||
content,
|
||||
contentType: attachment.contentType || attachment.mimeType || attachment.type || 'application/octet-stream'
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async toArrayBufferAttachments(attachments = []) {
|
||||
const result = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const content = await this.toAttachmentArrayBuffer(attachment);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({ ...attachment, content });
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async toAttachmentBase64(attachment) {
|
||||
let content = attachment.content;
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof content === 'string') {
|
||||
if (content.startsWith('data:')) {
|
||||
content = content.split(',')[1] || content;
|
||||
}
|
||||
return content.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
const arrayBuffer = await this.toAttachmentArrayBuffer(attachment);
|
||||
if (!arrayBuffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
let binary = '';
|
||||
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
},
|
||||
|
||||
async toAttachmentArrayBuffer(attachment) {
|
||||
let content = attachment.content;
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (content instanceof ArrayBuffer) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (content instanceof Uint8Array) {
|
||||
return content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength);
|
||||
}
|
||||
|
||||
if (typeof content === 'string') {
|
||||
if (content.startsWith('data:')) {
|
||||
content = content.split(',')[1] || content;
|
||||
}
|
||||
return fileUtils.base64ToUint8Array(content.replace(/\s+/g, '')).buffer;
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
|
||||
//处理站内邮件发送
|
||||
async HandleOnSiteEmail(c, receiveEmail, sendEmailData, attList) {
|
||||
|
||||
@@ -684,9 +857,9 @@ const emailService = {
|
||||
query.orderBy(desc(email.emailId));
|
||||
}
|
||||
|
||||
const listQuery = await query.limit(size).all();
|
||||
const totalQuery = await queryCount.get();
|
||||
const latestEmailQuery = await orm(c).select().from(email)
|
||||
const listQuery = query.limit(size).all();
|
||||
const totalQuery = queryCount.get();
|
||||
const latestEmailQuery = orm(c).select().from(email)
|
||||
.where(and(
|
||||
eq(email.type, emailConst.type.RECEIVE),
|
||||
ne(email.status, emailConst.status.SAVING)
|
||||
|
||||
@@ -17,9 +17,11 @@ const kvObjService = {
|
||||
await Promise.all(keys.map( key => c.env.kv.delete(key)));
|
||||
},
|
||||
|
||||
async toObjResp(c, key) {
|
||||
|
||||
async getObj(c, key) {
|
||||
const obj = await c.env.kv.getWithMetadata(key, { type: "arrayBuffer"});
|
||||
if (!obj.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Response(obj.value, {
|
||||
headers: {
|
||||
@@ -28,6 +30,11 @@ const kvObjService = {
|
||||
'Cache-Control': obj.metadata?.cacheControl || null
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async toObjResp(c, key) {
|
||||
|
||||
return await this.getObj(c, key);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,18 @@ const loginService = {
|
||||
throw new BizError(t('notExistUser'));
|
||||
}
|
||||
|
||||
if (!await cryptoUtils.verifyPassword(password, userRow.salt, userRow.password) && !noVerifyPwd) {
|
||||
throw new BizError(t('IncorrectPwd'));
|
||||
}
|
||||
|
||||
return this.loginUser(c, userRow);
|
||||
},
|
||||
|
||||
async loginUser(c, userRow) {
|
||||
if (!userRow) {
|
||||
throw new BizError(t('notExistUser'));
|
||||
}
|
||||
|
||||
if(userRow.isDel === isDel.DELETE) {
|
||||
throw new BizError(t('isDelUser'));
|
||||
}
|
||||
@@ -221,10 +233,6 @@ const loginService = {
|
||||
throw new BizError(t('isBanUser'));
|
||||
}
|
||||
|
||||
if (!await cryptoUtils.verifyPassword(password, userRow.salt, userRow.password) && !noVerifyPwd) {
|
||||
throw new BizError(t('IncorrectPwd'));
|
||||
}
|
||||
|
||||
const uuid = uuidv4();
|
||||
const jwt = await JwtUtils.generateToken(c,{ userId: userRow.userId, token: uuid });
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ const oauthService = {
|
||||
|
||||
userInfo.oauthUserId = String(userInfo.id);
|
||||
userInfo.active = userInfo.active ? 0 : 1;
|
||||
userInfo.silenced = userInfo.active ? 0 : 1;
|
||||
userInfo.silenced = userInfo.silenced ? 0 : 1;
|
||||
userInfo.trustLevel = userInfo.trust_level;
|
||||
userInfo.avatar = userInfo.avatar_url;
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import BizError from '../error/biz-error';
|
||||
import orm from '../entity/orm';
|
||||
import passkeyCredential from '../entity/passkey-credential';
|
||||
import cryptoUtils from '../utils/crypto-utils';
|
||||
import settingService from './setting-service';
|
||||
import userService from './user-service';
|
||||
import loginService from './login-service';
|
||||
import { t } from '../i18n/i18n.js';
|
||||
|
||||
const CHALLENGE_TTL = 5 * 60 * 1000;
|
||||
const PASSKEY_ENABLED = 1;
|
||||
|
||||
const passkeyService = {
|
||||
async status(c, userId) {
|
||||
const credential = await this.findByUserId(c, userId);
|
||||
return {
|
||||
registered: !!credential,
|
||||
createTime: credential?.createTime || null,
|
||||
};
|
||||
},
|
||||
|
||||
async registrationOptions(c, userId, params) {
|
||||
await this.ensureEnabled(c);
|
||||
const user = await this.verifyCurrentPassword(c, userId, params?.password);
|
||||
const { origin, rpID } = this.getRelyingParty(c);
|
||||
const userHandleBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(userHandleBytes);
|
||||
const userHandle = isoBase64URL.fromBuffer(userHandleBytes);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: (await settingService.query(c)).title || 'Cloud Mail',
|
||||
rpID,
|
||||
userID: userHandleBytes,
|
||||
userName: user.email,
|
||||
userDisplayName: user.email,
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'required',
|
||||
},
|
||||
});
|
||||
const transactionId = await this.saveChallenge(c, {
|
||||
type: 'registration',
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
userHandle,
|
||||
origin,
|
||||
rpID,
|
||||
});
|
||||
return { transactionId, options };
|
||||
},
|
||||
|
||||
async verifyRegistration(c, userId, params) {
|
||||
await this.ensureEnabled(c);
|
||||
const challenge = await this.consumeChallenge(c, params?.transactionId, 'registration');
|
||||
if (!challenge || challenge.user_id !== userId) {
|
||||
throw new BizError(t('passkeyChallengeInvalid'));
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: params?.response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rp_id,
|
||||
requireUserVerification: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(JSON.stringify({
|
||||
message: 'Passkey registration verification failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
throw new BizError(t('passkeyVerifyFailed'));
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw new BizError(t('passkeyVerifyFailed'));
|
||||
}
|
||||
|
||||
const credential = verification.registrationInfo.credential;
|
||||
const values = {
|
||||
userId,
|
||||
userHandle: challenge.user_handle,
|
||||
credentialId: credential.id,
|
||||
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: JSON.stringify(credential.transports || params?.response?.response?.transports || []),
|
||||
createTime: new Date().toISOString(),
|
||||
};
|
||||
await orm(c).insert(passkeyCredential).values(values).onConflictDoUpdate({
|
||||
target: passkeyCredential.userId,
|
||||
set: values,
|
||||
}).run();
|
||||
return this.status(c, userId);
|
||||
},
|
||||
|
||||
async authenticationOptions(c) {
|
||||
await this.ensureEnabled(c);
|
||||
const { origin, rpID } = this.getRelyingParty(c);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
userVerification: 'required',
|
||||
});
|
||||
const transactionId = await this.saveChallenge(c, {
|
||||
type: 'authentication',
|
||||
challenge: options.challenge,
|
||||
origin,
|
||||
rpID,
|
||||
});
|
||||
return { transactionId, options };
|
||||
},
|
||||
|
||||
async verifyAuthentication(c, params) {
|
||||
await this.ensureEnabled(c);
|
||||
const challenge = await this.consumeChallenge(c, params?.transactionId, 'authentication');
|
||||
if (!challenge) {
|
||||
throw new BizError(t('passkeyChallengeInvalid'));
|
||||
}
|
||||
const credential = await this.findByCredentialId(c, params?.response?.id);
|
||||
if (!credential || params?.response?.response?.userHandle !== credential.userHandle) {
|
||||
throw new BizError(t('passkeyNotFound'));
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: params.response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rp_id,
|
||||
requireUserVerification: true,
|
||||
credential: {
|
||||
id: credential.credentialId,
|
||||
publicKey: isoBase64URL.toBuffer(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: this.parseTransports(credential.transports),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(JSON.stringify({
|
||||
message: 'Passkey authentication verification failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
throw new BizError(t('passkeyVerifyFailed'));
|
||||
}
|
||||
if (!verification.verified) {
|
||||
throw new BizError(t('passkeyVerifyFailed'));
|
||||
}
|
||||
|
||||
await orm(c).update(passkeyCredential).set({
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
}).where(eq(passkeyCredential.passkeyId, credential.passkeyId)).run();
|
||||
const user = await userService.selectByIdIncludeDel(c, credential.userId);
|
||||
return loginService.loginUser(c, user);
|
||||
},
|
||||
|
||||
async delete(c, userId, params) {
|
||||
await this.verifyCurrentPassword(c, userId, params?.password);
|
||||
await orm(c).delete(passkeyCredential).where(eq(passkeyCredential.userId, userId)).run();
|
||||
},
|
||||
|
||||
async ensureEnabled(c) {
|
||||
const { passkey } = await settingService.query(c);
|
||||
if (passkey !== PASSKEY_ENABLED) {
|
||||
throw new BizError(t('passkeyDisabled'));
|
||||
}
|
||||
},
|
||||
|
||||
async verifyCurrentPassword(c, userId, password) {
|
||||
if (!password) {
|
||||
throw new BizError(t('emailAndPwdEmpty'));
|
||||
}
|
||||
const user = await userService.selectByIdIncludeDel(c, userId);
|
||||
if (!user || !await cryptoUtils.verifyPassword(password, user.salt, user.password)) {
|
||||
throw new BizError(t('IncorrectPwd'));
|
||||
}
|
||||
return user;
|
||||
},
|
||||
|
||||
async findByUserId(c, userId) {
|
||||
return orm(c).select().from(passkeyCredential).where(eq(passkeyCredential.userId, userId)).get();
|
||||
},
|
||||
|
||||
async findByCredentialId(c, credentialId) {
|
||||
if (!credentialId) return null;
|
||||
return orm(c).select().from(passkeyCredential).where(eq(passkeyCredential.credentialId, credentialId)).get();
|
||||
},
|
||||
|
||||
parseTransports(transports) {
|
||||
try {
|
||||
return JSON.parse(transports || '[]');
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getRelyingParty(c) {
|
||||
try {
|
||||
const requestUrl = new URL(c.req.url);
|
||||
const origin = new URL(c.req.header('Origin') || requestUrl.origin);
|
||||
const loopbackHosts = ['localhost', '127.0.0.1', '[::1]'];
|
||||
const originIsLoopback = loopbackHosts.includes(origin.hostname);
|
||||
const requestIsLoopback = loopbackHosts.includes(requestUrl.hostname);
|
||||
const secureOrigin = origin.protocol === 'https:'
|
||||
|| (origin.protocol === 'http:' && originIsLoopback);
|
||||
const trustedOrigin = origin.origin === requestUrl.origin
|
||||
|| (originIsLoopback && requestIsLoopback);
|
||||
if (secureOrigin && trustedOrigin) {
|
||||
return { origin: origin.origin, rpID: origin.hostname };
|
||||
}
|
||||
} catch (_) {
|
||||
throw new BizError(t('passkeyOriginInvalid'));
|
||||
}
|
||||
throw new BizError(t('passkeyOriginInvalid'));
|
||||
},
|
||||
|
||||
async saveChallenge(c, challenge) {
|
||||
const transactionId = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
await c.env.db.batch([
|
||||
c.env.db.prepare('DELETE FROM passkey_challenge WHERE expires_at <= ?').bind(now),
|
||||
c.env.db.prepare(`
|
||||
INSERT INTO passkey_challenge
|
||||
(challenge_id, type, challenge, user_id, user_handle, origin, rp_id, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).bind(
|
||||
transactionId,
|
||||
challenge.type,
|
||||
challenge.challenge,
|
||||
challenge.userId || 0,
|
||||
challenge.userHandle || '',
|
||||
challenge.origin,
|
||||
challenge.rpID,
|
||||
now + CHALLENGE_TTL,
|
||||
),
|
||||
]);
|
||||
return transactionId;
|
||||
},
|
||||
|
||||
async consumeChallenge(c, transactionId, type) {
|
||||
if (!transactionId) return null;
|
||||
return c.env.db.prepare(`
|
||||
DELETE FROM passkey_challenge
|
||||
WHERE challenge_id = ? AND type = ? AND expires_at > ?
|
||||
RETURNING *
|
||||
`).bind(transactionId, type, Date.now()).first();
|
||||
},
|
||||
};
|
||||
|
||||
export default passkeyService;
|
||||
@@ -41,7 +41,19 @@ const r2Service = {
|
||||
},
|
||||
|
||||
async getObj(c, key) {
|
||||
return await c.env.r2.get(key);
|
||||
const storageType = await this.storageType(c);
|
||||
|
||||
if (storageType === 'KV') {
|
||||
return await kvObjService.getObj(c, key);
|
||||
}
|
||||
|
||||
if (storageType === 'R2') {
|
||||
return await c.env.r2.get(key);
|
||||
}
|
||||
|
||||
if (storageType === 'S3') {
|
||||
return await s3Service.getObj(c, key);
|
||||
}
|
||||
},
|
||||
|
||||
async delete(c, key) {
|
||||
|
||||
@@ -165,7 +165,6 @@ const roleService = {
|
||||
const availIndex = availDomain.findIndex(item => {
|
||||
const domain = emailUtils.getDomain(email.toLowerCase());
|
||||
const availDomainItem = item.toLowerCase();
|
||||
console.log(domain,availDomainItem)
|
||||
return domain === availDomainItem
|
||||
})
|
||||
|
||||
@@ -178,7 +177,7 @@ const roleService = {
|
||||
|
||||
selectByUserIds(c, userIds) {
|
||||
|
||||
if (!userIds && userIds.length === 0) {
|
||||
if (!userIds || userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { S3Client, PutObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
|
||||
import { S3Client, PutObjectCommand, DeleteObjectsCommand, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import settingService from './setting-service';
|
||||
import domainUtils from '../utils/domain-uitls';
|
||||
import { settingConst } from '../const/entity-const';
|
||||
@@ -75,6 +75,23 @@ const s3Service = {
|
||||
);
|
||||
},
|
||||
|
||||
async getObj(c, key) {
|
||||
const client = await this.client(c);
|
||||
const { bucket } = await settingService.query(c);
|
||||
const result = await client.send(new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key
|
||||
}));
|
||||
|
||||
return new Response(result.Body, {
|
||||
headers: {
|
||||
'Content-Type': result.ContentType || 'application/octet-stream',
|
||||
'Content-Disposition': result.ContentDisposition || null,
|
||||
'Cache-Control': result.CacheControl || null
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
async client(c) {
|
||||
const { region, endpoint, s3AccessKey, s3SecretKey, forcePathStyle } = await settingService.query(c);
|
||||
|
||||
@@ -8,6 +8,7 @@ import constant from '../const/constant';
|
||||
import BizError from '../error/biz-error';
|
||||
import {t} from '../i18n/i18n'
|
||||
import verifyRecordService from './verify-record-service';
|
||||
import userContext from '../security/user-context';
|
||||
|
||||
const settingService = {
|
||||
|
||||
@@ -49,6 +50,7 @@ const settingService = {
|
||||
|
||||
|
||||
let linuxdoSwitch = c.env.linuxdo_switch;
|
||||
let projectLink = c.env.project_link;
|
||||
|
||||
if (typeof linuxdoSwitch === 'string' && linuxdoSwitch === 'true') {
|
||||
linuxdoSwitch = true
|
||||
@@ -58,6 +60,16 @@ const settingService = {
|
||||
linuxdoSwitch = false
|
||||
}
|
||||
|
||||
if (typeof projectLink === 'string' && projectLink === 'false') {
|
||||
projectLink = false
|
||||
} else if (projectLink === false) {
|
||||
projectLink = false
|
||||
} else {
|
||||
projectLink = true
|
||||
}
|
||||
|
||||
setting.projectLink = projectLink;
|
||||
|
||||
setting.linuxdoClientId = c.env.linuxdo_client_id;
|
||||
setting.linuxdoCallbackUrl = c.env.linuxdo_callback_url;
|
||||
setting.linuxdoSwitch = linuxdoSwitch;
|
||||
@@ -88,7 +100,9 @@ const settingService = {
|
||||
|
||||
settingRow.s3AccessKey = settingRow.s3AccessKey ? `${settingRow.s3AccessKey.slice(0, 12)}******` : null;
|
||||
settingRow.s3SecretKey = settingRow.s3SecretKey ? `${settingRow.s3SecretKey.slice(0, 12)}******` : null;
|
||||
settingRow.tgBotToken = settingRow.tgBotToken ? `${settingRow.tgBotToken.slice(0, 20)}******` : null;
|
||||
settingRow.hasR2 = !!c.env.r2
|
||||
settingRow.hasCfEmail = !!c.env.email
|
||||
|
||||
let regVerifyOpen = false
|
||||
let addVerifyOpen = false
|
||||
@@ -121,6 +135,10 @@ const settingService = {
|
||||
params.emailPrefixFilter = params.emailPrefixFilter + '';
|
||||
}
|
||||
|
||||
if (Array.isArray(params.aiCodeFilter)) {
|
||||
params.aiCodeFilter = params.aiCodeFilter + '';
|
||||
}
|
||||
|
||||
params.resendTokens = JSON.stringify(resendTokens);
|
||||
await orm(c).update(setting).set({ ...params }).returning().get();
|
||||
await this.refresh(c);
|
||||
@@ -171,9 +189,18 @@ const settingService = {
|
||||
return background;
|
||||
},
|
||||
|
||||
|
||||
async setBlacklist(c, params) {
|
||||
const { blackSubject, blackContent, blackFrom } = params
|
||||
await orm(c).update(setting).set({ blackSubject, blackContent, blackFrom }).run();
|
||||
await this.refresh(c);
|
||||
return this.get(c);
|
||||
},
|
||||
|
||||
async websiteConfig(c) {
|
||||
|
||||
const settingRow = await this.get(c, true);
|
||||
const token = await userContext.getToken(c);
|
||||
|
||||
return {
|
||||
register: settingRow.register,
|
||||
@@ -188,7 +215,7 @@ const settingService = {
|
||||
siteKey: settingRow.siteKey,
|
||||
background: settingRow.background,
|
||||
loginOpacity: settingRow.loginOpacity,
|
||||
domainList: settingRow.domainList,
|
||||
domainList: settingRow.loginDomain === 1 && !token ? [] : settingRow.domainList,
|
||||
regKey: settingRow.regKey,
|
||||
regVerifyOpen: settingRow.regVerifyOpen,
|
||||
addVerifyOpen: settingRow.addVerifyOpen,
|
||||
@@ -201,12 +228,15 @@ const settingService = {
|
||||
noticeOffset: settingRow.noticeOffset,
|
||||
notice: settingRow.notice,
|
||||
loginDomain: settingRow.loginDomain,
|
||||
passkey: settingRow.passkey,
|
||||
linuxdoClientId: settingRow.linuxdoClientId,
|
||||
linuxdoCallbackUrl: settingRow.linuxdoCallbackUrl,
|
||||
linuxdoSwitch: settingRow.linuxdoSwitch,
|
||||
minEmailPrefix: settingRow.minEmailPrefix
|
||||
minEmailPrefix: settingRow.minEmailPrefix,
|
||||
projectLink: settingRow.projectLink
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
export default settingService;
|
||||
|
||||
@@ -15,7 +15,7 @@ const starService = {
|
||||
if (!email) {
|
||||
throw new BizError(t('starNotExistEmail'));
|
||||
}
|
||||
if (!email.userId === userId) {
|
||||
if (email.userId !== userId) {
|
||||
throw new BizError(t('starNotExistEmail'));
|
||||
}
|
||||
const exist = await orm(c).select().from(star).where(
|
||||
|
||||
@@ -52,6 +52,23 @@ const telegramService = {
|
||||
const jwtToken = await jwtUtils.generateToken(c, { emailId: email.emailId })
|
||||
|
||||
const webAppUrl = customDomain ? `${domainUtils.toOssDomain(customDomain)}/api/telegram/getEmail/${jwtToken}` : 'https://www.cloudflare.com/404'
|
||||
const inlineKeyboard = [
|
||||
[
|
||||
{
|
||||
text: 'View',
|
||||
web_app: { url: webAppUrl }
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
if (email.code) {
|
||||
inlineKeyboard.push([
|
||||
{
|
||||
text: email.code,
|
||||
copy_text: { text: email.code }
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
await Promise.all(tgChatIds.map(async chatId => {
|
||||
try {
|
||||
@@ -65,14 +82,7 @@ const telegramService = {
|
||||
parse_mode: 'HTML',
|
||||
text: emailMsgTemplate(email, tgMsgTo, tgMsgFrom, tgMsgText),
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: '查看',
|
||||
web_app: { url: webAppUrl }
|
||||
}
|
||||
]
|
||||
]
|
||||
inline_keyboard: inlineKeyboard
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ const userService = {
|
||||
|
||||
const { password } = params;
|
||||
|
||||
if (password < 6) {
|
||||
if (password.length < 6) {
|
||||
throw new BizError(t('pwdMinLength'));
|
||||
}
|
||||
const { salt, hash } = await cryptoUtils.hashPassword(password);
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function emailHtmlTemplate(html, domain) {
|
||||
document.querySelectorAll('script').forEach(script => script.remove());
|
||||
html = document.toString();
|
||||
html = html.replace(/{{domain}}/g, domainUtils.toOssDomain(domain) + '/');
|
||||
const safeHtmlJson = JSON.stringify(html).replace(/</g, '\\u003C');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang='en' >
|
||||
@@ -127,7 +128,7 @@ export default function emailHtmlTemplate(html, domain) {
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const exampleHtml = \`${html}\`;
|
||||
const exampleHtml = ${safeHtmlJson};
|
||||
|
||||
// 渲染HTML
|
||||
renderHTML(exampleHtml);
|
||||
|
||||
@@ -1,39 +1,62 @@
|
||||
import emailUtils from '../utils/email-utils';
|
||||
|
||||
const TELEGRAM_MESSAGE_LIMIT = 3500;
|
||||
const TRUNCATED_SUFFIX = '...';
|
||||
|
||||
function escapeHtml(text = '') {
|
||||
return text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function truncateText(text, maxLength) {
|
||||
if (!text || text.length <= maxLength) {
|
||||
return text || '';
|
||||
}
|
||||
|
||||
if (maxLength <= TRUNCATED_SUFFIX.length) {
|
||||
return TRUNCATED_SUFFIX.slice(0, maxLength);
|
||||
}
|
||||
|
||||
return text.slice(0, maxLength - TRUNCATED_SUFFIX.length) + TRUNCATED_SUFFIX;
|
||||
}
|
||||
|
||||
export default function emailMsgTemplate(email, tgMsgTo, tgMsgFrom, tgMsgText) {
|
||||
|
||||
let template = `<b>${email.subject}</b>`
|
||||
let template = `<b>${escapeHtml(email.subject || '')}</b>`
|
||||
|
||||
if (tgMsgFrom === 'only-name') {
|
||||
template += `
|
||||
|
||||
From\u200B:${email.name}`
|
||||
From\u200B:${escapeHtml(email.name || '')}`
|
||||
}
|
||||
|
||||
if (tgMsgFrom === 'show') {
|
||||
template += `
|
||||
|
||||
From\u200B:${email.name} <${email.sendEmail}>`
|
||||
From\u200B:${escapeHtml(email.name || '')} <${escapeHtml(email.sendEmail || '')}>`
|
||||
}
|
||||
|
||||
if(tgMsgTo === 'show' && tgMsgFrom === 'hide') {
|
||||
template += `
|
||||
|
||||
To:\u200B${email.toEmail}`
|
||||
To:\u200B${escapeHtml(email.toEmail || '')}`
|
||||
|
||||
} else if(tgMsgTo === 'show') {
|
||||
template += `
|
||||
To:\u200B${email.toEmail}`
|
||||
To:\u200B${escapeHtml(email.toEmail || '')}`
|
||||
}
|
||||
|
||||
const text = (emailUtils.formatText(email.text) || emailUtils.htmlToText(email.content))
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const text = escapeHtml(emailUtils.formatText(email.text) || emailUtils.htmlToText(email.content));
|
||||
|
||||
if(tgMsgText === 'show') {
|
||||
const prefix = `${template}
|
||||
|
||||
`;
|
||||
const maxTextLength = Math.max(0, TELEGRAM_MESSAGE_LIMIT - prefix.length);
|
||||
template += `
|
||||
|
||||
${text}`
|
||||
${truncateText(text, maxTextLength)}`
|
||||
}
|
||||
|
||||
return template;
|
||||
|
||||
@@ -47,6 +47,7 @@ const jwtUtils = {
|
||||
},
|
||||
|
||||
async verifyToken(c, token) {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const [headerB64, payloadB64, signatureB64] = token.split('.');
|
||||
|
||||
|
||||
@@ -1,20 +1,103 @@
|
||||
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import worker from '../src';
|
||||
import { env, SELF } from 'cloudflare:test';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import passkeyService from '../src/service/passkey-service';
|
||||
import settingService from '../src/service/setting-service';
|
||||
|
||||
describe('Hello World worker', () => {
|
||||
it('responds with Hello World! (unit style)', async () => {
|
||||
const request = new Request('http://example.com');
|
||||
// Create an empty context to pass to `worker.fetch()`.
|
||||
const ctx = createExecutionContext();
|
||||
const response = await worker.fetch(request, env, ctx);
|
||||
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
||||
await waitOnExecutionContext(ctx);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
describe('Passkey support', () => {
|
||||
beforeAll(async () => {
|
||||
const response = await SELF.fetch(`http://127.0.0.1/api/init/${env.jwt_secret}`);
|
||||
expect(await response.text()).toBe('success');
|
||||
});
|
||||
|
||||
it('responds with Hello World! (integration style)', async () => {
|
||||
const response = await SELF.fetch('http://example.com');
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
it('creates disabled-by-default Passkey schema', async () => {
|
||||
const settingColumn = await env.db.prepare(`SELECT name FROM pragma_table_info('setting') WHERE name = 'passkey'`).first();
|
||||
const credentialTable = await env.db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'passkey_credential'`).first();
|
||||
const challengeTable = await env.db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'passkey_challenge'`).first();
|
||||
const setting = await env.db.prepare('SELECT passkey FROM setting').first();
|
||||
|
||||
expect(settingColumn?.name).toBe('passkey');
|
||||
expect(credentialTable?.name).toBe('passkey_credential');
|
||||
expect(challengeTable?.name).toBe('passkey_challenge');
|
||||
expect(setting?.passkey).toBe(0);
|
||||
});
|
||||
|
||||
it('exposes the disabled flag and rejects authentication options', async () => {
|
||||
const configResponse = await SELF.fetch('http://127.0.0.1/api/setting/websiteConfig');
|
||||
const config = await configResponse.json();
|
||||
expect(config.data.passkey).toBe(0);
|
||||
|
||||
const optionsResponse = await SELF.fetch('http://127.0.0.1/api/passkey/auth/options', {
|
||||
method: 'POST',
|
||||
headers: { Origin: 'http://127.0.0.1' },
|
||||
});
|
||||
const options = await optionsResponse.json();
|
||||
expect(options.code).toBe(501);
|
||||
});
|
||||
|
||||
it('keeps password login working', async () => {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const registerResponse = await SELF.fetch('http://127.0.0.1/api/register', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ email: '[email protected]', password: 'test-password' }),
|
||||
});
|
||||
expect((await registerResponse.json()).code).toBe(200);
|
||||
|
||||
const loginResponse = await SELF.fetch('http://127.0.0.1/api/login', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ email: '[email protected]', password: 'test-password' }),
|
||||
});
|
||||
const login = await loginResponse.json();
|
||||
expect(login.code).toBe(200);
|
||||
expect(login.data.token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepts only exact production origins or loopback development origins', () => {
|
||||
const context = (url, origin) => ({ req: { url, header: () => origin } });
|
||||
expect(passkeyService.getRelyingParty(context('https://mail.example.com/api', 'https://mail.example.com'))).toEqual({
|
||||
origin: 'https://mail.example.com',
|
||||
rpID: 'mail.example.com',
|
||||
});
|
||||
expect(passkeyService.getRelyingParty(context('http://127.0.0.1:8787/api', 'http://localhost:3001'))).toEqual({
|
||||
origin: 'http://localhost:3001',
|
||||
rpID: 'localhost',
|
||||
});
|
||||
expect(() => passkeyService.getRelyingParty(context('https://mail.example.com/api', 'https://evil.example.com'))).toThrow();
|
||||
expect(() => passkeyService.getRelyingParty(context('https://mail.example.com/api', 'http://mail.example.com'))).toThrow();
|
||||
expect(() => passkeyService.getRelyingParty(context('https://mail.example.com/api', 'https://mail.example.com:8443'))).toThrow();
|
||||
});
|
||||
|
||||
it('consumes each challenge only once', async () => {
|
||||
const context = { env };
|
||||
const transactionId = await passkeyService.saveChallenge(context, {
|
||||
type: 'authentication',
|
||||
challenge: 'test-challenge',
|
||||
origin: 'http://127.0.0.1',
|
||||
rpID: '127.0.0.1',
|
||||
});
|
||||
|
||||
const first = await passkeyService.consumeChallenge(context, transactionId, 'authentication');
|
||||
const second = await passkeyService.consumeChallenge(context, transactionId, 'authentication');
|
||||
expect(first?.challenge).toBe('test-challenge');
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
|
||||
it('generates discoverable authentication options when enabled', async () => {
|
||||
await env.db.prepare('UPDATE setting SET passkey = 1').run();
|
||||
await settingService.refresh({ env, set: () => {} });
|
||||
const response = await SELF.fetch('http://127.0.0.1/api/passkey/auth/options', {
|
||||
method: 'POST',
|
||||
headers: { Origin: 'http://127.0.0.1' },
|
||||
});
|
||||
const body = await response.json();
|
||||
const challenge = await env.db.prepare('SELECT challenge_id FROM passkey_challenge WHERE challenge_id = ?')
|
||||
.bind(body.data.transactionId).first();
|
||||
|
||||
expect(body.code).toBe(200);
|
||||
expect(body.data.options.rpId).toBe('127.0.0.1');
|
||||
expect(body.data.options.userVerification).toBe('required');
|
||||
expect(challenge?.challenge_id).toBe(body.data.transactionId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ export default defineWorkersConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: './wrangler.jsonc' },
|
||||
wrangler: { configPath: './wrangler-dev.toml' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name = "${NAME}"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2025-06-04"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
@@ -11,32 +12,38 @@ custom_domain = true
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "db"
|
||||
database_name = "cloud-mail" # 数据库的名称
|
||||
database_id = "${D1_DATABASE_ID}" # 使用占位符引用环境变量
|
||||
database_name = "cloud-mail"
|
||||
database_id = "${D1_DATABASE_ID}"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "kv"
|
||||
id = "${KV_NAMESPACE_ID}" # 使用占位符引用环境变量
|
||||
id = "${KV_NAMESPACE_ID}"
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "r2"
|
||||
bucket_name = "${R2_BUCKET_NAME}" # 使用占位符引用环境变量
|
||||
bucket_name = "${R2_BUCKET_NAME}"
|
||||
|
||||
[ai]
|
||||
binding = "ai"
|
||||
|
||||
[assets]
|
||||
binding = "assets" #静态资源绑定名默认不可修改
|
||||
directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist
|
||||
binding = "assets"
|
||||
directory = "./dist"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
[triggers]
|
||||
crons = ["0 16 * * *"] #定时任务每天晚上12点执行
|
||||
crons = ["0 16 * * *"]
|
||||
|
||||
|
||||
[vars]
|
||||
ai_model = "${AI_MODEL}"
|
||||
analysis_cache = "${ANALYSIS_CACHE}"
|
||||
#orm_log = false
|
||||
domain = "${DOMAIN}" #邮件域名可可配置多个 示例: ["example1.com","example2.com"]
|
||||
admin = "${ADMIN}" #管理员的邮箱 示例: [email protected]
|
||||
jwt_secret = "${JWT_SECRET}" #jwt令牌的密钥,随便填一串字符串
|
||||
domain = "${DOMAIN}"
|
||||
admin = "${ADMIN}"
|
||||
jwt_secret = "${JWT_SECRET}"
|
||||
project_link = "${PROJECT_LINK}"
|
||||
|
||||
linuxdo_client_id = "${LINUXDO_CLIENT_ID}"
|
||||
linuxdo_client_secret = "${LINUXDO_CLIENT_SECRET}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name = "cloud-mail-dev"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2025-06-04"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
keep_vars = true
|
||||
|
||||
[observability]
|
||||
@@ -22,14 +23,22 @@ id = "2io01d4b299e481b9de060ece9e7785c"
|
||||
#binding = "r2"
|
||||
#bucket_name = "email"
|
||||
|
||||
#[ai]
|
||||
#binding = "ai"
|
||||
|
||||
[assets]
|
||||
binding = "assets"
|
||||
directory = "./dist"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
#[[send_email]]
|
||||
#name = "email"
|
||||
|
||||
|
||||
[vars]
|
||||
ai_model = ""
|
||||
analysis_cache = false
|
||||
orm_log = false
|
||||
domain = ["example.com", "example2.com", "example3.com", "example4.com"]
|
||||
admin = "[email protected]"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name = "test-mail"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2025-06-04"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
keep_vars = true
|
||||
|
||||
[observability]
|
||||
@@ -20,6 +21,9 @@ id = "9be10cd058e04c55b526f8c0c116f50c" #kv数据库id
|
||||
#binding = "r2" #r2对象存储绑定名默认不可修改
|
||||
#bucket_name = "test-email" #r2对象存储桶的名字
|
||||
|
||||
[ai]
|
||||
binding = "ai"
|
||||
|
||||
[assets]
|
||||
binding = "assets" #静态资源绑定名默认不可修改
|
||||
directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist
|
||||
@@ -27,11 +31,13 @@ not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
[triggers]
|
||||
crons = ["0 16 * * *"] #定时任务每天晚上12点执行
|
||||
crons = ["0 16 * * *"] #每天晚上12点执行每日任务,刷新分析缓存
|
||||
|
||||
|
||||
[vars]
|
||||
orm_log = false
|
||||
ai_model = "" #ai模型,不填默认使用@cf/meta/llama-3.1-8b-instruct
|
||||
analysis_cache = false #是否开启分析数据缓存
|
||||
orm_log = false #是否sql日志
|
||||
domain = ["example.com"] #邮件域名可可配置多个 示例: ["example1.com","example2.com"]
|
||||
admin = "[email protected]" #管理员的邮箱 示例: [email protected]
|
||||
jwt_secret = "b7f29a1d-18e2-4d3b-941f-f6b2c97c02fd" #jwt令牌的密钥,随便填一串字符串
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name = "cloud-mail"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2025-06-04"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
keep_vars = true
|
||||
|
||||
[observability]
|
||||
@@ -19,6 +20,12 @@ enabled = true
|
||||
#binding = "r2" #r2对象存储绑定名默认不可修改
|
||||
#bucket_name = "" #r2对象存储桶的名字
|
||||
|
||||
#[[send_email]]
|
||||
#name = "email" #CF发件, 配置后优先使用
|
||||
|
||||
[ai]
|
||||
binding = "ai"
|
||||
|
||||
[assets]
|
||||
binding = "assets" #静态资源绑定名默认不可修改
|
||||
directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist
|
||||
@@ -26,11 +33,13 @@ not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
[triggers]
|
||||
crons = ["0 16 * * *"] #定时任务每天晚上12点执行
|
||||
crons = ["0 16 * * *"] #每天晚上12点执行每日任务,刷新分析缓存
|
||||
|
||||
|
||||
#[vars]
|
||||
#orm_log = false
|
||||
[vars]
|
||||
#ai_model = "" #ai模型,不填默认使用@cf/meta/llama-3.1-8b-instruct
|
||||
#analysis_cache = false #是否开启分析数据缓存
|
||||
#orm_log = false #是否sql日志
|
||||
#domain = [] #邮件域名可可配置多个 示例: ["example1.com","example2.com"]
|
||||
#admin = "" #管理员的邮箱 示例: [email protected]
|
||||
#jwt_secret = "" #jwt令牌的密钥,随便填一串字符串
|
||||
|
||||
Reference in New Issue
Block a user