mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-13 04:49:44 +08:00
Compare commits
380 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b337a44e62 | ||
|
|
eaeac8ebec | ||
|
|
7393519ba4 | ||
|
|
4ddc8e5c96 | ||
|
|
8b7ddae4f6 | ||
|
|
be36967b80 | ||
|
|
fac249ed31 | ||
|
|
bfd7d6811e | ||
|
|
b5c229b6c4 | ||
|
|
2728e9667b | ||
|
|
6109ab9e82 | ||
|
|
09a6cac8fe | ||
|
|
5f752c94f9 | ||
|
|
a2f3634c7e | ||
|
|
b62a3cbc3e | ||
|
|
8edb75587e | ||
|
|
de48661d0d | ||
|
|
a905ba5f06 | ||
|
|
6ae90be3bf | ||
|
|
5e24817de6 | ||
|
|
732189482e | ||
|
|
2bbde15f53 | ||
|
|
37cf0776b5 | ||
|
|
3fbace871c | ||
|
|
648e9f7adf | ||
|
|
ab2bfdd00f | ||
|
|
0565978930 | ||
|
|
89d8944e60 | ||
|
|
4084771621 | ||
|
|
840496c48f | ||
|
|
9843b35f54 | ||
|
|
bfd66f5019 | ||
|
|
0bc31360b0 | ||
|
|
267d9bb93e | ||
|
|
2cc84d565c | ||
|
|
c96d180591 | ||
|
|
1303b0f2a9 | ||
|
|
9f535a0a90 | ||
|
|
70109785c6 | ||
|
|
7fd10f2775 | ||
|
|
f59b8c7a1b | ||
|
|
312ac13185 | ||
|
|
e6c582be9f | ||
|
|
483c429feb | ||
|
|
da5482e095 | ||
|
|
de4646876a | ||
|
|
bbc8a96811 | ||
|
|
9ac9cd46b0 | ||
|
|
c694b07380 | ||
|
|
672c4c7273 | ||
|
|
ee023ac2e9 | ||
|
|
cc77bdf36d | ||
|
|
dec309a0fd | ||
|
|
9488543e44 | ||
|
|
50326bcc98 | ||
|
|
272b624b9b | ||
|
|
e230801a1c | ||
|
|
07833d5ca9 | ||
|
|
101a561894 | ||
|
|
327962432a | ||
|
|
6051d49315 | ||
|
|
95f361743b | ||
|
|
c6afc5d425 | ||
|
|
466f53254b | ||
|
|
ce0a10e6de | ||
|
|
26995982af | ||
|
|
0894ac0dc9 | ||
|
|
47e2cb56b4 | ||
|
|
32767176f0 | ||
|
|
31eb6c23d1 | ||
|
|
91a859bbcf | ||
|
|
525f5e2dce | ||
|
|
908fc0cc86 | ||
|
|
97d24b2087 | ||
|
|
983300acf4 | ||
|
|
144a792cb2 | ||
|
|
278f0112d0 | ||
|
|
764faebf9f | ||
|
|
d4f0c82e42 | ||
|
|
cf680e6349 | ||
|
|
c3987d364c | ||
|
|
3a542a8391 | ||
|
|
241e0b7b28 | ||
|
|
b43353ea47 | ||
|
|
6c334d32f6 | ||
|
|
7889d2edea | ||
|
|
2426e0b51a | ||
|
|
61434ab6f7 | ||
|
|
7f6a02ca38 | ||
|
|
6ae3b0d85e | ||
|
|
01e6cb1075 | ||
|
|
814f6fada2 | ||
|
|
31901aacc5 | ||
|
|
fb9b9f6ae4 | ||
|
|
095951ab45 | ||
|
|
37614ce6fa | ||
|
|
3f81fbee6d | ||
|
|
cf13236e7b | ||
|
|
36e9c611e6 | ||
|
|
047200c1c2 | ||
|
|
a22add0e14 | ||
|
|
7b1c4cc72a | ||
|
|
3870727a08 | ||
|
|
2bb033964c | ||
|
|
9db5a00b35 | ||
|
|
e161eb5d14 | ||
|
|
b604f56d56 | ||
|
|
52caf811f5 | ||
|
|
ee3884914b | ||
|
|
844fc52bbc | ||
|
|
b87b49f09d | ||
|
|
5bfa588f70 | ||
|
|
92620cdedb | ||
|
|
e9748be9fe | ||
|
|
479322c430 | ||
|
|
934e58e23b | ||
|
|
c964d77a59 | ||
|
|
8a03d3e57f | ||
|
|
6caba7c863 | ||
|
|
43e5bdc764 | ||
|
|
7bec0daba4 | ||
|
|
13e5adef17 | ||
|
|
440238133e | ||
|
|
4a881e2d2b | ||
|
|
b0bf7a5f13 | ||
|
|
a9bb8785ba | ||
|
|
0b48baff6d | ||
|
|
e0b5e80efd | ||
|
|
b0e36ac2aa | ||
|
|
51db19c85b | ||
|
|
e52b010aa4 | ||
|
|
8f6793402c | ||
|
|
e86c530116 | ||
|
|
0308f518da | ||
|
|
3c2a8ed056 | ||
|
|
5f45ec7c14 | ||
|
|
1b7ebc98c5 | ||
|
|
c102004f4d | ||
|
|
3c81e05a2f | ||
|
|
5ff2ceb5e8 | ||
|
|
6c82efb738 | ||
|
|
e99acdcc6e | ||
|
|
8f30505706 | ||
|
|
ddfa2c5d03 | ||
|
|
49b3f10838 | ||
|
|
cc9ac67319 | ||
|
|
7cc2a2b576 | ||
|
|
393c5902c3 | ||
|
|
5ece49a576 | ||
|
|
de80857e2c | ||
|
|
a57a42b2a1 | ||
|
|
a24cc1f642 | ||
|
|
4c6fd3c2af | ||
|
|
1cf38c1768 | ||
|
|
b5b59acdb3 | ||
|
|
6d4783e1cd | ||
|
|
34e3e1b439 | ||
|
|
56104cd23a | ||
|
|
3664028e06 | ||
|
|
9888f98d74 | ||
|
|
ac5605f17f | ||
|
|
a9719cb3ec | ||
|
|
5f4978645b | ||
|
|
621476cb79 | ||
|
|
c969c4b082 | ||
|
|
d90f54345d | ||
|
|
797b8bb019 | ||
|
|
7e5d142924 | ||
|
|
c6d0307eac | ||
|
|
ac31042e69 | ||
|
|
c733d3bf4d | ||
|
|
bf1243f4c4 | ||
|
|
15063b2e97 | ||
|
|
fc07f1cd87 | ||
|
|
9246550cc5 | ||
|
|
979b6eae1a | ||
|
|
10da337a9c | ||
|
|
9c5e8857af | ||
|
|
84b4baa99e | ||
|
|
b57d46244a | ||
|
|
5faae8796d | ||
|
|
a0805bc0ce | ||
|
|
d0ccc3ded1 | ||
|
|
163d9451f7 | ||
|
|
60dda7e3fe | ||
|
|
384eb9b041 | ||
|
|
38816cbf0f | ||
|
|
d7d1ba6b64 | ||
|
|
14725e9e9f | ||
|
|
2c1e63b8bc | ||
|
|
f3a1d980c5 | ||
|
|
75c48beb3b | ||
|
|
26ccfdd6e0 | ||
|
|
aa8f3b4d46 | ||
|
|
a749c829d2 | ||
|
|
4b2caf1a4b | ||
|
|
80a8848ed8 | ||
|
|
dcfc1b3721 | ||
|
|
b0a0a6a1ef | ||
|
|
00c671cf14 | ||
|
|
0b78d1ff4a | ||
|
|
d152a7ce9f | ||
|
|
21fed3fb00 | ||
|
|
9448b3c754 | ||
|
|
f1827f223a | ||
|
|
2a0a34869e | ||
|
|
881e66e484 | ||
|
|
de7c3d5176 | ||
|
|
720d097ed7 | ||
|
|
53a03dc6a0 | ||
|
|
72b99e0c5e | ||
|
|
c4d9fe1fb9 | ||
|
|
af9f46ba65 | ||
|
|
8bfd76bf71 | ||
|
|
dd477fe2c8 | ||
|
|
0db611bb3e | ||
|
|
6225f6521a | ||
|
|
da2e72e523 | ||
|
|
c5d01e09e8 | ||
|
|
201c7658be | ||
|
|
77155299e0 | ||
|
|
9725407c77 | ||
|
|
e91bbe273a | ||
|
|
b792c196c1 | ||
|
|
7a368d7b23 | ||
|
|
f882e4cf97 | ||
|
|
00abf79417 | ||
|
|
1f8edbc295 | ||
|
|
268f3d6446 | ||
|
|
8dc9d32a7e | ||
|
|
3b6736924b | ||
|
|
dc14338b69 | ||
|
|
954ae2dfb1 | ||
|
|
6d55acdd42 | ||
|
|
03bb210016 | ||
|
|
bf3c372d8c | ||
|
|
9414f7a977 | ||
|
|
32440706d2 | ||
|
|
c976664f4e | ||
|
|
aa04dc4efa | ||
|
|
02e3e755e7 | ||
|
|
37ed2955ff | ||
|
|
dd49768cfc | ||
|
|
9ec11f7040 | ||
|
|
2533257b68 | ||
|
|
96ea81e055 | ||
|
|
8459e0c306 | ||
|
|
91d7896e65 | ||
|
|
69771fc1d1 | ||
|
|
c00382259a | ||
|
|
8ac96bff1f | ||
|
|
9f3ff7b980 | ||
|
|
870b7b9198 | ||
|
|
46576316e6 | ||
|
|
a5ff4f2d90 | ||
|
|
745e36f838 | ||
|
|
a351839408 | ||
|
|
ca00a877ad | ||
|
|
53a06fc9d6 | ||
|
|
607c04c810 | ||
|
|
243dac976b | ||
|
|
4bd876a5f4 | ||
|
|
bbc4c05d69 | ||
|
|
78badf2eaa | ||
|
|
6bb6fa8298 | ||
|
|
a5b5335137 | ||
|
|
f2685f9830 | ||
|
|
45bc5cad9e | ||
|
|
ea4ce9bf63 | ||
|
|
9de2d23be1 | ||
|
|
62bec9ef90 | ||
|
|
edc110b6ac | ||
|
|
3fc8bba234 | ||
|
|
4b9d40d04b | ||
|
|
af027fd75e | ||
|
|
386441a743 | ||
|
|
46e04fd94a | ||
|
|
cdc5c5202b | ||
|
|
58c3fdb5b4 | ||
|
|
fc6b0246b1 | ||
|
|
98cd6d9fcc | ||
|
|
45783c7494 | ||
|
|
9bfded4d1d | ||
|
|
b7308587c6 | ||
|
|
1fa56dfe98 | ||
|
|
55b2603913 | ||
|
|
7738210b93 | ||
|
|
9d84eb0634 | ||
|
|
66a6d40499 | ||
|
|
41bed8b1db | ||
|
|
869bf99340 | ||
|
|
f63c4ebd9c | ||
|
|
26969bebb8 | ||
|
|
1d191a091a | ||
|
|
4d6c4e2d10 | ||
|
|
7f456078ea | ||
|
|
68c18a6153 | ||
|
|
2d01639ecd | ||
|
|
53b7cfccde | ||
|
|
854e4665b9 | ||
|
|
91a01784db | ||
|
|
55ae4d9d8e | ||
|
|
b97ce79fe4 | ||
|
|
51ad37e951 | ||
|
|
6e02e9b20b | ||
|
|
042736b67f | ||
|
|
bce503e433 | ||
|
|
83b9bc9d5f | ||
|
|
e81142f5ef | ||
|
|
32ce446a27 | ||
|
|
64bcf7d019 | ||
|
|
c015b57d73 | ||
|
|
0366699fec | ||
|
|
9c9f4565b1 | ||
|
|
8a02509ffa | ||
|
|
8b210388ee | ||
|
|
a456bfda7c | ||
|
|
23d1709ca1 | ||
|
|
6ce7e2e7f6 | ||
|
|
c0e870ce54 | ||
|
|
90e80fee53 | ||
|
|
4fd7f776f6 | ||
|
|
c73c86e86c | ||
|
|
08a3d4ce0e | ||
|
|
1404079073 | ||
|
|
829782d0cb | ||
|
|
f624fe5b58 | ||
|
|
b058a1bd12 | ||
|
|
0a8f50f9e0 | ||
|
|
a3edb09305 | ||
|
|
58dcdc65f8 | ||
|
|
1be94a81e0 | ||
|
|
27138dc6a0 | ||
|
|
90c8eff2e5 | ||
|
|
c92d7dee64 | ||
|
|
6854ddb508 | ||
|
|
e524f82674 | ||
|
|
3a35a10523 | ||
|
|
91203d7f66 | ||
|
|
74cf49a5e2 | ||
|
|
99e1bdc30d | ||
|
|
7a5ac89889 | ||
|
|
81ce099299 | ||
|
|
357ec97a5f | ||
|
|
d36bdd7a69 | ||
|
|
267947a82e | ||
|
|
dcfe17dac2 | ||
|
|
b619b11a92 | ||
|
|
f43ec672f3 | ||
|
|
d1e06b40cb | ||
|
|
2604a76ae5 | ||
|
|
00231e7ade | ||
|
|
50f04b2456 | ||
|
|
2ec1a61608 | ||
|
|
eafcf00e5e | ||
|
|
d73fee2c97 | ||
|
|
4bb1016887 | ||
|
|
aea8b964bb | ||
|
|
63cf97f5e2 | ||
|
|
209693673d | ||
|
|
fb74504282 | ||
|
|
cb758ec012 | ||
|
|
02835c18e9 | ||
|
|
f83492e683 | ||
|
|
074a3b6f2a | ||
|
|
d738210cb5 | ||
|
|
cfeafb2d30 | ||
|
|
49d29ac7cc | ||
|
|
372b71b08b | ||
|
|
0c5365da1f | ||
|
|
58ad025e61 | ||
|
|
6c41288a7b | ||
|
|
b8f0fa49cf | ||
|
|
ee2fdab279 | ||
|
|
fbd2e0e844 | ||
|
|
165efa69cc | ||
|
|
2790f65a5f | ||
|
|
37a9b0557a | ||
|
|
42a828e98b | ||
|
|
a124e00766 |
3
.flake8
Normal file
3
.flake8
Normal file
@@ -0,0 +1,3 @@
|
||||
[flake8]
|
||||
max-line-length = 180
|
||||
exclude = .git,__pycache__,build,dist
|
||||
23
.github/ISSUE_TEMPLATE/bug-反馈.md
vendored
Normal file
23
.github/ISSUE_TEMPLATE/bug-反馈.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Bug 反馈
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 复现步骤
|
||||
|
||||
|
||||
|
||||
## 预期行为
|
||||
|
||||
|
||||
|
||||
## 部署方式
|
||||
|
||||
- [ ] cli 部署
|
||||
- [ ] 用户界面部署
|
||||
|
||||
## 浏览器环境
|
||||
16
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
16
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea for this project
|
||||
title: "[Feature]"
|
||||
labels: enhancement, good first issue
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 请描述您的 Feature
|
||||
|
||||
## 描述您想要的解决方案
|
||||
|
||||
## 描述您考虑过的替代方案
|
||||
|
||||
## 附加上下文
|
||||
44
.github/config/mail-parser-wasm-worker.patch
vendored
Normal file
44
.github/config/mail-parser-wasm-worker.patch
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
diff --git a/worker/src/common.ts b/worker/src/common.ts
|
||||
index bd9bcc9..e7e2748 100644
|
||||
--- a/worker/src/common.ts
|
||||
+++ b/worker/src/common.ts
|
||||
@@ -273,23 +273,23 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
|
||||
}
|
||||
const raw_mail = parsedEmailContext.rawEmail;
|
||||
// TODO: WASM parse email
|
||||
- // try {
|
||||
- // const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
+ try {
|
||||
+ const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
|
||||
- // const parsedEmail = parse_message_wrapper(raw_mail);
|
||||
- // parsedEmailContext.parsedEmail = {
|
||||
- // sender: parsedEmail.sender || "",
|
||||
- // subject: parsedEmail.subject || "",
|
||||
- // text: parsedEmail.text || "",
|
||||
- // headers: parsedEmail.headers?.map(
|
||||
- // (header) => ({ key: header.key, value: header.value })
|
||||
- // ) || [],
|
||||
- // html: parsedEmail.body_html || "",
|
||||
- // };
|
||||
- // return parsedEmailContext.parsedEmail;
|
||||
- // } catch (e) {
|
||||
- // console.error("Failed use mail-parser-wasm-worker to parse email", e);
|
||||
- // }
|
||||
+ const parsedEmail = parse_message_wrapper(raw_mail);
|
||||
+ parsedEmailContext.parsedEmail = {
|
||||
+ sender: parsedEmail.sender || "",
|
||||
+ subject: parsedEmail.subject || "",
|
||||
+ text: parsedEmail.text || "",
|
||||
+ headers: parsedEmail.headers?.map(
|
||||
+ (header) => ({ key: header.key, value: header.value })
|
||||
+ ) || [],
|
||||
+ html: parsedEmail.body_html || "",
|
||||
+ };
|
||||
+ return parsedEmailContext.parsedEmail;
|
||||
+ } catch (e) {
|
||||
+ console.error("Failed use mail-parser-wasm-worker to parse email", e);
|
||||
+ }
|
||||
try {
|
||||
const { default: PostalMime } = await import('postal-mime');
|
||||
const parsedEmail = await PostalMime.parse(raw_mail);
|
||||
76
.github/workflows/backend_deploy.yaml
vendored
Normal file
76
.github/workflows/backend_deploy.yaml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Deploy Backend
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Upstream Sync]
|
||||
types: [completed]
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Backend for ${{ github.ref_name }}
|
||||
run: |
|
||||
export use_worker_assets=${{ secrets.USE_WORKER_ASSETS }}
|
||||
export use_worker_assets_with_telegram=${{ secrets.USE_WORKER_ASSETS_WITH_TELEGRAM }}
|
||||
if [ -n "$use_worker_assets" ]; then
|
||||
cd frontend/
|
||||
pnpm install --no-frozen-lockfile
|
||||
if [ -n "$use_worker_assets_with_telegram" ]; then
|
||||
echo "Building with telegram pages"
|
||||
pnpm build:telegram:pages
|
||||
else
|
||||
echo "Building with normal pages"
|
||||
pnpm build:pages
|
||||
fi
|
||||
cd ..
|
||||
fi
|
||||
|
||||
export debug_mode=${{ secrets.DEBUG_MODE }}
|
||||
export use_mail_wasm_parser=${{ secrets.BACKEND_USE_MAIL_WASM_PARSER }}
|
||||
cd worker/
|
||||
echo '${{ secrets.BACKEND_TOML }}' > wrangler.toml
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
if [ -n "$use_mail_wasm_parser" ]; then
|
||||
echo "Using mail-parser-wasm-worker"
|
||||
pnpm add mail-parser-wasm-worker
|
||||
git apply ../.github/config/mail-parser-wasm-worker.patch
|
||||
echo "Applied mail-parser-wasm-worker patch"
|
||||
fi
|
||||
|
||||
if [ "$debug_mode" = "true" ]; then
|
||||
pnpm run deploy
|
||||
else
|
||||
output=$(pnpm run deploy 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
code=$?
|
||||
echo "Command failed with exit code $code"
|
||||
exit $code
|
||||
fi
|
||||
fi
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
64
.github/workflows/docs_deploy.yml
vendored
Normal file
64
.github/workflows/docs_deploy.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "vitepress-docs/**"
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: check github release done
|
||||
run: |
|
||||
for ((attempt=1; attempt<=10; attempt++)); do
|
||||
if wget -q --spider "https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip"; then
|
||||
echo "frontend.zip found."
|
||||
break
|
||||
else
|
||||
if [ $attempt -eq 10 ]; then
|
||||
echo "Exceeded maximum retries. frontend.zip not found."
|
||||
else
|
||||
echo "frontend.zip not found. Retrying in 30 seconds..."
|
||||
sleep 30
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Deploy Docs for ${{github.ref_name}}
|
||||
run: |
|
||||
cd vitepress-docs/
|
||||
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip
|
||||
pnpm install --no-frozen-lockfile
|
||||
if [[ ${{github.ref}} == refs/tags/* ]]; then
|
||||
export TAG_NAME=${{github.ref_name}}
|
||||
else
|
||||
export TAG_NAME=$(git describe --tags --abbrev=0)
|
||||
fi
|
||||
echo "Deploying docs for tag $TAG_NAME"
|
||||
pnpm run deploy
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
64
.github/workflows/frontend_deploy.yaml
vendored
Normal file
64
.github/workflows/frontend_deploy.yaml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Deploy Frontend
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Upstream Sync]
|
||||
types: [completed]
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Frontend for ${{ github.ref_name }}
|
||||
run: |
|
||||
cd frontend/
|
||||
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
|
||||
export project_name=${{ secrets.FRONTEND_NAME }}
|
||||
pnpm install --no-frozen-lockfile
|
||||
export frontend_branch=${{ secrets.FRONTEND_BRANCH }}
|
||||
if [ -n "$frontend_branch" ]; then
|
||||
echo "Deploying branch $frontend_branch"
|
||||
pnpm run deploy:actions --project-name=$project_name --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying branch production"
|
||||
pnpm run deploy --project-name=$project_name
|
||||
fi
|
||||
echo "Deploying production for ${{ github.ref_name }}"
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
|
||||
export tg_mini_app_project_name=${{ secrets.TG_FRONTEND_NAME }}
|
||||
if [ -n "$tg_mini_app_project_name" ]; then
|
||||
echo "Deploying telegram mini app $tg_mini_app_project_name"
|
||||
if [ -n "$frontend_branch" ]; then
|
||||
echo "Deploying telegram mini app branch $frontend_branch"
|
||||
pnpm run deploy:actions:telegram --project-name=$tg_mini_app_project_name --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying telegram mini app branch production"
|
||||
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
|
||||
fi
|
||||
echo "Deployed telegram mini app for ${{ github.ref_name }}"
|
||||
fi
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
39
.github/workflows/frontend_pagefunction_deploy.yaml
vendored
Normal file
39
.github/workflows/frontend_pagefunction_deploy.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Deploy Frontend with page function
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Frontend for ${{ github.ref_name }}
|
||||
run: |
|
||||
cd frontend/
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm build:pages
|
||||
cd ../pages/
|
||||
echo '${{ secrets.PAGE_TOML }}' > wrangler.toml
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm run deploy
|
||||
echo "Deploying prodcution for ${{ github.ref_name }}"
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
25
.github/workflows/pr_agent.yml
vendored
Normal file
25
.github/workflows/pr_agent.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Codium PR Agent
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, ready_for_review]
|
||||
jobs:
|
||||
pr_agent_job:
|
||||
if: ${{ github.event.sender.type != 'Bot' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
name: Run pr agent on every pull request, respond to user comments
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: docker://codiumai/pr-agent:0.29-github_action
|
||||
env:
|
||||
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false"
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
CONFIG.MODEL: "gpt-4o"
|
||||
CONFIG.MODEL_TURBO: "gpt-4o"
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
48
.github/workflows/smtp_proxy_server.yml
vendored
Normal file
48
.github/workflows/smtp_proxy_server.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: SMTP Proxy Server Docker Image CI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "smtp_proxy_server/**"
|
||||
tags:
|
||||
- "*"
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: smtp_proxy_server
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./smtp_proxy_server
|
||||
file: ./smtp_proxy_server/dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:latest
|
||||
25
.github/workflows/sync.yaml
vendored
Normal file
25
.github/workflows/sync.yaml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Upstream Sync
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync_latest_from_upstream:
|
||||
name: Sync latest commits from upstream repo
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync upstream changes
|
||||
id: sync
|
||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||
with:
|
||||
upstream_sync_repo: dreamhunter2333/cloudflare_temp_email
|
||||
upstream_sync_branch: main
|
||||
target_sync_branch: main
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||
test_mode: false
|
||||
67
.github/workflows/tag_build.yml
vendored
Normal file
67
.github/workflows/tag_build.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Tag Build CI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Build Frontend
|
||||
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:release
|
||||
|
||||
- name: Zip Frontend dist
|
||||
run: cd frontend/dist/ && zip -r frontend.zip * && mv frontend.zip ../
|
||||
|
||||
- name: Build Telegram Frontend
|
||||
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:telegram:release
|
||||
|
||||
- name: Zip Telegram Frontend dist
|
||||
run: cd frontend/dist/ && zip -r telegram-frontend.zip * && mv telegram-frontend.zip ../
|
||||
|
||||
- name: cp wrangler.toml
|
||||
run: cd worker && cp wrangler.toml.template wrangler.toml
|
||||
|
||||
- name: Build Backend
|
||||
run: cd worker && pnpm install --no-frozen-lockfile && pnpm build
|
||||
|
||||
- name: Move worker.js
|
||||
run: cd worker/dist && mv worker.js ../
|
||||
|
||||
- name: Build Worker with wasm mail parser
|
||||
run: |
|
||||
cd worker
|
||||
echo "Using mail-parser-wasm-worker"
|
||||
pnpm add mail-parser-wasm-worker
|
||||
git apply ../.github/config/mail-parser-wasm-worker.patch
|
||||
echo "Applied mail-parser-wasm-worker patch"
|
||||
pnpm build
|
||||
zip -r worker-with-wasm-mail-parser.zip dist/worker.js dist/*.wasm
|
||||
|
||||
- name: Upload to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
frontend/frontend.zip
|
||||
frontend/telegram-frontend.zip
|
||||
worker/worker.js
|
||||
worker/worker-with-wasm-mail-parser.zip
|
||||
137
.gitignore
vendored
137
.gitignore
vendored
@@ -1,2 +1,139 @@
|
||||
.DS_Store
|
||||
dist/
|
||||
test/
|
||||
.vscode/
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
.wrangler
|
||||
wrangler.toml
|
||||
.dev.vars
|
||||
pnpm-lock.yaml
|
||||
|
||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-python.vscode-pylance",
|
||||
"1yib.rust-bundle",
|
||||
"rust-lang.rust-analyzer",
|
||||
"vue.volar"
|
||||
]
|
||||
}
|
||||
34
CHANGELOG
34
CHANGELOG
@@ -1,34 +0,0 @@
|
||||
# CHANGE LOG
|
||||
|
||||
## 2024-04-10 v0.0.1
|
||||
|
||||
Breaking changes:
|
||||
|
||||
- remove `ENABLE_ATTACHMENT` config
|
||||
- use rust wasm to parse email in frontend
|
||||
- deprecated api moved to `/api/v1`
|
||||
|
||||
DB changes
|
||||
|
||||
- `db/2024-04-09-patch.sql`
|
||||
|
||||
## 2024-04-09 v0.0.0
|
||||
|
||||
release v0.0.0
|
||||
|
||||
## 2024-04-03
|
||||
|
||||
DB changes
|
||||
|
||||
- `db/2024-04-03-patch.sql`
|
||||
|
||||
Changes:
|
||||
|
||||
- add delete account
|
||||
- add admin panel search
|
||||
|
||||
## 2024-01-13
|
||||
|
||||
DB changes
|
||||
|
||||
- `db/2024-01-13-patch.sql`
|
||||
550
CHANGELOG.md
Normal file
550
CHANGELOG.md
Normal file
@@ -0,0 +1,550 @@
|
||||
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
|
||||
# CHANGE LOG
|
||||
|
||||
## v1.0.7
|
||||
|
||||
- feat: |Admin| 新增 IP 黑名单功能,用于限制访问频率较高的 API
|
||||
- feat: |Admin| 新增 ASN 组织黑名单功能,支持基于 ASN 组织名称过滤请求(支持文本匹配和正则表达式)
|
||||
- feat: |Admin| 新增浏览器指纹黑名单功能,支持基于浏览器指纹过滤请求(支持精确匹配和正则表达式)
|
||||
|
||||
## v1.0.6
|
||||
|
||||
- feat: |DB| update db schema add index
|
||||
- feat: |地址密码| 增加地址密码登录功能, 通过 `ENABLE_ADDRESS_PASSWORD` 配置启用, 需要执行 `db/2025-09-23-patch.sql` 文件中的 SQL 更新 `D1` 数据库
|
||||
- fix: |GitHub Actions| 修复 debug 模式配置,仅当 DEBUG_MODE 为 'true' 时才启用调试模式
|
||||
- feat: |Admin| 账户管理页面新增多选批量操作功能(批量删除、批量清空收件箱、批量清空发件箱)
|
||||
- feat: |Admin| 维护页面增加清理未绑定用户地址的功能
|
||||
- feat: 支持针对角色配置不同的绑定地址数量上限, 可在 admin 页面配置
|
||||
|
||||
## v1.0.5
|
||||
|
||||
- feat: 新增 `DISABLE_CUSTOM_ADDRESS_NAME` 配置: 禁用自定义邮箱地址名称功能
|
||||
- feat: 新增 `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` 配置: 创建地址时优先使用第一个域名
|
||||
- feat: |UI| 主页增加进入极简模式按钮
|
||||
- feat: |Webhook| 增加白名单开关功能,支持灵活控制访问权限
|
||||
|
||||
## v1.0.4
|
||||
|
||||
- feat: |UI| 优化极简模式主页, 增加全部邮件页面功能(删除/下载/附件/...), 可在 `外观` 中切换
|
||||
- feat: admin 账号设置页面增加 `邮件转发规则` 配置
|
||||
- feat: admin 账号设置页面增加 `禁止接收未知地址邮件` 配置
|
||||
- feat: 邮件页面增加 上一封/下一封 按钮
|
||||
|
||||
## v1.0.3
|
||||
|
||||
- fix: 修复 github actions 部署问题
|
||||
- feat: telegram /new 不指定域名时, 使用随机地址
|
||||
|
||||
## v1.0.2
|
||||
|
||||
- fix: 修复 oauth2 登录失败的问题
|
||||
|
||||
## v1.0.1
|
||||
|
||||
- feat: |UI| 增加极简模式主页, 可在 `外观` 中切换
|
||||
- fix: 修复 oauth2 登录时,default role 不生效的问题
|
||||
|
||||
## v1.0.0
|
||||
|
||||
- fix: |UI| 修复 User 查看收件箱,不选择地址时,关键词查询不生效
|
||||
- fix: 修复自动清理任务,时间为 0 时不生效的问题
|
||||
- feat: 清理功能增加 创建 n 天前地址清理,n 天前未活跃地址清理
|
||||
- fix: |IMAP Proxy| 修复 IMAP Proxy 服务器,无法查看新邮件的问题
|
||||
|
||||
## v0.10.0
|
||||
|
||||
- feat: 支持 User 查看收件箱,`/user_api/mails` 接口, 支持 `address` 和 `keyword` 过滤
|
||||
- fix: 修复 Oauth2 登录获取 Token 时,一些 Oauth2 需要 `redirect_uri` 参数的问题
|
||||
- feat: 用户访问网页时,如果 `user token` 在 7 天内过期,自动刷新
|
||||
- feat: admin portal 中增加初始化 db 的功能
|
||||
- feat: 增加 `ALWAYS_SHOW_ANNOUNCEMENT` 变量,用于配置是否总是显示公告
|
||||
|
||||
## v0.9.1
|
||||
|
||||
- feat: |UI| support google ads
|
||||
- feat: |UI| 使用 shadow DOM 防止样式污染
|
||||
- feat: |UI| 支持 URL jwt 参数自动登录邮箱,jwt 参数会覆盖浏览器中的 jwt
|
||||
- fix: |CleanUP| 修复清理邮件时,清理时间超过 30 天报错的 bug
|
||||
- feat: admin 用户管理页面: 增加 用户地址查看功能
|
||||
- feat: | S3 附件| 增加 S3 附件删除功能
|
||||
- feat: | Admin API| 增加 admin 绑定用户和地址的 api
|
||||
- feat: | Oauth2 | Oatuh2 获取用户信息时,支持 `JSONPATH` 表达式
|
||||
|
||||
## v0.9.0
|
||||
|
||||
- feat: | Worker | 支持多语言
|
||||
- feat: | Worker | `NO_LIMIT_SEND_ROLE` 配置支持多角色, 逗号分割
|
||||
- feat: | Actions | build 里增加 `worker-with-wasm-mail-parser.zip` 支持 UI 部署带 `wasm` 的 worker
|
||||
|
||||
## v0.8.7
|
||||
|
||||
- fix: |UI| 修复移动设备日期显示问题
|
||||
- feat: |Worker| 支持通过 `SMTP` 发送邮件, 使用 [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
|
||||
|
||||
## v0.8.6
|
||||
|
||||
- feat: |UI| 公告支持 html 格式
|
||||
- feat: |UI| `COPYRIGHT` 支持 html 格式
|
||||
- feat: |Doc| 优化部署文档,补充了 `Github Actions 部署文档`,增加了 `Worker 变量说明`
|
||||
|
||||
## v0.8.5
|
||||
|
||||
- feat: |mail-parser-wasm-worker| 修复 `initSync` 函数调用时的 `deprecated` 参数警告
|
||||
- feat: rpc headers covert & typo (#559)
|
||||
- fix: telegram mail page use iframe show email (#561)
|
||||
- feat: |Worker| 增加 `REMOVE_ALL_ATTACHMENT` 和 `REMOVE_EXCEED_SIZE_ATTACHMENT` 用于移除邮件附件,由于是解析邮件的一些信息会丢失,比如图片等.
|
||||
|
||||
## v0.8.4
|
||||
|
||||
- fix: |UI| 修复 admin portal 无收件人邮箱删除调用api 错误
|
||||
- feat: |Telegram Bot| 增加 telegram bot 清理无效地址凭证命令
|
||||
- feat: 增加 worker 配置 `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` 禁用匿名用户创建邮箱地址,只允许登录用户创建邮箱地址
|
||||
- feat: 增加 worker 配置 `ENABLE_ANOTHER_WORKER` 及 `ANOTHER_WORKER_LIST` ,用于调用其他 worker 的 rpc 接口 (#547)
|
||||
- feat: |UI| 自动刷新配置保存到浏览器,可配置刷新间隔
|
||||
- feat: 垃圾邮件检测增加存在时才检查的列表 `JUNK_MAIL_CHECK_LIST` 配置
|
||||
- feat: | Worker | 增加 `ParsedEmailContext` 类用于缓存解析后的邮件内容,减少解析次数
|
||||
- feat: |Github Action| Worker 部署增加 `DEBUG_MODE` 输出日志, `BACKEND_USE_MAIL_WASM_PARSER` 配置是否使用 wasm 解析邮件
|
||||
|
||||
## v0.8.3
|
||||
|
||||
- feat: |Github Action| 增加自动更新并部署功能
|
||||
- feat: |UI| admin 用户设置,支持 oauth2 配置的删除
|
||||
- feat: 增加垃圾邮件检测必须通过的列表 `JUNK_MAIL_FORCE_PASS_LIST` 配置
|
||||
|
||||
## v0.8.2
|
||||
|
||||
- fix: |Doc| 修复文档中的一些错误
|
||||
- fix: |Github Action| 修复 frontend 部署分支错误的问题
|
||||
- feat: admin 发送邮件功能
|
||||
- feat: admin 后台,账号配置页面添加无限发送邮件的地址列表
|
||||
|
||||
## v0.8.1
|
||||
|
||||
- feat: |Doc| 更新 UI 安装的文档
|
||||
- feat: |UI| 对用户隐藏邮箱账号的 ID
|
||||
- feat: |UI| 增加邮件详情页的 `转发` 按钮
|
||||
|
||||
## v0.8.0
|
||||
|
||||
- feat: |UI| 随机生成地址时不超过最大长度
|
||||
- feat: |UI| 邮件时间显示浏览器时区,可在设置中切换显示为 UTC 时间
|
||||
- feat: 支持转移邮件到其他用户
|
||||
|
||||
## v0.7.6
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
UI 部署 worker 需要点击 Settings -> Runtime, 修改 Compatibility flags, 增加 `nodejs_compat`
|
||||
|
||||

|
||||
|
||||
### Changes
|
||||
|
||||
- feat: 支持提前设置 bot info, 降低 telegram 回调延迟 (#441)
|
||||
- feat: 增加 telegram mini app 的 build 压缩包
|
||||
- feat: 增加是否启用垃圾邮件检查 `ENABLE_CHECK_JUNK_MAIL` 配置
|
||||
|
||||
## v0.7.5
|
||||
|
||||
- fix: 修复 `name` 的校验检查
|
||||
|
||||
## v0.7.4
|
||||
|
||||
- feat: UI 列表页面增加最小宽度
|
||||
- fix: 修复 `name` 的校验检查
|
||||
- fix: 修复 `DEFAULT_DOMAINS` 配置为空不生效的问题
|
||||
|
||||
## v0.7.3
|
||||
|
||||
- feat: worker 增加 `ADDRESS_CHECK_REGEX`, address name 的正则表达式, 只用于检查,符合条件将通过检查
|
||||
- fix: UI 修复登录页面 tab 激活图标错位
|
||||
- fix: UI 修复 admin 页面刷新弹框输入密码的问题
|
||||
- feat: support `Oath2` 登录, 可以通过 `Github` `Authentik` 等第三方登录, 详情查看 [OAuth2 第三方登录](https://temp-mail-docs.awsl.uk/zh/guide/feature/user-oauth2.html)
|
||||
|
||||
## v0.7.2
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
`webhook` 的结构增加了 `enabled` 字段,已经配置了的需要重新在页面开启并保存。
|
||||
|
||||
### Changes
|
||||
|
||||
- fix: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 加载失败的问题
|
||||
- feat: worker 增加 `# ADDRESS_REGEX = "[^a-z.0-9]"` 配置, 替换非法符号的正则表达式,如果不设置,默认为 [^a-z0-9], 需谨慎使用, 有些符号可能导致无法收件
|
||||
- feat: worker 优化 webhook 逻辑, 支持 admin 配置全局 webhook, 添加 `message pusher` 集成示例
|
||||
|
||||
## v0.7.1
|
||||
|
||||
- fix: 修复用户角色加载失败的问题
|
||||
- feat: admin 账号设置增加来源邮件地址黑名单配置
|
||||
|
||||
## v0.7.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
DB changes: 增加用户 `passkey` 表, 需要执行 `db/2024-08-10-patch.sql` 更新 `D1` 数据库
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: Update new-address-api.md (#360)
|
||||
- feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363)
|
||||
- feat: worker 增加 `DISABLE_SHOW_GITHUB` 配置, 用于配置是否显示 github 链接
|
||||
- feat: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 用于配置可以无限发送邮件的角色
|
||||
- feat: 用户增加 `passkey` 登录方式, 用于用户登录, 无需输入密码
|
||||
- feat: worker 增加 `DISABLE_ADMIN_PASSWORD_CHECK` 配置, 用于配置是否禁用 admin 控制台密码检查, 若你的网站只可私人访问,可通过此禁用检查
|
||||
|
||||
## v0.6.1
|
||||
|
||||
- pages github actions && 修复清理邮件天数为 0 不生效 by @tqjason (#355)
|
||||
- fix: imap proxy server 不支持 密码 by @dreamhunter2333 (#356)
|
||||
- worker 新增 `ANNOUNCEMENT` 配置, 用于配置公告信息 by @dreamhunter2333 (#357)
|
||||
- fix: telegram bot 新建地址默认选择第一个域名 by @dreamhunter2333 (#358)
|
||||
|
||||
## v0.6.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
DB changes: 增加用户角色表, 需要执行 `db/2024-07-14-patch.sql` 更新 `D1` 数据库
|
||||
|
||||
### Changes
|
||||
|
||||
worker 配置文件新增 `DEFAULT_DOMAINS`, `USER_ROLES`, `USER_DEFAULT_ROLE`, 具体查看文档 [worker配置](https://temp-mail-docs.awsl.uk/zh/guide/cli/worker.html#%E4%BF%AE%E6%94%B9-wrangler-toml-%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6)
|
||||
|
||||
- 移除 `apiV1` 相关代码和相关的数据库表
|
||||
- 更新 `admin/statistics` api, 添加用户统计信息
|
||||
- 更新地址的规则,只允许小写+数字,对于历史的地址在查询邮件时会进行 `lowercase` 处理
|
||||
- 增加用户角色功能,`admin` 可以设置用户角色(目前可配置每个角色域名和前缀)
|
||||
- admin 页面搜索优化, 回车自动搜索, 输入内容自动 trim
|
||||
|
||||
## v0.5.4
|
||||
|
||||
- 点击 logo 5 次进入 admin 页面
|
||||
- 修复 401 时无法跳转登录页面(admin 和 网站认证)
|
||||
|
||||
## v0.5.3
|
||||
|
||||
- 修复 smtp imap proxy sever 的一些 bug
|
||||
- 完善用户/admin 删除收件箱/发件箱的功能
|
||||
- admin 可以删除 发件权限记录
|
||||
- 添加中文邮件别名配置 `DOMAIN_LABELS` [文档](https://temp-mail-docs.awsl.uk/zh/guide/cli/worker.html)
|
||||
- 移除 `mail channels` 相关代码
|
||||
- github actions 增加 `FRONTEND_BRANCH` 变量用于指定部署的分支 (#324)
|
||||
|
||||
## v0.5.1
|
||||
|
||||
- 添加 `mail-parser-wasm-worker` 用于 worker 解析邮件, [文档](https://temp-mail-docs.awsl.uk/zh/guide/feature/mail_parser_wasm_worker.html)
|
||||
- 添加校验用户邮箱长度配置 `MIN_ADDRESS_LEN` 和 `MAX_ADDRESS_LEN`
|
||||
- 修复 `pages function` 未转发 `telegram` api 问题
|
||||
|
||||
## v0.5.0
|
||||
|
||||
- UI: 增加本地缓存进行地址管理
|
||||
- worker: 增加 `FORWARD_ADDRESS_LIST` 全局邮件转发地址(等同于 `catch all`)
|
||||
- UI: 多语言使用路由进行切换
|
||||
- 添加保存附件到 S3 的功能
|
||||
- UI: 增加收取邮件列表 `批量删除` 和 `批量下载`
|
||||
|
||||
## v0.4.6
|
||||
|
||||
- worker 配置文件添加 `TITLE = "Custom Title"`, 可自定义网站标题
|
||||
- 修复 KV 未绑定无法删除地址的问题
|
||||
|
||||
## v0.4.5
|
||||
|
||||
- UI lazy load 懒加载
|
||||
- telegram bot 添加用户全局推送功能(admin 用户)
|
||||
- 增加对 cloudflare verified 用户发送邮件
|
||||
- 增加使用 `resend` 发送邮件, `resend` 提供 http 和 smtp api, 使用更加方便, 文档: https://temp-mail-docs.awsl.uk/zh/guide/config-send-mail.html
|
||||
|
||||
## v0.4.4
|
||||
|
||||
- 增加 telegram mini app
|
||||
- telegram bot 增加 `ubind`, `delete` 指令
|
||||
- 修复 webhook 多行文本的问题
|
||||
|
||||
## v0.4.3
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
配置文件 `main = "src/worker.js"` 改为 `main = "src/worker.ts"`
|
||||
|
||||
### Changes
|
||||
|
||||
- `telegram bot` 白名单配置
|
||||
- `ENABLE_WEBHOOK` 添加 webhook
|
||||
- UI: admin 页面使用双层 tab
|
||||
- UI: 登录后可直接主页切换地址
|
||||
- UI: 发件箱也采用左右分栏显示(类似收件箱)
|
||||
- `SMTP IMAP Proxy` 添加发件箱查看
|
||||
|
||||
* feat: telegram bot TelegramSettings && webhook by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/244
|
||||
* fix build by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/245
|
||||
* feat: UI changes by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/247
|
||||
* feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/248
|
||||
|
||||
## v0.4.2
|
||||
|
||||
- 修复 smtp imap proxy sever 的一些 bug
|
||||
- 修复 UI 界面文字错误, 界面增加版本号
|
||||
- 增加 telegram bot 文档 https://temp-mail-docs.awsl.uk/zh/guide/feature/telegram.html
|
||||
|
||||
* fix: imap server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/227
|
||||
* fix: Maintenance wrong label by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/229
|
||||
* feat: add version for frontend && backend by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/230
|
||||
* feat: add page functions proxy to make response faster by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/234
|
||||
* feat: add about page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/235
|
||||
* feat: remove mailV1Alert && fix mobile showSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/236
|
||||
* feat: telegram bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/238
|
||||
* fix: remove cleanup address due to many table need to be clean by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/240
|
||||
* feat: docs: Telegram Bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/241
|
||||
* fix: smtp_proxy: cannot decode 8bit && tg bot new random address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/242
|
||||
* fix: smtp_proxy: update raise imap4.NoSuchMailbox by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/243
|
||||
|
||||
### v0.4.1
|
||||
|
||||
- 用户名限制最长30个字符
|
||||
- 修复 `/external/api/send_mail` 未返回的 bug (#222)
|
||||
- 添加 `IMAP proxy` 服务,支持 `IMAP` 查看邮件
|
||||
- UI 界面增加版本号显示
|
||||
|
||||
* feat: use common function handleListQuery when query by page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/220
|
||||
* fix: typos by @lwd-temp in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
|
||||
* fix: name max 30 && /external/api/send_mail not return result by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/222
|
||||
* fix: smtp_proxy_server support decode from mail charset by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/223
|
||||
* feat: add imap proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/225
|
||||
* feat: UI show version by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/226
|
||||
|
||||
### New Contributors
|
||||
|
||||
* @lwd-temp made their first contribution in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
|
||||
|
||||
## v0.4.0
|
||||
|
||||
### DB Changes/Breaking changes
|
||||
|
||||
新增 user 相关表,用于存储用户信息
|
||||
|
||||
- `db/2024-05-08-patch.sql`
|
||||
|
||||
### config changs
|
||||
|
||||
启用用户注册邮箱验证需要 `KV`
|
||||
|
||||
```toml
|
||||
# kv config for send email verification code
|
||||
# [[kv_namespaces]]
|
||||
# binding = "KV"
|
||||
# id = "xxxx"
|
||||
```
|
||||
|
||||
### function changs
|
||||
|
||||
- 增加用户注册功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证
|
||||
- 增加默认以文本显示邮件,文本和HTML邮箱显示方式切换按钮
|
||||
- 修复 `BUG` 随机生成的邮箱名字不合法 #211
|
||||
- `admin` 邮件页面支持邮件内容搜索 #210
|
||||
- 修复删除地址时邮件未删除的BUG #213
|
||||
- UI 增加全局标签页位置配置, 侧边距配置
|
||||
|
||||
* feat: update docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/204
|
||||
* feat: add Deploy to Cloudflare Workers button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/205
|
||||
* feat: add Deploy to Cloudflare Workers docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/206
|
||||
* feat: add UserLogin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/209
|
||||
* feat: admin search mailbox && fix generateName multi dot && user jwt exp in 30 days && UI globalTabplacement && useSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/214
|
||||
* feat: UI check openSettings in Login page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/215
|
||||
* feat: UI move AdminContact to common by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/217
|
||||
* feat: docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/218
|
||||
|
||||
## v0.3.3
|
||||
|
||||
- 修复 Admin 删除邮件报错
|
||||
- UI: 回复邮件按钮, 引用原始邮件文本 #186
|
||||
- 添加发送邮件地址黑名单
|
||||
- 添加 `CF Turnstile` 人机验证配置
|
||||
- 添加 `/external/api/send_mail` 发送邮件 api, 使用 body 验证 #194
|
||||
|
||||
## v0.3.2
|
||||
|
||||
## What's Changed
|
||||
|
||||
- UI: 添加回复邮件按钮
|
||||
- 添加定时清理功能,可在 admin 页面配置(需要在配置文件启用定时任务)
|
||||
- 修复删除账户无反应的问题
|
||||
|
||||
* feat: UI: MailBox add reply button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/187
|
||||
* feat: add cron auto clean up by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/189
|
||||
* fix: delete account by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/190
|
||||
|
||||
## v0.3.1
|
||||
|
||||
### DB Changes
|
||||
|
||||
新增 `settings` 表,用于存储通用配置信息
|
||||
|
||||
- `db/2024-05-01-patch.sql`
|
||||
|
||||
### Changes
|
||||
|
||||
- `ENABLE_USER_CREATE_EMAIL` 是否允许用户创建邮件
|
||||
- 允许 admin 创建无前缀的邮件
|
||||
- 添加 `SMTP proxy server`,支持 SMTP 发送邮件
|
||||
- 修复某些情况浏览器无法加载 `wasm` 时使用 js 解析邮件
|
||||
- 页脚添加 `COPYRIGHT`
|
||||
- UI 允许用户切换邮件展示模式 `v-html` / `iframe`
|
||||
- 添加 `admin` 账户配置页面,支持配置用户注册名称黑名单
|
||||
|
||||
* feat: support admin create address && add ENABLE_USER_CREATE_EMAIL co… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/175
|
||||
* feat: add SMTP proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/177
|
||||
* fix: cf ui var is string by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/178
|
||||
* fix: UI mailbox 100vh to 80vh by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/179
|
||||
* fix: smtp_proxy_server hostname && add docker image for linux/arm64 by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/180
|
||||
* fix: some browser do not support wasm by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/182
|
||||
* feat: add COPYRIGHT by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/183
|
||||
* feat: UI: add user page: useIframeShowMail && mailboxSplitSize by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/184
|
||||
* feat: add address_block_list for new address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/185
|
||||
|
||||
## v0.3.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
`address` 表的前缀将从代码中迁移到 db 中,请将下面 sql 中的 `tmp` 替换为你的前缀,然后执行。
|
||||
如果你的数据很重要,请先备份数据库。
|
||||
|
||||
**注意替换前缀**
|
||||
|
||||
```sql
|
||||
update
|
||||
address
|
||||
set
|
||||
name = 'tmp' || name;
|
||||
```
|
||||
|
||||
### Changes
|
||||
|
||||
- `address` 表的前缀将从代码中迁移到 db 中
|
||||
- `admin` 账户页面添加收发邮件数量
|
||||
- `admin` 发件页面默认显示全部
|
||||
- `admin` 发件权限页面支持搜索地址
|
||||
- `admin` 邮件页面使用左右分栏 UI
|
||||
|
||||
* feat: remove PREFIX logic in db by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/171
|
||||
* feat: admin page add account mail count && sendbox default all && sen… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/172
|
||||
* feat: all mail use MailBox Component by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/173
|
||||
|
||||
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/0.2.10...v0.3.0
|
||||
|
||||
## v0.2.10
|
||||
|
||||
- `ENABLE_USER_DELETE_EMAIL` 是否允许用户删除账户和邮件
|
||||
- `ENABLE_AUTO_REPLY` 是否启用自动回复
|
||||
- fetchAddressError 提示改进
|
||||
- 自动刷新显示倒计时
|
||||
|
||||
* feat: docs update by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/165
|
||||
* feat: add ENABLE_USER_DELETE_EMAIL && ENABLE_AUTO_REPLY && modify fetchAddressError i18n && UI: show autoRefreshInterval by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/169
|
||||
|
||||
## v0.2.9
|
||||
|
||||
- 添加富文本编辑器
|
||||
- admin 联系方式,不配置则不显示,可配置任意字符串 `ADMIN_CONTACT = "xx@xx.xxx"`
|
||||
- 默认发送邮件余额,如果不设置,将为 0 `DEFAULT_SEND_BALANCE = 1`
|
||||
|
||||
## v0.2.8
|
||||
|
||||
- 允许用户删除邮件
|
||||
- admin 修改发件权限时邮件通知用户
|
||||
- 发件权限默认 1 条
|
||||
- 添加 RATE_LIMITER 限流 发送邮件 和 新建地址
|
||||
- 一些 bug 修复
|
||||
|
||||
- feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132
|
||||
- feat: requset_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143
|
||||
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146
|
||||
- fix: delete_address not delete address_sender by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/153
|
||||
- fix: send_balance not update when click sendmail by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/155
|
||||
|
||||
## v0.2.7
|
||||
|
||||
- Added user interface installation documentation
|
||||
- Support email DKIM
|
||||
- Rate limiting configuration for `/api/new_address`
|
||||
|
||||
## v0.2.6
|
||||
|
||||
- Added admin query outbox page
|
||||
- Add admin data cleaning page
|
||||
|
||||
## 2024-04-12 v0.2.5
|
||||
|
||||
- support send email
|
||||
|
||||
DB changes:
|
||||
|
||||
- `db/2024-04-12-patch.sql`
|
||||
|
||||
## 2024-04-10 v0.2.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- remove `ENABLE_ATTACHMENT` config
|
||||
- use rust wasm to parse email in frontend
|
||||
- deprecated api moved to `/api/v1`
|
||||
|
||||
### Rust Mail Parser
|
||||
|
||||
由于 nodejs 解析 email 的库有些问题,此版本切换到使用 rust wasm 调用 rust 的mail 解析库
|
||||
|
||||
- 速度更快,附件支持好,可以显示邮件的附件图片
|
||||
- 解析支持更多 rfc 规范
|
||||
|
||||
Due to some problems with nodejs' email parsing library, this version switches to using rust wasm to call rust's mail parsing library.
|
||||
|
||||
- Faster speed, good attachment support, can display attachment images of emails
|
||||
- Parsing supports more rfc specifications
|
||||
|
||||
### DB changs
|
||||
|
||||
将 `mails` 表废弃,新的 `mail` 的 `raw` 文本将直接存入 `raw_mails` 表.
|
||||
The `mails` table will be discarded, and the `raw` text of the new `mail` will be directly stored in the `raw_mails` table
|
||||
|
||||
## Upgrade Step
|
||||
|
||||
```bash
|
||||
git checkout v0.2.0
|
||||
cd worker
|
||||
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql --remote
|
||||
pnpm run deploy
|
||||
cd ../frontend
|
||||
pnpm run deploy
|
||||
```
|
||||
|
||||
注意:对于历史邮件,请使用部署新网页查看旧数据。
|
||||
Note: For historical messages, use the Deploy New web page to view old data.
|
||||
|
||||
```bash
|
||||
git checkout feature/backup
|
||||
cd frontend
|
||||
# 创建一个新的 pages, 用于访问旧数据
|
||||
pnpm run deploy --project-name temp-email-v1
|
||||
```
|
||||
|
||||
## 2024-04-09 v0.0.0
|
||||
|
||||
release v0.0.0
|
||||
|
||||
## 2024-04-03
|
||||
|
||||
DB changes
|
||||
|
||||
- `db/2024-04-03-patch.sql`
|
||||
|
||||
Changes:
|
||||
|
||||
- add delete account
|
||||
- add admin panel search
|
||||
|
||||
## 2024-01-13
|
||||
|
||||
DB changes
|
||||
|
||||
- `db/2024-01-13-patch.sql`
|
||||
310
README.md
310
README.md
@@ -1,24 +1,75 @@
|
||||
# 使用 cloudflare 免费服务,搭建临时邮箱
|
||||
<!-- markdownlint-disable-file MD033 MD045 -->
|
||||
# Cloudflare 临时邮箱 - 免费搭建临时邮件服务
|
||||
|
||||
## [English](README_EN.md)
|
||||
<p align="center">
|
||||
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
|
||||
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
|
||||
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
|
||||
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
|
||||
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
[CHANGELOG](CHANGELOG)
|
||||
<p align="center">
|
||||
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
|
||||
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="Featured|HelloGitHub" height="30"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
[Backend](https://temp-email-api.dreamhunter2333.xyz/)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
<p align="center">
|
||||
<a href="README.md">🇨🇳 中文文档</a> |
|
||||
<a href="README_EN.md">🇺🇸 English Document</a>
|
||||
</p>
|
||||
|
||||
[Frontend](https://temp-email.dreamhunter2333.xyz/)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
|
||||
|
||||
**🎉 一个功能完整的临时邮箱服务!**
|
||||
|
||||
- 🆓 **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
|
||||
- ⚡ **高性能** - Rust WASM 邮件解析,响应速度极快
|
||||
- 🎨 **现代化界面** - 响应式设计,支持多语言,操作简便
|
||||
- 🔐 **地址密码** - 支持为邮箱地址设置独立密码,增强安全性 (通过 `ENABLE_ADDRESS_PASSWORD` 启用)
|
||||
|
||||
## 📚 部署文档 - 快速开始
|
||||
|
||||
[📖 部署文档](https://temp-mail-docs.awsl.uk) | [🚀 Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html)
|
||||
|
||||
<a href="https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html">
|
||||
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
|
||||
</a>
|
||||
|
||||
## 📝 更新日志
|
||||
|
||||
查看 [CHANGELOG](CHANGELOG.md) 了解最新更新内容。
|
||||
|
||||
## 🎯 在线体验
|
||||
|
||||
立即体验 → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>📊 服务状态监控(点击收缩/展开)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Backend](https://temp-email-api.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml)       |
|
||||
| [Frontend](https://mail.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml)       |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⭐ Star History(点击收缩/展开)</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
@@ -26,173 +77,112 @@
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
|
||||
- [English](#english)
|
||||
- [在线演示](#在线演示)
|
||||
- [功能/TODO](#功能todo)
|
||||
- [什么是临时邮箱](#什么是临时邮箱)
|
||||
- [Cloudflare 服务](#cloudflare-服务)
|
||||
- [wrangler 的安装](#wrangler-的安装)
|
||||
- [D1 数据库](#d1-数据库)
|
||||
- [Cloudflare workers 后端](#cloudflare-workers-后端)
|
||||
- [Cloudflare Workers 后端](#cloudflare-workers-后端-1)
|
||||
- [Cloudflare Email Routing](#cloudflare-email-routing)
|
||||
- [Cloudflare Pages 前端](#cloudflare-pages-前端)
|
||||
- [参考资料](#参考资料)
|
||||
</details>
|
||||
|
||||
## [在线演示](https://temp-email.dreamhunter2333.xyz/)
|
||||
<details open>
|
||||
<summary>📖 目录(点击收缩/展开)</summary>
|
||||
|
||||
## 功能/TODO
|
||||
- [Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#cloudflare-临时邮箱---免费搭建临时邮件服务)
|
||||
- [📚 部署文档 - 快速开始](#-部署文档---快速开始)
|
||||
- [📝 更新日志](#-更新日志)
|
||||
- [🎯 在线体验](#-在线体验)
|
||||
- [✨ 核心功能](#-核心功能)
|
||||
- [📧 邮件处理](#-邮件处理)
|
||||
- [👥 用户管理](#-用户管理)
|
||||
- [🔧 管理功能](#-管理功能)
|
||||
- [🌐 多语言与界面](#-多语言与界面)
|
||||
- [🤖 集成与扩展](#-集成与扩展)
|
||||
- [🏗️ 技术架构](#️-技术架构)
|
||||
- [🏛️ 系统架构](#️-系统架构)
|
||||
- [🛠️ 技术栈](#️-技术栈)
|
||||
- [📦 主要组件](#-主要组件)
|
||||
- [🌟 加入社区](#-加入社区)
|
||||
|
||||
- [x] Cloudflare D1 作为数据库
|
||||
- [x] 使用 Cloudflare Pages 部署前端
|
||||
- [x] 使用 Cloudflare Workers 部署后端
|
||||
- [x] email 转发使用 Cloudflare Email Routing
|
||||
- [x] 使用 password 重新登录之前的邮箱
|
||||
- [x] 获取自定义名字的邮箱
|
||||
- [x] 支持多语言
|
||||
- [x] 增加访问授权,可作为私人站点
|
||||
- [x] 增加自动回复功能
|
||||
- [x] 增加查看附件功能
|
||||
- [x] 使用 rust wasm 解析邮件
|
||||
</details>
|
||||
|
||||
---
|
||||
## ✨ 核心功能
|
||||
|
||||
## 什么是临时邮箱
|
||||
<details open>
|
||||
<summary>✨ 核心功能详情(点击收缩/展开)</summary>
|
||||
|
||||
临时邮箱,也被称为一次性邮箱或临时邮件地址,是一种用于临时接收邮件的虚拟邮箱。与常规邮箱不同,临时邮箱旨在提供一种匿名且临时的邮件接收解决方案。
|
||||
### 📧 邮件处理
|
||||
|
||||
临时邮箱往往由网站或在线服务提供商提供,用户可以在需要注册或接收验证邮件时使用临时邮箱地址,而无需暴露自己的真实邮箱地址。这样做的好处是可以保护个人隐私
|
||||
- [x] 使用 `rust wasm` 解析邮件,解析速度快,几乎所有邮件都能解析,node 的解析模块解析邮件失败的邮件,rust wasm 也能解析成功
|
||||
- [x] 支持发送邮件,支持 `DKIM` 验证
|
||||
- [x] 支持 `SMTP` 和 `Resend` 等多种发送方式
|
||||
- [x] 增加查看 `附件` 功能,支持附件图片显示
|
||||
- [x] 支持 S3 附件存储和删除功能
|
||||
- [x] 垃圾邮件检测和黑白名单配置
|
||||
- [x] 邮件转发功能,支持全局转发地址
|
||||
|
||||
---
|
||||
### 👥 用户管理
|
||||
|
||||
## Cloudflare 服务
|
||||
- [x] 使用 `凭证` 重新登录之前的邮箱
|
||||
- [x] 添加完整的用户注册登录功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证切换不同邮箱
|
||||
- [x] 支持 `OAuth2` 第三方登录(Github、Authentik 等)
|
||||
- [x] 支持 `Passkey` 无密码登录
|
||||
- [x] 用户角色管理,支持多角色域名和前缀配置
|
||||
- [x] 用户收件箱查看,支持地址和关键词过滤
|
||||
|
||||
- `D1` 是 `Cloudflare` 的原生无服务器数据库。
|
||||
- `Pages` 是 `Cloudflare` 的静态网站托管服务, 速度超快,始终保持最新状态。
|
||||
- `Workers` 是 `Cloudflare` 的 `serverless` 应用服务,可以在全球 300 个数据中心运行代码, 而无需配置或维护基础架构。
|
||||
- `Cloudflare Email Routing` 可以处理域名的所有电子邮件流量,而无需管理电子邮件服务器。
|
||||
### 🔧 管理功能
|
||||
|
||||
---
|
||||
- [x] 完整的 admin 控制台
|
||||
- [x] `admin` 后台创建无前缀邮箱
|
||||
- [x] admin 用户管理页面,增加用户地址查看功能
|
||||
- [x] 定时清理功能,支持多种清理策略
|
||||
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
|
||||
- [x] 增加访问密码,可作为私人站点
|
||||
|
||||
## wrangler 的安装
|
||||
### 🌐 多语言与界面
|
||||
|
||||
安装 wrangler
|
||||
- [x] 前后台均支持多语言
|
||||
- [x] 现代化 UI 设计,支持响应式布局
|
||||
- [x] 支持 Google Ads 集成
|
||||
- [x] 使用 shadow DOM 防止样式污染
|
||||
- [x] 支持 URL JWT 参数自动登录
|
||||
|
||||
```bash
|
||||
npm install wrangler -g
|
||||
```
|
||||
### 🤖 集成与扩展
|
||||
|
||||
克隆项目
|
||||
- [x] 完整的 `Telegram Bot` 支持,以及 `Telegram` 推送,Telegram Bot 小程序
|
||||
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件,`IMAP` 查看邮件
|
||||
- [x] Webhook 支持,消息推送集成
|
||||
- [x] 支持 `CF Turnstile` 人机验证
|
||||
- [x] 限流配置,防止滥用
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dreamhunter2333/cloudflare_temp_email.git
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
## 🏗️ 技术架构
|
||||
|
||||
## D1 数据库
|
||||
<details>
|
||||
<summary>🏗️ 技术架构详情(点击收缩/展开)</summary>
|
||||
|
||||
第一次执行登录 wrangler 命令时,会提示登录, 按提示操作即可
|
||||
### 🏛️ 系统架构
|
||||
|
||||
```bash
|
||||
# 创建 D1 并执行 schema.sql
|
||||
wrangler d1 create dev
|
||||
wrangler d1 execute dev --file=db/schema.sql
|
||||
# schema 更新,如果你在此日期之前初始化过数据库,可以执行此命令更新
|
||||
# wrangler d1 execute dev --file=db/2024-01-13-patch.sql
|
||||
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql
|
||||
```
|
||||
- **数据库**: Cloudflare D1 作为主数据库
|
||||
- **前端部署**: 使用 Cloudflare Pages 部署前端
|
||||
- **后端部署**: 使用 Cloudflare Workers 部署后端
|
||||
- **邮件转发**: 使用 Cloudflare Email Routing
|
||||
|
||||
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
|
||||
### 🛠️ 技术栈
|
||||
|
||||

|
||||
- **前端**: Vue 3 + Vite + TypeScript
|
||||
- **后端**: TypeScript + Cloudflare Workers
|
||||
- **邮件解析**: Rust WASM (mail-parser-wasm)
|
||||
- **数据库**: Cloudflare D1 (SQLite)
|
||||
- **存储**: Cloudflare KV + R2 (可选 S3)
|
||||
- **代理服务**: Python SMTP/IMAP Proxy Server
|
||||
|
||||
---
|
||||
### 📦 主要组件
|
||||
|
||||
## Cloudflare workers 后端
|
||||
- **Worker**: 核心后端服务
|
||||
- **Frontend**: Vue 3 用户界面
|
||||
- **Mail Parser WASM**: Rust 邮件解析模块
|
||||
- **SMTP Proxy Server**: Python 邮件代理服务
|
||||
- **Pages Functions**: Cloudflare Pages 中间件
|
||||
- **Documentation**: VitePress 文档站点
|
||||
|
||||
初始化项目
|
||||
</details>
|
||||
|
||||
```bash
|
||||
cd worker
|
||||
pnpm install
|
||||
cp wrangler.toml.template wrangler.toml
|
||||
```
|
||||
## 🌟 加入社区
|
||||
|
||||
修改 `wrangler.toml` 文件
|
||||
|
||||
```bash
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.js"
|
||||
compatibility_date = "2023-08-14"
|
||||
node_compat = true
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp" # 要处理的邮箱名称前缀
|
||||
# 如果你想要你的网站私有,取消下面的注释,并修改密码
|
||||
# PASSWORDS = ["123", "456"]
|
||||
# admin 控制台密码, 不配置则不允许访问控制台
|
||||
# ADMIN_PASSWORDS = ["123", "456"]
|
||||
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名
|
||||
JWT_SECRET = "xxx" # 用于生成 jwt 的密钥
|
||||
BLACK_LIST = "" # 黑名单,用于过滤发件人,逗号分隔
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "xxx" # D1 数据库名称
|
||||
database_id = "xxx" # D1 数据库 ID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare Workers 后端
|
||||
|
||||
部署
|
||||
|
||||
```bash
|
||||
pnpm run deploy
|
||||
```
|
||||
|
||||
部署成功之后再路由中可以看到 `worker` 的 `url`,控制台也会输出 `worker` 的 `url`
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Cloudflare Email Routing
|
||||
|
||||
配置对应域名的 `电子邮件 DNS 记录`
|
||||
|
||||
配置 `Cloudflare Email Routing` catch-all 发送到 `worker`
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Cloudflare Pages 前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
修改 `.env.local` 文件, 将 `VITE_API_BASE` 修改为 `worker` 的 `url`, 不要在末尾加 `/`
|
||||
|
||||
例如: `VITE_API_BASE=https://xxx.xxx.workers.dev`
|
||||
|
||||
```bash
|
||||
pnpm build --emptyOutDir
|
||||
# 根据提示创建 pages
|
||||
pnpm run deploy
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 参考资料
|
||||
|
||||
- https://developers.cloudflare.com/d1/
|
||||
- https://developers.cloudflare.com/pages/
|
||||
- https://developers.cloudflare.com/workers/
|
||||
- https://developers.cloudflare.com/email-routing/
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
|
||||
102
README_EN.md
102
README_EN.md
@@ -1,84 +1,46 @@
|
||||
# cloudflare temp email
|
||||
<!-- markdownlint-disable-file MD033 MD045 -->
|
||||
# Cloudflare Temp Email
|
||||
|
||||
## [中文](README.md)
|
||||
<p align="center">
|
||||
<a href="README.md">🇨🇳 中文</a> |
|
||||
<a href="README_EN.md">🇺🇸 English</a>
|
||||
</p>
|
||||
|
||||
[CHANGELOG](CHANGELOG)
|
||||
**A fully-featured temporary email service built on Cloudflare's free services.**
|
||||
|
||||
## [Live Demo](https://temp-email.dreamhunter2333.xyz/)
|
||||
> This project is for learning and personal use only.
|
||||
|
||||
This is a temporary email service that uses Cloudflare Workers to create a temporary email address and view the received email in web browser.
|
||||
## 🚀 Quick Start
|
||||
|
||||
## Features
|
||||
- [📖 Documentation](https://temp-mail-docs.awsl.uk/en/)
|
||||
- [🎯 Live Demo](https://mail.awsl.uk/)
|
||||
- [📝 CHANGELOG](CHANGELOG.md)
|
||||
|
||||
- [x] Cloudflare D1 as a database
|
||||
- [x] Deploy the front end with Cloudflare Pages
|
||||
- [x] Deploy the backend with Cloudflare Workers
|
||||
- [x] Email forwarding using Cloudflare Email Routing
|
||||
- [x] Use password to login to the previous mailbox again.
|
||||
- [x] Get Custom Name Email
|
||||
- [x] Support multiple languages
|
||||
- [x] Add access authorization, which can be used as a private site
|
||||
- [x] Add auto reply feature
|
||||
- [x] Add attachment viewing function
|
||||
- [x] use rust wasm to parse email
|
||||
<p align="center">
|
||||
<a href="https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html">
|
||||
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers">
|
||||
</a>
|
||||
</p>
|
||||
|
||||

|
||||
## ✨ Key Features
|
||||
|
||||
## Deploy
|
||||
- **<2A> Email Processing**: Rust WASM parser, SMTP/IMAP support, attachments, auto-reply
|
||||
- **👥 User Management**: OAuth2 login, Passkey authentication, role management
|
||||
- **🌐 Admin Panel**: Complete admin console, user management, scheduled cleanup
|
||||
- **🤖 Integrations**: Telegram Bot, webhooks, CAPTCHA, rate limiting
|
||||
- **<2A> Modern UI**: Multi-language, responsive design, JWT auto-login
|
||||
|
||||
[Install/Update Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/)
|
||||
## 🏗️ Tech Stack
|
||||
|
||||
## DB - Cloudflare D1
|
||||
- **Frontend**: Vue 3 + TypeScript + Vite
|
||||
- **Backend**: Cloudflare Workers + D1 Database
|
||||
- **Email**: Cloudflare Email Routing + Rust WASM Parser
|
||||
- **Storage**: Cloudflare KV + R2 (optional S3)
|
||||
|
||||
```bash
|
||||
# create a database, and copy the output to wrangler.toml in the next step
|
||||
wrangler d1 create dev
|
||||
wrangler d1 execute dev --file=db/schema.sql
|
||||
# schema update, if you have initialized the database before this date, you can execute this command to update
|
||||
# wrangler d1 execute dev --file=db/2024-01-13-patch.sql
|
||||
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql
|
||||
```
|
||||
## 🌟 Community
|
||||
|
||||

|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
|
||||
### Backend - Cloudflare workers
|
||||
## 📄 License
|
||||
|
||||
```bash
|
||||
cd worker
|
||||
pnpm install
|
||||
# copy wrangler.toml.template to wrangler.toml
|
||||
# and add your d1 config and these config
|
||||
# PREFIX = "tmp" - the email create will be like tmp<xxxxx>@DOMAIN
|
||||
# IF YOU WANT TO MAKE YOUR SITE PRIVATE, UNCOMMENT THE FOLLOWING LINES
|
||||
# PASSWORDS = ["123", "456"]
|
||||
# For admin panel, if not set will no allow to access the admin panel
|
||||
# ADMIN_PASSWORDS = ["123", "456"]
|
||||
# DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] you domain name
|
||||
# JWT_SECRET = "xxx"
|
||||
# BLACK_LIST = ""
|
||||
cp wrangler.toml.template wrangler.toml
|
||||
# deploy
|
||||
pnpm run deploy
|
||||
```
|
||||
|
||||
you can find and test the worker's url in the workers dashboard
|
||||
|
||||

|
||||
|
||||
enable email route and config email forward catch-all to the worker
|
||||
|
||||

|
||||
|
||||
### Frontend - Cloudflare pages
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
# add .env.local and modify VITE_API_BASE to your worker's url
|
||||
# VITE_API_BASE=https://xxx.xxx.workers.dev - don't put / in the end
|
||||
cp .env.example .env.local
|
||||
pnpm build --emptyOutDir
|
||||
pnpm run deploy
|
||||
```
|
||||
|
||||

|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
14
db/2024-04-12-patch.sql
Normal file
14
db/2024-04-12-patch.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS address_sender (
|
||||
id INTEGER PRIMARY KEY,
|
||||
address TEXT UNIQUE,
|
||||
balance INTEGER DEFAULT 0,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sendbox (
|
||||
id INTEGER PRIMARY KEY,
|
||||
address TEXT,
|
||||
raw TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
6
db/2024-05-01-patch.sql
Normal file
6
db/2024-05-01-patch.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
21
db/2024-05-08-patch.sql
Normal file
21
db/2024-05-08-patch.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
user_info TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users_address (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
address_id INTEGER UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);
|
||||
9
db/2024-07-14-patch.sql
Normal file
9
db/2024-07-14-patch.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER UNIQUE NOT NULL,
|
||||
role_text TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
|
||||
14
db/2024-08-10-patch.sql
Normal file
14
db/2024-08-10-patch.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS user_passkeys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
passkey_name TEXT NOT NULL,
|
||||
passkey_id TEXT NOT NULL,
|
||||
passkey TEXT NOT NULL,
|
||||
counter INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
|
||||
4
db/2025-09-23-patch.sql
Normal file
4
db/2025-09-23-patch.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE
|
||||
address
|
||||
ADD
|
||||
password TEXT;
|
||||
101
db/schema.sql
101
db/schema.sql
@@ -1,13 +1,3 @@
|
||||
CREATE TABLE IF NOT EXISTS mails (
|
||||
id INTEGER PRIMARY KEY,
|
||||
message_id TEXT,
|
||||
source TEXT,
|
||||
address TEXT,
|
||||
subject TEXT,
|
||||
message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS raw_mails (
|
||||
id INTEGER PRIMARY KEY,
|
||||
message_id TEXT,
|
||||
@@ -17,13 +7,24 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address (
|
||||
id INTEGER PRIMARY KEY,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
password TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auto_reply_mails (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_prefix TEXT,
|
||||
@@ -35,11 +36,79 @@ CREATE TABLE IF NOT EXISTS auto_reply_mails (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
CREATE INDEX IF NOT EXISTS idx_auto_reply_mails_address ON auto_reply_mails(address);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address_sender (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source TEXT,
|
||||
address TEXT,
|
||||
message_id TEXT,
|
||||
data TEXT,
|
||||
address TEXT UNIQUE,
|
||||
balance INTEGER DEFAULT 0,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_sender_address ON address_sender(address);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sendbox (
|
||||
id INTEGER PRIMARY KEY,
|
||||
address TEXT,
|
||||
raw TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sendbox_created_at ON sendbox(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
user_info TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users_address (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
address_id INTEGER UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER UNIQUE NOT NULL,
|
||||
role_text TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_passkeys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
passkey_name TEXT NOT NULL,
|
||||
passkey_id TEXT NOT NULL,
|
||||
passkey TEXT NOT NULL,
|
||||
counter INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
VITE_API_BASE=https://temp-email-api.xxx.xxx
|
||||
VITE_CF_WEB_ANALY_TOKEN=
|
||||
VITE_IS_TELEGRAM=false
|
||||
|
||||
2
frontend/.env.pages
Normal file
2
frontend/.env.pages
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_CF_WEB_ANALY_TOKEN=
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -28,4 +28,7 @@ coverage
|
||||
*.sw?
|
||||
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.pages
|
||||
*-dist/
|
||||
components.d.ts
|
||||
|
||||
3
frontend/.vscode/extensions.json
vendored
3
frontend/.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/logo.png" sizes="any">
|
||||
<link rel="apple-touch-icon" href="/logo.png">
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,36 +1,55 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build -m prod --emptyOutDir",
|
||||
"build:release": "vite build -m example --emptyOutDir",
|
||||
"build:pages": "vite build -m pages --emptyOutDir",
|
||||
"build:pages:nopwa": "VITE_PWA_DISABLED=true vite build -m pages --emptyOutDir",
|
||||
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
|
||||
"build:telegram:pages": "VITE_IS_TELEGRAM=true vite build -m pages --emptyOutDir",
|
||||
"build:telegram:release": "VITE_IS_TELEGRAM=true vite build -m example --emptyOutDir",
|
||||
"preview": "vite preview",
|
||||
"deploy": "npm run build && wrangler pages deploy ../dist --branch production"
|
||||
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
|
||||
"deploy:actions:telegram": "npm run build:telegram && wrangler pages deploy ./dist",
|
||||
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
|
||||
"deploy": "npm run build && wrangler pages deploy ./dist --branch production",
|
||||
"deploy:actions": "npm run build && wrangler pages deploy ./dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vicons/material": "^0.12.0",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"axios": "^1.6.8",
|
||||
"mail-parser-wasm": "^0.1.6",
|
||||
"naive-ui": "^2.38.1",
|
||||
"postal-mime": "^2.2.1",
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@simplewebauthn/browser": "10.0.0",
|
||||
"@unhead/vue": "^1.11.20",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.13.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.2.1",
|
||||
"naive-ui": "^2.43.1",
|
||||
"postal-mime": "^2.6.0",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.4.21",
|
||||
"vue": "^3.5.22",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^9.10.2",
|
||||
"vue-router": "^4.3.0"
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.12.0",
|
||||
"@vitejs/plugin-vue": "^4.6.2",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.2.6",
|
||||
"vite-plugin-pwa": "^0.19.7",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"workbox-window": "^7.0.0"
|
||||
}
|
||||
"@vicons/fa": "^0.13.0",
|
||||
"@vicons/material": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-vue-components": "^28.8.0",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"workbox-build": "^7.3.0",
|
||||
"workbox-window": "^7.3.0",
|
||||
"wrangler": "^4.45.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
8438
frontend/pnpm-lock.yaml
generated
8438
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -1,23 +1,34 @@
|
||||
<script setup>
|
||||
import { darkTheme, NGlobalStyle } from 'naive-ui'
|
||||
import { zhCN } from 'naive-ui'
|
||||
import { darkTheme, NGlobalStyle, zhCN } from 'naive-ui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useScript } from '@unhead/vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from './store'
|
||||
import { useIsMobile } from './utils/composables'
|
||||
import Header from './views/Header.vue';
|
||||
import Footer from './views/Footer.vue';
|
||||
import { api } from './api'
|
||||
|
||||
const { localeCache, themeSwitch, loading } = useGlobalState()
|
||||
const theme = computed(() => themeSwitch.value ? darkTheme : null)
|
||||
const localeConfig = computed(() => localeCache.value == 'zh' ? zhCN : null)
|
||||
const {
|
||||
isDark, loading, useSideMargin, telegramApp, isTelegram
|
||||
} = useGlobalState()
|
||||
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
|
||||
const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
|
||||
const { locale } = useI18n({});
|
||||
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||
const localeConfig = computed(() => locale.value == 'zh' ? zhCN : null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const { locale } = useI18n({
|
||||
useScope: 'global',
|
||||
});
|
||||
locale.value = localeCache.value;
|
||||
const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
|
||||
const showAd = computed(() => !isMobile.value && adClient && adSlot);
|
||||
const gridMaxCols = computed(() => showAd.value ? 8 : 12);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await api.getUserSettings();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
|
||||
|
||||
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
|
||||
@@ -29,6 +40,35 @@ onMounted(async () => {
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
// check if google ad is enabled
|
||||
if (showAd.value) {
|
||||
useScript({
|
||||
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
|
||||
async: true,
|
||||
crossorigin: "anonymous",
|
||||
});
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
}
|
||||
|
||||
|
||||
// check if telegram is enabled
|
||||
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
|
||||
if (
|
||||
(typeof enableTelegram === 'boolean' && enableTelegram === true)
|
||||
||
|
||||
(typeof enableTelegram === 'string' && enableTelegram === 'true')
|
||||
) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://telegram.org/js/telegram-web-app.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
telegramApp.value = window.Telegram?.WebApp || {};
|
||||
isTelegram.value = !!window.Telegram?.WebApp?.initData;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -36,21 +76,36 @@ onMounted(async () => {
|
||||
<n-config-provider :locale="localeConfig" :theme="theme">
|
||||
<n-global-style />
|
||||
<n-spin description="loading..." :show="loading">
|
||||
<n-message-provider>
|
||||
<n-grid x-gap="12" :cols="12">
|
||||
<n-gi v-if="!isMobile" span="1"></n-gi>
|
||||
<n-gi :span="isMobile ? 12 : 10">
|
||||
<div class="main">
|
||||
<n-space vertical>
|
||||
<Header />
|
||||
<router-view></router-view>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi v-if="!isMobile" span="1"></n-gi>
|
||||
</n-grid>
|
||||
<n-back-top />
|
||||
</n-message-provider>
|
||||
<n-notification-provider container-style="margin-top: 60px;">
|
||||
<n-message-provider container-style="margin-top: 20px;">
|
||||
<n-grid x-gap="12" :cols="gridMaxCols">
|
||||
<n-gi v-if="showSideMargin" span="1">
|
||||
<div class="side" v-if="showAd">
|
||||
<ins class="adsbygoogle" style="display:block" :data-ad-client="adClient" :data-ad-slot="adSlot"
|
||||
data-ad-format="auto" data-full-width-responsive="true"></ins>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi :span="!showSideMargin ? gridMaxCols : (gridMaxCols - 2)">
|
||||
<div class="main">
|
||||
<n-space vertical>
|
||||
<n-layout style="min-height: 80vh;">
|
||||
<Header />
|
||||
<router-view></router-view>
|
||||
</n-layout>
|
||||
<Footer />
|
||||
</n-space>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi v-if="showSideMargin" span="1">
|
||||
<div class="side" v-if="showAd">
|
||||
<ins class="adsbygoogle" style="display:block" :data-ad-client="adClient" :data-ad-slot="adSlot"
|
||||
data-ad-format="auto" data-full-width-responsive="true"></ins>
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-back-top />
|
||||
</n-message-provider>
|
||||
</n-notification-provider>
|
||||
</n-spin>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
@@ -1,38 +1,51 @@
|
||||
import { useGlobalState } from '../store'
|
||||
import { h } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { getFingerprint } from '../utils/fingerprint'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||
const { loading, auth, jwt, settings, openSettings } = useGlobalState();
|
||||
const { showAuth, adminAuth, showAdminAuth } = useGlobalState();
|
||||
const {
|
||||
loading, auth, jwt, settings, openSettings,
|
||||
userOpenSettings, userSettings, announcement,
|
||||
showAuth, adminAuth, showAdminAuth, userJwt
|
||||
} = useGlobalState();
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: API_BASE,
|
||||
timeout: 10000
|
||||
timeout: 30000,
|
||||
validateStatus: (status) => status >= 200 && status <= 500
|
||||
});
|
||||
|
||||
const apiFetch = async (path, options = {}) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Get browser fingerprint for request tracking
|
||||
const fingerprint = await getFingerprint();
|
||||
|
||||
const response = await instance.request(path, {
|
||||
method: options.method || 'GET',
|
||||
data: options.body || null,
|
||||
headers: {
|
||||
'x-lang': i18n.global.locale.value,
|
||||
'x-user-token': options.userJwt || userJwt.value,
|
||||
'x-user-access-token': userSettings.value.access_token,
|
||||
'x-custom-auth': auth.value,
|
||||
'x-admin-auth': adminAuth.value,
|
||||
'x-fingerprint': fingerprint,
|
||||
'Authorization': `Bearer ${jwt.value}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (response.status === 401 && openSettings.value.auth) {
|
||||
showAuth.value = true;
|
||||
throw new Error("Unauthorized, you password is wrong")
|
||||
}
|
||||
if (response.status === 401 && path.startsWith("/admin")) {
|
||||
showAdminAuth.value = true;
|
||||
throw new Error("Unauthorized, you admin password is wrong")
|
||||
}
|
||||
if (response.status === 401 && openSettings.value.auth) {
|
||||
showAuth.value = true;
|
||||
}
|
||||
if (response.status >= 300) {
|
||||
throw new Error(`${response.status} ${response.data}` || "error");
|
||||
throw new Error(`[${response.status}]: ${response.data}` || "error");
|
||||
}
|
||||
const data = response.data;
|
||||
return data;
|
||||
@@ -46,24 +59,61 @@ const apiFetch = async (path, options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getOpenSettings = async (message) => {
|
||||
const getOpenSettings = async (message, notification) => {
|
||||
try {
|
||||
const res = await api.fetch("/open_api/settings");
|
||||
openSettings.value = {
|
||||
const domainLabels = res["domainLabels"] || [];
|
||||
if (res["domains"]?.length < 1) {
|
||||
message.error("No domains found, please check your worker settings");
|
||||
}
|
||||
Object.assign(openSettings.value, {
|
||||
...res,
|
||||
title: res["title"] || "",
|
||||
prefix: res["prefix"] || "",
|
||||
minAddressLen: res["minAddressLen"] || 1,
|
||||
maxAddressLen: res["maxAddressLen"] || 30,
|
||||
needAuth: res["needAuth"] || false,
|
||||
domains: res["domains"].map((domain) => {
|
||||
defaultDomains: res["defaultDomains"] || [],
|
||||
domains: res["domains"].map((domain, index) => {
|
||||
return {
|
||||
label: domain,
|
||||
label: domainLabels.length > index ? domainLabels[index] : domain,
|
||||
value: domain
|
||||
}
|
||||
})
|
||||
};
|
||||
}),
|
||||
adminContact: res["adminContact"] || "",
|
||||
enableUserCreateEmail: res["enableUserCreateEmail"] || false,
|
||||
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
|
||||
disableCustomAddressName: res["disableCustomAddressName"] || false,
|
||||
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
||||
enableAutoReply: res["enableAutoReply"] || false,
|
||||
enableIndexAbout: res["enableIndexAbout"] || false,
|
||||
copyright: res["copyright"] || openSettings.value.copyright,
|
||||
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
|
||||
enableWebhook: res["enableWebhook"] || false,
|
||||
isS3Enabled: res["isS3Enabled"] || false,
|
||||
enableAddressPassword: res["enableAddressPassword"] || false,
|
||||
});
|
||||
if (openSettings.value.needAuth) {
|
||||
showAuth.value = true;
|
||||
}
|
||||
if (openSettings.value.announcement
|
||||
&& !openSettings.value.fetched
|
||||
&& (openSettings.value.announcement != announcement.value
|
||||
|| openSettings.value.alwaysShowAnnouncement)
|
||||
) {
|
||||
announcement.value = openSettings.value.announcement;
|
||||
notification.info({
|
||||
content: () => {
|
||||
return h("div", {
|
||||
innerHTML: announcement.value
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
openSettings.value.fetched = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,17 +126,54 @@ const getSettings = async () => {
|
||||
settings.value = {
|
||||
address: res["address"],
|
||||
auto_reply: res["auto_reply"],
|
||||
has_v1_mails: res["has_v1_mails"],
|
||||
send_balance: res["send_balance"],
|
||||
};
|
||||
} finally {
|
||||
settings.value.fetched = true;
|
||||
}
|
||||
}
|
||||
|
||||
const adminShowPassword = async (id) => {
|
||||
|
||||
const getUserOpenSettings = async (message) => {
|
||||
try {
|
||||
const { password } = await apiFetch(`/admin/show_password/${id}`);
|
||||
return password;
|
||||
const res = await api.fetch(`/user_api/open_settings`);
|
||||
Object.assign(userOpenSettings.value, res);
|
||||
} catch (error) {
|
||||
message.error(error.message || "fetch settings failed");
|
||||
} finally {
|
||||
userOpenSettings.value.fetched = true;
|
||||
}
|
||||
}
|
||||
|
||||
const getUserSettings = async (message) => {
|
||||
try {
|
||||
if (!userJwt.value) return;
|
||||
const res = await api.fetch("/user_api/settings")
|
||||
Object.assign(userSettings.value, res)
|
||||
// auto refresh user jwt
|
||||
if (userSettings.value.new_user_token) {
|
||||
try {
|
||||
await api.fetch("/user_api/settings", {
|
||||
userJwt: userSettings.value.new_user_token,
|
||||
})
|
||||
userJwt.value = userSettings.value.new_user_token;
|
||||
console.log("User JWT updated successfully");
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to update user JWT", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message?.error(error.message || "error");
|
||||
} finally {
|
||||
userSettings.value.fetched = true;
|
||||
}
|
||||
}
|
||||
|
||||
const adminShowAddressCredential = async (id) => {
|
||||
try {
|
||||
const { jwt: addressCredential } = await apiFetch(`/admin/show_password/${id}`);
|
||||
return addressCredential;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
@@ -102,10 +189,24 @@ const adminDeleteAddress = async (id) => {
|
||||
}
|
||||
}
|
||||
|
||||
const bindUserAddress = async () => {
|
||||
if (!userJwt.value) return;
|
||||
try {
|
||||
await apiFetch(`/user_api/bind_address`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
fetch: apiFetch,
|
||||
getSettings: getSettings,
|
||||
getOpenSettings: getOpenSettings,
|
||||
adminShowPassword: adminShowPassword,
|
||||
adminDeleteAddress: adminDeleteAddress,
|
||||
getSettings,
|
||||
getOpenSettings,
|
||||
getUserOpenSettings,
|
||||
getUserSettings,
|
||||
adminShowAddressCredential,
|
||||
adminDeleteAddress,
|
||||
bindUserAddress,
|
||||
}
|
||||
|
||||
587
frontend/src/components/MailBox.vue
Normal file
587
frontend/src/components/MailBox.vue
Normal file
@@ -0,0 +1,587 @@
|
||||
<script setup>
|
||||
import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled } from '@vicons/material'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { processItem } from '../utils/email-parser'
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import MailContentRenderer from "./MailContentRenderer.vue";
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const props = defineProps({
|
||||
enableUserDeleteEmail: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
showEMailTo: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
required: false
|
||||
},
|
||||
fetchMailData: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
required: true
|
||||
},
|
||||
deleteMail: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
required: false
|
||||
},
|
||||
showReply: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
showSaveS3: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
saveToS3: {
|
||||
type: Function,
|
||||
default: (mail_id, filename, blob) => { },
|
||||
required: false
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
|
||||
autoRefresh, configAutoRefreshInterval, sendMailModel
|
||||
} = useGlobalState()
|
||||
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
|
||||
const data = ref([])
|
||||
const timer = ref(null)
|
||||
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const canGoPrevMail = computed(() => {
|
||||
if (!curMail.value) return false
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
return currentIndex > 0 || page.value > 1
|
||||
})
|
||||
|
||||
const canGoNextMail = computed(() => {
|
||||
if (!curMail.value) return false
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
return currentIndex < data.value.length - 1 || count.value > page.value * pageSize.value
|
||||
})
|
||||
|
||||
const prevMail = async () => {
|
||||
if (!canGoPrevMail.value) return
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
curMail.value = data.value[currentIndex - 1]
|
||||
} else if (page.value > 1) {
|
||||
page.value--
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
curMail.value = data.value[data.value.length - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextMail = async () => {
|
||||
if (!canGoNextMail.value) return
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex < data.value.length - 1) {
|
||||
curMail.value = data.value[currentIndex + 1]
|
||||
} else if (count.value > page.value * pageSize.value) {
|
||||
page.value++
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
curMail.value = data.value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const curMail = ref(null);
|
||||
|
||||
const multiActionMode = ref(false)
|
||||
const showMultiActionDownload = ref(false)
|
||||
const showMultiActionDelete = ref(false)
|
||||
const multiActionDownloadZip = ref({})
|
||||
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
success: 'Success',
|
||||
autoRefresh: 'Auto Refresh',
|
||||
refreshAfter: 'Refresh After {msg} Seconds',
|
||||
refresh: 'Refresh',
|
||||
attachments: 'Show Attachments',
|
||||
downloadMail: 'Download Mail',
|
||||
pleaseSelectMail: "Please select mail",
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete mail?',
|
||||
reply: 'Reply',
|
||||
forwardMail: 'Forward',
|
||||
showTextMail: 'Show Text Mail',
|
||||
showHtmlMail: 'Show Html Mail',
|
||||
saveToS3: 'Save to S3',
|
||||
multiAction: 'Multi Action',
|
||||
cancelMultiAction: 'Cancel Multi Action',
|
||||
selectAll: 'Select All of This Page',
|
||||
unselectAll: 'Unselect All',
|
||||
prevMail: 'Previous',
|
||||
nextMail: 'Next',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
autoRefresh: '自动刷新',
|
||||
refreshAfter: '{msg}秒后刷新',
|
||||
refresh: '刷新',
|
||||
downloadMail: '下载邮件',
|
||||
attachments: '查看附件',
|
||||
pleaseSelectMail: "请选择邮件",
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
reply: '回复',
|
||||
forwardMail: '转发',
|
||||
showTextMail: '显示纯文本邮件',
|
||||
showHtmlMail: '显示HTML邮件',
|
||||
saveToS3: '保存到S3',
|
||||
multiAction: '多选',
|
||||
cancelMultiAction: '取消多选',
|
||||
selectAll: '全选本页',
|
||||
unselectAll: '取消全选',
|
||||
prevMail: '上一封',
|
||||
nextMail: '下一封',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const setupAutoRefresh = async (autoRefresh) => {
|
||||
// auto refresh every configAutoRefreshInterval seconds
|
||||
autoRefreshInterval.value = configAutoRefreshInterval.value;
|
||||
if (autoRefresh) {
|
||||
clearInterval(timer.value);
|
||||
timer.value = setInterval(async () => {
|
||||
if (loading.value) return;
|
||||
autoRefreshInterval.value--;
|
||||
if (autoRefreshInterval.value <= 0) {
|
||||
autoRefreshInterval.value = configAutoRefreshInterval.value;
|
||||
await backFirstPageAndRefresh();
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
clearInterval(timer.value)
|
||||
timer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(autoRefresh, async (autoRefresh, old) => {
|
||||
setupAutoRefresh(autoRefresh)
|
||||
}, { immediate: true })
|
||||
|
||||
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
|
||||
if (page !== oldPage || pageSize !== oldPageSize) {
|
||||
await refresh();
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const { results, count: totalCount } = await props.fetchMailData(
|
||||
pageSize.value, (page.value - 1) * pageSize.value
|
||||
);
|
||||
loading.value = true;
|
||||
data.value = await Promise.all(results.map(async (item) => {
|
||||
item.checked = false;
|
||||
return await processItem(item);
|
||||
}));
|
||||
if (totalCount > 0) {
|
||||
count.value = totalCount;
|
||||
}
|
||||
curMail.value = null;
|
||||
if (!isMobile.value && data.value.length > 0) {
|
||||
curMail.value = data.value[0];
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const backFirstPageAndRefresh = async () => {
|
||||
page.value = 1;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
const clickRow = async (row) => {
|
||||
if (multiActionMode.value) {
|
||||
row.checked = !row.checked;
|
||||
return;
|
||||
}
|
||||
curMail.value = row;
|
||||
};
|
||||
|
||||
|
||||
const mailItemClass = (row) => {
|
||||
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
|
||||
};
|
||||
|
||||
const deleteMail = async () => {
|
||||
try {
|
||||
await props.deleteMail(curMail.value.id);
|
||||
message.success(t("success"));
|
||||
curMail.value = null;
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
const replyMail = async () => {
|
||||
const emailRegex = /(.+?) <(.+?)>/;
|
||||
let toMail = curMail.value.originalSource;
|
||||
let toName = ""
|
||||
const match = emailRegex.exec(curMail.value.source);
|
||||
if (match) {
|
||||
toName = match[1];
|
||||
toMail = match[2];
|
||||
}
|
||||
Object.assign(sendMailModel.value, {
|
||||
toName: toName,
|
||||
toMail: toMail,
|
||||
subject: `${t('reply')}: ${curMail.value.subject}`,
|
||||
contentType: 'rich',
|
||||
content: curMail.value.text ? `<p><br></p><blockquote>${curMail.value.text}</blockquote><p><br></p>` : '',
|
||||
});
|
||||
indexTab.value = 'sendmail';
|
||||
};
|
||||
|
||||
const forwardMail = async () => {
|
||||
Object.assign(sendMailModel.value, {
|
||||
subject: `${t('forwardMail')}: ${curMail.value.subject}`,
|
||||
contentType: curMail.value.message ? 'html' : 'text',
|
||||
content: curMail.value.message || curMail.value.text,
|
||||
});
|
||||
indexTab.value = 'sendmail';
|
||||
};
|
||||
|
||||
const onSpiltSizeChange = (size) => {
|
||||
mailboxSplitSize.value = size;
|
||||
}
|
||||
|
||||
const saveToS3Proxy = async (filename, blob) => {
|
||||
await props.saveToS3(curMail.value.id, filename, blob);
|
||||
}
|
||||
|
||||
const multiActionModeClick = (enableMulti) => {
|
||||
if (enableMulti) {
|
||||
data.value.forEach((item) => {
|
||||
item.checked = false;
|
||||
});
|
||||
multiActionMode.value = true;
|
||||
} else {
|
||||
multiActionMode.value = false;
|
||||
data.value.forEach((item) => {
|
||||
item.checked = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const multiActionSelectAll = (checked) => {
|
||||
data.value.forEach((item) => {
|
||||
item.checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
const multiActionDeleteMail = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const selectedMails = data.value.filter((item) => item.checked);
|
||||
if (selectedMails.length === 0) {
|
||||
message.error(t('pleaseSelectMail'));
|
||||
return;
|
||||
}
|
||||
multiActionDeleteProgress.value = {
|
||||
percentage: 0,
|
||||
tip: `0/${selectedMails.length}`
|
||||
};
|
||||
for (const [index, mail] of selectedMails.entries()) {
|
||||
await props.deleteMail(mail.id);
|
||||
showMultiActionDelete.value = true;
|
||||
multiActionDeleteProgress.value = {
|
||||
percentage: Math.floor((index + 1) / selectedMails.length * 100),
|
||||
tip: `${index + 1}/${selectedMails.length}`
|
||||
};
|
||||
}
|
||||
message.success(t("success"));
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
showMultiActionDelete.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
const multiActionDownload = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const selectedMails = data.value.filter((item) => item.checked);
|
||||
if (selectedMails.length === 0) {
|
||||
message.error(t('pleaseSelectMail'));
|
||||
return;
|
||||
}
|
||||
const JSZipModlue = await import('jszip');
|
||||
const JSZip = JSZipModlue.default;
|
||||
const zip = new JSZip();
|
||||
for (const mail of selectedMails) {
|
||||
zip.file(`${mail.id}.eml`, mail.raw);
|
||||
}
|
||||
multiActionDownloadZip.value = {
|
||||
url: URL.createObjectURL(await zip.generateAsync({ type: "blob" })),
|
||||
filename: `mails-${new Date().toISOString().replace(/:/g, '-')}.zip`
|
||||
}
|
||||
showMultiActionDownload.value = true;
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!isMobile" class="left">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<n-space v-if="multiActionMode">
|
||||
<n-button @click="multiActionModeClick(false)" tertiary>
|
||||
{{ t('cancelMultiAction') }}
|
||||
</n-button>
|
||||
<n-button @click="multiActionSelectAll(true)" tertiary>
|
||||
{{ t('selectAll') }}
|
||||
</n-button>
|
||||
<n-button @click="multiActionSelectAll(false)" tertiary>
|
||||
{{ t('unselectAll') }}
|
||||
</n-button>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="multiActionDeleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
<n-button @click="multiActionDownload" tertiary type="info">
|
||||
<template #icon>
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</template>
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-space v-else>
|
||||
<n-button @click="multiActionModeClick(true)" type="primary" tertiary>
|
||||
{{ t('multiAction') }}
|
||||
</n-button>
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
|
||||
show-size-picker />
|
||||
<n-switch v-model:value="autoRefresh" :round="false">
|
||||
<template #checked>
|
||||
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
|
||||
</template>
|
||||
<template #unchecked>
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
</n-switch>
|
||||
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div style="overflow: auto; min-height: 50vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
</template>
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
<n-ellipsis style="max-width: 240px;">
|
||||
{{ showEMailTo ? "FROM: " + row.source : row.source }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
<n-ellipsis style="max-width: 240px;">
|
||||
TO: {{ row.address }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</template>
|
||||
<template #2>
|
||||
<div v-if="curMail" style="margin: 8px;">
|
||||
<n-flex justify="space-between">
|
||||
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackIosNewFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('prevMail') }}
|
||||
</n-button>
|
||||
<n-button @click="nextMail" :disabled="!canGoNextMail" text size="small" icon-placement="right">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowForwardIosFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('nextMail') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
<n-card :bordered="false" embedded v-if="curMail" class="mail-item" :title="curMail.subject"
|
||||
style="overflow: auto; max-height: 100vh;">
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||
<n-result status="info" :title="t('pleaseSelectMail')">
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-split>
|
||||
</div>
|
||||
<div class="left" v-else>
|
||||
<n-space justify="center">
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
</div>
|
||||
<n-switch v-model:value="autoRefresh" size="small" :round="false">
|
||||
<template #checked>
|
||||
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
|
||||
</template>
|
||||
<template #unchecked>
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
</n-switch>
|
||||
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ showEMailTo ? "FROM: " + row.source : row.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ row.address }}
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
<n-drawer v-model:show="curMail" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
|
||||
style="height: 80vh;">
|
||||
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
|
||||
<n-card :bordered="false" embedded style="overflow: auto;">
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
||||
:onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
<n-modal v-model:show="showMultiActionDownload" preset="dialog" :title="t('downloadMail')">
|
||||
<n-tag type="info">
|
||||
{{ multiActionDownloadZip.filename }}
|
||||
</n-tag>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="multiActionDownloadZip.filename"
|
||||
:href="multiActionDownloadZip.url">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
{{ t('downloadMail') + " zip" }}
|
||||
</n-button>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showMultiActionDelete" preset="dialog" :title="t('delete') + t('success')"
|
||||
negative-text="OK">
|
||||
<n-space justify="center">
|
||||
<n-progress type="circle" status="error" :percentage="multiActionDeleteProgress.percentage">
|
||||
<span style="text-align: center">
|
||||
{{ multiActionDeleteProgress.tip }}
|
||||
</span>
|
||||
</n-progress>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.overlay-dark-backgroud {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.overlay-light-backgroud {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mail-item {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
282
frontend/src/components/MailContentRenderer.vue
Normal file
282
frontend/src/components/MailContentRenderer.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
|
||||
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
|
||||
import { getDownloadEmlUrl } from '../utils/email-parser';
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { useGlobalState } from '../store';
|
||||
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate } = useGlobalState();
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete mail?',
|
||||
attachments: 'View Attachments',
|
||||
downloadMail: 'Download Mail',
|
||||
reply: 'Reply',
|
||||
forward: 'Forward',
|
||||
showTextMail: 'Show Text Mail',
|
||||
showHtmlMail: 'Show HTML Mail',
|
||||
saveToS3: 'Save to S3',
|
||||
size: 'Size',
|
||||
fullscreen: 'Fullscreen',
|
||||
},
|
||||
zh: {
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
attachments: '查看附件',
|
||||
downloadMail: '下载邮件',
|
||||
reply: '回复',
|
||||
forward: '转发',
|
||||
showTextMail: '显示纯文本邮件',
|
||||
showHtmlMail: '显示HTML邮件',
|
||||
saveToS3: '保存到S3',
|
||||
size: '大小',
|
||||
fullscreen: '全屏',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
mail: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showEMailTo: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
enableUserDeleteEmail: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showReply: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showSaveS3: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 回调函数 props
|
||||
onDelete: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onReply: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onForward: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onSaveToS3: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
}
|
||||
});
|
||||
|
||||
const showTextMail = ref(preferShowTextMail.value);
|
||||
const showAttachments = ref(false);
|
||||
const curAttachments = ref([]);
|
||||
const attachmentLoding = ref(false);
|
||||
const showFullscreen = ref(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
props.onDelete();
|
||||
};
|
||||
|
||||
const handleViewAttachments = () => {
|
||||
curAttachments.value = props.mail.attachments;
|
||||
showAttachments.value = true;
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
props.onReply();
|
||||
};
|
||||
|
||||
const handleForward = () => {
|
||||
props.onForward();
|
||||
};
|
||||
|
||||
|
||||
const handleSaveToS3 = async (filename, blob) => {
|
||||
attachmentLoding.value = true;
|
||||
try {
|
||||
await props.onSaveToS3(filename, blob);
|
||||
} finally {
|
||||
attachmentLoding.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mail-content-renderer">
|
||||
<!-- 邮件信息标签 -->
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ mail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ utcToLocalDate(mail.created_at, useUTCDate.value) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ mail.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ mail.address }}
|
||||
</n-tag>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="handleDelete">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
|
||||
<n-button v-if="mail.attachments && mail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="handleViewAttachments">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(mail.raw)">
|
||||
<template #icon>
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</template>
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleReply">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleForward">
|
||||
<template #icon>
|
||||
<n-icon :component="ForwardFilled" />
|
||||
</template>
|
||||
{{ t('forward') }}
|
||||
</n-button>
|
||||
|
||||
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
|
||||
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
|
||||
</n-button>
|
||||
|
||||
<n-button size="small" tertiary type="info" @click="showFullscreen = true">
|
||||
<template #icon>
|
||||
<n-icon :component="FullscreenRound" />
|
||||
</template>
|
||||
{{ t('fullscreen') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<!-- 邮件内容 -->
|
||||
<div class="mail-content">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-drawer v-model:show="showFullscreen" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
|
||||
style="height: 100vh;">
|
||||
<n-drawer-content :title="mail.subject" closable>
|
||||
<div class="fullscreen-mail-content">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
|
||||
</div>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
|
||||
<!-- 附件模态框 -->
|
||||
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('attachments') }}</div>
|
||||
</template>
|
||||
<n-spin v-model:show="attachmentLoding">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.filename">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
Size: {{ row.size }}
|
||||
</n-tag>
|
||||
<n-button v-if="showSaveS3" @click="handleSaveToS3(row.filename, row.blob)" ghost type="info"
|
||||
size="small">
|
||||
{{ t('saveToS3') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-thing>
|
||||
<template #suffix>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
|
||||
:href="row.url">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</n-button>
|
||||
</template>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-content-renderer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mail-content {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mail-text {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.mail-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.mail-html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fullscreen-mail-content {
|
||||
height: calc(100vh - 120px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fullscreen-mail-content .mail-iframe {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
</style>
|
||||
405
frontend/src/components/SendBox.vue
Normal file
405
frontend/src/components/SendBox.vue
Normal file
@@ -0,0 +1,405 @@
|
||||
<script setup>
|
||||
import { watch, onMounted, ref, computed } from "vue";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { utcToLocalDate } from '../utils';
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const props = defineProps({
|
||||
enableUserDeleteEmail: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
showEMailFrom: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
fetchMailData: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
required: true
|
||||
},
|
||||
deleteMail: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
required: false
|
||||
},
|
||||
})
|
||||
|
||||
const { isDark, mailboxSplitSize, loading, useUTCDate } = useGlobalState()
|
||||
const data = ref([])
|
||||
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const curMail = ref(null);
|
||||
const showCode = ref(false)
|
||||
|
||||
const multiActionMode = ref(false)
|
||||
const showMultiActionDelete = ref(false)
|
||||
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
success: 'Success',
|
||||
refresh: 'Refresh',
|
||||
showCode: 'Change View Original Code',
|
||||
pleaseSelectMail: "Please select a mail to view.",
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete mail?',
|
||||
multiAction: 'Multi Action',
|
||||
cancelMultiAction: 'Cancel Multi Action',
|
||||
selectAll: 'Select All of This Page',
|
||||
unselectAll: 'Unselect All',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
refresh: '刷新',
|
||||
showCode: '切换查看元数据',
|
||||
pleaseSelectMail: "请选择一封邮件查看。",
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
multiAction: '多选',
|
||||
cancelMultiAction: '取消多选',
|
||||
selectAll: '全选本页',
|
||||
unselectAll: '取消全选',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
|
||||
if (page !== oldPage || pageSize !== oldPageSize) {
|
||||
await refresh();
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const { results, count: totalCount } = await props.fetchMailData(
|
||||
pageSize.value, (page.value - 1) * pageSize.value
|
||||
);
|
||||
data.value = results.map((item) => {
|
||||
try {
|
||||
const data = JSON.parse(item.raw);
|
||||
if (data.version == "v2") {
|
||||
item.to_mail = data.to_name ? `${data.to_name} <${data.to_mail}>` : data.to_mail;
|
||||
item.subject = data.subject;
|
||||
item.is_html = data.is_html;
|
||||
item.content = data.content;
|
||||
item.raw = JSON.stringify(data, null, 2);
|
||||
} else {
|
||||
item.to_mail = data?.personalizations?.map(
|
||||
(p) => p.to?.map((t) => t.email).join(',')
|
||||
).join(';');
|
||||
item.subject = data.subject;
|
||||
item.is_html = (data.content[0]?.type != 'text/plain');
|
||||
item.content = data.content[0]?.value;
|
||||
item.raw = JSON.stringify(data, null, 2);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (totalCount > 0) {
|
||||
count.value = totalCount;
|
||||
}
|
||||
if (!isMobile.value && !curMail.value && data.value.length > 0) {
|
||||
curMail.value = data.value[0];
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const clickRow = async (row) => {
|
||||
curMail.value = row;
|
||||
};
|
||||
|
||||
const mailItemClass = (row) => {
|
||||
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
|
||||
};
|
||||
|
||||
const onSpiltSizeChange = (size) => {
|
||||
mailboxSplitSize.value = size;
|
||||
}
|
||||
|
||||
const deleteMail = async () => {
|
||||
try {
|
||||
await props.deleteMail(curMail.value.id);
|
||||
message.success(t("success"));
|
||||
curMail.value = null;
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
const showMultiActionMode = computed(() => {
|
||||
return props.enableUserDeleteEmail;
|
||||
});
|
||||
|
||||
const multiActionModeClick = (enableMulti) => {
|
||||
if (enableMulti) {
|
||||
data.value.forEach((item) => {
|
||||
item.checked = false;
|
||||
});
|
||||
multiActionMode.value = true;
|
||||
} else {
|
||||
multiActionMode.value = false;
|
||||
data.value.forEach((item) => {
|
||||
item.checked = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const multiActionSelectAll = (checked) => {
|
||||
data.value.forEach((item) => {
|
||||
item.checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
const multiActionDeleteMail = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const selectedMails = data.value.filter((item) => item.checked);
|
||||
if (selectedMails.length === 0) {
|
||||
message.error(t('pleaseSelectMail'));
|
||||
return;
|
||||
}
|
||||
multiActionDeleteProgress.value = {
|
||||
percentage: 0,
|
||||
tip: `0/${selectedMails.length}`
|
||||
};
|
||||
for (const [index, mail] of selectedMails.entries()) {
|
||||
await props.deleteMail(mail.id);
|
||||
showMultiActionDelete.value = true;
|
||||
multiActionDeleteProgress.value = {
|
||||
percentage: Math.floor((index + 1) / selectedMails.length * 100),
|
||||
tip: `${index + 1}/${selectedMails.length}`
|
||||
};
|
||||
}
|
||||
message.success(t("success"));
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
showMultiActionDelete.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!isMobile" class="left">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<n-space v-if="multiActionMode">
|
||||
<n-button @click="multiActionModeClick(false)" tertiary>
|
||||
{{ t('cancelMultiAction') }}
|
||||
</n-button>
|
||||
<n-button @click="multiActionSelectAll(true)" tertiary>
|
||||
{{ t('selectAll') }}
|
||||
</n-button>
|
||||
<n-button @click="multiActionSelectAll(false)" tertiary>
|
||||
{{ t('unselectAll') }}
|
||||
</n-button>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="multiActionDeleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
<n-space v-else>
|
||||
<n-button v-if="showMultiActionMode" @click="multiActionModeClick(true)" type="primary" tertiary>
|
||||
{{ t('multiAction') }}
|
||||
</n-button>
|
||||
<div style="display: inline-block; margin-right: 10px;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
|
||||
:page-sizes="[20, 50, 100]" show-size-picker />
|
||||
</div>
|
||||
<n-button @click="refresh" type="primary" tertiary>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
</template>
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailFrom" type="info">
|
||||
FROM: {{ row.address }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
TO: {{ row.to_mail }}
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</template>
|
||||
<template #2>
|
||||
<n-card :bordered="false" embedded v-if="curMail" class="mail-item" :title="curMail.subject"
|
||||
style="overflow: auto; max-height: 100vh;">
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
TO: {{ curMail.to_mail }}
|
||||
</n-tag>
|
||||
<n-button size="small" tertiary type="info" @click="showCode = !showCode">
|
||||
{{ t('showCode') }}
|
||||
</n-button>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
|
||||
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
|
||||
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||
<n-result status="info" :title="t('pleaseSelectMail')">
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-split>
|
||||
</div>
|
||||
<div class="left" v-else>
|
||||
<div class="center">
|
||||
<div style="display: inline-block; margin-right: 10px;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
</div>
|
||||
<n-button @click="refresh" size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailFrom" type="info">
|
||||
FROM: {{ row.address }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
TO: {{ row.to_mail }}
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
<n-drawer v-model:show="curMail" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
|
||||
style="height: 80vh;">
|
||||
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
|
||||
<n-card :bordered="false" embedded style="overflow: auto;">
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
TO: {{ curMail.to_mail }}
|
||||
</n-tag>
|
||||
<n-button size="small" tertiary type="info" @click="showCode = !showCode">
|
||||
{{ t('showCode') }}
|
||||
</n-button>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
|
||||
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
|
||||
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.overlay-dark-backgroud {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.overlay-light-backgroud {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mail-item {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
75
frontend/src/components/ShadowHtmlComponent.vue
Normal file
75
frontend/src/components/ShadowHtmlComponent.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div v-if="useFallback" v-html="htmlContent"></div>
|
||||
<div v-else ref="shadowHost"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
htmlContent: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const shadowHost = ref(null);
|
||||
let shadowRoot = null;
|
||||
const useFallback = ref(false);
|
||||
|
||||
/**
|
||||
* Renders content into Shadow DOM with fallback to v-html
|
||||
*/
|
||||
const renderShadowDom = () => {
|
||||
if (!shadowHost.value && !useFallback.value) return;
|
||||
|
||||
try {
|
||||
// Don't attempt to use Shadow DOM if already in fallback mode
|
||||
if (useFallback.value) return;
|
||||
|
||||
// Initialize Shadow DOM if not already created
|
||||
if (!shadowRoot && shadowHost.value) {
|
||||
try {
|
||||
shadowRoot = shadowHost.value.attachShadow({ mode: 'open' });
|
||||
} catch (error) {
|
||||
console.warn('Shadow DOM not supported, falling back to v-html:', error);
|
||||
useFallback.value = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update content if Shadow DOM exists
|
||||
if (shadowRoot) {
|
||||
shadowRoot.innerHTML = props.htmlContent;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to render Shadow DOM, falling back to v-html:', error);
|
||||
useFallback.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial render when component is mounted
|
||||
onMounted(() => {
|
||||
// Check if Shadow DOM is supported in this browser
|
||||
if (typeof Element.prototype.attachShadow !== 'function') {
|
||||
console.warn('Shadow DOM is not supported in this browser, using v-html fallback');
|
||||
useFallback.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
renderShadowDom();
|
||||
});
|
||||
|
||||
// Clean up resources when component is unmounted
|
||||
onBeforeUnmount(() => {
|
||||
if (shadowRoot) {
|
||||
shadowRoot.innerHTML = '';
|
||||
}
|
||||
shadowRoot = null;
|
||||
});
|
||||
|
||||
// Update Shadow DOM when htmlContent changes
|
||||
watch(() => props.htmlContent, () => {
|
||||
renderShadowDom();
|
||||
}, { flush: 'post' });
|
||||
</script>
|
||||
90
frontend/src/components/Turnstile.vue
Normal file
90
frontend/src/components/Turnstile.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
const { openSettings, isDark } = useGlobalState()
|
||||
|
||||
const cfToken = defineModel('value')
|
||||
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
refresh: 'Refresh'
|
||||
},
|
||||
zh: {
|
||||
refresh: '刷新'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const cfTurnstileId = ref("")
|
||||
const turnstileLoading = ref(false)
|
||||
|
||||
const checkCfTurnstile = async (remove) => {
|
||||
if (!openSettings.value.cfTurnstileSiteKey) return;
|
||||
turnstileLoading.value = true;
|
||||
try {
|
||||
let container = document.getElementById("cf-turnstile");
|
||||
let count = 100;
|
||||
while (!container && count-- > 0) {
|
||||
container = document.getElementById("cf-turnstile");
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
count = 100;
|
||||
while (!window.turnstile && count-- > 0) {
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
if (remove && cfTurnstileId.value) {
|
||||
window.turnstile.remove(cfTurnstileId.value);
|
||||
}
|
||||
cfTurnstileId.value = window.turnstile.render(
|
||||
"#cf-turnstile",
|
||||
{
|
||||
sitekey: openSettings.value.cfTurnstileSiteKey,
|
||||
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
|
||||
theme: isDark.value ? 'dark' : 'light',
|
||||
callback: function (token) {
|
||||
cfToken.value = token;
|
||||
},
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
turnstileLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, async (isDark) => {
|
||||
checkCfTurnstile(true)
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
cfToken.value = "";
|
||||
checkCfTurnstile(true);
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="openSettings.cfTurnstileSiteKey" class="center">
|
||||
<n-spin description="loading..." :show="turnstileLoading">
|
||||
<n-form-item-row>
|
||||
<n-flex vertical>
|
||||
<div id="cf-turnstile"></div>
|
||||
<n-button text @click="checkCfTurnstile(true)">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-form-item-row>
|
||||
</n-spin>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
179
frontend/src/components/WebhookComponent.vue
Normal file
179
frontend/src/components/WebhookComponent.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
fetchData: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
required: true
|
||||
},
|
||||
saveSettings: {
|
||||
type: Function,
|
||||
default: (webhookSettings: WebhookSettings) => { },
|
||||
required: true
|
||||
},
|
||||
testSettings: {
|
||||
type: Function,
|
||||
default: (webhookSettings: WebhookSettings) => { },
|
||||
required: true
|
||||
},
|
||||
})
|
||||
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
successTip: 'Success',
|
||||
test: 'Test',
|
||||
save: 'Save',
|
||||
notEnabled: 'Webhook is not enabled for you',
|
||||
urlMissing: 'URL is required',
|
||||
enable: 'Enable',
|
||||
messagePusherDemo: 'Fill with Message Pusher Demo',
|
||||
messagePusherDoc: 'Message Pusher Doc',
|
||||
fillInDemoTip: 'Please modify the URL and other settings to your own',
|
||||
},
|
||||
zh: {
|
||||
successTip: '成功',
|
||||
test: '测试',
|
||||
save: '保存',
|
||||
notEnabled: 'Webhook 未开启,请联系管理员开启',
|
||||
urlMissing: 'URL 不能为空',
|
||||
enable: '启用',
|
||||
messagePusherDemo: '填入MessagePusher示例',
|
||||
messagePusherDoc: 'MessagePusher文档',
|
||||
fillInDemoTip: '请修改URL和其他设置为您自己的配置',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
class WebhookSettings {
|
||||
enabled: boolean = false
|
||||
url: string = ''
|
||||
method: string = 'POST'
|
||||
headers: string = JSON.stringify({}, null, 2)
|
||||
body: string = JSON.stringify({}, null, 2)
|
||||
}
|
||||
|
||||
const messagePusherDocLink = "https://github.com/songquanpeng/message-pusher";
|
||||
|
||||
const messagePusherDemo = {
|
||||
enabled: true,
|
||||
url: 'https://msgpusher.com/push/username',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"token": "token",
|
||||
"title": "${subject}",
|
||||
"description": "${subject}",
|
||||
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
|
||||
}, null, 2),
|
||||
} as WebhookSettings;
|
||||
|
||||
const fillMessagePuhserDemo = () => {
|
||||
Object.assign(webhookSettings.value, messagePusherDemo)
|
||||
message.success(t('fillInDemoTip'))
|
||||
}
|
||||
|
||||
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
|
||||
const enableWebhook = ref(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await props.fetchData()
|
||||
Object.assign(webhookSettings.value, res)
|
||||
enableWebhook.value = true
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!webhookSettings.value.url) {
|
||||
message.error(t('urlMissing'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await props.saveSettings(webhookSettings.value)
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const testSettings = async () => {
|
||||
if (!webhookSettings.value.url) {
|
||||
message.error(t('urlMissing'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await props.testSettings(webhookSettings.value)
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
|
||||
<n-flex justify="end">
|
||||
<n-button tag="a" :href="messagePusherDocLink" target="_blank" secondary>
|
||||
{{ t('messagePusherDoc') }}
|
||||
</n-button>
|
||||
<n-button @click="fillMessagePuhserDemo" secondary>
|
||||
{{ t('messagePusherDemo') }}
|
||||
</n-button>
|
||||
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
|
||||
{{ t('test') }}
|
||||
</n-button>
|
||||
<n-button @click="saveSettings" type="primary">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-form-item-row :label="t('enable')">
|
||||
<n-switch v-model:value="webhookSettings.enabled" :round="false" />
|
||||
</n-form-item-row>
|
||||
<div v-if="webhookSettings.enabled">
|
||||
<n-form-item-row label="URL">
|
||||
<n-input v-model:value="webhookSettings.url" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="METHOD">
|
||||
<n-select v-model:value="webhookSettings.method" tag :options='[
|
||||
{ label: "POST", value: "POST" }
|
||||
]' />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="HEADERS">
|
||||
<n-input v-model:value="webhookSettings.headers" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="BODY">
|
||||
<n-input v-model:value="webhookSettings.body" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
</n-form-item-row>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-result v-else status="404" :title="t('notEnabled')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
8
frontend/src/constant/index.ts
Normal file
8
frontend/src/constant/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
const COMMOM_MAIL = [
|
||||
"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com",
|
||||
"icloud.com", "yahoo.com", "foxmail.com"
|
||||
]
|
||||
|
||||
export default {
|
||||
COMMOM_MAIL
|
||||
}
|
||||
15
frontend/src/i18n.ts
Normal file
15
frontend/src/i18n.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false, // you must set `false`, to use Composition API
|
||||
locale: 'zh', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
'en': {
|
||||
messages: {}
|
||||
},
|
||||
'zh': {
|
||||
messages: {}
|
||||
}
|
||||
})
|
||||
|
||||
export default i18n;
|
||||
@@ -1,22 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import router from './router'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { createHead } from '@unhead/vue'
|
||||
|
||||
registerSW({ immediate: true })
|
||||
const i18n = createI18n({
|
||||
legacy: false, // you must set `false`, to use Composition API
|
||||
locale: 'zh', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
'en': {
|
||||
messages: {}
|
||||
},
|
||||
'zhCN': {
|
||||
messages: {}
|
||||
}
|
||||
})
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
|
||||
const head = createHead()
|
||||
const app = createApp(App)
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
app.use(head)
|
||||
app.mount('#app')
|
||||
|
||||
15
frontend/src/models/index.ts
Normal file
15
frontend/src/models/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type UserOauth2Settings = {
|
||||
name: string;
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
authorizationURL: string;
|
||||
accessTokenURL: string;
|
||||
accessTokenFormat?: string;
|
||||
userInfoURL: string;
|
||||
redirectURL: string;
|
||||
logoutURL?: string;
|
||||
userEmailKey: string;
|
||||
scope: string;
|
||||
enableMailAllowList?: boolean | undefined;
|
||||
mailAllowList?: string[] | undefined;
|
||||
}
|
||||
@@ -1,24 +1,60 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Index from '../views/Index.vue'
|
||||
import Settings from '../views/Settings.vue'
|
||||
import Admin from '../views/Admin.vue'
|
||||
import User from '../views/User.vue'
|
||||
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
|
||||
import i18n from '../i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
|
||||
const { jwt } = useGlobalState()
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
alias: "/:lang/",
|
||||
component: Index
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: Settings
|
||||
path: '/user',
|
||||
alias: "/:lang/user",
|
||||
component: User
|
||||
},
|
||||
{
|
||||
path: '/user/oauth2/callback',
|
||||
alias: "/:lang/user/oauth2/callback",
|
||||
component: UserOauth2Callback
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: Admin
|
||||
alias: "/:lang/admin",
|
||||
component: () => import('../views/Admin.vue')
|
||||
},
|
||||
{
|
||||
path: '/telegram_mail',
|
||||
alias: "/:lang/telegram_mail",
|
||||
component: () => import('../views/telegram/Mail.vue')
|
||||
},
|
||||
{
|
||||
name: 'not-found',
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/'
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.params.lang && ['en', 'zh'].includes(to.params.lang)) {
|
||||
i18n.global.locale.value = to.params.lang
|
||||
} else {
|
||||
i18n.global.locale.value = 'zh'
|
||||
}
|
||||
// check if query parameter has jwt, set it to store
|
||||
if (to.query.jwt) {
|
||||
jwt.value = to.query.jwt;
|
||||
}
|
||||
next()
|
||||
});
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,20 +1,46 @@
|
||||
import { ref } from "vue";
|
||||
import { createGlobalState, useStorage } from '@vueuse/core'
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
createGlobalState, useStorage, useDark, useToggle,
|
||||
useLocalStorage, useSessionStorage
|
||||
} from '@vueuse/core'
|
||||
|
||||
export const useGlobalState = createGlobalState(
|
||||
() => {
|
||||
const isDark = useDark()
|
||||
const toggleDark = useToggle(isDark)
|
||||
const loading = ref(false);
|
||||
const announcement = useLocalStorage('announcement', '');
|
||||
const useSimpleIndex = useLocalStorage('useSimpleIndex', false);
|
||||
const openSettings = ref({
|
||||
fetched: false,
|
||||
title: '',
|
||||
announcement: '',
|
||||
alwaysShowAnnouncement: false,
|
||||
prefix: '',
|
||||
addressRegex: '',
|
||||
needAuth: false,
|
||||
domains: [{
|
||||
label: 'test.com',
|
||||
value: 'test.com'
|
||||
}]
|
||||
adminContact: '',
|
||||
enableUserCreateEmail: false,
|
||||
disableAnonymousUserCreateEmail: false,
|
||||
disableCustomAddressName: false,
|
||||
enableUserDeleteEmail: false,
|
||||
enableAutoReply: false,
|
||||
enableIndexAbout: false,
|
||||
/** @type {string[]} */
|
||||
defaultDomains: [],
|
||||
/** @type {Array<{label: string, value: string}>} */
|
||||
domains: [],
|
||||
copyright: 'Dream Hunter',
|
||||
cfTurnstileSiteKey: '',
|
||||
enableWebhook: false,
|
||||
isS3Enabled: false,
|
||||
showGithub: true,
|
||||
disableAdminPasswordCheck: false,
|
||||
enableAddressPassword: false,
|
||||
})
|
||||
const settings = ref({
|
||||
fetched: false,
|
||||
has_v1_mails: false,
|
||||
send_balance: 0,
|
||||
address: '',
|
||||
auto_reply: {
|
||||
subject: '',
|
||||
@@ -23,27 +49,107 @@ export const useGlobalState = createGlobalState(
|
||||
source_prefix: '',
|
||||
name: '',
|
||||
}
|
||||
})
|
||||
});
|
||||
const sendMailModel = useSessionStorage('sendMailModel', {
|
||||
fromName: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
subject: "",
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
});
|
||||
const showAuth = ref(false);
|
||||
const showAddressCredential = ref(false);
|
||||
const showAdminAuth = ref(false);
|
||||
const auth = useStorage('auth', '');
|
||||
const adminAuth = useStorage('adminAuth', '');
|
||||
const jwt = useStorage('jwt', '');
|
||||
const localeCache = useStorage('locale', 'zhCN');
|
||||
const themeSwitch = useStorage('themeSwitch', false);
|
||||
const showLogin = ref(false);
|
||||
const addressPassword = useSessionStorage('addressPassword', '');
|
||||
const adminTab = useSessionStorage('adminTab', "account");
|
||||
const adminMailTabAddress = ref("");
|
||||
const adminSendBoxTabAddress = ref("");
|
||||
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
|
||||
const useIframeShowMail = useStorage('useIframeShowMail', false);
|
||||
const preferShowTextMail = useStorage('preferShowTextMail', false);
|
||||
const userJwt = useStorage('userJwt', '');
|
||||
const userTab = useSessionStorage('userTab', 'address_management');
|
||||
const indexTab = useSessionStorage('indexTab', 'mailbox');
|
||||
const globalTabplacement = useStorage('globalTabplacement', 'top');
|
||||
const useSideMargin = useStorage('useSideMargin', true);
|
||||
const useUTCDate = useStorage('useUTCDate', false);
|
||||
const autoRefresh = useStorage('autoRefresh', false);
|
||||
const configAutoRefreshInterval = useStorage("configAutoRefreshInterval", 60);
|
||||
const userOpenSettings = ref({
|
||||
fetched: false,
|
||||
enable: false,
|
||||
enableMailVerify: false,
|
||||
/** @type {{ clientID: string, name: string }[]} */
|
||||
oauth2ClientIDs: [],
|
||||
});
|
||||
const userSettings = ref({
|
||||
/** @type {boolean} */
|
||||
fetched: false,
|
||||
/** @type {string} */
|
||||
user_email: '',
|
||||
/** @type {number} */
|
||||
user_id: 0,
|
||||
/** @type {boolean} */
|
||||
is_admin: false,
|
||||
/** @type {string | null} */
|
||||
access_token: null,
|
||||
/** @type {string | null} */
|
||||
new_user_token: null,
|
||||
/** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
|
||||
user_role: null,
|
||||
});
|
||||
const showAdminPage = computed(() =>
|
||||
!!adminAuth.value
|
||||
|| userSettings.value.is_admin
|
||||
|| openSettings.value.disableAdminPasswordCheck
|
||||
);
|
||||
const telegramApp = ref(window.Telegram?.WebApp || {});
|
||||
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
|
||||
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
|
||||
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
|
||||
const browserFingerprint = ref('');
|
||||
return {
|
||||
isDark,
|
||||
toggleDark,
|
||||
loading,
|
||||
settings,
|
||||
sendMailModel,
|
||||
announcement,
|
||||
openSettings,
|
||||
showAuth,
|
||||
showAddressCredential,
|
||||
auth,
|
||||
jwt,
|
||||
localeCache,
|
||||
themeSwitch,
|
||||
adminAuth,
|
||||
showAdminAuth,
|
||||
showLogin,
|
||||
adminTab,
|
||||
adminMailTabAddress,
|
||||
adminSendBoxTabAddress,
|
||||
mailboxSplitSize,
|
||||
useIframeShowMail,
|
||||
preferShowTextMail,
|
||||
userJwt,
|
||||
userTab,
|
||||
indexTab,
|
||||
userOpenSettings,
|
||||
userSettings,
|
||||
globalTabplacement,
|
||||
useSideMargin,
|
||||
useUTCDate,
|
||||
autoRefresh,
|
||||
configAutoRefreshInterval,
|
||||
telegramApp,
|
||||
isTelegram,
|
||||
showAdminPage,
|
||||
userOauth2SessionState,
|
||||
userOauth2SessionClientID,
|
||||
useSimpleIndex,
|
||||
addressPassword,
|
||||
browserFingerprint,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import PostalMime from 'postal-mime';
|
||||
import { parse_message } from 'mail-parser-wasm'
|
||||
|
||||
function humanFileSize(size) {
|
||||
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
|
||||
return parseFloat((size / Math.pow(1024, i)).toFixed(2)) + ' ' + ['B', 'KB', 'MB', 'GB', 'TB'][i];
|
||||
}
|
||||
|
||||
export async function processItem(item) {
|
||||
// Try to parse the email using mail-parser-wasm
|
||||
item.originalSource = item.source;
|
||||
try {
|
||||
const { parse_message } = await import('mail-parser-wasm');
|
||||
const parsedEmail = parse_message(item.raw);
|
||||
item.source = parsedEmail.sender || item.source;
|
||||
item.subject = parsedEmail.subject || '';
|
||||
item.message = parsedEmail.body_html || parsedEmail.text || '';
|
||||
item.text = parsedEmail.text || '';
|
||||
item.attachments = parsedEmail.attachments?.map((a_item) => {
|
||||
const blob_url = URL.createObjectURL(
|
||||
new Blob(
|
||||
[a_item.content],
|
||||
{ type: a_item.content_type || 'application/octet-stream' }
|
||||
))
|
||||
const blob = new Blob(
|
||||
[a_item.content],
|
||||
{ type: a_item.content_type || 'application/octet-stream' }
|
||||
);
|
||||
const blob_url = URL.createObjectURL(blob);
|
||||
if (a_item.content_id && a_item.content_id.length > 0) {
|
||||
item.message = item.message.replace(`cid:${a_item.content_id}`, blob_url);
|
||||
}
|
||||
return {
|
||||
id: a_item.content_id || Math.random().toString(36).substring(2, 15),
|
||||
filename: a_item.filename || a_item.content_id || "",
|
||||
size: a_item.content?.length || 0,
|
||||
url: blob_url
|
||||
size: humanFileSize(a_item.content?.length || 0),
|
||||
url: blob_url,
|
||||
blob: blob
|
||||
}
|
||||
}) || [];
|
||||
} catch (error) {
|
||||
@@ -40,20 +48,22 @@ export async function processItem(item) {
|
||||
}
|
||||
item.subject = parsedEmail.subject || 'No Subject';
|
||||
item.message = parsedEmail.html || parsedEmail.text || item.raw;
|
||||
item.text = parsedEmail.text || '';
|
||||
item.attachments = parsedEmail.attachments?.map((a_item) => {
|
||||
const blob_url = URL.createObjectURL(
|
||||
new Blob(
|
||||
[a_item.content],
|
||||
{ type: a_item.mimeType || 'application/octet-stream' }
|
||||
))
|
||||
const blob = new Blob(
|
||||
[a_item.content],
|
||||
{ type: a_item.mimeType || 'application/octet-stream' }
|
||||
);
|
||||
const blob_url = URL.createObjectURL(blob)
|
||||
if (a_item.contentId && a_item.contentId.length > 0) {
|
||||
item.message = item.message.replace(`cid:${a_item.contentId}`, blob_url);
|
||||
}
|
||||
return {
|
||||
id: a_item.contentId || Math.random().toString(36).substring(2, 15),
|
||||
filename: a_item.filename || a_item.contentId || "",
|
||||
size: a_item.content?.length || 0,
|
||||
url: blob_url
|
||||
size: humanFileSize(a_item.content?.length || 0),
|
||||
url: blob_url,
|
||||
blob: blob
|
||||
}
|
||||
}) || [];
|
||||
} catch (error) {
|
||||
@@ -62,6 +72,7 @@ export async function processItem(item) {
|
||||
item.subject = 'No Subject';
|
||||
item.message = item.raw;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export function getDownloadEmlUrl(raw) {
|
||||
|
||||
30
frontend/src/utils/fingerprint.ts
Normal file
30
frontend/src/utils/fingerprint.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import FingerprintJS from '@fingerprintjs/fingerprintjs';
|
||||
import { useGlobalState } from '../store';
|
||||
|
||||
const { browserFingerprint } = useGlobalState();
|
||||
|
||||
/**
|
||||
* Get browser fingerprint
|
||||
* Uses cached value from global state if available to avoid unnecessary computation
|
||||
* @returns Fingerprint visitor ID, or 'ERROR' if failed
|
||||
*/
|
||||
export const getFingerprint = async (): Promise<string> => {
|
||||
// Return cached fingerprint if available
|
||||
if (browserFingerprint.value) {
|
||||
return browserFingerprint.value;
|
||||
}
|
||||
|
||||
try {
|
||||
const fp = await FingerprintJS.load();
|
||||
const result = await fp.get();
|
||||
browserFingerprint.value = result.visitorId;
|
||||
return browserFingerprint.value;
|
||||
} catch (error) {
|
||||
console.error('Failed to get fingerprint:', error);
|
||||
// Return special error value to prevent blocking requests
|
||||
const errorValue = 'ERROR';
|
||||
browserFingerprint.value = errorValue;
|
||||
return errorValue;
|
||||
}
|
||||
};
|
||||
|
||||
30
frontend/src/utils/index.ts
Normal file
30
frontend/src/utils/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export const hashPassword = async (password: string) => {
|
||||
// user crypto to hash password
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
|
||||
const hashArray = Array.from(new Uint8Array(digest));
|
||||
return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export const getRouterPathWithLang = (path: string, lang: string) => {
|
||||
if (!lang || lang === 'zh') {
|
||||
return path;
|
||||
}
|
||||
return `/${lang}${path}`;
|
||||
}
|
||||
|
||||
export const utcToLocalDate = (utcDate: string, useUTCDate: boolean) => {
|
||||
const utcDateString = `${utcDate} UTC`;
|
||||
if (useUTCDate) {
|
||||
return utcDateString;
|
||||
}
|
||||
try {
|
||||
const date = new Date(utcDateString);
|
||||
// if invalid date string
|
||||
if (isNaN(date.getTime())) return utcDateString;
|
||||
|
||||
return date.toLocaleString();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return utcDateString;
|
||||
}
|
||||
@@ -1,23 +1,47 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, UserCheck, MailBulk } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
|
||||
|
||||
const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
|
||||
const router = useRouter()
|
||||
import SenderAccess from './admin/SenderAccess.vue'
|
||||
import Statistics from "./admin/Statistics.vue"
|
||||
import SendBox from './admin/SendBox.vue';
|
||||
import Account from './admin/Account.vue';
|
||||
import CreateAccount from './admin/CreateAccount.vue';
|
||||
import AccountSettings from './admin/AccountSettings.vue';
|
||||
import UserManagement from './admin/UserManagement.vue';
|
||||
import UserSettings from './admin/UserSettings.vue';
|
||||
import UserOauth2Settings from './admin/UserOauth2Settings.vue';
|
||||
import RoleAddressConfig from './admin/RoleAddressConfig.vue';
|
||||
import Mails from './admin/Mails.vue';
|
||||
import MailsUnknow from './admin/MailsUnknow.vue';
|
||||
import About from './common/About.vue';
|
||||
import Maintenance from './admin/Maintenance.vue';
|
||||
import DatabaseManager from './admin/DatabaseManager.vue';
|
||||
import Appearance from './common/Appearance.vue';
|
||||
import Telegram from './admin/Telegram.vue';
|
||||
import Webhook from './admin/Webhook.vue';
|
||||
import MailWebhook from './admin/MailWebhook.vue';
|
||||
import WorkerConfig from './admin/WorkerConfig.vue';
|
||||
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
|
||||
|
||||
const {
|
||||
adminAuth, showAdminAuth, adminTab, loading,
|
||||
globalTabplacement, showAdminPage, userSettings
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const showEmailPassword = ref(false)
|
||||
const curEmailPassword = ref("")
|
||||
const addressQuery = ref("")
|
||||
const SendMail = defineAsyncComponent(() => {
|
||||
loading.value = true;
|
||||
return import('./admin/SendMail.vue')
|
||||
.finally(() => loading.value = false);
|
||||
});
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
adminAuth.value = tmpAdminAuth.value;
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
@@ -25,376 +49,187 @@ const authFunc = async () => {
|
||||
}
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
title: 'Temp Email Admin',
|
||||
auth: 'Admin Auth',
|
||||
home: 'Home',
|
||||
authTip: 'Please enter the correct auth code',
|
||||
name: 'Name',
|
||||
created_at: 'Created At',
|
||||
showPass: 'Show Passwrod',
|
||||
password: 'Password',
|
||||
passwordTip: 'Please copy the password and you can use it to login to your email account.',
|
||||
delete: 'Delete',
|
||||
deleteTip: 'Are you sure to delete this email?',
|
||||
refresh: 'Refresh',
|
||||
accessHeader: 'Admin Password',
|
||||
accessTip: 'Please enter the admin password',
|
||||
mails: 'Emails',
|
||||
itemCount: 'itemCount',
|
||||
query: 'Query',
|
||||
userCount: 'User Count',
|
||||
activeUser: '7 days Active User',
|
||||
mailCount: 'Mail Count',
|
||||
sendMail: 'Send Mail',
|
||||
qucickSetup: 'Quick Setup',
|
||||
account: 'Account',
|
||||
unknow: 'Unknow',
|
||||
addressQueryTip: 'Leave blank to query all addresses',
|
||||
account_create: 'Create Account',
|
||||
account_settings: 'Account Settings',
|
||||
user: 'User',
|
||||
user_management: 'User Management',
|
||||
user_settings: 'User Settings',
|
||||
userOauth2Settings: 'Oauth2 Settings',
|
||||
roleAddressConfig: 'Role Address Config',
|
||||
unknow: 'Mails with unknow receiver',
|
||||
senderAccess: 'Sender Access Control',
|
||||
sendBox: 'Send Box',
|
||||
telegram: 'Telegram Bot',
|
||||
webhookSettings: 'Webhook Settings',
|
||||
statistics: 'Statistics',
|
||||
maintenance: 'Maintenance',
|
||||
database: 'Database',
|
||||
workerconfig: 'Worker Config',
|
||||
ipBlacklistSettings: 'IP Blacklist',
|
||||
appearance: 'Appearance',
|
||||
about: 'About',
|
||||
ok: 'OK',
|
||||
mailWebhook: 'Mail Webhook',
|
||||
},
|
||||
zh: {
|
||||
title: '临时邮件 Admin',
|
||||
auth: 'Admin 授权',
|
||||
home: '首页',
|
||||
authTip: '请输入正确的授权码',
|
||||
name: '名称',
|
||||
created_at: '创建时间',
|
||||
showPass: '显示密码',
|
||||
password: '密码',
|
||||
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
|
||||
delete: '删除',
|
||||
deleteTip: '确定要删除这个邮箱吗?',
|
||||
refresh: '刷新',
|
||||
accessHeader: 'Admin 密码',
|
||||
accessTip: '请输入 Admin 密码',
|
||||
mails: '邮件',
|
||||
itemCount: '总数',
|
||||
query: '查询',
|
||||
userCount: '用户总数',
|
||||
activeUser: '周活跃用户',
|
||||
mailCount: '邮件总数',
|
||||
sendMail: '发送邮件',
|
||||
qucickSetup: '快速设置',
|
||||
account: '账号',
|
||||
unknow: '未知',
|
||||
addressQueryTip: '留空查询所有地址',
|
||||
account_create: '创建账号',
|
||||
account_settings: '账号设置',
|
||||
user: '用户',
|
||||
user_management: '用户管理',
|
||||
user_settings: '用户设置',
|
||||
userOauth2Settings: 'Oauth2 设置',
|
||||
roleAddressConfig: '角色地址配置',
|
||||
unknow: '无收件人邮件',
|
||||
senderAccess: '发件权限控制',
|
||||
sendBox: '发件箱',
|
||||
telegram: '电报机器人',
|
||||
webhookSettings: 'Webhook 设置',
|
||||
statistics: '统计',
|
||||
maintenance: '维护',
|
||||
database: '数据库',
|
||||
workerconfig: 'Worker 配置',
|
||||
ipBlacklistSettings: 'IP 黑名单',
|
||||
appearance: '外观',
|
||||
about: '关于',
|
||||
ok: '确定',
|
||||
mailWebhook: '邮件 Webhook',
|
||||
}
|
||||
}
|
||||
});
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const showPassword = async (id) => {
|
||||
try {
|
||||
curEmailPassword.value = await api.adminShowPassword(id)
|
||||
showEmailPassword.value = true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
showEmailPassword.value = false
|
||||
curEmailPassword.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEmail = async (id) => {
|
||||
try {
|
||||
await api.adminDeleteAddress(id)
|
||||
message.success("success");
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const { results, count: addressCount } = await api.fetch(
|
||||
`/admin/address`
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
|
||||
);
|
||||
data.value = results;
|
||||
if (addressCount > 0) {
|
||||
count.value = addressCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
},
|
||||
{
|
||||
title: t('name'),
|
||||
key: "name"
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NButton,
|
||||
{
|
||||
type: 'success',
|
||||
ghost: true,
|
||||
onClick: () => showPassword(row.id)
|
||||
},
|
||||
{ default: () => t('showPass') }
|
||||
),
|
||||
h(NButton,
|
||||
{
|
||||
type: 'success',
|
||||
ghost: true,
|
||||
onClick: () => {
|
||||
mailAddress.value = row.name
|
||||
tab.value = "mails"
|
||||
}
|
||||
},
|
||||
{ default: () => t('mails') }
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => deleteEmail(row.id)
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton, { type: "error" }, () => t('delete')),
|
||||
default: () => t('deleteTip')
|
||||
}
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
const statistics = ref({
|
||||
userCount: 0,
|
||||
mailCount: 0,
|
||||
activeUserCount7days: 0,
|
||||
})
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
const { userCount, activeUserCount7days, mailCount } = await api.fetch(`/admin/statistics`);
|
||||
statistics.value.mailCount = mailCount || 0;
|
||||
statistics.value.userCount = userCount || 0;
|
||||
statistics.value.activeUserCount7days = activeUserCount7days || 0;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
const showAdminPasswordModal = computed(() => !showAdminPage.value || showAdminAuth.value)
|
||||
const tmpAdminAuth = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!adminAuth.value) {
|
||||
showAdminAuth.value = true
|
||||
} else {
|
||||
await fetchData()
|
||||
await fetchStatistics()
|
||||
}
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
})
|
||||
|
||||
const tab = ref("account")
|
||||
const mailAddress = ref("")
|
||||
const mailData = ref([])
|
||||
const mailCount = ref(0)
|
||||
const mailPage = ref(1)
|
||||
const mailPageSize = ref(20)
|
||||
|
||||
watch([mailPage, mailPageSize, mailAddress], async () => {
|
||||
await fetchMailData()
|
||||
})
|
||||
|
||||
const fetchMailData = async () => {
|
||||
if (!mailAddress.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { results, count } = await api.fetch(
|
||||
`/admin/mails`
|
||||
+ `?address=${mailAddress.value}`
|
||||
+ `&limit=${mailPageSize.value}`
|
||||
+ `&offset=${(mailPage.value - 1) * mailPageSize.value}`
|
||||
);
|
||||
mailData.value = await Promise.all(results.map(async (item) => {
|
||||
return await processItem(item);
|
||||
}));
|
||||
if (count > 0) {
|
||||
mailCount.value = count;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const mailUnknowData = ref([])
|
||||
const mailUnknowCount = ref(0)
|
||||
const mailUnknowPage = ref(1)
|
||||
const mailUnknowPageSize = ref(20)
|
||||
|
||||
watch([mailUnknowPage, mailUnknowPageSize], async () => {
|
||||
await fetchMailUnknowData()
|
||||
})
|
||||
|
||||
const fetchMailUnknowData = async () => {
|
||||
try {
|
||||
const { results, count } = await api.fetch(
|
||||
`/admin/mails_unknow`
|
||||
+ `?limit=${mailPageSize.value}`
|
||||
+ `&offset=${(mailPage.value - 1) * mailPageSize.value}`
|
||||
);
|
||||
mailUnknowData.value = await Promise.all(results.map(async (item) => {
|
||||
return await processItem(item);
|
||||
}));
|
||||
if (count > 0) {
|
||||
mailUnknowCount.value = count;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
|
||||
title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('auth') }}</div>
|
||||
</template>
|
||||
<p>{{ t('authTip') }}</p>
|
||||
<n-input v-model:value="adminAuth" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
<div v-if="userSettings.fetched">
|
||||
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
|
||||
preset="dialog" :title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" />
|
||||
<template #action>
|
||||
<n-button @click="authFunc" size="small" tertiary round type="primary">
|
||||
{{ t('auth') }}
|
||||
<n-button @click="authFunc" type="primary" :loading="loading">
|
||||
{{ t('ok') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showEmailPassword" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t("password") }}</div>
|
||||
</template>
|
||||
<span>
|
||||
<p>{{ t("passwordTip") }}</p>
|
||||
</span>
|
||||
<n-card>
|
||||
<b>{{ curEmailPassword }}</b>
|
||||
</n-card>
|
||||
<template #action>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-row>
|
||||
<n-col :span="8">
|
||||
<n-statistic :label="t('userCount')" :value="statistics.userCount">
|
||||
<template #prefix>
|
||||
<n-icon :component="User" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-col>
|
||||
<n-col :span="8">
|
||||
<n-statistic :label="t('activeUser')" :value="statistics.activeUserCount7days">
|
||||
<template #prefix>
|
||||
<n-icon :component="UserCheck" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-col>
|
||||
<n-col :span="8">
|
||||
<n-statistic :label="t('mailCount')" :value="statistics.mailCount">
|
||||
<template #prefix>
|
||||
<n-icon :component="MailBulk" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-col>
|
||||
</n-row>
|
||||
<n-tabs type="segment" v-model:value="tab">
|
||||
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="database" :tab="t('database')">
|
||||
<DatabaseManager />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="workerconfig" :tab="t('workerconfig')">
|
||||
<WorkerConfig />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
|
||||
<n-button @click="fetchData" type="primary" ghost>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
|
||||
show-size-picker>
|
||||
<template #prefix="{ itemCount }">
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" />
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<Account />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_create" :tab="t('account_create')">
|
||||
<CreateAccount />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
|
||||
<SenderAccess />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="ipBlacklistSettings" :tab="t('ipBlacklistSettings')">
|
||||
<IpBlacklistSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="webhook" :tab="t('webhookSettings')">
|
||||
<Webhook />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user" :tab="t('user')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="user_management" :tab="t('user_management')">
|
||||
<UserManagement />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="userOauth2Settings" :tab="t('userOauth2Settings')">
|
||||
<UserOauth2Settings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="roleAddressConfig" :tab="t('roleAddressConfig')">
|
||||
<RoleAddressConfig />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="mailAddress" />
|
||||
<n-button @click="fetchMailData" type="primary" ghost>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<n-list hoverable clickable>
|
||||
<div style="display: inline-block; margin-bottom: 10px;">
|
||||
<n-pagination v-model:page="mailPage" v-model:page-size="mailPageSize" :item-count="mailCount" simple>
|
||||
<template #prefix="{ itemCount }">
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-list-item v-for="row in mailData" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.subject">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-html="row.message"></div>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<Mails />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="unknow" :tab="t('unknow')">
|
||||
<MailsUnknow />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendBox" :tab="t('sendBox')">
|
||||
<SendBox />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendMail" :tab="t('sendMail')">
|
||||
<SendMail />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mailWebhook" :tab="t('mailWebhook')">
|
||||
<MailWebhook />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="unknow" :tab="t('unknow')">
|
||||
<n-button @click="fetchMailUnknowData" type="primary" ghost>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
<n-list hoverable clickable>
|
||||
<div style="display: inline-block; margin-bottom: 10px;">
|
||||
<n-pagination v-model:page="mailUnknowPage" v-model:page-size="mailUnknowPageSize"
|
||||
:item-count="mailUnknowCount" simple>
|
||||
<template #prefix="{ itemCount }">
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-list-item v-for="row in mailUnknowData" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.subject">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-html="row.message"></div>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
<n-tab-pane name="telegram" :tab="t('telegram')">
|
||||
<Telegram />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="statistics" :tab="t('statistics')">
|
||||
<Statistics />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="maintenance" :tab="t('maintenance')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="database" :tab="t('database')">
|
||||
<DatabaseManager />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="workerconfig" :tab="t('workerconfig')">
|
||||
<WorkerConfig />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="maintenance" :tab="t('maintenance')">
|
||||
<Maintenance />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="appearance" :tab="t('appearance')">
|
||||
<Appearance />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="about" :tab="t('about')">
|
||||
<About />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
|
||||
42
frontend/src/views/Footer.vue
Normal file
42
frontend/src/views/Footer.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
const { openSettings } = useGlobalState()
|
||||
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
copyright: "Copyright"
|
||||
},
|
||||
zh: {
|
||||
copyright: "版权所有"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-divider class="footer-divider" />
|
||||
<div style="text-align: center; padding: 20px">
|
||||
<n-space justify="center">
|
||||
<n-text depth="3">
|
||||
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }}
|
||||
</n-text>
|
||||
<n-text depth="3">
|
||||
<div v-html="openSettings.copyright"></div>
|
||||
</n-text>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.footer-divider {
|
||||
margin: 0;
|
||||
padding: 0 var(--x-padding);
|
||||
}
|
||||
</style>
|
||||
@@ -1,46 +1,36 @@
|
||||
<script setup>
|
||||
import useClipboard from 'vue-clipboard3'
|
||||
import { ref, h, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { DarkModeFilled, LightModeFilled, MenuFilled, AdminPanelSettingsFilled } from '@vicons/material'
|
||||
import { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
|
||||
import {
|
||||
DarkModeFilled, LightModeFilled, MenuFilled,
|
||||
AdminPanelSettingsFilled
|
||||
} from '@vicons/material'
|
||||
import { GithubAlt, Language, User, Home } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
const { toClipboard } = useClipboard()
|
||||
const message = useMessage()
|
||||
import { getRouterPathWithLang } from '../utils'
|
||||
|
||||
const { jwt, localeCache, themeSwitch, showAuth, adminAuth, auth } = useGlobalState()
|
||||
const { showLogin, openSettings, settings } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const notification = useNotification()
|
||||
|
||||
const {
|
||||
toggleDark, isDark, isTelegram, showAdminPage,
|
||||
showAuth, auth, loading, openSettings, userSettings
|
||||
} = useGlobalState()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const isAdminRoute = computed(() => route.path.includes('admin'))
|
||||
|
||||
const showNewEmail = ref(false)
|
||||
const showLogout = ref(false)
|
||||
const showDelteAccount = ref(false)
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
jwt.value = password.value;
|
||||
await api.getSettings()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
jwt.value = '';
|
||||
location.reload()
|
||||
}
|
||||
const showMobileMenu = ref(false)
|
||||
const menuValue = computed(() => {
|
||||
if (route.path.includes("user")) return "user";
|
||||
if (route.path.includes("admin")) return "admin";
|
||||
return "home";
|
||||
});
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
@@ -50,195 +40,119 @@ const authFunc = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const changeLocale = (locale) => {
|
||||
localeCache.value = locale;
|
||||
location.reload()
|
||||
const changeLocale = async (lang) => {
|
||||
if (lang == 'zh') {
|
||||
await router.push(route.fullPath.replace('/en', ''));
|
||||
} else {
|
||||
await router.push(`/${lang}${route.fullPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
title: 'Cloudflare Temp Email',
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
login: 'Login',
|
||||
logout: 'Logout',
|
||||
logoutConfirm: 'Are you sure to logout?',
|
||||
delteAccount: "Delete Account",
|
||||
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
|
||||
auth: 'Auth',
|
||||
authTip: 'Please enter the correct auth code',
|
||||
settings: 'Settings',
|
||||
accessHeader: 'Access Password',
|
||||
accessTip: 'Please enter the correct access password',
|
||||
home: 'Home',
|
||||
menu: 'Menu',
|
||||
user: 'User',
|
||||
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
|
||||
getNewEmail: 'Get New Email',
|
||||
getNewEmailTip1: 'Please input the email you want to use.',
|
||||
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
|
||||
yourAddress: 'Your email address is',
|
||||
password: 'Password',
|
||||
passwordTip: 'Please copy the password and you can use it to login to your email account.', cancel: 'Cancel',
|
||||
ok: 'OK',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
showPassword: 'Show Password',
|
||||
fetchAddressError: 'Fetch address error, maybe your jwt is invalid or network error.',
|
||||
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
|
||||
},
|
||||
zh: {
|
||||
title: 'Cloudflare 临时邮件',
|
||||
dark: '暗色',
|
||||
light: '亮色',
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
logoutConfirm: '确定要登出吗?',
|
||||
delteAccount: "删除账户",
|
||||
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
|
||||
auth: '授权',
|
||||
authTip: '请输入正确的授权码',
|
||||
settings: '设置',
|
||||
accessHeader: '访问密码',
|
||||
accessTip: '请输入站点访问密码',
|
||||
home: '主页',
|
||||
menu: '菜单',
|
||||
user: '用户',
|
||||
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
|
||||
getNewEmail: '获取新邮箱',
|
||||
getNewEmailTip1: '请输入你想要使用的邮箱地址。',
|
||||
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
|
||||
yourAddress: '你的邮箱地址是',
|
||||
password: '密码',
|
||||
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
|
||||
cancel: '取消',
|
||||
ok: '确定',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
showPassword: '查看密码',
|
||||
fetchAddressError: '获取地址失败, 请检查你的 jwt 是否有效 或 网络是否正常。',
|
||||
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const showUserMenu = computed(() => !!settings.value.address)
|
||||
const version = import.meta.env.PACKAGE_VERSION ? `v${import.meta.env.PACKAGE_VERSION}` : "";
|
||||
|
||||
const menuOptions = computed(() => [
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
label: () => h(NButton,
|
||||
{
|
||||
bordered: false,
|
||||
ghost: true,
|
||||
text: true,
|
||||
size: "small",
|
||||
onClick: () => router.push('/')
|
||||
type: menuValue.value == "home" ? "primary" : "default",
|
||||
style: "width: 100%",
|
||||
onClick: async () => {
|
||||
await router.push(getRouterPathWithLang('/', locale.value));
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => t('home'),
|
||||
icon: () => h(NIcon, { component: Home })
|
||||
}
|
||||
),
|
||||
}),
|
||||
key: "home"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
bordered: false,
|
||||
ghost: true,
|
||||
size: "small",
|
||||
onClick: () => router.push('/admin')
|
||||
},
|
||||
{
|
||||
default: () => "Admin",
|
||||
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
|
||||
}
|
||||
),
|
||||
show: !!adminAuth.value,
|
||||
key: "admin"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
bordered: false,
|
||||
ghost: true,
|
||||
text: true,
|
||||
size: "small",
|
||||
type: menuValue.value == "user" ? "primary" : "default",
|
||||
style: "width: 100%",
|
||||
onClick: async () => {
|
||||
await router.push(getRouterPathWithLang("/user", locale.value));
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => t('user'),
|
||||
icon: () => h(NIcon, { component: User }),
|
||||
}
|
||||
),
|
||||
show: showUserMenu.value,
|
||||
key: "user",
|
||||
children: [
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
ghost: true,
|
||||
size: "small",
|
||||
onClick: () => { showPassword.value = true }
|
||||
},
|
||||
{ default: () => t('showPassword') }
|
||||
),
|
||||
key: "showPassword"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
ghost: true,
|
||||
size: "small",
|
||||
onClick: () => { router.push('/settings') }
|
||||
},
|
||||
{ default: () => t('settings') }
|
||||
),
|
||||
key: "settings"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
ghost: true,
|
||||
size: "small",
|
||||
onClick: () => { showLogout.value = true }
|
||||
},
|
||||
{ default: () => t('logout') }
|
||||
),
|
||||
key: "logout"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
ghost: true,
|
||||
size: "small",
|
||||
onClick: () => { showDelteAccount.value = true }
|
||||
},
|
||||
{ default: () => t('delteAccount') }
|
||||
),
|
||||
key: "delte_account"
|
||||
}
|
||||
]
|
||||
show: !isTelegram.value
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
bordered: false,
|
||||
ghost: true,
|
||||
text: true,
|
||||
size: "small",
|
||||
onClick: () => { themeSwitch.value = !themeSwitch.value }
|
||||
type: menuValue.value == "admin" ? "primary" : "default",
|
||||
style: "width: 100%",
|
||||
onClick: async () => {
|
||||
loading.value = true;
|
||||
await router.push(getRouterPathWithLang('/admin', locale.value));
|
||||
loading.value = false;
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => themeSwitch.value ? t('light') : t('dark'),
|
||||
default: () => "Admin",
|
||||
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
|
||||
}
|
||||
),
|
||||
show: showAdminPage.value,
|
||||
key: "admin"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
onClick: () => { toggleDark(); showMobileMenu.value = false; }
|
||||
},
|
||||
{
|
||||
default: () => isDark.value ? t('light') : t('dark'),
|
||||
icon: () => h(
|
||||
NIcon, { component: themeSwitch.value ? LightModeFilled : DarkModeFilled }
|
||||
NIcon, { component: isDark.value ? LightModeFilled : DarkModeFilled }
|
||||
)
|
||||
}
|
||||
),
|
||||
@@ -248,13 +162,16 @@ const menuOptions = computed(() => [
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
bordered: false,
|
||||
ghost: true,
|
||||
text: true,
|
||||
size: "small",
|
||||
onClick: () => localeCache.value == 'zh' ? changeLocale('en') : changeLocale('zh')
|
||||
style: "width: 100%",
|
||||
onClick: async () => {
|
||||
locale.value == 'zh' ? await changeLocale('en') : await changeLocale('zh');
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => localeCache.value == 'zh' ? "English" : "中文",
|
||||
default: () => locale.value == 'zh' ? "English" : "中文",
|
||||
icon: () => h(
|
||||
NIcon, { component: Language }
|
||||
)
|
||||
@@ -266,212 +183,92 @@ const menuOptions = computed(() => [
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
bordered: !isMobile.value,
|
||||
ghost: true,
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
tag: "a",
|
||||
target: "_blank",
|
||||
href: "https://github.com/dreamhunter2333/cloudflare_temp_email",
|
||||
},
|
||||
{
|
||||
default: () => "Github",
|
||||
default: () => version || "Github",
|
||||
icon: () => h(NIcon, { component: GithubAlt })
|
||||
}
|
||||
),
|
||||
show: openSettings.value?.showGithub,
|
||||
key: "github"
|
||||
}
|
||||
]);
|
||||
|
||||
const menuOptionsMobile = computed(() => [
|
||||
{
|
||||
label: t('menu'),
|
||||
icon: () => h(
|
||||
NIcon,
|
||||
{
|
||||
component: MenuFilled
|
||||
}
|
||||
),
|
||||
key: "menu",
|
||||
children: menuOptions.value
|
||||
},
|
||||
]);
|
||||
useHead({
|
||||
title: () => openSettings.value.title || t('title'),
|
||||
meta: [
|
||||
{ name: "description", content: openSettings.value.description || t('title') },
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await toClipboard(settings.value.address)
|
||||
message.success(t('copied'));
|
||||
} catch (e) {
|
||||
message.error(e.message || "error");
|
||||
const logoClickCount = ref(0);
|
||||
const logoClick = async () => {
|
||||
if (route.path.includes("admin")) {
|
||||
logoClickCount.value = 0;
|
||||
return;
|
||||
}
|
||||
if (logoClickCount.value >= 5) {
|
||||
logoClickCount.value = 0;
|
||||
message.info("Change to admin Page");
|
||||
loading.value = true;
|
||||
await router.push(getRouterPathWithLang('/admin', locale.value));
|
||||
loading.value = false;
|
||||
} else {
|
||||
logoClickCount.value++;
|
||||
}
|
||||
if (logoClickCount.value > 0) {
|
||||
message.info(`Click ${5 - logoClickCount.value + 1} times to enter the admin page`);
|
||||
}
|
||||
}
|
||||
|
||||
const newEmail = async () => {
|
||||
try {
|
||||
const res = await api.fetch(
|
||||
`/api/new_address`
|
||||
+ `?name=${emailName.value || ''}`
|
||||
+ `&domain=${emailDomain.value || ''}`
|
||||
);
|
||||
jwt.value = res["jwt"];
|
||||
await api.getSettings();
|
||||
showNewEmail.value = false;
|
||||
showPassword.value = true;
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/delete_address`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
jwt.value = '';
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getOpenSettings(message);
|
||||
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
|
||||
await api.getOpenSettings(message, notification);
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-layout-header>
|
||||
<h2 style="display: inline-block; margin-left: 10px;">{{ t('title') }}</h2>
|
||||
<div>
|
||||
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" />
|
||||
<n-menu v-else mode="horizontal" :options="menuOptionsMobile" />
|
||||
</div>
|
||||
</n-layout-header>
|
||||
<div v-if="!isAdminRoute">
|
||||
<n-card v-if="!settings.fetched">
|
||||
<n-skeleton style="height: 50vh" />
|
||||
</n-card>
|
||||
<div v-else-if="settings.address">
|
||||
<n-alert v-if="settings.has_v1_mails" type="warning" show-icon closable>
|
||||
<span>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small"
|
||||
href="https://temp-email-v1.dreamhunter2333.xyz/">
|
||||
<b>{{ t('mailV1Alert') }} </b>
|
||||
</n-button>
|
||||
</span>
|
||||
</n-alert>
|
||||
<n-alert type="info" show-icon>
|
||||
<span>
|
||||
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
|
||||
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary round type="primary">
|
||||
<n-icon :component="Copy" /> {{ t('copy') }}
|
||||
</n-button>
|
||||
</span>
|
||||
</n-alert>
|
||||
</div>
|
||||
<n-card v-else>
|
||||
<n-result status="info" :description="t('pleaseGetNewEmail')">
|
||||
<template #footer>
|
||||
<n-alert v-if="jwt" type="warning" show-icon>
|
||||
<span>{{ t('fetchAddressError') }}</span>
|
||||
</n-alert>
|
||||
<n-button @click="showLogin = true" tertiary round type="primary">
|
||||
{{ t('login') }}
|
||||
</n-button>
|
||||
<n-button @click="showNewEmail = true" tertiary round type="primary">
|
||||
{{ t('getNewEmail') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</n-card>
|
||||
</div>
|
||||
<n-modal v-model:show="showNewEmail" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('getNewEmail') }}</div>
|
||||
<n-page-header>
|
||||
<template #title>
|
||||
<h3>{{ openSettings.title || t('title') }}</h3>
|
||||
</template>
|
||||
<span>
|
||||
<p>{{ t("getNewEmailTip1") }}</p>
|
||||
<p>{{ t("getNewEmailTip2") }}</p>
|
||||
</span>
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-model:value="emailName" />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false" :options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
<template #action>
|
||||
<n-button @click="showNewEmail = false">
|
||||
{{ t('cancel') }}
|
||||
</n-button>
|
||||
<n-button @click="newEmail" type="primary">
|
||||
{{ t('ok') }}
|
||||
</n-button>
|
||||
<template #avatar>
|
||||
<div @click="logoClick">
|
||||
<n-avatar style="margin-left: 10px;" src="/logo.png" />
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showPassword" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t("password") }}</div>
|
||||
<template #extra>
|
||||
<n-space>
|
||||
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" responsive />
|
||||
<n-button v-else :text="true" @click="showMobileMenu = !showMobileMenu" style="margin-right: 10px;">
|
||||
<template #icon>
|
||||
<n-icon :component="MenuFilled" />
|
||||
</template>
|
||||
{{ t('menu') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
<span>
|
||||
<p>{{ t("passwordTip") }}</p>
|
||||
</span>
|
||||
<n-card>
|
||||
<b>{{ jwt }}</b>
|
||||
</n-card>
|
||||
<template #action>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showLogin" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('login') }}</div>
|
||||
</template>
|
||||
<n-input v-model:value="password" type="textarea" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
<template #action>
|
||||
<n-button @click="login" size="small" tertiary round type="primary">
|
||||
{{ t('login') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showLogout" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('logout') }}</div>
|
||||
</template>
|
||||
<p>{{ t('logoutConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button @click="logout" size="small" tertiary round type="primary">
|
||||
{{ t('logout') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDelteAccount" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('delteAccount') }}</div>
|
||||
</template>
|
||||
<p>{{ t('delteAccountConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button @click="deleteAccount" size="small" tertiary round type="error">
|
||||
{{ t('delteAccount') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</n-page-header>
|
||||
<n-drawer v-model:show="showMobileMenu" placement="top" style="height: 100vh;">
|
||||
<n-drawer-content :title="t('menu')" closable>
|
||||
<n-menu :options="menuOptions" />
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
|
||||
title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('auth') }}</div>
|
||||
</template>
|
||||
<p>{{ t('authTip') }}</p>
|
||||
<n-input v-model:value="auth" type="textarea" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
:title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="auth" type="password" show-password-on="click" />
|
||||
<template #action>
|
||||
<n-button @click="authFunc" size="small" tertiary round type="primary">
|
||||
{{ t('auth') }}
|
||||
<n-button :loading="loading" @click="authFunc" type="primary">
|
||||
{{ t('ok') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
@@ -494,4 +291,16 @@ onMounted(async () => {
|
||||
.n-card {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.n-form .n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,299 +1,191 @@
|
||||
<script setup>
|
||||
import { watch, onMounted, ref } from "vue";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { defineAsyncComponent, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { CloudDownloadRound } from '@vicons/material'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
|
||||
import { FullscreenExitOutlined } from '@vicons/material'
|
||||
|
||||
import AddressBar from './index/AddressBar.vue';
|
||||
import MailBox from '../components/MailBox.vue';
|
||||
import SendBox from '../components/SendBox.vue';
|
||||
import AutoReply from './index/AutoReply.vue';
|
||||
import AccountSettings from './index/AccountSettings.vue';
|
||||
import Appearance from './common/Appearance.vue';
|
||||
import Webhook from './index/Webhook.vue';
|
||||
import Attachment from './index/Attachment.vue';
|
||||
import About from './common/About.vue';
|
||||
import SimpleIndex from './index/SimpleIndex.vue';
|
||||
|
||||
const { loading, settings, openSettings, indexTab, globalTabplacement, useSimpleIndex } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const { settings, themeSwitch } = useGlobalState()
|
||||
const autoRefresh = ref(false)
|
||||
const data = ref([])
|
||||
const timer = ref(null)
|
||||
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const showAttachments = ref(false)
|
||||
const curAttachments = ref([])
|
||||
const curMail = ref(null);
|
||||
const SendMail = defineAsyncComponent(() => {
|
||||
loading.value = true;
|
||||
return import('./index/SendMail.vue')
|
||||
.finally(() => loading.value = false);
|
||||
});
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
autoRefresh: 'Auto Refresh',
|
||||
refresh: 'Refresh',
|
||||
attachments: 'Show Attachments',
|
||||
downloadMail: 'Download Mail',
|
||||
pleaseSelectMail: "Please select a mail to view."
|
||||
mailbox: 'Mail Box',
|
||||
sendbox: 'Send Box',
|
||||
sendmail: 'Send Mail',
|
||||
auto_reply: 'Auto Reply',
|
||||
accountSettings: 'Account Settings',
|
||||
appearance: 'Appearance',
|
||||
about: 'About',
|
||||
s3Attachment: 'S3 Attachment',
|
||||
saveToS3Success: 'save to s3 success',
|
||||
webhookSettings: 'Webhook Settings',
|
||||
query: 'Query',
|
||||
enterSimpleMode: 'Simple Mode',
|
||||
},
|
||||
zh: {
|
||||
autoRefresh: '自动刷新',
|
||||
refresh: '刷新',
|
||||
downloadMail: '下载邮件',
|
||||
attachments: '查看附件',
|
||||
pleaseSelectMail: "请选择一封邮件查看。"
|
||||
mailbox: '收件箱',
|
||||
sendbox: '发件箱',
|
||||
sendmail: '发送邮件',
|
||||
auto_reply: '自动回复',
|
||||
accountSettings: '账户',
|
||||
appearance: '外观',
|
||||
about: '关于',
|
||||
s3Attachment: 'S3附件',
|
||||
saveToS3Success: '保存到s3成功',
|
||||
webhookSettings: 'Webhook 设置',
|
||||
query: '查询',
|
||||
enterSimpleMode: '极简模式',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const setupAutoRefresh = async (autoRefresh) => {
|
||||
if (autoRefresh) {
|
||||
timer.value = setInterval(async () => {
|
||||
await refresh();
|
||||
}, 30000)
|
||||
} else {
|
||||
clearInterval(timer.value)
|
||||
timer.value = null
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
if (mailIdQuery.value > 0) {
|
||||
const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`);
|
||||
if (singleMail) return { results: [singleMail], count: 1 };
|
||||
return { results: [], count: 0 };
|
||||
}
|
||||
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
|
||||
};
|
||||
|
||||
const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
const deleteSenboxMail = async (curMailId) => {
|
||||
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
const fetchSenboxData = async (limit, offset) => {
|
||||
return await api.fetch(`/api/sendbox?limit=${limit}&offset=${offset}`);
|
||||
};
|
||||
|
||||
const saveToS3 = async (mail_id, filename, blob) => {
|
||||
try {
|
||||
const { url } = await api.fetch(`/api/attachment/put_url`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: `${mail_id}/${filename}` })
|
||||
});
|
||||
// upload to s3 by formdata
|
||||
const formData = new FormData();
|
||||
formData.append(filename, blob);
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
message.success(t('saveToS3Success'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || "save to s3 error");
|
||||
}
|
||||
}
|
||||
|
||||
watch(autoRefresh, async (autoRefresh, old) => {
|
||||
setupAutoRefresh(autoRefresh)
|
||||
})
|
||||
const mailBoxKey = ref("")
|
||||
const mailIdQuery = ref("")
|
||||
const showMailIdQuery = ref(false)
|
||||
|
||||
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
|
||||
if (page !== oldPage || pageSize !== oldPageSize) {
|
||||
await refresh();
|
||||
const queryMail = () => {
|
||||
mailBoxKey.value = Date.now();
|
||||
}
|
||||
|
||||
watch(route, () => {
|
||||
if (!route.query.mail_id) {
|
||||
showMailIdQuery.value = false;
|
||||
mailIdQuery.value = "";
|
||||
queryMail();
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
if (typeof settings.value.address != 'string' || settings.value.address.trim() === '') {
|
||||
return;
|
||||
onMounted(() => {
|
||||
if (route.query.mail_id) {
|
||||
showMailIdQuery.value = true;
|
||||
mailIdQuery.value = route.query.mail_id;
|
||||
queryMail();
|
||||
}
|
||||
try {
|
||||
const { results, count: totalCount } = await api.fetch(
|
||||
`/api/mails`
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
);
|
||||
data.value = await Promise.all(results.map(async (item) => {
|
||||
return await processItem(item);
|
||||
}));
|
||||
if (totalCount > 0) {
|
||||
count.value = totalCount;
|
||||
}
|
||||
if (!isMobile.value && !curMail.value && data.value.length > 0) {
|
||||
curMail.value = data.value[0];
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const clickRow = async (row) => {
|
||||
curMail.value = row;
|
||||
};
|
||||
|
||||
const getAttachments = (attachments) => {
|
||||
curAttachments.value = attachments;
|
||||
showAttachments.value = true;
|
||||
};
|
||||
|
||||
const mailItemClass = (row) => {
|
||||
return curMail.value && row.id == curMail.value.id ? (themeSwitch.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getSettings();
|
||||
await refresh();
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-layout v-if="settings.address">
|
||||
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25" :default-size="0.25">
|
||||
<template #1>
|
||||
<div>
|
||||
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
</div>
|
||||
<n-switch v-model:value="autoRefresh" size="small">
|
||||
<template #checked>
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
<template #unchecked>
|
||||
{{ t('autoRefresh') }}
|
||||
</template></n-switch>
|
||||
<n-button class="center" @click="refresh" size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div style="overflow: scroll; height: 80vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
<n-thing class="center" :title="row.subject" style="overflow: scroll">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ row.created_at }}
|
||||
</n-tag>
|
||||
<div style="word-break: break-all; font-size: small;">
|
||||
FROM: {{ row.source }}
|
||||
</div>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</template>
|
||||
<template #2>
|
||||
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: scroll;">
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ curMail.created_at }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
</n-tag>
|
||||
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="getAttachments(curMail.attachments)">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(curMail.raw)">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div v-html="curMail.message" style="margin-top: 10px;max-height: 100vh;"></div>
|
||||
</n-card>
|
||||
<n-card class="mail-item" v-else>
|
||||
<n-result status="info" :title="t('pleaseSelectMail')">
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-split>
|
||||
<div class="left" v-else>
|
||||
<div>
|
||||
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
</div>
|
||||
<n-switch v-model:value="autoRefresh" size="small">
|
||||
<template #checked>
|
||||
{{ t('autoRefresh') }}
|
||||
<div v-if="useSimpleIndex">
|
||||
<SimpleIndex />
|
||||
</div>
|
||||
<div v-else>
|
||||
<AddressBar />
|
||||
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
|
||||
<template #prefix v-if="!isMobile">
|
||||
<n-button @click="useSimpleIndex = true" tertiary size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<FullscreenExitOutlined />
|
||||
</n-icon>
|
||||
</template>
|
||||
<template #unchecked>
|
||||
{{ t('autoRefresh') }}
|
||||
</template></n-switch>
|
||||
<n-button class="center" @click="refresh" size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
{{ t('enterSimpleMode') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div id="drawer-target" style="overflow: scroll; max-height: 80vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-thing class="center" :title="row.subject" style="overflow: scroll">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ row.created_at }}
|
||||
</n-tag>
|
||||
<div style="word-break: break-all; font-size: small;">
|
||||
FROM: {{ row.source }}
|
||||
</div>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
<n-drawer v-model:show="curMail" width="100%" :trap-focus="false" :block-scroll="false" to="#drawer-target">
|
||||
<n-drawer-content :title="curMail.subject" closable>
|
||||
<n-card style="overflow: scroll;">
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ curMail.created_at }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
</n-tag>
|
||||
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="getAttachments(curMail.attachments)">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(curMail)">
|
||||
{{ t('downloadMail') }}
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div v-html="curMail.message" style="max-height: 100vh;"></div>
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
</n-layout>
|
||||
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t("attachments") }}</div>
|
||||
</template>
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.filename">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
Size: {{ row.size }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-thing>
|
||||
<template #suffix>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
|
||||
:href="row.url">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</n-button>
|
||||
</template>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
<template #action>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
<n-tab-pane name="mailbox" :tab="t('mailbox')">
|
||||
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="mailIdQuery" />
|
||||
<n-button @click="queryMail" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</div>
|
||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled"
|
||||
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:deleteMail="deleteSenboxMail" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendmail" :tab="t('sendmail')">
|
||||
<SendMail />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="appearance" :tab="t('appearance')">
|
||||
<Appearance :showUseSimpleIndex="true" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
|
||||
<AutoReply />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhookSettings')">
|
||||
<Webhook />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.isS3Enabled" name="s3_attachment" :tab="t('s3Attachment')">
|
||||
<Attachment />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableIndexAbout" name="about" :tab="t('about')">
|
||||
<About />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.left {
|
||||
overflow: scroll;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.overlay-dark-backgroud {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.overlay-light-backgroud {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mail-item {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
53
frontend/src/views/User.vue
Normal file
53
frontend/src/views/User.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
|
||||
import AddressMangement from './user/AddressManagement.vue';
|
||||
import UserSettingsPage from './user/UserSettings.vue';
|
||||
import UserBar from './user/UserBar.vue';
|
||||
import BindAddress from './user/BindAddress.vue';
|
||||
import UserMailBox from './user/UserMailBox.vue';
|
||||
|
||||
const {
|
||||
userTab, globalTabplacement, userSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
address_management: 'Address Management',
|
||||
user_mail_box_tab: 'Mail Box',
|
||||
user_settings: 'User Settings',
|
||||
bind_address: 'Bind Mail Address',
|
||||
},
|
||||
zh: {
|
||||
address_management: '地址管理',
|
||||
user_mail_box_tab: '收件箱',
|
||||
user_settings: '用户设置',
|
||||
bind_address: '绑定邮箱地址',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UserBar />
|
||||
<n-tabs v-if="userSettings.user_email" type="card" v-model:value="userTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="address_management" :tab="t('address_management')">
|
||||
<AddressMangement />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
|
||||
<UserMailBox />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettingsPage />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="bind_address" :tab="t('bind_address')">
|
||||
<BindAddress />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
614
frontend/src/views/admin/Account.vue
Normal file
614
frontend/src/views/admin/Account.vue
Normal file
@@ -0,0 +1,614 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch, computed } from 'vue';
|
||||
import { NBadge, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { NButton, NMenu } from 'naive-ui';
|
||||
import { MenuFilled } from '@vicons/material'
|
||||
|
||||
const {
|
||||
loading, adminTab, openSettings,
|
||||
adminMailTabAddress, adminSendBoxTabAddress
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
name: 'Name',
|
||||
created_at: 'Created At',
|
||||
updated_at: 'Update At',
|
||||
mail_count: 'Mail Count',
|
||||
send_count: 'Send Count',
|
||||
showCredential: 'Show Mail Address Credential',
|
||||
addressCredential: 'Mail Address Credential',
|
||||
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
|
||||
delete: 'Delete',
|
||||
deleteTip: 'Are you sure to delete this email?',
|
||||
deleteAccount: 'Delete Account',
|
||||
viewMails: 'View Mails',
|
||||
viewSendBox: 'View SendBox',
|
||||
itemCount: 'itemCount',
|
||||
query: 'Query',
|
||||
addressQueryTip: 'Leave blank to query all addresses',
|
||||
clearInbox: 'Clear Inbox',
|
||||
clearSentItems: 'Clear Sent Items',
|
||||
clearInboxTip: 'Are you sure to clear inbox for this email?',
|
||||
clearSentItemsTip: 'Are you sure to clear sent items for this email?',
|
||||
actions: 'Actions',
|
||||
success: 'Success',
|
||||
resetPassword: 'Reset Password',
|
||||
newPassword: 'New Password',
|
||||
passwordResetSuccess: 'Password reset successfully',
|
||||
selectAll: 'Select All of This Page',
|
||||
unselectAll: 'Unselect All',
|
||||
pleaseSelectAddress: 'Please select address',
|
||||
selectedItems: 'Selected',
|
||||
multiDelete: 'Multi Delete',
|
||||
multiDeleteTip: 'Are you sure to delete selected addresses?',
|
||||
multiClearInbox: 'Multi Clear Inbox',
|
||||
multiClearInboxTip: 'Are you sure to clear inbox for selected addresses?',
|
||||
multiClearSentItems: 'Multi Clear Sent Items',
|
||||
multiClearSentItemsTip: 'Are you sure to clear sent items for selected addresses?',
|
||||
},
|
||||
zh: {
|
||||
name: '名称',
|
||||
created_at: '创建时间',
|
||||
updated_at: '更新时间',
|
||||
mail_count: '邮件数量',
|
||||
send_count: '发送数量',
|
||||
showCredential: '查看邮箱地址凭证',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
delete: '删除',
|
||||
deleteTip: '确定要删除这个邮箱吗?',
|
||||
deleteAccount: '删除邮箱',
|
||||
viewMails: '查看邮件',
|
||||
viewSendBox: '查看发件箱',
|
||||
itemCount: '总数',
|
||||
query: '查询',
|
||||
addressQueryTip: '留空查询所有地址',
|
||||
clearInbox: '清空收件箱',
|
||||
clearSentItems: '清空发件箱',
|
||||
clearInboxTip: '确定要清空这个邮箱的收件箱吗?',
|
||||
clearSentItemsTip: '确定要清空这个邮箱的发件箱吗?',
|
||||
actions: '操作',
|
||||
success: '成功',
|
||||
resetPassword: '重置密码',
|
||||
newPassword: '新密码',
|
||||
passwordResetSuccess: '密码重置成功',
|
||||
selectAll: '全选本页',
|
||||
unselectAll: '取消全选',
|
||||
pleaseSelectAddress: '请选择地址',
|
||||
selectedItems: '已选择',
|
||||
multiDelete: '批量删除',
|
||||
multiDeleteTip: '确定要删除选中的邮箱吗?',
|
||||
multiClearInbox: '批量清空收件箱',
|
||||
multiClearInboxTip: '确定要清空选中邮箱的收件箱吗?',
|
||||
multiClearSentItems: '批量清空发件箱',
|
||||
multiClearSentItemsTip: '确定要清空选中邮箱的发件箱吗?',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const showEmailCredential = ref(false)
|
||||
const curEmailCredential = ref("")
|
||||
const curDeleteAddressId = ref(0);
|
||||
const curClearInboxAddressId = ref(0);
|
||||
const curClearSentItemsAddressId = ref(0);
|
||||
const showResetPassword = ref(false);
|
||||
const curResetPasswordAddressId = ref(0);
|
||||
const newPassword = ref('');
|
||||
|
||||
// Multi-action mode state
|
||||
const checkedRowKeys = ref([]);
|
||||
const showMultiActionModal = ref(false);
|
||||
const multiActionProgress = ref({ percentage: 0, tip: '0/0' });
|
||||
const multiActionTitle = ref('');
|
||||
|
||||
const selectedCount = computed(() => checkedRowKeys.value.length);
|
||||
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
|
||||
|
||||
const addressQuery = ref("")
|
||||
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const showDeleteAccount = ref(false)
|
||||
const showClearInbox = ref(false)
|
||||
const showClearSentItems = ref(false)
|
||||
|
||||
const showCredential = async (id) => {
|
||||
try {
|
||||
curEmailCredential.value = await api.adminShowAddressCredential(id)
|
||||
showEmailCredential.value = true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
showEmailCredential.value = false
|
||||
curEmailCredential.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEmail = async () => {
|
||||
try {
|
||||
await api.adminDeleteAddress(curDeleteAddressId.value)
|
||||
message.success(t("success"));
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
showDeleteAccount.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearInbox = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/clear_inbox/${curClearInboxAddressId.value}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
message.success(t("success"));
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
showClearInbox.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearSentItems = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/clear_sent_items/${curClearSentItemsAddressId.value}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
message.success(t("success"));
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
showClearSentItems.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/address/${curResetPasswordAddressId.value}/reset_password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password: newPassword.value
|
||||
})
|
||||
});
|
||||
message.success(t("passwordResetSuccess"));
|
||||
newPassword.value = '';
|
||||
showResetPassword.value = false;
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-action mode functions
|
||||
const multiActionSelectAll = () => {
|
||||
checkedRowKeys.value = data.value.map(item => item.id);
|
||||
}
|
||||
|
||||
const multiActionUnselectAll = () => {
|
||||
checkedRowKeys.value = [];
|
||||
}
|
||||
|
||||
// 通用批量操作函数
|
||||
const executeBatchOperation = async ({
|
||||
shouldSkip = () => false,
|
||||
apiCall,
|
||||
title,
|
||||
operationName = 'operation'
|
||||
}) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const selectedAddresses = data.value.filter((item) =>
|
||||
checkedRowKeys.value.includes(item.id)
|
||||
);
|
||||
|
||||
if (selectedAddresses.length === 0) {
|
||||
message.error(t('pleaseSelectAddress'));
|
||||
return;
|
||||
}
|
||||
|
||||
const failedIds = [];
|
||||
const totalCount = selectedAddresses.length;
|
||||
|
||||
multiActionProgress.value = {
|
||||
percentage: 0,
|
||||
tip: `0/${totalCount}`
|
||||
};
|
||||
multiActionTitle.value = title;
|
||||
showMultiActionModal.value = true;
|
||||
|
||||
for (const [index, address] of selectedAddresses.entries()) {
|
||||
try {
|
||||
if (!shouldSkip(address)) {
|
||||
await apiCall(address.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${operationName} failed for address ${address.id}:`, error);
|
||||
failedIds.push(address.id);
|
||||
}
|
||||
multiActionProgress.value = {
|
||||
percentage: Math.floor((index + 1) / totalCount * 100),
|
||||
tip: `${index + 1}/${totalCount}`
|
||||
};
|
||||
}
|
||||
|
||||
await fetchData();
|
||||
checkedRowKeys.value = failedIds;
|
||||
message.success(t("success"));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const multiActionDeleteAccounts = async () => {
|
||||
await executeBatchOperation({
|
||||
apiCall: (id) => api.adminDeleteAddress(id),
|
||||
title: t('multiDelete') + ' ' + t('success'),
|
||||
operationName: 'Delete'
|
||||
});
|
||||
}
|
||||
|
||||
const multiActionClearInbox = async () => {
|
||||
await executeBatchOperation({
|
||||
shouldSkip: (address) => address.mail_count <= 0,
|
||||
apiCall: (id) => api.fetch(`/admin/clear_inbox/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
title: t('multiClearInbox') + ' ' + t('success'),
|
||||
operationName: 'ClearInbox'
|
||||
});
|
||||
}
|
||||
|
||||
const multiActionClearSentItems = async () => {
|
||||
await executeBatchOperation({
|
||||
shouldSkip: (address) => address.send_count <= 0,
|
||||
apiCall: (id) => api.fetch(`/admin/clear_sent_items/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
title: t('multiClearSentItems') + ' ' + t('success'),
|
||||
operationName: 'ClearSentItems'
|
||||
});
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
addressQuery.value = addressQuery.value.trim()
|
||||
const { results, count: addressCount } = await api.fetch(
|
||||
`/admin/address`
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
|
||||
);
|
||||
data.value = results;
|
||||
if (addressCount > 0) {
|
||||
count.value = addressCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
type: 'selection'
|
||||
},
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
},
|
||||
{
|
||||
title: t('name'),
|
||||
key: "name"
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
},
|
||||
{
|
||||
title: t('updated_at'),
|
||||
key: "updated_at"
|
||||
},
|
||||
{
|
||||
title: t('mail_count'),
|
||||
key: "mail_count",
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.mail_count > 0) {
|
||||
adminMailTabAddress.value = row.name;
|
||||
adminTab.value = "mails";
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: () => h(NBadge, {
|
||||
value: row.mail_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
}),
|
||||
default: () => row.mail_count > 0 ? t('viewMails') : ""
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('send_count'),
|
||||
key: "send_count",
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.send_count > 0) {
|
||||
adminSendBoxTabAddress.value = row.name;
|
||||
adminTab.value = "sendBox";
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: () => h(NBadge, {
|
||||
value: row.send_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
}),
|
||||
default: () => row.send_count > 0 ? t('viewSendBox') : ""
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NMenu, {
|
||||
mode: "horizontal",
|
||||
options: [
|
||||
{
|
||||
label: t('actions'),
|
||||
icon: () => h(MenuFilled),
|
||||
key: "action",
|
||||
children: [
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => showCredential(row.id)
|
||||
},
|
||||
{ default: () => t('showCredential') }
|
||||
),
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
adminMailTabAddress.value = row.name;
|
||||
adminTab.value = "mails";
|
||||
}
|
||||
},
|
||||
{ default: () => t('viewMails') }
|
||||
),
|
||||
show: row.mail_count > 0
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
adminSendBoxTabAddress.value = row.name;
|
||||
adminTab.value = "sendBox";
|
||||
}
|
||||
},
|
||||
{ default: () => t('viewSendBox') }
|
||||
),
|
||||
show: row.send_count > 0
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curClearInboxAddressId.value = row.id;
|
||||
showClearInbox.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('clearInbox') }
|
||||
),
|
||||
show: row.mail_count > 0
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curClearSentItemsAddressId.value = row.id;
|
||||
showClearSentItems.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('clearSentItems') }
|
||||
),
|
||||
show: row.send_count > 0
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curResetPasswordAddressId.value = row.id;
|
||||
showResetPassword.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('resetPassword') }
|
||||
),
|
||||
show: openSettings.value?.enableAddressPassword
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curDeleteAddressId.value = row.id;
|
||||
showDeleteAccount.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('delete') }
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="margin-top: 10px;">
|
||||
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t("addressCredential") }}</div>
|
||||
</template>
|
||||
<span>
|
||||
<p>{{ t("addressCredentialTip") }}</p>
|
||||
</span>
|
||||
<n-card :bordered="false" embedded>
|
||||
<b>{{ curEmailCredential }}</b>
|
||||
</n-card>
|
||||
<template #action>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
|
||||
<p>{{ t('deleteTip') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
|
||||
{{ t('deleteAccount') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showClearInbox" preset="dialog" :title="t('clearInbox')">
|
||||
<p>{{ t('clearInboxTip') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="clearInbox" size="small" tertiary type="error">
|
||||
{{ t('clearInbox') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showClearSentItems" preset="dialog" :title="t('clearSentItems')">
|
||||
<p>{{ t('clearSentItemsTip') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="clearSentItems" size="small" tertiary type="error">
|
||||
{{ t('clearSentItems') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
|
||||
<n-form-item :label="t('newPassword')">
|
||||
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="info">
|
||||
{{ t('resetPassword') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-input-group style="margin-bottom: 10px;">
|
||||
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
|
||||
@keydown.enter="fetchData" />
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
|
||||
<n-space v-if="showMultiActionBar" style="margin-bottom: 10px;">
|
||||
<n-button @click="multiActionSelectAll" tertiary>
|
||||
{{ t('selectAll') }}
|
||||
</n-button>
|
||||
<n-button @click="multiActionUnselectAll" tertiary>
|
||||
{{ t('unselectAll') }}
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="multiActionDeleteAccounts">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error">{{ t('multiDelete') }}</n-button>
|
||||
</template>
|
||||
{{ t('multiDeleteTip') }}
|
||||
</n-popconfirm>
|
||||
<n-popconfirm @positive-click="multiActionClearInbox">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="warning">{{ t('multiClearInbox') }}</n-button>
|
||||
</template>
|
||||
{{ t('multiClearInboxTip') }}
|
||||
</n-popconfirm>
|
||||
<n-popconfirm @positive-click="multiActionClearSentItems">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="warning">{{ t('multiClearSentItems') }}</n-button>
|
||||
</template>
|
||||
{{ t('multiClearSentItemsTip') }}
|
||||
</n-popconfirm>
|
||||
<n-tag type="info">
|
||||
{{ t('selectedItems') }}: {{ selectedCount }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<div style="overflow: auto;">
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
|
||||
:page-sizes="[20, 50, 100]" show-size-picker>
|
||||
<template #prefix="{ itemCount }">
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
|
||||
:row-key="row => row.id" embedded />
|
||||
</div>
|
||||
|
||||
<!-- Multi-action progress modal -->
|
||||
<n-modal v-model:show="showMultiActionModal" preset="dialog" :title="multiActionTitle" negative-text="OK">
|
||||
<n-space justify="center">
|
||||
<n-progress type="circle" status="info" :percentage="multiActionProgress.percentage">
|
||||
<span style="text-align: center">
|
||||
{{ multiActionProgress.tip }}
|
||||
</span>
|
||||
</n-progress>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-pagination {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.n-data-table {
|
||||
min-width: 1000px;
|
||||
}
|
||||
</style>
|
||||
296
frontend/src/views/admin/AccountSettings.vue
Normal file
296
frontend/src/views/admin/AccountSettings.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, h } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton, NPopconfirm, NInput, NSelect } from 'naive-ui'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { loading, openSettings } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
tip: 'You can manually input the following multiple select input and enter',
|
||||
manualInputPrompt: 'Type and press Enter to add',
|
||||
save: 'Save',
|
||||
successTip: 'Save Success',
|
||||
address_block_list: 'Address Block Keywords for Users(Admin can skip)',
|
||||
address_block_list_placeholder: 'Please enter the keywords you want to block',
|
||||
send_address_block_list: 'Address Block Keywords for send email',
|
||||
noLimitSendAddressList: 'No Balance Limit Send Address List',
|
||||
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
|
||||
fromBlockList: 'Block Keywords for receive email',
|
||||
block_receive_unknow_address_email: 'Block receive unknow address email',
|
||||
email_forwarding_config: 'Email Forwarding Configuration',
|
||||
domain_list: 'Domain List',
|
||||
forward_address: 'Forward Address',
|
||||
actions: 'Actions',
|
||||
select_domain: 'Select Domain',
|
||||
forward_placeholder: 'forward@example.com',
|
||||
delete_rule: 'Delete',
|
||||
delete_rule_confirm: 'Are you sure you want to delete this rule?',
|
||||
delete_success: 'Delete Success',
|
||||
forwarding_rule_warning: 'Each rule will run, if domains is empty, all emails will be forwarded, forward address needs to be a verified address',
|
||||
add: 'Add',
|
||||
cancel: 'Cancel',
|
||||
config: 'Config',
|
||||
},
|
||||
zh: {
|
||||
tip: '您可以手动输入以下多选输入框, 回车增加',
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
save: '保存',
|
||||
successTip: '保存成功',
|
||||
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
|
||||
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
|
||||
send_address_block_list: '发送邮件地址屏蔽关键词',
|
||||
noLimitSendAddressList: '无余额限制发送地址列表',
|
||||
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
|
||||
fromBlockList: '接收邮件地址屏蔽关键词',
|
||||
block_receive_unknow_address_email: '禁止接收未知地址邮件',
|
||||
email_forwarding_config: '邮件转发配置',
|
||||
domain_list: '域名列表',
|
||||
forward_address: '转发地址',
|
||||
actions: '操作',
|
||||
select_domain: '选择域名',
|
||||
forward_placeholder: 'forward@example.com',
|
||||
delete_rule: '删除',
|
||||
delete_rule_confirm: '确定要删除这条规则吗?',
|
||||
delete_success: '删除成功',
|
||||
forwarding_rule_warning: '每条规则都会运行,如果 domains 为空,则转发所有邮件,转发地址需要为已验证的地址',
|
||||
add: '添加',
|
||||
cancel: '取消',
|
||||
config: '配置',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const addressBlockList = ref([])
|
||||
const sendAddressBlockList = ref([])
|
||||
const noLimitSendAddressList = ref([])
|
||||
const verifiedAddressList = ref([])
|
||||
const fromBlockList = ref([])
|
||||
const emailRuleSettings = ref({
|
||||
blockReceiveUnknowAddressEmail: false,
|
||||
emailForwardingList: []
|
||||
})
|
||||
|
||||
const showEmailForwardingModal = ref(false)
|
||||
const emailForwardingList = ref([])
|
||||
|
||||
|
||||
const emailForwardingColumns = [
|
||||
{
|
||||
title: t('domain_list'),
|
||||
key: 'domains',
|
||||
render: (row, index) => {
|
||||
return h(NSelect, {
|
||||
value: Array.isArray(row.domains) ? row.domains : [],
|
||||
onUpdateValue: (val) => {
|
||||
emailForwardingList.value[index].domains = val
|
||||
},
|
||||
options: openSettings.value?.domains || [],
|
||||
multiple: true,
|
||||
filterable: true,
|
||||
tag: true,
|
||||
placeholder: t('select_domain')
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('forward_address'),
|
||||
key: 'forward',
|
||||
render: (row, index) => {
|
||||
return h(NInput, {
|
||||
value: row.forward,
|
||||
onUpdateValue: (val) => {
|
||||
emailForwardingList.value[index].forward = val
|
||||
},
|
||||
placeholder: 'forward@example.com'
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render: (row, index) => {
|
||||
return h('div', { style: 'display: flex; gap: 8px;' }, [
|
||||
h(NPopconfirm, {
|
||||
onPositiveClick: () => {
|
||||
emailForwardingList.value = emailForwardingList.value.filter((_, i) => i !== index)
|
||||
message.success(t('delete_success'))
|
||||
}
|
||||
}, {
|
||||
default: () => t('delete_rule_confirm'),
|
||||
trigger: () => h(NButton, {
|
||||
size: 'small',
|
||||
type: 'error'
|
||||
}, { default: () => t('delete_rule') })
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const openEmailForwardingModal = () => {
|
||||
// 从 emailRuleSettings 转换出列表数据
|
||||
emailForwardingList.value = emailRuleSettings.value.emailForwardingList ?
|
||||
[...emailRuleSettings.value.emailForwardingList] : []
|
||||
showEmailForwardingModal.value = true
|
||||
}
|
||||
|
||||
const addNewEmailForwardingItem = () => {
|
||||
emailForwardingList.value = [
|
||||
...emailForwardingList.value,
|
||||
{
|
||||
domains: [],
|
||||
forward: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const saveEmailForwardingConfig = () => {
|
||||
emailRuleSettings.value.emailForwardingList = [...emailForwardingList.value]
|
||||
showEmailForwardingModal.value = false
|
||||
}
|
||||
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/account_settings`)
|
||||
addressBlockList.value = res.blockList || []
|
||||
sendAddressBlockList.value = res.sendBlockList || []
|
||||
verifiedAddressList.value = res.verifiedAddressList || []
|
||||
fromBlockList.value = res.fromBlockList || []
|
||||
noLimitSendAddressList.value = res.noLimitSendAddressList || []
|
||||
emailRuleSettings.value = res.emailRuleSettings || {
|
||||
blockReceiveUnknowAddressEmail: false,
|
||||
emailForwardingList: []
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/account_settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
blockList: addressBlockList.value || [],
|
||||
sendBlockList: sendAddressBlockList.value || [],
|
||||
verifiedAddressList: verifiedAddressList.value || [],
|
||||
fromBlockList: fromBlockList.value || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList.value || [],
|
||||
emailRuleSettings: emailRuleSettings.value,
|
||||
})
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<n-alert :show-icon="false" :bordered="false" type="warning" style="margin-bottom: 10px;">
|
||||
<span>{{ t("tip") }}</span>
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="save" type="primary" :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-form-item-row :label="t('address_block_list')">
|
||||
<n-select v-model:value="addressBlockList" filterable multiple tag
|
||||
:placeholder="t('address_block_list_placeholder')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('send_address_block_list')">
|
||||
<n-select v-model:value="sendAddressBlockList" filterable multiple tag
|
||||
:placeholder="t('address_block_list_placeholder')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('noLimitSendAddressList')">
|
||||
<n-select v-model:value="noLimitSendAddressList" filterable multiple tag
|
||||
:placeholder="t('noLimitSendAddressList')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('verified_address_list')">
|
||||
<n-select v-model:value="verifiedAddressList" filterable multiple tag
|
||||
:placeholder="t('verified_address_list')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('fromBlockList')">
|
||||
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('block_receive_unknow_address_email')">
|
||||
<n-switch v-model:value="emailRuleSettings.blockReceiveUnknowAddressEmail" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('email_forwarding_config')">
|
||||
<n-button @click="openEmailForwardingModal">{{ t('config') }}</n-button>
|
||||
</n-form-item-row>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 邮件转发配置弹窗 -->
|
||||
<n-modal v-model:show="showEmailForwardingModal" preset="card" :title="t('email_forwarding_config')"
|
||||
style="max-width: 800px;">
|
||||
<n-space vertical>
|
||||
<n-alert :show-icon="false" :bordered="false" type="warning">
|
||||
<span>{{ t('forwarding_rule_warning') }}</span>
|
||||
</n-alert>
|
||||
<n-space justify="end">
|
||||
<n-button @click="addNewEmailForwardingItem">{{ t('add') }}</n-button>
|
||||
</n-space>
|
||||
<n-data-table :columns="emailForwardingColumns" :data="emailForwardingList" :bordered="false" striped />
|
||||
<n-space justify="end">
|
||||
<n-button @click="saveEmailForwardingConfig" type="primary">{{ t('save') }}</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
137
frontend/src/views/admin/CreateAccount.vue
Normal file
137
frontend/src/views/admin/CreateAccount.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const {
|
||||
loading, openSettings,
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
address: 'Address',
|
||||
enablePrefix: 'If enable Prefix',
|
||||
creatNewEmail: 'Create New Email',
|
||||
fillInAllFields: 'Please fill in all fields',
|
||||
successTip: 'Success Created',
|
||||
addressCredential: 'Mail Address Credential',
|
||||
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
|
||||
addressPassword: 'Address Password',
|
||||
linkWithAddressCredential: 'Open to auto login email link',
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
enablePrefix: '是否启用前缀',
|
||||
creatNewEmail: '创建新邮箱',
|
||||
fillInAllFields: '请填写完整信息',
|
||||
successTip: '创建成功',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
addressPassword: '地址密码',
|
||||
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const enablePrefix = ref(true)
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const showReultModal = ref(false)
|
||||
const result = ref("")
|
||||
const addressPassword = ref("")
|
||||
const createdAddress = ref("")
|
||||
|
||||
const newEmail = async () => {
|
||||
if (!emailName.value || !emailDomain.value) {
|
||||
message.error(t('fillInAllFields'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/admin/new_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
enablePrefix: enablePrefix.value,
|
||||
name: emailName.value,
|
||||
domain: emailDomain.value,
|
||||
})
|
||||
})
|
||||
result.value = res["jwt"];
|
||||
addressPassword.value = res["password"] || '';
|
||||
createdAddress.value = res["address"] || '';
|
||||
message.success(t('successTip'))
|
||||
showReultModal.value = true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const getUrlWithJwt = () => {
|
||||
return `${window.location.origin}/?jwt=${result.value}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (openSettings.prefix) {
|
||||
enablePrefix.value = true
|
||||
}
|
||||
emailDomain.value = openSettings.value.domains?.[0]?.value || ""
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
|
||||
<span>
|
||||
<p>{{ t("addressCredentialTip") }}</p>
|
||||
</span>
|
||||
<n-card embedded>
|
||||
<b>{{ result }}</b>
|
||||
</n-card>
|
||||
<n-card embedded v-if="addressPassword">
|
||||
<p><b>{{ createdAddress }}</b></p>
|
||||
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
|
||||
</n-card>
|
||||
<n-card embedded>
|
||||
<n-collapse>
|
||||
<n-collapse-item :title='t("linkWithAddressCredential")'>
|
||||
<n-card embedded>
|
||||
<b>{{ getUrlWithJwt() }}</b>
|
||||
</n-card>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
|
||||
<n-switch v-model:value="enablePrefix" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('address')">
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-model:value="emailName" />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-button @click="newEmail" type="primary" block :loading="loading">
|
||||
{{ t('creatNewEmail') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
126
frontend/src/views/admin/DatabaseManager.vue
Normal file
126
frontend/src/views/admin/DatabaseManager.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CleaningServicesFilled } from '@vicons/material'
|
||||
|
||||
import { api } from '../../api'
|
||||
import { init } from 'vooks/lib/on-fonts-ready';
|
||||
|
||||
const message = useMessage()
|
||||
const dbVersionData = ref({
|
||||
need_initialization: false,
|
||||
need_migration: false,
|
||||
current_db_version: '',
|
||||
code_db_version: ''
|
||||
})
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
need_initialization_tip: 'Database initialization is required. Please initialize the database.',
|
||||
need_migration_tip: 'Database migration is required. Please migrate the database.',
|
||||
current_db_version: 'Current DB Version',
|
||||
code_db_version: 'Code Needed DB Version',
|
||||
init: 'Initialize Database',
|
||||
migration: 'Migrate Database',
|
||||
initializationSuccess: 'Database initialized successfully',
|
||||
migrationSuccess: 'Database migrated successfully',
|
||||
},
|
||||
zh: {
|
||||
need_initialization_tip: '需要初始化数据库,请初始化数据库',
|
||||
need_migration_tip: '需要迁移数据库,请迁移数据库',
|
||||
current_db_version: '当前数据库版本',
|
||||
code_db_version: '需要的数据库版本',
|
||||
init: '初始化数据库',
|
||||
migration: '升级数据库 Schema',
|
||||
initializationSuccess: '数据库初始化成功',
|
||||
migrationSuccess: '数据库升级成功',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch('/admin/db_version');
|
||||
if (res) Object.assign(dbVersionData.value, res);
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const initialization = async () => {
|
||||
try {
|
||||
await api.fetch('/admin/db_initialize', {
|
||||
method: 'POST'
|
||||
});
|
||||
await fetchData();
|
||||
message.success(t('initializationSuccess'));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const migration = async () => {
|
||||
try {
|
||||
await api.fetch('/admin/db_migration', {
|
||||
method: 'POST'
|
||||
});
|
||||
await fetchData();
|
||||
message.success(t('migrationSuccess'));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-alert v-if="dbVersionData.need_initialization" type="warning" :show-icon="false" :bordered="false">
|
||||
<span>{{ t('need_initialization_tip') }}</span>
|
||||
<n-button @click="initialization" type="primary" secondary block :loading="loading">
|
||||
{{ t('init') }}
|
||||
</n-button>
|
||||
</n-alert>
|
||||
<n-alert v-if="dbVersionData.need_migration" type="warning" :show-icon="false" :bordered="false">
|
||||
<span>{{ t('need_migration_tip') }}</span>
|
||||
<n-button @click="migration" type="primary" secondary block :loading="loading">
|
||||
{{ t('migration') }}
|
||||
</n-button>
|
||||
</n-alert>
|
||||
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||
<span>
|
||||
{{ t('current_db_version') }}: {{ dbVersionData.current_db_version || "unknown" }},
|
||||
{{ t('code_db_version') }}: {{ dbVersionData.code_db_version }}
|
||||
</span>
|
||||
</n-alert>
|
||||
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
220
frontend/src/views/admin/IpBlacklistSettings.vue
Normal file
220
frontend/src/views/admin/IpBlacklistSettings.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
title: 'IP Blacklist Settings',
|
||||
manualInputPrompt: 'Type pattern and press Enter to add',
|
||||
save: 'Save',
|
||||
successTip: 'Save Success',
|
||||
enable_ip_blacklist: 'Enable IP Blacklist',
|
||||
enable_tip: 'Block IPs matching blacklist patterns from accessing rate-limited APIs',
|
||||
ip_blacklist: 'IP Blacklist Patterns',
|
||||
ip_blacklist_placeholder: 'Enter pattern (e.g., 192.168.1 or ^10\\.0\\.0\\.5$)',
|
||||
asn_blacklist: 'ASN Organization Blacklist',
|
||||
asn_blacklist_placeholder: 'Enter ASN organization (e.g., Google, Amazon)',
|
||||
fingerprint_blacklist: 'Browser Fingerprint Blacklist',
|
||||
fingerprint_blacklist_placeholder: 'Enter fingerprint ID (e.g., a1b2c3d4e5f6g7h8)',
|
||||
tip_ip: 'IP Blacklist: Supports text matching (e.g., "192.168.1") or regex (e.g., "^10\\.0\\.0\\.5$").',
|
||||
tip_asn: 'ASN Organization: Block by ISP/provider. Case-insensitive text matching or regex.',
|
||||
tip_fingerprint: 'Browser Fingerprint: Block by browser fingerprint. Supports exact matching or regex patterns.',
|
||||
tip_daily_limit: 'Daily Limit: Restrict the maximum number of requests per IP address per day (1-1000000).',
|
||||
tip_scope: 'Applies to: Create Address, Send Mail, External Send Mail API, User Registration, Verify Code',
|
||||
enable_daily_limit: 'Enable Daily Request Limit',
|
||||
enable_daily_limit_tip: 'Limit the number of API requests per IP address per day',
|
||||
daily_request_limit: 'Daily Request Limit',
|
||||
daily_request_limit_placeholder: 'Enter limit (e.g., 1000)',
|
||||
},
|
||||
zh: {
|
||||
title: 'IP 黑名单设置',
|
||||
manualInputPrompt: '输入匹配模式后按回车键添加',
|
||||
save: '保存',
|
||||
successTip: '保存成功',
|
||||
enable_ip_blacklist: '启用 IP 黑名单',
|
||||
enable_tip: '阻止匹配黑名单的 IP 访问限流 API',
|
||||
ip_blacklist: 'IP 黑名单匹配模式',
|
||||
ip_blacklist_placeholder: '输入匹配模式(例如:192.168.1 或 ^10\\.0\\.0\\.5$)',
|
||||
asn_blacklist: 'ASN 组织(运营商)黑名单',
|
||||
asn_blacklist_placeholder: '输入 ASN 组织名称(例如:Google, Amazon)',
|
||||
fingerprint_blacklist: '浏览器指纹黑名单',
|
||||
fingerprint_blacklist_placeholder: '输入指纹 ID(例如:a1b2c3d4e5f6g7h8)',
|
||||
tip_ip: 'IP 黑名单:支持文本匹配(如 "192.168.1")或正则表达式(如 "^10\\.0\\.0\\.5$")。',
|
||||
tip_asn: 'ASN 组织:根据运营商/ISP 拉黑。支持不区分大小写的文本匹配或正则表达式。',
|
||||
tip_fingerprint: '浏览器指纹:根据浏览器指纹拉黑。支持完全匹配或正则表达式。',
|
||||
tip_daily_limit: '每日限流:限制单个 IP 地址每天最多请求次数(1-1000000)。',
|
||||
tip_scope: '作用范围:创建邮箱地址、发送邮件、外部发送邮件 API、用户注册、验证码验证',
|
||||
enable_daily_limit: '启用每日请求限流',
|
||||
enable_daily_limit_tip: '限制每个 IP 地址每天的 API 请求次数',
|
||||
daily_request_limit: '每日请求次数上限',
|
||||
daily_request_limit_placeholder: '输入限制次数(例如:1000)',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const enabled = ref(false)
|
||||
const ipBlacklist = ref([])
|
||||
const asnBlacklist = ref([])
|
||||
const fingerprintBlacklist = ref([])
|
||||
const enableDailyLimit = ref(false)
|
||||
const dailyRequestLimit = ref(1000)
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await api.fetch(`/admin/ip_blacklist/settings`)
|
||||
enabled.value = res.enabled || false
|
||||
ipBlacklist.value = res.blacklist || []
|
||||
asnBlacklist.value = res.asnBlacklist || []
|
||||
fingerprintBlacklist.value = res.fingerprintBlacklist || []
|
||||
enableDailyLimit.value = res.enableDailyLimit || false
|
||||
dailyRequestLimit.value = res.dailyRequestLimit || 1000
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
await api.fetch(`/admin/ip_blacklist/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
enabled: enabled.value,
|
||||
blacklist: ipBlacklist.value || [],
|
||||
asnBlacklist: asnBlacklist.value || [],
|
||||
fingerprintBlacklist: fingerprintBlacklist.value || [],
|
||||
enableDailyLimit: enableDailyLimit.value,
|
||||
dailyRequestLimit: dailyRequestLimit.value
|
||||
})
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :title="t('title')" :bordered="false" embedded style="max-width: 800px;">
|
||||
<template #header-extra>
|
||||
<n-button @click="save" type="primary" :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<n-space vertical :size="20">
|
||||
<n-alert :show-icon="false" :bordered="false" type="info">
|
||||
<div style="line-height: 1.8;">
|
||||
<div><strong>{{ t("tip_scope") }}</strong></div>
|
||||
<div>• {{ t("tip_ip") }}</div>
|
||||
<div>• {{ t("tip_asn") }}</div>
|
||||
<div>• {{ t("tip_fingerprint") }}</div>
|
||||
<div>• {{ t("tip_daily_limit") }}</div>
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<n-form-item-row :label="t('enable_ip_blacklist')">
|
||||
<n-switch v-model:value="enabled" :round="false" />
|
||||
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
|
||||
{{ t('enable_tip') }}
|
||||
</n-text>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('ip_blacklist')">
|
||||
<n-select
|
||||
v-model:value="ipBlacklist"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
:placeholder="t('ip_blacklist_placeholder')"
|
||||
:disabled="!enabled">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('asn_blacklist')">
|
||||
<n-select
|
||||
v-model:value="asnBlacklist"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
:placeholder="t('asn_blacklist_placeholder')"
|
||||
:disabled="!enabled">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('fingerprint_blacklist')">
|
||||
<n-select
|
||||
v-model:value="fingerprintBlacklist"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
:placeholder="t('fingerprint_blacklist_placeholder')"
|
||||
:disabled="!enabled">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<n-form-item-row :label="t('enable_daily_limit')">
|
||||
<n-switch v-model:value="enableDailyLimit" :round="false" />
|
||||
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
|
||||
{{ t('enable_daily_limit_tip') }}
|
||||
</n-text>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('daily_request_limit')">
|
||||
<n-input-number
|
||||
v-model:value="dailyRequestLimit"
|
||||
:min="1"
|
||||
:max="1000000"
|
||||
:placeholder="t('daily_request_limit_placeholder')"
|
||||
:disabled="!enableDailyLimit"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</n-form-item-row>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
30
frontend/src/views/admin/MailWebhook.vue
Normal file
30
frontend/src/views/admin/MailWebhook.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
|
||||
// @ts-ignore
|
||||
import WebhookComponent from '../../components/WebhookComponent.vue'
|
||||
|
||||
const fetchData = async () => {
|
||||
return await api.fetch(`/admin/mail_webhook/settings`)
|
||||
}
|
||||
|
||||
const saveSettings = async (webhookSettings: any) => {
|
||||
await api.fetch(`/admin/mail_webhook/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings),
|
||||
})
|
||||
}
|
||||
|
||||
const testSettings = async (webhookSettings: any) => {
|
||||
await api.fetch(`/admin/mail_webhook/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings),
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WebhookComponent :fetchData="fetchData" :saveSettings="saveSettings" :testSettings="testSettings" />
|
||||
</template>
|
||||
64
frontend/src/views/admin/Mails.vue
Normal file
64
frontend/src/views/admin/Mails.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import MailBox from '../../components/MailBox.vue';
|
||||
|
||||
const { adminMailTabAddress } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
addressQueryTip: 'Leave blank to query all addresses',
|
||||
keywordQueryTip: 'Leave blank to not query by keyword',
|
||||
query: 'Query',
|
||||
},
|
||||
zh: {
|
||||
addressQueryTip: '留空查询所有地址',
|
||||
keywordQueryTip: '留空不按关键字查询',
|
||||
query: '查询',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const mailBoxKey = ref("")
|
||||
const mailKeyword = ref("")
|
||||
|
||||
const queryMail = () => {
|
||||
adminMailTabAddress.value = adminMailTabAddress.value.trim();
|
||||
mailKeyword.value = mailKeyword.value.trim();
|
||||
mailBoxKey.value = Date.now();
|
||||
}
|
||||
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
return await api.fetch(
|
||||
`/admin/mails`
|
||||
+ `?limit=${limit}`
|
||||
+ `&offset=${offset}`
|
||||
+ (adminMailTabAddress.value ? `&address=${adminMailTabAddress.value}` : '')
|
||||
+ (mailKeyword.value ? `&keyword=${mailKeyword.value}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="margin-top: 10px;">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')"
|
||||
@keydown.enter="queryMail" />
|
||||
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" @keydown.enter="queryMail" />
|
||||
<n-button @click="queryMail" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div style="margin-top: 10px;"></div>
|
||||
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="true" :fetchMailData="fetchMailData"
|
||||
:deleteMail="deleteMail" />
|
||||
</div>
|
||||
</template>
|
||||
22
frontend/src/views/admin/MailsUnknow.vue
Normal file
22
frontend/src/views/admin/MailsUnknow.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { api } from '../../api'
|
||||
import MailBox from '../../components/MailBox.vue';
|
||||
|
||||
const fetchMailUnknowData = async (limit, offset) => {
|
||||
return await api.fetch(
|
||||
`/admin/mails_unknow`
|
||||
+ `?limit=${limit}`
|
||||
+ `&offset=${offset}`
|
||||
);
|
||||
}
|
||||
|
||||
const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="margin-top: 10px;">
|
||||
<MailBox :enableUserDeleteEmail="true" :fetchMailData="fetchMailUnknowData" :deleteMail="deleteMail" />
|
||||
</div>
|
||||
</template>
|
||||
205
frontend/src/views/admin/Maintenance.vue
Normal file
205
frontend/src/views/admin/Maintenance.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CleaningServicesFilled } from '@vicons/material'
|
||||
|
||||
import { api } from '../../api'
|
||||
|
||||
const message = useMessage()
|
||||
const cleanupModel = ref({
|
||||
enableMailsAutoCleanup: false,
|
||||
cleanMailsDays: 30,
|
||||
enableUnknowMailsAutoCleanup: false,
|
||||
cleanUnknowMailsDays: 30,
|
||||
enableSendBoxAutoCleanup: false,
|
||||
cleanSendBoxDays: 30,
|
||||
enableAddressAutoCleanup: false,
|
||||
cleanAddressDays: 30,
|
||||
enableInactiveAddressAutoCleanup: false,
|
||||
cleanInactiveAddressDays: 30,
|
||||
enableUnboundAddressAutoCleanup: false,
|
||||
cleanUnboundAddressDays: 30,
|
||||
})
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
tip: 'Please input the days',
|
||||
mailBoxLabel: 'Cleanup the inbox before n days',
|
||||
mailUnknowLabel: "Cleanup the unknow mail before n days",
|
||||
sendBoxLabel: "Cleanup the sendbox before n days",
|
||||
addressCreateLabel: "Cleanup the address created before n days",
|
||||
inactiveAddressLabel: "Cleanup the inactive address before n days",
|
||||
unboundAddressLabel: "Cleanup the unbound address before n days",
|
||||
cleanupNow: "Cleanup now",
|
||||
autoCleanup: "Auto cleanup",
|
||||
cleanupSuccess: "Cleanup success",
|
||||
save: "Save",
|
||||
cronTip: "Enable cron cleanup, need to configure [crons] in worker, please refer to the document, setting 0 days means clear all",
|
||||
},
|
||||
zh: {
|
||||
tip: '请输入天数',
|
||||
mailBoxLabel: '清理 n 天前的收件箱',
|
||||
mailUnknowLabel: "清理 n 天前的无收件人邮件",
|
||||
sendBoxLabel: "清理 n 天前的发件箱",
|
||||
addressCreateLabel: "清理 n 天前创建的地址",
|
||||
inactiveAddressLabel: "清理 n 天前的未活跃地址",
|
||||
unboundAddressLabel: "清理 n 天前的未绑定用户地址",
|
||||
autoCleanup: "自动清理",
|
||||
cleanupSuccess: "清理成功",
|
||||
cleanupNow: "立即清理",
|
||||
save: "保存",
|
||||
cronTip: "启用定时清理, 需在 worker 配置 [crons] 参数, 请参考文档, 配置为 0 天表示全部清空",
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const cleanup = async (cleanType, cleanDays) => {
|
||||
try {
|
||||
await api.fetch('/admin/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cleanType, cleanDays })
|
||||
});
|
||||
message.success(t('cleanupSuccess'));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch('/admin/auto_cleanup');
|
||||
if (res) Object.assign(cleanupModel.value, res);
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.fetch('/admin/auto_cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(cleanupModel.value)
|
||||
});
|
||||
message.success(t('cleanupSuccess'));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-alert :show-icon="false" :bordered="false" type="warning">
|
||||
<span>{{ t('cronTip') }}</span>
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="save" type="primary" :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-form :model="cleanupModel">
|
||||
<n-form-item-row :label="t('mailBoxLabel')">
|
||||
<n-checkbox v-model:checked="cleanupModel.enableMailsAutoCleanup">
|
||||
{{ t('autoCleanup') }}
|
||||
</n-checkbox>
|
||||
<n-input-number v-model:value="cleanupModel.cleanMailsDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('mails', cleanupModel.cleanMailsDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('cleanupNow') }}
|
||||
</n-button>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('mailUnknowLabel')">
|
||||
<n-checkbox v-model:checked="cleanupModel.enableUnknowMailsAutoCleanup">
|
||||
{{ t('autoCleanup') }}
|
||||
</n-checkbox>
|
||||
<n-input-number v-model:value="cleanupModel.cleanUnknowMailsDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('mails_unknow', cleanupModel.cleanUnknowMailsDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('cleanupNow') }}
|
||||
</n-button>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('sendBoxLabel')">
|
||||
<n-checkbox v-model:checked="cleanupModel.enableSendBoxAutoCleanup">
|
||||
{{ t('autoCleanup') }}
|
||||
</n-checkbox>
|
||||
<n-input-number v-model:value="cleanupModel.cleanSendBoxDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('sendbox', cleanupModel.cleanSendBoxDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('cleanupNow') }}
|
||||
</n-button>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('addressCreateLabel')">
|
||||
<n-checkbox v-model:checked="cleanupModel.enableAddressAutoCleanup">
|
||||
{{ t('autoCleanup') }}
|
||||
</n-checkbox>
|
||||
<n-input-number v-model:value="cleanupModel.cleanAddressDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('addressCreated', cleanupModel.cleanAddressDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('cleanupNow') }}
|
||||
</n-button>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('inactiveAddressLabel')">
|
||||
<n-checkbox v-model:checked="cleanupModel.enableInactiveAddressAutoCleanup">
|
||||
{{ t('autoCleanup') }}
|
||||
</n-checkbox>
|
||||
<n-input-number v-model:value="cleanupModel.cleanInactiveAddressDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('inactiveAddress', cleanupModel.cleanInactiveAddressDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('cleanupNow') }}
|
||||
</n-button>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('unboundAddressLabel')">
|
||||
<n-checkbox v-model:checked="cleanupModel.enableUnboundAddressAutoCleanup">
|
||||
{{ t('autoCleanup') }}
|
||||
</n-checkbox>
|
||||
<n-input-number v-model:value="cleanupModel.cleanUnboundAddressDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('unboundAddress', cleanupModel.cleanUnboundAddressDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('cleanupNow') }}
|
||||
</n-button>
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
margin: 10px;
|
||||
}
|
||||
</style>
|
||||
153
frontend/src/views/admin/RoleAddressConfig.vue
Normal file
153
frontend/src/views/admin/RoleAddressConfig.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, h } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NInputNumber, NTag, NSpace, NButton } from 'naive-ui';
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
role: 'Role',
|
||||
maxAddressCount: 'Max Address Count',
|
||||
save: 'Save',
|
||||
successTip: 'Success',
|
||||
noRolesAvailable: 'No roles available in system config',
|
||||
roleConfigDesc: 'Configure maximum address count for each user role. Role-based limits take priority over global settings.',
|
||||
notConfigured: 'Not Configured (Use Global Settings)',
|
||||
},
|
||||
zh: {
|
||||
role: '角色',
|
||||
maxAddressCount: '最大地址数量',
|
||||
save: '保存',
|
||||
successTip: '成功',
|
||||
noRolesAvailable: '系统配置中没有可用的角色',
|
||||
roleConfigDesc: '为每个用户角色配置最大地址数量。角色配置优先于全局设置。',
|
||||
notConfigured: '未配置(使用全局设置)',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const systemRoles = ref([])
|
||||
const tableData = ref([])
|
||||
|
||||
const fetchUserRoles = async () => {
|
||||
try {
|
||||
const results = await api.fetch(`/admin/user_roles`);
|
||||
systemRoles.value = results;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRoleConfigs = async () => {
|
||||
try {
|
||||
const { configs } = await api.fetch(`/admin/role_address_config`);
|
||||
tableData.value = systemRoles.value.map(roleObj => ({
|
||||
role: roleObj.role,
|
||||
max_address_count: configs[roleObj.role]?.maxAddressCount ?? null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
// convert tableData to object with nested structure
|
||||
const configs = {};
|
||||
tableData.value.forEach(row => {
|
||||
if (row.max_address_count !== null && row.max_address_count !== undefined) {
|
||||
configs[row.role] = { maxAddressCount: row.max_address_count };
|
||||
}
|
||||
});
|
||||
|
||||
await api.fetch(`/admin/role_address_config`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ configs })
|
||||
});
|
||||
message.success(t('successTip'));
|
||||
await fetchRoleConfigs();
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('role'),
|
||||
key: 'role',
|
||||
width: 200,
|
||||
render(row) {
|
||||
return h(NTag, {
|
||||
type: 'info',
|
||||
bordered: false
|
||||
}, {
|
||||
default: () => row.role
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('maxAddressCount'),
|
||||
key: 'max_address_count',
|
||||
render(row) {
|
||||
return h(NInputNumber, {
|
||||
value: row.max_address_count,
|
||||
min: 0,
|
||||
max: 999,
|
||||
clearable: true,
|
||||
placeholder: t('notConfigured'),
|
||||
style: 'width: 200px;',
|
||||
onUpdateValue: (value) => {
|
||||
row.max_address_count = value;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchUserRoles();
|
||||
await fetchRoleConfigs();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="margin-top: 10px;">
|
||||
<n-alert type="info" :bordered="false" style="margin-bottom: 20px;">
|
||||
{{ t('roleConfigDesc') }}
|
||||
</n-alert>
|
||||
|
||||
<n-alert v-if="systemRoles.length === 0" type="warning" :bordered="false">
|
||||
{{ t('noRolesAvailable') }}
|
||||
</n-alert>
|
||||
|
||||
<div v-else>
|
||||
<n-space justify="end" style="margin-bottom: 12px;">
|
||||
<n-button :loading="loading" @click="saveConfig" type="primary">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="tableData"
|
||||
:bordered="false"
|
||||
embedded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-data-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
</style>
|
||||
54
frontend/src/views/admin/SendBox.vue
Normal file
54
frontend/src/views/admin/SendBox.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import SendBox from '../../components/SendBox.vue';
|
||||
|
||||
const { adminSendBoxTabAddress } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
query: 'Query',
|
||||
queryTip: 'Please input address to query, leave blank to query all',
|
||||
},
|
||||
zh: {
|
||||
query: '查询',
|
||||
queryTip: '请输入地址查询, 留空则查询所有',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fetchData = async (limit, offset) => {
|
||||
adminSendBoxTabAddress.value = adminSendBoxTabAddress.value.trim();
|
||||
return await api.fetch(
|
||||
`/admin/sendbox?limit=${limit}&offset=${offset}`
|
||||
+ (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
const deleteSenboxMail = async (curMailId) => {
|
||||
await api.fetch(`/admin/sendbox/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="adminSendBoxTabAddress" :placeholder="t('queryTip')" @keydown.enter="fetchData" />
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<SendBox style="margin-top: 10px;" :enableUserDeleteEmail="true" :deleteMail="deleteSenboxMail"
|
||||
:fetchMailData="fetchData" :showEMailFrom="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-pagination {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
199
frontend/src/views/admin/SendMail.vue
Normal file
199
frontend/src/views/admin/SendMail.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
import { api } from '../../api'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
|
||||
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
||||
fromName: "",
|
||||
fromMail: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
subject: "",
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
});
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
successSend: 'Please check your sendbox. If failed, please try again later.',
|
||||
fromName: 'Your Name and Address, leave Name blank to use email address',
|
||||
toName: 'Recipient Name and Address, leave Name blank to use email address',
|
||||
subject: 'Subject',
|
||||
options: 'Options',
|
||||
edit: 'Edit',
|
||||
preview: 'Preview',
|
||||
content: 'Content',
|
||||
send: 'Send',
|
||||
text: 'Text',
|
||||
html: 'HTML',
|
||||
'rich text': 'Rich Text',
|
||||
tooLarge: 'Too large file, please upload file less than 1MB.',
|
||||
},
|
||||
zh: {
|
||||
successSend: '请查看您的发件箱, 如果失败, 请检查稍后重试。',
|
||||
fromName: '你的名称和地址,名称不填写则使用邮箱地址',
|
||||
toName: '收件人名称和地址,名称不填写则使用邮箱地址',
|
||||
subject: '主题',
|
||||
options: '选项',
|
||||
edit: '编辑',
|
||||
preview: '预览',
|
||||
content: '内容',
|
||||
send: '发送',
|
||||
text: '文本',
|
||||
html: 'HTML',
|
||||
'rich text': '富文本',
|
||||
tooLarge: '文件过大, 请上传小于1MB的文件。',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const contentTypes = [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
]
|
||||
|
||||
const send = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/send_mail`,
|
||||
{
|
||||
method: 'POST',
|
||||
body:
|
||||
JSON.stringify({
|
||||
from_name: sendMailModel.value.fromName,
|
||||
from_mail: sendMailModel.value.fromMail,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: sendMailModel.value.toMail,
|
||||
subject: sendMailModel.value.subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content: sendMailModel.value.content,
|
||||
})
|
||||
})
|
||||
sendMailModel.value = {
|
||||
fromName: "",
|
||||
fromMail: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
subject: "",
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
message.success(t("successSend"));
|
||||
}
|
||||
}
|
||||
|
||||
const toolbarConfig = {
|
||||
excludeKeys: ["uploadVideo"]
|
||||
}
|
||||
|
||||
const editorConfig = {
|
||||
MENU_CONF: {
|
||||
'uploadImage': {
|
||||
async customUpload() {
|
||||
message.error(t('tooLarge'))
|
||||
},
|
||||
maxFileSize: 1 * 1024 * 1024,
|
||||
base64LimitSize: 1 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.fromName" />
|
||||
<n-input v-model:value="sendMailModel.fromMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card :bordered="false" embedded v-if="isPreview">
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
text-align: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.left {
|
||||
text-align: left;
|
||||
place-items: left;
|
||||
justify-content: left;
|
||||
}
|
||||
</style>
|
||||
224
frontend/src/views/admin/SenderAccess.vue
Normal file
224
frontend/src/views/admin/SenderAccess.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
address: 'Address',
|
||||
success: 'Success',
|
||||
is_enabled: 'Is Enabled',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
modify: 'Modify',
|
||||
delete: 'Delete',
|
||||
deleteTip: 'Are you sure to delete this?',
|
||||
created_at: 'Created At',
|
||||
action: 'Action',
|
||||
itemCount: 'itemCount',
|
||||
modalTip: 'Please input the sender balance',
|
||||
balance: 'Balance',
|
||||
query: 'Query',
|
||||
ok: 'OK'
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
success: '成功',
|
||||
is_enabled: '是否启用',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
modify: '修改',
|
||||
delete: '删除',
|
||||
deleteTip: '确定删除吗?',
|
||||
created_at: '创建时间',
|
||||
action: '操作',
|
||||
itemCount: '总数',
|
||||
modalTip: '请输入发件额度',
|
||||
balance: '余额',
|
||||
query: '查询',
|
||||
ok: '确定'
|
||||
}
|
||||
}
|
||||
});
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const curRow = ref({})
|
||||
const showModal = ref(false)
|
||||
const senderBalance = ref(0)
|
||||
const senderEnabled = ref(false)
|
||||
|
||||
const addressQuery = ref('')
|
||||
|
||||
const updateData = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/address_sender`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
address: curRow.value.address,
|
||||
address_id: curRow.value.id,
|
||||
balance: senderBalance.value,
|
||||
enabled: senderEnabled.value ? 1 : 0
|
||||
})
|
||||
});
|
||||
showModal.value = false;
|
||||
message.success(t("success"));
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
addressQuery.value = addressQuery.value.trim();
|
||||
const { results, count: addressCount } = await api.fetch(
|
||||
`/admin/address_sender`
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (addressQuery.value ? `&address=${addressQuery.value}` : '')
|
||||
);
|
||||
data.value = results;
|
||||
if (addressCount > 0) {
|
||||
count.value = addressCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
},
|
||||
{
|
||||
title: t('address'),
|
||||
key: "address"
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
},
|
||||
{
|
||||
title: t('balance'),
|
||||
key: "balance"
|
||||
},
|
||||
{
|
||||
title: t('is_enabled'),
|
||||
key: "enabled",
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h('span', row.enabled ? t('enable') : t('disable'))
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('action'),
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NButton,
|
||||
{
|
||||
type: 'success',
|
||||
tertiary: true,
|
||||
onClick: () => {
|
||||
showModal.value = true;
|
||||
curRow.value = row;
|
||||
senderEnabled.value = row.enabled ? true : false;
|
||||
senderBalance.value = row.balance;
|
||||
}
|
||||
},
|
||||
{ default: () => t('modify') }
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: async () => {
|
||||
await api.fetch(`/admin/address_sender/${row.id}`, { method: 'DELETE' });
|
||||
await fetchData();
|
||||
}
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "error",
|
||||
},
|
||||
{ default: () => t('delete') }
|
||||
),
|
||||
default: () => t('deleteTip')
|
||||
}
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal v-model:show="showModal" preset="dialog">
|
||||
<p>{{ curRow.address }}</p>
|
||||
<p>{{ t('modalTip') }}</p>
|
||||
<n-form-item :show-label="false">
|
||||
<n-checkbox v-model:checked="senderEnabled">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
</n-form-item>
|
||||
<n-form-item :show-label="false">
|
||||
<n-input-number v-model:value="senderBalance" :min="0" :max="1000" />
|
||||
</n-form-item>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="updateData()" size="small" tertiary type="primary">
|
||||
{{ t('ok') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="addressQuery" @keydown.enter="fetchData" />
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div style="overflow: auto;">
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
|
||||
show-size-picker>
|
||||
<template #prefix="{ itemCount }">
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-pagination {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.n-data-table {
|
||||
min-width: 700px;
|
||||
}
|
||||
</style>
|
||||
125
frontend/src/views/admin/Statistics.vue
Normal file
125
frontend/src/views/admin/Statistics.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { User, UserCheck, MailBulk } from '@vicons/fa'
|
||||
import { SendOutlined } from '@vicons/material'
|
||||
|
||||
import { api } from '../../api'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
userCount: 'User Count',
|
||||
addressCount: 'Address Count',
|
||||
activeAddressCount7days: '7 days Active Address Count',
|
||||
activeAddressCount30days: '30 days Active Address Count',
|
||||
mailCount: 'Mail Count',
|
||||
sendMailCount: 'Send Mail Count'
|
||||
},
|
||||
zh: {
|
||||
userCount: '用户总数',
|
||||
addressCount: '邮箱地址总数',
|
||||
activeAddressCount7days: '7天活跃邮箱地址总数',
|
||||
activeAddressCount30days: '30天活跃邮箱地址总数',
|
||||
mailCount: '邮件总数',
|
||||
sendMailCount: '发送邮件总数'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const statistics = ref({
|
||||
addressCount: 0,
|
||||
userCount: 0,
|
||||
mailCount: 0,
|
||||
activeAddressCount7days: 0,
|
||||
activeAddressCount30days: 0,
|
||||
sendMailCount: 0,
|
||||
})
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
const {
|
||||
userCount, mailCount, sendMailCount,
|
||||
addressCount, activeAddressCount7days,
|
||||
activeAddressCount30days,
|
||||
} = await api.fetch(`/admin/statistics`);
|
||||
statistics.value.mailCount = mailCount || 0;
|
||||
statistics.value.sendMailCount = sendMailCount || 0;
|
||||
statistics.value.userCount = userCount || 0;
|
||||
statistics.value.addressCount = addressCount || 0;
|
||||
statistics.value.activeAddressCount7days = activeAddressCount7days || 0;
|
||||
statistics.value.activeAddressCount30days = activeAddressCount30days || 0;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchStatistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-row>
|
||||
|
||||
<n-col :span="8">
|
||||
<n-statistic :label="t('addressCount')" :value="statistics.addressCount">
|
||||
<template #prefix>
|
||||
<n-icon :component="User" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-col>
|
||||
<n-col :span="8">
|
||||
<n-statistic :label="t('activeAddressCount7days')" :value="statistics.activeAddressCount7days">
|
||||
<template #prefix>
|
||||
<n-icon :component="UserCheck" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-col>
|
||||
<n-col :span="8">
|
||||
<n-statistic :label="t('activeAddressCount30days')" :value="statistics.activeAddressCount30days">
|
||||
<template #prefix>
|
||||
<n-icon :component="UserCheck" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-col>
|
||||
</n-row>
|
||||
</n-card>
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-row>
|
||||
<n-col :span="8">
|
||||
<n-statistic :label="t('userCount')" :value="statistics.userCount">
|
||||
<template #prefix>
|
||||
<n-icon :component="User" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-col>
|
||||
<n-col :span="8">
|
||||
<n-statistic :label="t('mailCount')" :value="statistics.mailCount">
|
||||
<template #prefix>
|
||||
<n-icon :component="MailBulk" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-col>
|
||||
<n-col :span="8">
|
||||
<n-statistic :label="t('sendMailCount')" :value="statistics.sendMailCount">
|
||||
<template #prefix>
|
||||
<n-icon :component="SendOutlined" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-col>
|
||||
</n-row>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
185
frontend/src/views/admin/Telegram.vue
Normal file
185
frontend/src/views/admin/Telegram.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
init: 'Init',
|
||||
successTip: 'Success',
|
||||
status: 'Check Status',
|
||||
enableTelegramAllowList: 'Enable Telegram Allow List(Manually input Chat ID)',
|
||||
enable: 'Enable',
|
||||
telegramAllowList: 'Telegram Allow List(Manually input telegram Chat ID)',
|
||||
manualInputPrompt: 'Type and press Enter to add',
|
||||
save: 'Save',
|
||||
miniAppUrl: 'Telegram Mini App URL',
|
||||
enableGlobalMailPush: 'Enable Global Mail Push(Manually input telegram Chat ID)',
|
||||
globalMailPushList: 'Global Mail Push Chat ID List',
|
||||
globalMailPushListTip: 'Support chat_id of private chat/group/channel. You can send a message to your bot, then visit this link to see chat_id, https://api.telegram.org/bot<Replace with your BOT TOKEN>/getUpdates',
|
||||
},
|
||||
zh: {
|
||||
init: '初始化',
|
||||
successTip: '成功',
|
||||
status: '查看状态',
|
||||
enableTelegramAllowList: '启用 Telegram 白名单(手动输入 Chat ID, 回车增加)',
|
||||
enable: '启用',
|
||||
telegramAllowList: 'Telegram 白名单(手动输入 Chat ID, 回车增加)',
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
save: '保存',
|
||||
miniAppUrl: '电报小程序 URL(请输入你部署的电报小程序网页地址)',
|
||||
enableGlobalMailPush: '启用全局邮件推送(手动输入邮箱管理员的 telegram Chat ID, 回车增加)',
|
||||
globalMailPushList: '全局邮件推送 Chat ID 列表',
|
||||
globalMailPushListTip: '支持对话/群组/频道的 Chat ID, 您可以发送一条消息给您的机器人,然后访问此链接来查看 chat_id, https://api.telegram.org/bot<这里替换成您的 BOT TOKEN>/getUpdates',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const status = ref({
|
||||
fetched: false,
|
||||
})
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/telegram/status`)
|
||||
Object.assign(status.value, res)
|
||||
status.value.fetched = true
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/telegram/init`, {
|
||||
method: 'POST',
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
class TelegramSettings {
|
||||
enableAllowList: boolean;
|
||||
allowList: string[];
|
||||
miniAppUrl: string;
|
||||
enableGlobalMailPush: boolean;
|
||||
globalMailPushList: string[];
|
||||
|
||||
constructor(
|
||||
enableAllowList: boolean, allowList: string[], miniAppUrl: string,
|
||||
enableGlobalMailPush: boolean, globalMailPushList: string[]
|
||||
) {
|
||||
this.enableAllowList = enableAllowList;
|
||||
this.allowList = allowList;
|
||||
this.miniAppUrl = miniAppUrl;
|
||||
this.enableGlobalMailPush = enableGlobalMailPush;
|
||||
this.globalMailPushList = globalMailPushList;
|
||||
}
|
||||
}
|
||||
|
||||
const settings = ref(new TelegramSettings(false, [], '', false, []))
|
||||
|
||||
const getSettings = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/telegram/settings`)
|
||||
Object.assign(settings.value, res)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/telegram/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(settings.value),
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getSettings();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 800px; overflow: auto;">
|
||||
<n-flex justify="end">
|
||||
<n-button @click="fetchStatus" secondary>
|
||||
{{ t('status') }}
|
||||
</n-button>
|
||||
<n-button @click="init" type="primary">
|
||||
{{ t('init') }}
|
||||
</n-button>
|
||||
<n-button @click="saveSettings" type="primary">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-form-item-row :label="t('enableTelegramAllowList')">
|
||||
<n-input-group>
|
||||
<n-checkbox v-model:checked="settings.enableAllowList" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-select v-model:value="settings.allowList" filterable multiple tag style="width: 80%;"
|
||||
:placeholder="t('telegramAllowList')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<br />
|
||||
<n-form-item-row :label="t('enableGlobalMailPush')">
|
||||
<n-input-group>
|
||||
<n-checkbox v-model:checked="settings.enableGlobalMailPush" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-select v-model:value="settings.globalMailPushList" filterable multiple tag
|
||||
style="width: 80%;" :placeholder="t('globalMailPushList')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-input-group>
|
||||
<template #feedback>
|
||||
<n-text depth="3">
|
||||
{{ t('globalMailPushListTip') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-form-item-row>
|
||||
<br />
|
||||
<n-form-item-row :label="t('miniAppUrl')">
|
||||
<n-input v-model:value="settings.miniAppUrl"></n-input>
|
||||
</n-form-item-row>
|
||||
</n-card>
|
||||
<pre v-if="status.fetched">{{ JSON.stringify(status, null, 2) }}</pre>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
94
frontend/src/views/admin/UserAddressManagement.vue
Normal file
94
frontend/src/views/admin/UserAddressManagement.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NBadge } from 'naive-ui'
|
||||
|
||||
import { api } from '../../api'
|
||||
|
||||
const props = defineProps({
|
||||
user_id: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
success: 'success',
|
||||
name: 'Name',
|
||||
mail_count: 'Mail Count',
|
||||
send_count: 'Send Count',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
name: '名称',
|
||||
mail_count: '邮件数量',
|
||||
send_count: '发送数量',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const data = ref([])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const { results } = await api.fetch(
|
||||
`/admin/users/bind_address/${props.user_id}`,
|
||||
);
|
||||
data.value = results;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('name'),
|
||||
key: "name"
|
||||
},
|
||||
{
|
||||
title: t('mail_count'),
|
||||
key: "mail_count",
|
||||
render(row) {
|
||||
return h(NBadge, {
|
||||
value: row.mail_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('send_count'),
|
||||
key: "send_count",
|
||||
render(row) {
|
||||
return h(NBadge, {
|
||||
value: row.send_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="overflow: auto;">
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-data-table {
|
||||
min-width: 700px;
|
||||
}
|
||||
</style>
|
||||
434
frontend/src/views/admin/UserManagement.vue
Normal file
434
frontend/src/views/admin/UserManagement.vue
Normal file
@@ -0,0 +1,434 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NMenu, NButton, NBadge, NTag } from 'naive-ui';
|
||||
import { MenuFilled } from '@vicons/material'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { hashPassword } from '../../utils';
|
||||
|
||||
import UserAddressManagement from './UserAddressManagement.vue'
|
||||
|
||||
const { loading, openSettings } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
success: 'Success',
|
||||
user_email: 'User Email',
|
||||
role: 'Role',
|
||||
address_count: 'Address Count',
|
||||
created_at: 'Created At',
|
||||
actions: 'Actions',
|
||||
query: 'Query',
|
||||
itemCount: 'itemCount',
|
||||
deleteUser: 'Delete User',
|
||||
delete: 'Delete',
|
||||
deleteUserTip: 'Are you sure you want to delete this user?',
|
||||
resetPassword: 'Reset Password',
|
||||
pleaseInput: 'Please input complete information',
|
||||
createUser: 'Create User',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
changeRole: 'Change Role',
|
||||
prefix: 'Prefix',
|
||||
domains: 'Domains',
|
||||
roleDonotExist: 'Current Role does not exist',
|
||||
userAddressManagement: 'Address Management',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
user_email: '用户邮箱',
|
||||
role: '角色',
|
||||
address_count: '地址数量',
|
||||
created_at: '创建时间',
|
||||
actions: '操作',
|
||||
query: '查询',
|
||||
itemCount: '总数',
|
||||
deleteUser: '删除用户',
|
||||
delete: '删除',
|
||||
deleteUserTip: '确定要删除此用户吗?',
|
||||
resetPassword: '重置密码',
|
||||
pleaseInput: '请输入完整信息',
|
||||
createUser: '创建用户',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
changeRole: '更改角色',
|
||||
prefix: '前缀',
|
||||
domains: '域名',
|
||||
roleDonotExist: '当前角色不存在',
|
||||
userAddressManagement: '地址管理',
|
||||
}
|
||||
}
|
||||
});
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const userQuery = ref('')
|
||||
const showResetPassword = ref(false)
|
||||
const newResetPassword = ref('')
|
||||
const showDeleteUser = ref(false)
|
||||
const curUserId = ref(0)
|
||||
const showCreateUser = ref(false)
|
||||
const user = ref({
|
||||
email: "",
|
||||
password: ""
|
||||
})
|
||||
const showChangeRole = ref(false)
|
||||
const showUserAddressManagement = ref(false)
|
||||
const userRoles = ref([])
|
||||
const curUserRole = ref('')
|
||||
const userRolesOptions = computed(() => {
|
||||
return userRoles.value.map(role => {
|
||||
return {
|
||||
label: role.role,
|
||||
value: role.role
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const fetchUserRoles = async () => {
|
||||
try {
|
||||
const results = await api.fetch(`/admin/user_roles`);
|
||||
userRoles.value = results;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
userQuery.value = userQuery.value.trim()
|
||||
const { results, count: userCount } = await api.fetch(
|
||||
`/admin/users`
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (userQuery.value ? `&query=${userQuery.value}` : '')
|
||||
);
|
||||
data.value = results;
|
||||
if (userCount > 0) {
|
||||
count.value = userCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async () => {
|
||||
if (!newResetPassword.value) {
|
||||
message.error(t('pleaseInput'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.fetch(`/admin/users/${curUserId.value}/reset_password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
password: await hashPassword(newResetPassword.value)
|
||||
})
|
||||
});
|
||||
message.success(t('success'));
|
||||
showResetPassword.value = false;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const createUser = async () => {
|
||||
if (!user.value.email || !user.value.password) {
|
||||
message.error(t('pleaseInput'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.fetch(`/admin/users`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
password: await hashPassword(user.value.password)
|
||||
})
|
||||
});
|
||||
message.success(t('success'));
|
||||
await fetchData();
|
||||
showCreateUser.value = false;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUser = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/users/${curUserId.value}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
message.success(t('success'));
|
||||
showDeleteUser.value = false;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const changeRole = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/user_roles`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
user_id: curUserId.value,
|
||||
role_text: curUserRole.value
|
||||
})
|
||||
});
|
||||
message.success(t('success'));
|
||||
showChangeRole.value = false;
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
},
|
||||
{
|
||||
title: t('user_email'),
|
||||
key: "user_email"
|
||||
},
|
||||
{
|
||||
title: t('role'),
|
||||
key: "role_text",
|
||||
render(row) {
|
||||
if (!row.role_text) return null;
|
||||
return h(NTag, {
|
||||
bordered: false,
|
||||
type: "info"
|
||||
}, {
|
||||
default: () => row.role_text
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('address_count'),
|
||||
key: "address_count",
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.address_count <= 0) return;
|
||||
curUserId.value = row.id;
|
||||
showUserAddressManagement.value = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: () => h(NBadge, {
|
||||
value: row.address_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
}),
|
||||
default: () => row.address_count > 0 ? t('userAddressManagement') : ""
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NMenu, {
|
||||
mode: "horizontal",
|
||||
options: [
|
||||
{
|
||||
label: t('actions'),
|
||||
icon: () => h(MenuFilled),
|
||||
key: "action",
|
||||
children: [
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curUserId.value = row.id;
|
||||
showUserAddressManagement.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('userAddressManagement') }
|
||||
),
|
||||
show: row.address_count > 0
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curUserId.value = row.id;
|
||||
curUserRole.value = row.role_text;
|
||||
showChangeRole.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('changeRole') }
|
||||
),
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curUserId.value = row.id;
|
||||
newResetPassword.value = '';
|
||||
showResetPassword.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('resetPassword') }
|
||||
),
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curUserId.value = row.id;
|
||||
user.value.email = '';
|
||||
user.value.password = '';
|
||||
showDeleteUser.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('delete') }
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const getRolePrefix = (role) => {
|
||||
const res = userRoles.value.find(r => r.role === role)?.prefix;
|
||||
if (res === undefined || res === null) return openSettings.value.prefix;
|
||||
return res;
|
||||
}
|
||||
|
||||
const getRoleDomains = (role) => {
|
||||
const res = userRoles.value.find(r => r.role === role)?.domains;
|
||||
if (res === undefined || res === null || res.length == 0) return openSettings.value.defaultDomains;
|
||||
return res;
|
||||
}
|
||||
|
||||
const roleDonotExist = computed(() => {
|
||||
return curUserRole.value && !userRoles.value.some(r => r.role === curUserRole.value);
|
||||
})
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchUserRoles();
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="margin-top: 10px;">
|
||||
<n-modal v-model:show="showCreateUser" preset="dialog" :title="t('createUser')">
|
||||
<n-form>
|
||||
<n-form-item-row :label="t('email')" required>
|
||||
<n-input v-model:value="user.email" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="createUser" size="small" tertiary type="primary">
|
||||
{{ t('createUser') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="newResetPassword" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="primary">
|
||||
{{ t('resetPassword') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDeleteUser" preset="dialog" :title="t('deleteUser')">
|
||||
<p>{{ t('deleteUserTip') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="deleteUser" size="small" tertiary type="error">
|
||||
{{ t('deleteUser') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showChangeRole" preset="dialog" :title="t('changeRole')">
|
||||
<n-alert type="error" :bordered="false" v-if="roleDonotExist">
|
||||
<span>{{ t('roleDonotExist') }}</span>
|
||||
</n-alert>
|
||||
<p>{{ t('prefix') + ": " + getRolePrefix(curUserRole) }}</p>
|
||||
<p>{{ t('domains') + ": " + JSON.stringify(getRoleDomains(curUserRole)) }}</p>
|
||||
<n-select clearable v-model:value="curUserRole" :options="userRolesOptions" />
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="changeRole" size="small" tertiary type="primary">
|
||||
{{ t('changeRole') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showUserAddressManagement" preset="card" :title="t('userAddressManagement')">
|
||||
<UserAddressManagement :user_id="curUserId" />
|
||||
</n-modal>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="userQuery" @keydown.enter="fetchData" />
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div style="overflow: auto;">
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
|
||||
:page-sizes="[20, 50, 100]" show-size-picker>
|
||||
<template #prefix="{ itemCount }">
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
<template #suffix>
|
||||
<n-button @click="showCreateUser = true" size="small" tertiary type="primary"
|
||||
style="margin-left: 10px">
|
||||
{{ t('createUser') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-pagination {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.n-data-table {
|
||||
min-width: 800px;
|
||||
}
|
||||
</style>
|
||||
274
frontend/src/views/admin/UserOauth2Settings.vue
Normal file
274
frontend/src/views/admin/UserOauth2Settings.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
import constant from '../../constant'
|
||||
import { UserOauth2Settings } from '../../models';
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
save: 'Save',
|
||||
delete: 'Delete',
|
||||
successTip: 'Save Success',
|
||||
enable: 'Enable',
|
||||
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
|
||||
manualInputPrompt: 'Type and press Enter to add',
|
||||
mailAllowList: 'Mail Address Allow List',
|
||||
addOauth2: 'Add Oauth2',
|
||||
name: 'Name',
|
||||
oauth2Type: 'Oauth2 Type',
|
||||
tip: 'Third-party login will automatically use the user\'s email to register an account (the same email will be regarded as the same account), this account is the same as the registered account, and you can also set the password through the forget password',
|
||||
},
|
||||
zh: {
|
||||
save: '保存',
|
||||
delete: '删除',
|
||||
successTip: '保存成功',
|
||||
enable: '启用',
|
||||
enableMailAllowList: '启用邮件地址白名单(可手动输入, 回车增加)',
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
mailAllowList: '邮件地址白名单',
|
||||
addOauth2: '添加 Oauth2',
|
||||
name: '名称',
|
||||
oauth2Type: 'Oauth2 类型',
|
||||
tip: '第三方登录会自动使用用户邮箱注册账号(邮箱相同将视为同一账号), 此账号和注册的账号相同, 也可以通过忘记密码设置密码',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const mailAllowOptions = constant.COMMOM_MAIL.map((item) => {
|
||||
return { label: item, value: item }
|
||||
})
|
||||
|
||||
const userOauth2Settings = ref([] as UserOauth2Settings[])
|
||||
const showAddOauth2 = ref(false)
|
||||
const newOauth2Name = ref('')
|
||||
const newOauth2Type = ref('custom')
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/user_oauth2_settings`)
|
||||
Object.assign(userOauth2Settings.value, res)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/user_oauth2_settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userOauth2Settings.value)
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const addNewOauth2 = () => {
|
||||
const authorizationURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://github.com/login/oauth/authorize'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/authorize/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const accessTokenURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://github.com/login/oauth/access_token'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/token/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const accessTokenFormat = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'json'
|
||||
case 'authentik':
|
||||
return 'urlencoded'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const userInfoURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://api.github.com/user'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/userinfo/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const userEmailKey = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'email'
|
||||
case 'authentik':
|
||||
return 'email'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const scope = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'user:email'
|
||||
case 'authentik':
|
||||
return 'email openid'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
userOauth2Settings.value.push({
|
||||
name: newOauth2Name.value,
|
||||
clientID: '',
|
||||
clientSecret: '',
|
||||
authorizationURL: authorizationURL(),
|
||||
accessTokenURL: accessTokenURL(),
|
||||
accessTokenFormat: accessTokenFormat(),
|
||||
userInfoURL: userInfoURL(),
|
||||
userEmailKey: userEmailKey(),
|
||||
redirectURL: `${window.location.origin}/user/oauth2/callback`,
|
||||
logoutURL: '',
|
||||
scope: scope(),
|
||||
enableMailAllowList: false,
|
||||
mailAllowList: constant.COMMOM_MAIL
|
||||
} as UserOauth2Settings)
|
||||
newOauth2Name.value = ''
|
||||
showAddOauth2.value = false
|
||||
}
|
||||
|
||||
const accessTokenFormatOptions = [
|
||||
{ label: 'json', value: 'json' },
|
||||
{ label: 'urlencoded', value: 'urlencoded' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-modal v-model:show="showAddOauth2" preset="dialog" :title="t('addOauth2')">
|
||||
<n-form>
|
||||
<n-form-item-row :label="t('name')" required>
|
||||
<n-input v-model:value="newOauth2Name" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('oauth2Type')" required>
|
||||
<n-radio-group v-model:value="newOauth2Type">
|
||||
<n-radio-button value="github" label="Github" />
|
||||
<n-radio-button value="authentik" label="Authentik" />
|
||||
<n-radio-button value="custom" label="Custom" />
|
||||
</n-radio-group>
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="addNewOauth2" size="small" tertiary type="primary">
|
||||
{{ t('addOauth2') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<n-alert :show-icon="false" :bordered="false" type="warning" closable style="margin-bottom: 10px;">
|
||||
{{ t("tip") }}
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="showAddOauth2 = true" secondary :loading="loading">
|
||||
{{ t('addOauth2') }}
|
||||
</n-button>
|
||||
<n-button @click="save" type="primary" :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-divider />
|
||||
<n-collapse default-expanded-names="1" accordion :trigger-areas="['main', 'arrow']">
|
||||
<n-collapse-item v-for="(item, index) in userOauth2Settings" :key="index" :title="item.name">
|
||||
<template #header-extra>
|
||||
<n-popconfirm @positive-click="userOauth2Settings.splice(index, 1)">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error">
|
||||
{{ t('delete') }}
|
||||
</n-button>
|
||||
</template>
|
||||
{{ t('delete') }}
|
||||
</n-popconfirm>
|
||||
</template>
|
||||
<n-form :model="item">
|
||||
<n-form-item-row :label="t('name')" required>
|
||||
<n-input v-model:value="item.name" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Client ID" required>
|
||||
<n-input v-model:value="item.clientID" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Client Secret" required>
|
||||
<n-input v-model:value="item.clientSecret" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Authorization URL" required>
|
||||
<n-input v-model:value="item.authorizationURL" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Access Token URL" required>
|
||||
<n-input v-model:value="item.accessTokenURL" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Access Token Params Format" required>
|
||||
<n-select v-model:value="item.accessTokenFormat" :options="accessTokenFormatOptions" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="User Info URL" required>
|
||||
<n-input v-model:value="item.userInfoURL" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="User Email Key (Support JSONPATH like $[0].email)" required>
|
||||
<n-input v-model:value="item.userEmailKey" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Redirect URL" required>
|
||||
<n-input v-model:value="item.redirectURL" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Scope" required>
|
||||
<n-input v-model:value="item.scope" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableMailAllowList')">
|
||||
<n-input-group>
|
||||
<n-checkbox v-model:checked="item.enableMailAllowList" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-select v-model:value="item.mailAllowList" v-if="item.enableMailAllowList" filterable
|
||||
multiple tag style="width: 80%;" :options="mailAllowOptions"
|
||||
:placeholder="t('mailAllowList')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
140
frontend/src/views/admin/UserSettings.vue
Normal file
140
frontend/src/views/admin/UserSettings.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
save: 'Save',
|
||||
successTip: 'Save Success',
|
||||
enable: 'Enable',
|
||||
enableUserRegister: 'Allow User Register',
|
||||
enableMailVerify: 'Enable Mail Verify (Send address must be an address in the system with a balance and can send mail normally)',
|
||||
verifyMailSender: 'Verify Mail Sender',
|
||||
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
|
||||
manualInputPrompt: 'Type and press Enter to add',
|
||||
mailAllowList: 'Mail Address Allow List',
|
||||
maxAddressCount: 'Maximum number of email addresses that can be binded',
|
||||
},
|
||||
zh: {
|
||||
save: '保存',
|
||||
successTip: '保存成功',
|
||||
enable: '启用',
|
||||
enableUserRegister: "允许用户注册",
|
||||
enableMailVerify: '启用邮件验证(发送地址必须是系统中能有余额且能正常发送邮件的地址)',
|
||||
verifyMailSender: '验证邮件发送地址',
|
||||
enableMailAllowList: '启用邮件地址白名单(可手动输入, 回车增加)',
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
mailAllowList: '邮件地址白名单',
|
||||
maxAddressCount: '可绑定最大邮箱地址数量',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const commonMail = [
|
||||
"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com",
|
||||
"icloud.com", "yahoo.com", "foxmail.com"
|
||||
]
|
||||
|
||||
const mailAllowOptions = commonMail.map((item) => {
|
||||
return { label: item, value: item }
|
||||
})
|
||||
|
||||
const userSettings = ref({
|
||||
enable: false,
|
||||
enableMailVerify: false,
|
||||
verifyMailSender: "",
|
||||
enableMailAllowList: false,
|
||||
mailAllowList: commonMail,
|
||||
maxAddressCount: 5,
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/user_settings`)
|
||||
Object.assign(userSettings.value, res)
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/user_settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userSettings.value)
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<n-flex justify="end">
|
||||
<n-button @click="save" type="primary" :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-form :model="userSettings">
|
||||
<n-form-item-row :label="t('enableUserRegister')">
|
||||
<n-switch v-model:value="userSettings.enable" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableMailVerify')">
|
||||
<n-input-group>
|
||||
<n-checkbox v-model:checked="userSettings.enableMailVerify" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-input v-model:value="userSettings.verifyMailSender" v-if="userSettings.enableMailVerify"
|
||||
style="width: 80%;" :placeholder="t('verifyMailSender')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableMailAllowList')">
|
||||
<n-input-group>
|
||||
<n-checkbox v-model:checked="userSettings.enableMailAllowList" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-select v-model:value="userSettings.mailAllowList" v-if="userSettings.enableMailAllowList"
|
||||
filterable multiple tag style="width: 80%;" :options="mailAllowOptions"
|
||||
:placeholder="t('mailAllowList')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('maxAddressCount')">
|
||||
<n-input-group>
|
||||
<n-input-number v-model:value="userSettings.maxAddressCount"
|
||||
:placeholder="t('maxAddressCount')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
107
frontend/src/views/admin/Webhook.vue
Normal file
107
frontend/src/views/admin/Webhook.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
successTip: 'Success',
|
||||
enableAllowList: 'Enable Allow List (Restrict webhook access to specific users)',
|
||||
webhookAllowList: 'Webhook Allow List(Enter the mail address that is allowed to use webhook and enter)',
|
||||
manualInputPrompt: 'Type and press Enter to add',
|
||||
save: 'Save',
|
||||
notEnabled: 'Webhook is not enabled',
|
||||
},
|
||||
zh: {
|
||||
successTip: '成功',
|
||||
enableAllowList: '启用白名单 (限制 webhook 访问权限,只有白名单中的用户可以使用)',
|
||||
webhookAllowList: 'Webhook 白名单(请输入允许使用webhook 的邮箱地址, 回车增加)',
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
save: '保存',
|
||||
notEnabled: 'Webhook 未开启',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
class WebhookSettings {
|
||||
enableAllowList: boolean;
|
||||
allowList: string[];
|
||||
|
||||
constructor(enableAllowList: boolean, allowList: string[]) {
|
||||
this.enableAllowList = enableAllowList;
|
||||
this.allowList = allowList;
|
||||
}
|
||||
}
|
||||
|
||||
const webhookSettings = ref(new WebhookSettings(false, []))
|
||||
const webhookEnabled = ref(false)
|
||||
const errorInfo = ref('')
|
||||
|
||||
const getSettings = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/webhook/settings`)
|
||||
Object.assign(webhookSettings.value, res)
|
||||
webhookEnabled.value = true
|
||||
} catch (error) {
|
||||
errorInfo.value = (error as Error).message || "error";
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/webhook/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings.value),
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getSettings();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card v-if="webhookEnabled" :bordered="false" embedded style="max-width: 800px; overflow: auto;">
|
||||
<n-flex justify="end">
|
||||
<n-button @click="saveSettings" type="primary">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-form-item-row :label="t('enableAllowList')">
|
||||
<n-switch v-model:value="webhookSettings.enableAllowList" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('webhookAllowList')">
|
||||
<n-select v-model:value="webhookSettings.allowList" filterable multiple tag
|
||||
:placeholder="t('webhookAllowList')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
</n-card>
|
||||
<n-result v-else status="404" :title="t('notEnabled')" :description="errorInfo" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
42
frontend/src/views/admin/WorkerConfig.vue
Normal file
42
frontend/src/views/admin/WorkerConfig.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const settings = ref({})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/worker/configs`)
|
||||
Object.assign(settings.value, res)
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 800px; overflow: auto;">
|
||||
<pre>{{ JSON.stringify(settings, null, 2) }}</pre>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
47
frontend/src/views/common/About.vue
Normal file
47
frontend/src/views/common/About.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import { GithubAlt, Discord, Telegram } from '@vicons/fa'
|
||||
import { useGlobalState } from '../../store'
|
||||
const { announcement } = useGlobalState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<div v-html="announcement"></div>
|
||||
<n-button tag="a" target="_blank" href="https://github.com/dreamhunter2333/cloudflare_temp_email">
|
||||
<template #icon>
|
||||
<n-icon :component="GithubAlt" />
|
||||
</template>
|
||||
Github
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" href="https://discord.gg/dQEwTWhA6Q">
|
||||
<template #icon>
|
||||
<n-icon :component="Discord" />
|
||||
</template>
|
||||
Discord
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" href="https://t.me/cloudflare_temp_email">
|
||||
<template #icon>
|
||||
<n-icon :component="Telegram" />
|
||||
</template>
|
||||
Telegram
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
22
frontend/src/views/common/AdminContact.vue
Normal file
22
frontend/src/views/common/AdminContact.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../../store'
|
||||
const { openSettings } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
adminContact: 'If you need help, please contact the administrator ({msg})',
|
||||
},
|
||||
zh: {
|
||||
adminContact: '如果你需要帮助,请联系管理员 ({msg})',
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-alert v-if="openSettings.adminContact" :show-icon="false" :bordered="false">
|
||||
<span>{{ t('adminContact', { msg: openSettings.adminContact }) }}</span>
|
||||
</n-alert>
|
||||
</template>
|
||||
106
frontend/src/views/common/Appearance.vue
Normal file
106
frontend/src/views/common/Appearance.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useIsMobile } from '../../utils/composables'
|
||||
import { useGlobalState } from '../../store'
|
||||
const props = defineProps({
|
||||
showUseSimpleIndex: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
|
||||
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex
|
||||
} = useGlobalState()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
useSimpleIndex: 'Use Simple Index',
|
||||
mailboxSplitSize: 'Mailbox Split Size',
|
||||
useIframeShowMail: 'Use iframe Show HTML Mail',
|
||||
preferShowTextMail: 'Display text Mail by default',
|
||||
useSideMargin: 'Turn on the side margins on the left and right sides of the page',
|
||||
globalTabplacement: 'Global Tab Placement',
|
||||
left: 'left',
|
||||
top: 'top',
|
||||
right: 'right',
|
||||
bottom: 'bottom',
|
||||
useUTCDate: 'Use UTC Date',
|
||||
autoRefreshInterval: 'Auto Refresh Interval(Sec)',
|
||||
},
|
||||
zh: {
|
||||
useSimpleIndex: '使用极简主页',
|
||||
mailboxSplitSize: '邮箱界面分栏大小',
|
||||
preferShowTextMail: '默认以文本显示邮件',
|
||||
useIframeShowMail: '使用iframe显示HTML邮件',
|
||||
globalTabplacement: '全局选项卡位置',
|
||||
useSideMargin: '开启页面左右两侧侧边距',
|
||||
left: '左侧',
|
||||
top: '顶部',
|
||||
right: '右侧',
|
||||
bottom: '底部',
|
||||
useUTCDate: '使用 UTC 时间',
|
||||
autoRefreshInterval: '自动刷新间隔(秒)',
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-form-item-row v-if="!isMobile" :label="t('mailboxSplitSize')">
|
||||
<n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{
|
||||
0.25: '0.25',
|
||||
0.5: '0.5',
|
||||
0.75: '0.75'
|
||||
}" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('autoRefreshInterval')">
|
||||
<n-slider v-model:value="configAutoRefreshInterval" :min="30" :max="300" :step="1" :marks="{
|
||||
60: '60', 120: '120', 180: '180', 240: '240'
|
||||
}" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="props.showUseSimpleIndex" :label="t('useSimpleIndex')">
|
||||
<n-switch v-model:value="useSimpleIndex" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('preferShowTextMail')">
|
||||
<n-switch v-model:value="preferShowTextMail" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('useIframeShowMail')">
|
||||
<n-switch v-model:value="useIframeShowMail" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('useUTCDate')">
|
||||
<n-switch v-model:value="useUTCDate" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="!isMobile" :label="t('useSideMargin')">
|
||||
<n-switch v-model:value="useSideMargin" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('globalTabplacement')">
|
||||
<n-radio-group v-model:value="globalTabplacement">
|
||||
<n-radio-button value="top" :label="t('top')" />
|
||||
<n-radio-button value="left" :label="t('left')" />
|
||||
<n-radio-button value="right" :label="t('right')" />
|
||||
<n-radio-button value="bottom" :label="t('bottom')" />
|
||||
</n-radio-group>
|
||||
</n-form-item-row>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
379
frontend/src/views/common/Login.vue
Normal file
379
frontend/src/views/common/Login.vue
Normal file
@@ -0,0 +1,379 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
|
||||
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
import Turnstile from '../../components/Turnstile.vue'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { getRouterPathWithLang, hashPassword } from '../../utils'
|
||||
|
||||
const props = defineProps({
|
||||
bindUserAddress: {
|
||||
type: Function,
|
||||
default: async () => { await api.bindUserAddress(); },
|
||||
required: true
|
||||
},
|
||||
newAddressPath: {
|
||||
type: Function,
|
||||
default: async (address_name, domain, cf_token) => {
|
||||
return await api.fetch("/api/new_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: address_name,
|
||||
domain: domain,
|
||||
cf_token: cf_token,
|
||||
}),
|
||||
});
|
||||
},
|
||||
required: true
|
||||
},
|
||||
})
|
||||
|
||||
const message = useMessage()
|
||||
const notification = useNotification()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
jwt, loading, openSettings,
|
||||
showAddressCredential, userSettings, addressPassword
|
||||
} = useGlobalState()
|
||||
|
||||
const tabValue = ref('signin')
|
||||
const credential = ref('')
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const cfToken = ref("")
|
||||
const loginMethod = ref('credential') // 'credential' or 'password'
|
||||
const loginAddress = ref('')
|
||||
const loginPassword = ref('')
|
||||
|
||||
// 根据 openSettings 初始化登录方式
|
||||
const initLoginMethod = () => {
|
||||
if (openSettings.value?.enableAddressPassword) {
|
||||
loginMethod.value = 'password';
|
||||
} else {
|
||||
loginMethod.value = 'credential';
|
||||
}
|
||||
}
|
||||
|
||||
const login = async () => {
|
||||
if (loginMethod.value === 'password') {
|
||||
// Password login
|
||||
if (!loginAddress.value || !loginPassword.value) {
|
||||
message.error(t('emailPasswordRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch('/api/address_login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: loginAddress.value,
|
||||
password: await hashPassword(loginPassword.value)
|
||||
})
|
||||
});
|
||||
jwt.value = res.jwt;
|
||||
await api.getSettings();
|
||||
try {
|
||||
await props.bindUserAddress();
|
||||
} catch (error) {
|
||||
message.error(`${t('bindUserAddressError')}: ${error.message}`);
|
||||
}
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!credential.value) {
|
||||
message.error(t('credentialInput'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
jwt.value = credential.value;
|
||||
await api.getSettings();
|
||||
try {
|
||||
await props.bindUserAddress();
|
||||
} catch (error) {
|
||||
message.error(`${t('bindUserAddressError')}: ${error.message}`);
|
||||
}
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
login: 'Login',
|
||||
loginAndBind: 'Login and Bind',
|
||||
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
|
||||
getNewEmail: 'Create New Email',
|
||||
getNewEmailTip1: 'Please input the email you want to use. only allow: ',
|
||||
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
|
||||
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
|
||||
credential: 'Email Address Credential',
|
||||
ok: 'OK',
|
||||
generateName: 'Generate Fake Name',
|
||||
help: 'Help',
|
||||
credentialInput: 'Please input the Mail Address Credential',
|
||||
bindUserInfo: 'Logged in user, login without binding email or create new email address will bind to current user',
|
||||
bindUserAddressError: 'Error when bind email address to user',
|
||||
autoGeneratedName: 'Auto-generated name',
|
||||
passwordLogin: 'Password Login',
|
||||
credentialLogin: 'Credential Login',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
emailPasswordRequired: 'Email and password are required',
|
||||
},
|
||||
zh: {
|
||||
login: '登录',
|
||||
loginAndBind: '登录并绑定',
|
||||
pleaseGetNewEmail: '请"登录"或点击 "注册新邮箱" 按钮来获取一个新的邮箱地址',
|
||||
getNewEmail: '创建新邮箱',
|
||||
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许: ',
|
||||
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
|
||||
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
|
||||
credential: '邮箱地址凭据',
|
||||
ok: '确定',
|
||||
generateName: '生成随机名字',
|
||||
help: '帮助',
|
||||
credentialInput: '请输入邮箱地址凭据',
|
||||
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
|
||||
bindUserAddressError: '绑定邮箱地址到用户时错误',
|
||||
autoGeneratedName: '自动生成名称',
|
||||
passwordLogin: '密码登录',
|
||||
credentialLogin: '凭据登录',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
emailPasswordRequired: '邮箱和密码不能为空',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const loginAndBindTag = computed(() => {
|
||||
if (userSettings.value.user_email) {
|
||||
return t('loginAndBind')
|
||||
}
|
||||
return t('login')
|
||||
})
|
||||
|
||||
const addressRegex = computed(() => {
|
||||
try {
|
||||
if (openSettings.value.addressRegex) {
|
||||
return new RegExp(openSettings.value.addressRegex, 'g');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(`Invalid addressRegex: ${openSettings.value.addressRegex}`);
|
||||
}
|
||||
return /[^a-z0-9]/g;
|
||||
});
|
||||
|
||||
const generateNameLoading = ref(false);
|
||||
const generateName = async () => {
|
||||
try {
|
||||
generateNameLoading.value = true;
|
||||
const { faker } = await import('https://esm.sh/@faker-js/faker');
|
||||
emailName.value = faker.internet.email()
|
||||
.split('@')[0]
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(addressRegex.value, '')
|
||||
.toLowerCase();
|
||||
// support maxAddressLen
|
||||
if (emailName.value.length > openSettings.value.maxAddressLen) {
|
||||
emailName.value = emailName.value.slice(0, openSettings.value.maxAddressLen);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
generateNameLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const newEmail = async () => {
|
||||
try {
|
||||
// If custom names are disabled, send empty name to trigger backend auto-generation
|
||||
const nameToSend = openSettings.value.disableCustomAddressName ? "" : emailName.value;
|
||||
const res = await props.newAddressPath(
|
||||
nameToSend,
|
||||
emailDomain.value,
|
||||
cfToken.value
|
||||
);
|
||||
jwt.value = res["jwt"];
|
||||
addressPassword.value = res["password"] || '';
|
||||
await api.getSettings();
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
showAddressCredential.value = true;
|
||||
try {
|
||||
await props.bindUserAddress();
|
||||
} catch (error) {
|
||||
message.error(`${t('bindUserAddressError')}: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
const addressPrefix = computed(() => {
|
||||
// if user has role, return role prefix
|
||||
if (userSettings.value?.user_role) {
|
||||
return userSettings.value.user_role.prefix || "";
|
||||
}
|
||||
// if user has no role, return default prefix
|
||||
return openSettings.value.prefix;
|
||||
});
|
||||
|
||||
const domainsOptions = computed(() => {
|
||||
// if user has role, return role domains
|
||||
if (userSettings.value.user_role) {
|
||||
const allDomains = userSettings.value.user_role.domains;
|
||||
if (!allDomains) return openSettings.value.domains;
|
||||
return openSettings.value.domains.filter((domain) => {
|
||||
return allDomains.includes(domain.value);
|
||||
});
|
||||
}
|
||||
// if user has no role, return default domains
|
||||
if (!openSettings.value.defaultDomains) {
|
||||
return openSettings.value.domains;
|
||||
}
|
||||
// if user has no role and no default domains, return all domains
|
||||
return openSettings.value.domains.filter((domain) => {
|
||||
return openSettings.value.defaultDomains.includes(domain.value);
|
||||
});
|
||||
});
|
||||
|
||||
const showNewAddressTab = computed(() => {
|
||||
if (openSettings.value.disableAnonymousUserCreateEmail
|
||||
&& !userSettings.value.user_email
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return openSettings.value.enableUserCreateEmail;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
|
||||
await api.getOpenSettings(message, notification);
|
||||
}
|
||||
emailDomain.value = domainsOptions.value ? domainsOptions.value[0]?.value : "";
|
||||
initLoginMethod();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-alert v-if="userSettings.user_email" :show-icon="false" :bordered="false" closable>
|
||||
<span>{{ t('bindUserInfo') }}</span>
|
||||
</n-alert>
|
||||
<n-tabs v-if="openSettings.fetched" v-model:value="tabValue" size="large" justify-content="space-evenly">
|
||||
<n-tab-pane name="signin" :tab="loginAndBindTag">
|
||||
<n-form>
|
||||
<div v-if="loginMethod === 'password'">
|
||||
<n-form-item-row :label="t('email')" required>
|
||||
<n-input v-model:value="loginAddress" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="loginPassword" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<n-form-item-row :label="t('credential')" required>
|
||||
<n-input v-model:value="credential" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
</n-form-item-row>
|
||||
</div>
|
||||
|
||||
<div class="switch-login-button">
|
||||
<n-button v-if="openSettings?.enableAddressPassword"
|
||||
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
|
||||
type="info" quaternary size="tiny">
|
||||
{{ loginMethod === 'password' ? t('credentialLogin') : t('passwordLogin') }}
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<n-button @click="login" :loading="loading" type="primary" block secondary strong>
|
||||
<template #icon>
|
||||
<n-icon :component="EmailOutlined" />
|
||||
</template>
|
||||
{{ loginAndBindTag }}
|
||||
</n-button>
|
||||
<n-button v-if="showNewAddressTab" @click="tabValue = 'register'" block secondary strong>
|
||||
<template #icon>
|
||||
<n-icon :component="NewLabelOutlined" />
|
||||
</template>
|
||||
{{ t('getNewEmail') }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="showNewAddressTab" name="register" :tab="t('getNewEmail')">
|
||||
<n-spin :show="generateNameLoading">
|
||||
<n-form>
|
||||
<span>
|
||||
<p v-if="!openSettings.disableCustomAddressName">{{ t("getNewEmailTip1") +
|
||||
addressRegex.source }}</p>
|
||||
<p v-if="!openSettings.disableCustomAddressName">{{ t("getNewEmailTip2") }}</p>
|
||||
<p>{{ t("getNewEmailTip3") }}</p>
|
||||
</span>
|
||||
<n-button v-if="!openSettings.disableCustomAddressName" @click="generateName"
|
||||
style="margin-bottom: 10px;">
|
||||
{{ t('generateName') }}
|
||||
</n-button>
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="addressPrefix">
|
||||
{{ addressPrefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-if="!openSettings.disableCustomAddressName" v-model:value="emailName" show-count
|
||||
:minlength="openSettings.minAddressLen" :maxlength="openSettings.maxAddressLen" />
|
||||
<n-input v-else :value="t('autoGeneratedName')" disabled />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="domainsOptions" />
|
||||
</n-input-group>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
|
||||
<template #icon>
|
||||
<n-icon :component="NewLabelOutlined" />
|
||||
</template>
|
||||
{{ t('getNewEmail') }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="help" :tab="t('help')">
|
||||
<n-alert :show-icon="false" :bordered="false">
|
||||
<span>{{ t('pleaseGetNewEmail') }}</span>
|
||||
</n-alert>
|
||||
<AdminContact />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.n-alert {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.n-form .n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.switch-login-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.n-form {
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
223
frontend/src/views/index/AccountSettings.vue
Normal file
223
frontend/src/views/index/AccountSettings.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { hashPassword } from '../../utils'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
|
||||
const {
|
||||
jwt, settings, showAddressCredential, loading, openSettings
|
||||
} = useGlobalState()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const showLogout = ref(false)
|
||||
const showDeleteAccount = ref(false)
|
||||
const showClearInbox = ref(false)
|
||||
const showClearSentItems = ref(false)
|
||||
const showChangePassword = ref(false)
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
logout: "Logout",
|
||||
deleteAccount: "Delete Account",
|
||||
showAddressCredential: 'Show Address Credential',
|
||||
logoutConfirm: 'Are you sure to logout?',
|
||||
deleteAccount: "Delete Account",
|
||||
deleteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
|
||||
clearInbox: "Clear Inbox",
|
||||
clearSentItems: "Clear Sent Items",
|
||||
clearInboxConfirm: "Are you sure to clear all emails in your inbox?",
|
||||
clearSentItemsConfirm: "Are you sure to clear all emails in your sent items?",
|
||||
success: "Success",
|
||||
changePassword: "Change Password",
|
||||
newPassword: "New Password",
|
||||
confirmPassword: "Confirm Password",
|
||||
passwordMismatch: "Passwords do not match",
|
||||
passwordChanged: "Password changed successfully",
|
||||
},
|
||||
zh: {
|
||||
logout: '退出登录',
|
||||
deleteAccount: "删除账户",
|
||||
showAddressCredential: '查看邮箱地址凭证',
|
||||
logoutConfirm: '确定要退出登录吗?',
|
||||
deleteAccount: "删除账户",
|
||||
deleteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
|
||||
clearInbox: "清空收件箱",
|
||||
clearSentItems: "清空发件箱",
|
||||
clearInboxConfirm: "确定要清空你收件箱中的所有邮件吗?",
|
||||
clearSentItemsConfirm: "确定要清空你发件箱中的所有邮件吗?",
|
||||
success: "成功",
|
||||
changePassword: "修改密码",
|
||||
newPassword: "新密码",
|
||||
confirmPassword: "确认密码",
|
||||
passwordMismatch: "密码不匹配",
|
||||
passwordChanged: "密码修改成功",
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const logout = async () => {
|
||||
jwt.value = '';
|
||||
await router.push(getRouterPathWithLang("/", locale.value))
|
||||
location.reload()
|
||||
}
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/delete_address`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
jwt.value = '';
|
||||
await router.push(getRouterPathWithLang("/", locale.value))
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
const clearInbox = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/clear_inbox`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
message.success(t("success"));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
showClearInbox.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSentItems = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/clear_sent_items`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
message.success(t("success"));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
showClearSentItems.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
message.error(t("passwordMismatch"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.fetch(`/api/address_change_password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
new_password: await hashPassword(newPassword.value)
|
||||
})
|
||||
});
|
||||
message.success(t("passwordChanged"));
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
showChangePassword.value = false;
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
|
||||
{{ t('showAddressCredential') }}
|
||||
</n-button>
|
||||
<n-button v-if="openSettings?.enableAddressPassword" @click="showChangePassword = true" type="info" secondary block strong>
|
||||
{{ t('changePassword') }}
|
||||
</n-button>
|
||||
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showClearInbox = true" type="warning" secondary
|
||||
block strong>
|
||||
{{ t('clearInbox') }}
|
||||
</n-button>
|
||||
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showClearSentItems = true" type="warning"
|
||||
secondary block strong>
|
||||
{{ t('clearSentItems') }}
|
||||
</n-button>
|
||||
<n-button @click="showLogout = true" secondary block strong>
|
||||
{{ t('logout') }}
|
||||
</n-button>
|
||||
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary
|
||||
block strong>
|
||||
{{ t('deleteAccount') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
|
||||
<p>{{ t('logoutConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="logout" size="small" tertiary type="warning">
|
||||
{{ t('logout') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
|
||||
<p>{{ t('deleteAccountConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
|
||||
{{ t('deleteAccount') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showClearInbox" preset="dialog" :title="t('clearInbox')">
|
||||
<p>{{ t('clearInboxConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="clearInbox" size="small" tertiary type="warning">
|
||||
{{ t('clearInbox') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showClearSentItems" preset="dialog" :title="t('clearSentItems')">
|
||||
<p>{{ t('clearSentItemsConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="clearSentItems" size="small" tertiary type="warning">
|
||||
{{ t('clearSentItems') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="showChangePassword" preset="dialog" :title="t('changePassword')">
|
||||
<n-form :model="{ newPassword, confirmPassword }">
|
||||
<n-form-item :label="t('newPassword')">
|
||||
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('confirmPassword')">
|
||||
<n-input v-model:value="confirmPassword" type="password" placeholder="" show-password-on="click" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="changePassword" size="small" tertiary type="info">
|
||||
{{ t('changePassword') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
189
frontend/src/views/index/AddressBar.vue
Normal file
189
frontend/src/views/index/AddressBar.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup>
|
||||
import useClipboard from 'vue-clipboard3'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Copy, User, ExchangeAlt } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import Login from '../common/Login.vue'
|
||||
import AddressManagement from '../user/AddressManagement.vue'
|
||||
import TelegramAddress from './TelegramAddress.vue'
|
||||
import LocalAddress from './LocalAddress.vue'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
|
||||
const { toClipboard } = useClipboard()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
jwt, settings, showAddressCredential, userJwt,
|
||||
isTelegram, openSettings, addressPassword
|
||||
} = useGlobalState()
|
||||
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
addressManage: 'Address Manage',
|
||||
changeAddress: 'Change Address',
|
||||
ok: 'OK',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
fetchAddressError: 'Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.',
|
||||
addressCredential: 'Mail Address Credential',
|
||||
linkWithAddressCredential: 'Open to auto login email link',
|
||||
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
|
||||
addressPassword: 'Address Password',
|
||||
userLogin: 'User Login',
|
||||
},
|
||||
zh: {
|
||||
addressManage: '地址管理',
|
||||
changeAddress: '更换地址',
|
||||
ok: '确定',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
fetchAddressError: '邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
addressPassword: '地址密码',
|
||||
userLogin: '用户登录',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const showChangeAddress = ref(false)
|
||||
const showTelegramChangeAddress = ref(false)
|
||||
const showLocalAddress = ref(false)
|
||||
const addressLabel = computed(() => {
|
||||
if (settings.value.address) {
|
||||
const domain = settings.value.address.split('@')[1]
|
||||
const domainLabel = openSettings.value.domains.find(
|
||||
d => d.value === domain
|
||||
)?.label;
|
||||
if (!domainLabel) return settings.value.address;
|
||||
return settings.value.address.replace('@' + domain, `@${domainLabel}`);
|
||||
}
|
||||
return settings.value.address;
|
||||
})
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await toClipboard(settings.value.address)
|
||||
message.success(t('copied'));
|
||||
} catch (e) {
|
||||
message.error(e.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const getUrlWithJwt = () => {
|
||||
return `${window.location.origin}/?jwt=${jwt.value}`
|
||||
}
|
||||
|
||||
const onUserLogin = async () => {
|
||||
await router.push(getRouterPathWithLang("/user", locale.value))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-card :bordered="false" embedded v-if="!settings.fetched">
|
||||
<n-skeleton style="height: 50vh" />
|
||||
</n-card>
|
||||
<div v-else-if="settings.address">
|
||||
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||
<span>
|
||||
<b>{{ addressLabel }}</b>
|
||||
<n-button v-if="isTelegram" style="margin-left: 10px" @click="showTelegramChangeAddress = true"
|
||||
size="small" tertiary type="primary">
|
||||
<n-icon :component="ExchangeAlt" /> {{ t('addressManage') }}
|
||||
</n-button>
|
||||
<n-button v-else-if="userJwt" style="margin-left: 10px" @click="showChangeAddress = true"
|
||||
size="small" tertiary type="primary">
|
||||
<n-icon :component="ExchangeAlt" /> {{ t('changeAddress') }}
|
||||
</n-button>
|
||||
<n-button v-else style="margin-left: 10px" @click="showLocalAddress = true" size="small" tertiary
|
||||
type="primary">
|
||||
<n-icon :component="ExchangeAlt" /> {{ t('addressManage') }}
|
||||
</n-button>
|
||||
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
|
||||
<n-icon :component="Copy" /> {{ t('copy') }}
|
||||
</n-button>
|
||||
</span>
|
||||
</n-alert>
|
||||
</div>
|
||||
<div v-else-if="isTelegram">
|
||||
<TelegramAddress />
|
||||
</div>
|
||||
<div v-else class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<n-alert v-if="jwt" type="warning" :show-icon="false" :bordered="false" closable>
|
||||
<span>{{ t('fetchAddressError') }}</span>
|
||||
</n-alert>
|
||||
<Login />
|
||||
<n-divider />
|
||||
<n-button @click="onUserLogin" type="primary" block secondary strong>
|
||||
<template #icon>
|
||||
<n-icon :component="User" />
|
||||
</template>
|
||||
{{ t('userLogin') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
<n-modal v-model:show="showTelegramChangeAddress" preset="card" :title="t('changeAddress')">
|
||||
<TelegramAddress />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showChangeAddress" preset="card" :title="t('changeAddress')">
|
||||
<AddressManagement />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showLocalAddress" preset="card" :title="t('changeAddress')">
|
||||
<LocalAddress />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
|
||||
<span>
|
||||
<p>{{ t("addressCredentialTip") }}</p>
|
||||
</span>
|
||||
<n-card embedded>
|
||||
<b>{{ jwt }}</b>
|
||||
</n-card>
|
||||
<n-card embedded v-if="addressPassword">
|
||||
<p><b>{{ settings.address }}</b></p>
|
||||
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
|
||||
</n-card>
|
||||
<n-card embedded>
|
||||
<n-collapse>
|
||||
<n-collapse-item :title='t("linkWithAddressCredential")'>
|
||||
<n-card embedded>
|
||||
<b>{{ getUrlWithJwt() }}</b>
|
||||
</n-card>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-alert {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.n-card {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
126
frontend/src/views/index/Attachment.vue
Normal file
126
frontend/src/views/index/Attachment.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { api } from '../../api'
|
||||
import { NPopconfirm } from 'naive-ui';
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
download: 'Download',
|
||||
action: 'Action',
|
||||
delete: 'Delete',
|
||||
deleteConfirm: 'Are you sure to delete this attachment?',
|
||||
deleteSuccess: 'Deleted successfully',
|
||||
},
|
||||
zh: {
|
||||
download: '下载',
|
||||
action: '操作',
|
||||
delete: '删除',
|
||||
deleteConfirm: '确定要删除此附件吗?',
|
||||
deleteSuccess: '删除成功',
|
||||
}
|
||||
}
|
||||
});
|
||||
const data = ref([])
|
||||
const showDownload = ref(false)
|
||||
const curRow = ref({})
|
||||
const curDownloadUrl = ref('')
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const { results } = await api.fetch(
|
||||
`/api/attachment/list`
|
||||
);
|
||||
data.value = results;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "key",
|
||||
key: "key"
|
||||
},
|
||||
{
|
||||
title: t('action'),
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NButton,
|
||||
{
|
||||
type: 'success',
|
||||
tertiary: true,
|
||||
onClick: async () => {
|
||||
try {
|
||||
const { url } = await api.fetch(`/api/attachment/get_url`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: row.key })
|
||||
});
|
||||
curDownloadUrl.value = url;
|
||||
curRow.value = row;
|
||||
showDownload.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
{ default: () => t('download') }
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await api.fetch(`/api/attachment/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: row.key })
|
||||
});
|
||||
message.success(t('deleteSuccess'));
|
||||
await fetchData();
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "error",
|
||||
},
|
||||
{ default: () => t('delete') }
|
||||
),
|
||||
default: () => t('deleteConfirm')
|
||||
}
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal v-model:show="showDownload" preset="dialog" :title="t('download')">
|
||||
<n-tag type="info">{{ curRow.key }}</n-tag>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curRow.key.replace('/', '_')"
|
||||
:href="curDownloadUrl">
|
||||
{{ t('download') }}
|
||||
</n-button>
|
||||
</n-modal>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,9 +2,8 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import Header from './Header.vue'
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const message = useMessage()
|
||||
const sourcePrefix = ref("")
|
||||
@@ -42,18 +41,22 @@ const { t } = useI18n({
|
||||
}
|
||||
});
|
||||
|
||||
const getSettings = async () => {
|
||||
await api.getSettings()
|
||||
sourcePrefix.value = settings.value.auto_reply.source_prefix || ""
|
||||
enableAutoReply.value = settings.value.auto_reply.enabled || false
|
||||
name.value = settings.value.auto_reply.name || ""
|
||||
autoReplyMessage.value = settings.value.auto_reply.message || ""
|
||||
subject.value = settings.value.auto_reply.subject || ""
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch("/api/auto_reply")
|
||||
sourcePrefix.value = res.source_prefix || ""
|
||||
enableAutoReply.value = res.enabled || false
|
||||
name.value = res.name || ""
|
||||
autoReplyMessage.value = res.message || ""
|
||||
subject.value = res.subject || ""
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
const saveData = async () => {
|
||||
try {
|
||||
await api.fetch("/api/settings", {
|
||||
await api.fetch("/api/auto_reply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
auto_reply: {
|
||||
@@ -72,15 +75,15 @@ const saveSettings = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getSettings()
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card v-if="settings.address" :title='t("settings")'>
|
||||
<n-card :bordered="false" embedded v-if="settings.address" :title='t("settings")'>
|
||||
<div class="right">
|
||||
<n-button type="primary" @click="saveSettings">{{ t('save') }}</n-button>
|
||||
<n-button type="primary" @click="saveData">{{ t('save') }}</n-button>
|
||||
</div>
|
||||
<div class="left">
|
||||
<n-form-item :label="t('enableAutoReply')" label-placement="left">
|
||||
159
frontend/src/views/index/LocalAddress.vue
Normal file
159
frontend/src/views/index/LocalAddress.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, h, computed } from 'vue';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NPopconfirm, NButton } from 'naive-ui'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import Login from '../common/Login.vue';
|
||||
|
||||
const { jwt } = useGlobalState()
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
tip: 'These addresses are stored in your browser, maybe loss if you clear the browser cache.',
|
||||
success: 'success',
|
||||
address: 'Address',
|
||||
actions: 'Actions',
|
||||
changeMailAddress: 'Change Mail Address',
|
||||
unbindMailAddress: 'Unbind Mail Address credential',
|
||||
create_or_bind: 'Create or Bind',
|
||||
bindAddressSuccess: 'Bind Address Success',
|
||||
},
|
||||
zh: {
|
||||
tip: '这些地址存储在您的浏览器中,如果您清除浏览器缓存,可能会丢失。',
|
||||
success: '成功',
|
||||
address: '地址',
|
||||
actions: '操作',
|
||||
changeMailAddress: '切换邮箱地址',
|
||||
unbindMailAddress: '解绑邮箱地址',
|
||||
create_or_bind: '创建或绑定',
|
||||
bindAddressSuccess: '绑定地址成功',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const tabValue = ref('address')
|
||||
const localAddressCache = useLocalStorage("LocalAddressCache", []);
|
||||
const data = computed(() => {
|
||||
// @ts-ignore
|
||||
if (!localAddressCache.value.includes(jwt.value)) {
|
||||
// @ts-ignore
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
return localAddressCache.value.map((curJwt: string) => {
|
||||
try {
|
||||
var payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
atob(curJwt.split(".")[1]
|
||||
.replace(/-/g, "+").replace(/_/g, "/")
|
||||
)
|
||||
)
|
||||
);
|
||||
return {
|
||||
valid: true,
|
||||
address: payload.address,
|
||||
jwt: curJwt
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
address: `invalid jwt [${curJwt}]`,
|
||||
jwt: curJwt
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
const bindAddress = async () => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (!localAddressCache.value.includes(jwt.value)) {
|
||||
// @ts-ignore
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
tabValue.value = 'address'
|
||||
message.success(t('bindAddressSuccess'));
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('address'),
|
||||
key: "address"
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render(row: any) {
|
||||
return h('div', [
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => {
|
||||
jwt.value = row.jwt
|
||||
location.reload()
|
||||
}
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
},
|
||||
{ default: () => t('changeMailAddress') }
|
||||
),
|
||||
default: () => `${t('changeMailAddress')}?`
|
||||
}
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => {
|
||||
if (jwt.value === row.jwt) {
|
||||
return;
|
||||
}
|
||||
localAddressCache.value = localAddressCache.value.filter(
|
||||
(curJwt: string) => curJwt !== row.jwt
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
disabled: jwt.value === row.jwt,
|
||||
type: "warning",
|
||||
},
|
||||
{ default: () => t('unbindMailAddress') }
|
||||
),
|
||||
default: () => `${t('unbindMailAddress')}?`
|
||||
}
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-alert type="warning" :show-icon="false" :bordered="false">
|
||||
<span>{{ t('tip') }}</span>
|
||||
</n-alert>
|
||||
<n-tabs type="segment" v-model:value="tabValue">
|
||||
<n-tab-pane name="address" :tab="t('address')">
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="create_or_bind" :tab="t('create_or_bind')">
|
||||
<Login :bindUserAddress="bindAddress" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
237
frontend/src/views/index/SendMail.vue
Normal file
237
frontend/src/views/index/SendMail.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
|
||||
|
||||
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
successSend: 'Please check your sendbox. If failed, please check your balance or try again later.',
|
||||
fromName: 'Your Name and Address, leave Name blank to use email address',
|
||||
toName: 'Recipient Name and Address, leave Name blank to use email address',
|
||||
subject: 'Subject',
|
||||
options: 'Options',
|
||||
edit: 'Edit',
|
||||
preview: 'Preview',
|
||||
content: 'Content',
|
||||
send: 'Send',
|
||||
requestAccess: 'Request Access',
|
||||
requestAccessTip: 'You need to request access to send mail, if have request, please contact admin.',
|
||||
send_balance: 'Send Mail Balance Left',
|
||||
text: 'Text',
|
||||
html: 'HTML',
|
||||
'rich text': 'Rich Text',
|
||||
tooLarge: 'Too large file, please upload file less than 1MB.',
|
||||
},
|
||||
zh: {
|
||||
successSend: '请查看您的发件箱, 如果失败, 请检查您的余额或稍后重试。',
|
||||
fromName: '你的名称和地址,名称不填写则使用邮箱地址',
|
||||
toName: '收件人名称和地址,名称不填写则使用邮箱地址',
|
||||
subject: '主题',
|
||||
options: '选项',
|
||||
edit: '编辑',
|
||||
preview: '预览',
|
||||
content: '内容',
|
||||
send: '发送',
|
||||
requestAccess: '申请权限',
|
||||
requestAccessTip: '您需要申请权限才能发送邮件, 如果已经申请过, 请联系管理员提升额度。',
|
||||
send_balance: '剩余发送邮件额度',
|
||||
text: '文本',
|
||||
html: 'HTML',
|
||||
'rich text': '富文本',
|
||||
tooLarge: '文件过大, 请上传小于1MB的文件。',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const contentTypes = [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
]
|
||||
|
||||
const send = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/send_mail`,
|
||||
{
|
||||
method: 'POST',
|
||||
body:
|
||||
JSON.stringify({
|
||||
from_name: sendMailModel.value.fromName,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: sendMailModel.value.toMail,
|
||||
subject: sendMailModel.value.subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content: sendMailModel.value.content,
|
||||
})
|
||||
})
|
||||
sendMailModel.value = {
|
||||
fromName: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
subject: "",
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
message.success(t("successSend"));
|
||||
indexTab.value = 'sendbox'
|
||||
}
|
||||
}
|
||||
|
||||
const requestAccess = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/requset_send_mail_access`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
}
|
||||
)
|
||||
message.success(t("success"))
|
||||
await api.getSettings();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const toolbarConfig = {
|
||||
excludeKeys: ["uploadVideo"]
|
||||
}
|
||||
|
||||
const editorConfig = {
|
||||
MENU_CONF: {
|
||||
'uploadImage': {
|
||||
async customUpload() {
|
||||
message.error(t('tooLarge'))
|
||||
},
|
||||
maxFileSize: 1 * 1024 * 1024,
|
||||
base64LimitSize: 1 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
await api.getSettings();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card :bordered="false" embedded>
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<n-alert type="warning" :show-icon="false" :bordered="false">
|
||||
{{ t('requestAccessTip') }}
|
||||
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
|
||||
}}</n-button>
|
||||
</n-alert>
|
||||
<AdminContact />
|
||||
</div>
|
||||
<div v-else>
|
||||
<n-alert type="info" :show-icon="false" :bordered="false" closable>
|
||||
{{ t('send_balance') }}: {{ settings.send_balance }}
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.fromName" />
|
||||
<n-input :value="settings.address" disabled />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card :bordered="false" embedded v-if="isPreview">
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
text-align: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.left {
|
||||
text-align: left;
|
||||
place-items: left;
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.n-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
283
frontend/src/views/index/SimpleIndex.vue
Normal file
283
frontend/src/views/index/SimpleIndex.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import {
|
||||
ExitToAppFilled,
|
||||
ContentCopyFilled,
|
||||
RefreshFilled,
|
||||
ArrowBackIosNewFilled,
|
||||
ArrowForwardIosFilled,
|
||||
SettingsFilled
|
||||
} from '@vicons/material'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import Login from '../common/Login.vue'
|
||||
import AccountSettings from './AccountSettings.vue'
|
||||
import { processItem } from '../../utils/email-parser'
|
||||
import MailContentRenderer from '../../components/MailContentRenderer.vue'
|
||||
|
||||
const { jwt, settings, useSimpleIndex, showAddressCredential, openSettings, loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
// 邮件数据
|
||||
const currentPage = ref(1)
|
||||
const totalCount = ref(0)
|
||||
const currentMail = ref(null)
|
||||
const showAccountSettingsCard = ref(false)
|
||||
const currentAutoRefreshInterval = ref(60)
|
||||
const timer = ref(null)
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
exitSimpleIndex: 'Exit Simple',
|
||||
copyAddress: 'Copy',
|
||||
addressCopied: 'Address copied successfully',
|
||||
refreshMails: 'Refresh',
|
||||
noMails: 'No mails found',
|
||||
prevPage: 'Previous',
|
||||
nextPage: 'Next',
|
||||
refreshSuccess: 'Mails refreshed successfully',
|
||||
mailCount: '{current} / {total} emails',
|
||||
accountSettings: "Account Settings",
|
||||
addressCredential: 'Mail Address Credential',
|
||||
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login',
|
||||
deleteSuccess: 'Mail deleted successfully',
|
||||
refreshAfter: 'Refresh After {msg} Seconds',
|
||||
},
|
||||
zh: {
|
||||
exitSimpleIndex: '退出极简',
|
||||
copyAddress: '复制',
|
||||
addressCopied: '地址复制成功',
|
||||
refreshMails: '刷新',
|
||||
noMails: '暂无邮件',
|
||||
prevPage: '上一页',
|
||||
nextPage: '下一页',
|
||||
refreshSuccess: '邮件刷新成功',
|
||||
mailCount: '{current} / {total} 封邮件',
|
||||
accountSettings: "账户设置",
|
||||
addressCredential: '邮箱地址凭证',
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
deleteSuccess: '邮件删除成功',
|
||||
refreshAfter: '{msg}秒后刷新',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 复制地址
|
||||
const copyAddress = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(settings.value.address)
|
||||
message.success(t('addressCopied'))
|
||||
} catch (error) {
|
||||
message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取邮件数据
|
||||
const fetchMails = async () => {
|
||||
if (!settings.value.address) return
|
||||
try {
|
||||
const { results, count } = await api.fetch(`/api/mails?limit=1&offset=${currentPage.value - 1}`)
|
||||
totalCount.value = count > 0 ? count : totalCount.value;
|
||||
const rawMail = results && results.length > 0 ? results[0] : null
|
||||
currentMail.value = rawMail ? await processItem(rawMail) : null
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch mails:', error)
|
||||
message.error('获取邮件失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除邮件
|
||||
const deleteMail = async () => {
|
||||
if (!currentMail.value) return;
|
||||
try {
|
||||
await api.fetch(`/api/mails/${currentMail.value.id}`, { method: 'DELETE' });
|
||||
message.success(t('deleteSuccess'));
|
||||
currentMail.value = null;
|
||||
await refreshMails();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete mail:', error);
|
||||
message.error('删除邮件失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新邮件
|
||||
const refreshMails = async () => {
|
||||
if (loading.value) return
|
||||
currentPage.value = 1
|
||||
showAccountSettingsCard.value = false
|
||||
currentAutoRefreshInterval.value = 60
|
||||
await fetchMails()
|
||||
message.success(t('refreshSuccess'))
|
||||
}
|
||||
|
||||
// 分页控制
|
||||
const currentPageDisplay = computed(() => currentPage.value)
|
||||
const totalPages = computed(() => Math.max(1, totalCount.value))
|
||||
const canGoPrev = computed(() => currentPage.value > 1)
|
||||
const canGoNext = computed(() => currentPage.value < totalPages.value)
|
||||
const isFirstPage = computed(() => currentPage.value === 1)
|
||||
|
||||
const prevPage = async () => {
|
||||
if (canGoPrev.value) {
|
||||
currentPage.value--
|
||||
}
|
||||
}
|
||||
|
||||
const nextPage = async () => {
|
||||
if (canGoNext.value) {
|
||||
currentPage.value++
|
||||
}
|
||||
}
|
||||
|
||||
// 监听页面变化
|
||||
watch(currentPage, () => {
|
||||
fetchMails()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getSettings()
|
||||
await fetchMails()
|
||||
|
||||
// 启动自动刷新
|
||||
timer.value = setInterval(async () => {
|
||||
if (!isFirstPage.value) {
|
||||
currentAutoRefreshInterval.value = 60
|
||||
return
|
||||
}
|
||||
|
||||
if (--currentAutoRefreshInterval.value <= 0) {
|
||||
await refreshMails()
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<div v-if="!settings.address">
|
||||
<n-card :bordered="false" embedded>
|
||||
<Login />
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<n-card :bordered="false" embedded>
|
||||
<div style="text-align: center; margin-bottom: 16px; font-size: 18px;">
|
||||
<n-text strong size="large">{{ settings.address }}</n-text>
|
||||
</div>
|
||||
<n-flex justify="center">
|
||||
<n-button @click="refreshMails" :loading="loading" type="primary" tertiary size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<RefreshFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('refreshMails') }}
|
||||
</n-button>
|
||||
<n-button @click="copyAddress" tertiary size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ContentCopyFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('copyAddress') }}
|
||||
</n-button>
|
||||
<n-button @click="useSimpleIndex = false" tertiary size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ExitToAppFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('exitSimpleIndex') }}
|
||||
</n-button>
|
||||
<n-button @click="showAccountSettingsCard = true" tertiary size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<SettingsFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('accountSettings') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<div v-if="isFirstPage" style="text-align: center; margin-top: 12px;">
|
||||
<n-text depth="3" size="12">
|
||||
{{ t('refreshAfter', { msg: Math.max(0, currentAutoRefreshInterval) }) }}
|
||||
</n-text>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 账户设置卡片 -->
|
||||
<n-card v-if="showAccountSettingsCard" :bordered="false" embedded closable
|
||||
@close="showAccountSettingsCard = false" :title="t('accountSettings')">
|
||||
<AccountSettings />
|
||||
</n-card>
|
||||
|
||||
<n-card v-else :bordered="false" embedded style="text-align: left;">
|
||||
|
||||
<div v-if="totalCount > 1">
|
||||
<n-flex justify="space-between">
|
||||
<n-button @click="prevPage" :disabled="!canGoPrev" text size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackIosNewFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('prevPage') }}
|
||||
</n-button>
|
||||
<n-text size="small">
|
||||
{{ t('mailCount', { current: currentPageDisplay, total: totalCount }) }}
|
||||
</n-text>
|
||||
<n-button @click="nextPage" :disabled="!canGoNext" text size="small" icon-placement="right">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowForwardIosFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('nextPage') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
|
||||
<div v-if="!currentMail" class="no-mail">
|
||||
<n-empty :description="t('noMails')" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<h3 v-if="currentMail.subject">{{ currentMail.subject }}</h3>
|
||||
<div style="margin-top: 16px;">
|
||||
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
|
||||
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
|
||||
:onDelete="deleteMail" />
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
|
||||
<span>
|
||||
<p>{{ t("addressCredentialTip") }}</p>
|
||||
</span>
|
||||
<n-card embedded>
|
||||
<b>{{ jwt }}</b>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.n-card {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
158
frontend/src/views/index/TelegramAddress.vue
Normal file
158
frontend/src/views/index/TelegramAddress.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, h, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NPopconfirm, NButton } from 'naive-ui'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
// @ts-ignore
|
||||
import Login from '../common/Login.vue';
|
||||
|
||||
const { jwt, telegramApp } = useGlobalState()
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
success: 'success',
|
||||
address: 'Address',
|
||||
actions: 'Actions',
|
||||
changeMailAddress: 'Change Mail Address',
|
||||
unbindMailAddress: 'Unbind Mail Address',
|
||||
bind: 'Bind',
|
||||
bindAddressSuccess: 'Bind Address Success',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
address: '地址',
|
||||
actions: '操作',
|
||||
changeMailAddress: '切换邮箱地址',
|
||||
unbindMailAddress: '解绑邮箱地址',
|
||||
bind: '绑定',
|
||||
bindAddressSuccess: '绑定地址成功',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const data = ref([]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
data.value = await api.fetch(`/telegram/get_bind_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const newAddressPath = async (address_name: string, domain: string, cf_token: string) => {
|
||||
return await api.fetch("/telegram/new_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData,
|
||||
address: `${address_name}@${domain}`,
|
||||
cf_token: cf_token,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const bindAddress = async () => {
|
||||
try {
|
||||
await api.fetch(`/telegram/bind_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData,
|
||||
jwt: jwt.value
|
||||
})
|
||||
});
|
||||
message.success(t('bindAddressSuccess'));
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('address'),
|
||||
key: "address"
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render(row: any) {
|
||||
return h('div', [
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => {
|
||||
jwt.value = row.jwt
|
||||
location.reload()
|
||||
}
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
},
|
||||
{ default: () => t('changeMailAddress') }
|
||||
),
|
||||
default: () => `${t('changeMailAddress')}?`
|
||||
}
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => {
|
||||
api.fetch(`/telegram/unbind_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData,
|
||||
address: row.address
|
||||
})
|
||||
});
|
||||
jwt.value = ""
|
||||
location.reload()
|
||||
}
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "warning",
|
||||
},
|
||||
{ default: () => t('unbindMailAddress') }
|
||||
),
|
||||
default: () => `${t('unbindMailAddress')}?`
|
||||
}
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
if (!telegramApp.value?.initData || data.value.length > 0) {
|
||||
return
|
||||
}
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-tabs type="segment">
|
||||
<n-tab-pane name="address" :tab="t('address')">
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="bind" :tab="t('bind')">
|
||||
<Login :newAddressPath="newAddressPath" :bindUserAddress="bindAddress" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
28
frontend/src/views/index/Webhook.vue
Normal file
28
frontend/src/views/index/Webhook.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
// @ts-ignore
|
||||
import WebhookComponent from '../../components/WebhookComponent.vue'
|
||||
|
||||
const fetchData = async () => {
|
||||
return await api.fetch(`/api/webhook/settings`)
|
||||
}
|
||||
|
||||
const saveSettings = async (webhookSettings: any) => {
|
||||
await api.fetch(`/api/webhook/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings),
|
||||
})
|
||||
}
|
||||
|
||||
const testSettings = async (webhookSettings: any) => {
|
||||
await api.fetch(`/api/webhook/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WebhookComponent :fetchData="fetchData" :saveSettings="saveSettings" :testSettings="testSettings" />
|
||||
</template>
|
||||
77
frontend/src/views/telegram/Mail.vue
Normal file
77
frontend/src/views/telegram/Mail.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { onMounted, watch } from 'vue';
|
||||
import { processItem } from '../../utils/email-parser'
|
||||
import { utcToLocalDate } from '../../utils';
|
||||
|
||||
const { telegramApp, loading, useUTCDate } = useGlobalState()
|
||||
const route = useRoute()
|
||||
|
||||
const curMail = ref({});
|
||||
|
||||
watch(telegramApp, async () => {
|
||||
if (telegramApp.value.initData) {
|
||||
curMail.value = await fetchMailData();
|
||||
}
|
||||
});
|
||||
|
||||
const fetchMailData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/telegram/get_mail`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData,
|
||||
mailId: route.query.mail_id
|
||||
})
|
||||
});
|
||||
loading.value = true;
|
||||
return await processItem(res);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
curMail.value = await fetchMailData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded v-if="curMail.message" style="max-width: 800px; height: 100%;">
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
Date: {{ utcToLocalDate(curMail.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<iframe :srcdoc="curMail.message" style="margin-top: 10px;width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
height: 80vh;
|
||||
}
|
||||
</style>
|
||||
247
frontend/src/views/user/AddressManagement.vue
Normal file
247
frontend/src/views/user/AddressManagement.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router';
|
||||
import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
|
||||
import Login from '../common/Login.vue';
|
||||
|
||||
const { jwt } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
success: 'success',
|
||||
name: 'Name',
|
||||
mail_count: 'Mail Count',
|
||||
send_count: 'Send Count',
|
||||
actions: 'Actions',
|
||||
changeMailAddress: 'Change Address',
|
||||
unbindAddress: 'Unbind Address',
|
||||
unbindAddressTip: 'Before unbinding, please switch to this email address and save the email address credential.',
|
||||
transferAddress: 'Transfer Address',
|
||||
targetUserEmail: 'Target User Email',
|
||||
transferAddressTip: 'Transfer address to another user will remove the address from your account and transfer it to another user. Are you sure to transfer the address?',
|
||||
address: 'Address',
|
||||
create_or_bind: 'Create or Bind',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
name: '名称',
|
||||
mail_count: '邮件数量',
|
||||
send_count: '发送数量',
|
||||
actions: '操作',
|
||||
changeMailAddress: '切换地址',
|
||||
unbindAddress: '解绑地址',
|
||||
unbindAddressTip: '解绑前请切换到此邮箱地址并保存邮箱地址凭证。',
|
||||
transferAddress: '转移地址',
|
||||
targetUserEmail: '目标用户邮箱',
|
||||
transferAddressTip: '转移地址到其他用户将会从你的账户中移除此地址并转移给其他用户。确定要转移地址吗?',
|
||||
address: '地址',
|
||||
create_or_bind: '创建或绑定',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const data = ref([])
|
||||
const showTranferAddress = ref(false)
|
||||
const currentAddress = ref("")
|
||||
const currentAddressId = ref(0)
|
||||
const targetUserEmail = ref('')
|
||||
|
||||
const changeMailAddress = async (address_id) => {
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/bind_address_jwt/${address_id}`);
|
||||
message.success(t('changeMailAddress') + " " + t('success'));
|
||||
if (!res.jwt) {
|
||||
message.error("jwt not found");
|
||||
return;
|
||||
}
|
||||
jwt.value = res.jwt;
|
||||
await router.push(getRouterPathWithLang("/", locale.value))
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const unbindAddress = async (address_id) => {
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/unbind_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ address_id })
|
||||
});
|
||||
message.success(t('unbindAddress') + " " + t('success'));
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const transferAddress = async () => {
|
||||
if (!targetUserEmail.value) {
|
||||
message.error("targetUserEmail is required");
|
||||
return;
|
||||
}
|
||||
if (!currentAddressId.value) {
|
||||
message.error("currentAddressId is required");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/transfer_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
address_id: currentAddressId.value,
|
||||
target_user_email: targetUserEmail.value
|
||||
})
|
||||
});
|
||||
message.success(t('transferAddress') + " " + t('success'));
|
||||
await fetchData();
|
||||
showTranferAddress.value = false;
|
||||
currentAddressId.value = 0;
|
||||
currentAddress.value = "";
|
||||
targetUserEmail.value = "";
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const { results } = await api.fetch(
|
||||
`/user_api/bind_address`
|
||||
);
|
||||
data.value = results;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('name'),
|
||||
key: "name"
|
||||
},
|
||||
{
|
||||
title: t('mail_count'),
|
||||
key: "mail_count",
|
||||
render(row) {
|
||||
return h(NBadge, {
|
||||
value: row.mail_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('send_count'),
|
||||
key: "send_count",
|
||||
render(row) {
|
||||
return h(NBadge, {
|
||||
value: row.send_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => changeMailAddress(row.id)
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
},
|
||||
{ default: () => t('changeMailAddress') }
|
||||
),
|
||||
default: () => `${t('changeMailAddress')}?`
|
||||
}
|
||||
),
|
||||
h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
onClick: () => {
|
||||
currentAddressId.value = row.id;
|
||||
currentAddress.value = row.name;
|
||||
showTranferAddress.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('transferAddress') }
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => unbindAddress(row.id)
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "error",
|
||||
},
|
||||
{ default: () => t('unbindAddress') }
|
||||
),
|
||||
default: () => t('unbindAddressTip')
|
||||
}
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
||||
<span>
|
||||
<p>{{ t("transferAddressTip") }}</p>
|
||||
<p>{{ t('transferAddress') + ": " + currentAddress }}</p>
|
||||
<n-input v-model:value="targetUserEmail" :placeholder="t('targetUserEmail')" />
|
||||
</span>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="transferAddress" size="small" tertiary type="error">
|
||||
{{ t('transferAddress') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-tabs type="segment">
|
||||
<n-tab-pane name="address" :tab="t('address')">
|
||||
<div style="overflow: auto;">
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="create_or_bind" :tab="t('create_or_bind')">
|
||||
<Login />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-data-table {
|
||||
min-width: 700px;
|
||||
}
|
||||
</style>
|
||||
45
frontend/src/views/user/BindAddress.vue
Normal file
45
frontend/src/views/user/BindAddress.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import Login from '../common/Login.vue'
|
||||
|
||||
const { userJwt, userSettings, } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
logout: 'Logout',
|
||||
},
|
||||
zh: {
|
||||
logout: '退出登录',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="userSettings.user_email">
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<Login />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
76
frontend/src/views/user/UserBar.vue
Normal file
76
frontend/src/views/user/UserBar.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import UserLogin from './UserLogin.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
userSettings, userJwt, userOpenSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
currentUser: 'Current Login User',
|
||||
fetchUserSettingsError: 'Login password is invalid or account not exist, it may be network connection issue, please try again later.',
|
||||
},
|
||||
zh: {
|
||||
currentUser: '当前登录用户',
|
||||
fetchUserSettingsError: '登录信息已过期或账号不存在,也可能是网络连接异常,请稍后再尝试。',
|
||||
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getUserOpenSettings(message);
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-card :bordered="false" embedded v-if="!userSettings.fetched">
|
||||
<n-skeleton style="height: 50vh" />
|
||||
</n-card>
|
||||
<div v-else-if="userSettings.user_email">
|
||||
<n-alert type="success" :show-icon="false" :bordered="false">
|
||||
<span>
|
||||
<b>{{ t('currentUser') }} <b>{{ userSettings.user_email }}</b></b>
|
||||
</span>
|
||||
</n-alert>
|
||||
</div>
|
||||
<div v-else class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<n-alert v-if="userJwt" type="warning" :show-icon="false" :bordered="false" closable>
|
||||
<span>{{ t('fetchUserSettingsError') }}</span>
|
||||
</n-alert>
|
||||
<UserLogin />
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-alert {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
308
frontend/src/views/user/UserLogin.vue
Normal file
308
frontend/src/views/user/UserLogin.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<script setup>
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { KeyFilled } from '@vicons/material'
|
||||
|
||||
import { api } from '../../api';
|
||||
import { useGlobalState } from '../../store'
|
||||
import { hashPassword } from '../../utils';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
|
||||
import Turnstile from '../../components/Turnstile.vue';
|
||||
|
||||
const {
|
||||
userJwt, userOpenSettings, openSettings,
|
||||
userOauth2SessionState, userOauth2SessionClientID
|
||||
} = useGlobalState()
|
||||
const message = useMessage();
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
verifyCode: 'Verification Code',
|
||||
verifyCodeSent: 'Verification Code Sent, expires in {timeout} seconds',
|
||||
waitforVerifyCode: 'Wait for {timeout} seconds',
|
||||
sendVerificationCode: 'Send Verification Code',
|
||||
forgotPassword: 'Forgot Password',
|
||||
cannotForgotPassword: 'Mail verification is disabled or register is disabled, cannot reset password, please contact administrator',
|
||||
resetPassword: 'Reset Password',
|
||||
pleaseInput: 'Please input email and password',
|
||||
pleaseInputEmail: 'Please input email',
|
||||
pleaseInputCode: 'Please input code',
|
||||
pleaseCompleteTurnstile: 'Please complete turnstile',
|
||||
pleaseLogin: 'Please login',
|
||||
loginWithPasskey: 'Login with Passkey',
|
||||
loginWith: 'Login with {provider}',
|
||||
},
|
||||
zh: {
|
||||
login: '登录',
|
||||
register: '注册',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
verifyCode: '验证码',
|
||||
sendVerificationCode: '发送验证码',
|
||||
verifyCodeSent: '验证码已发送, {timeout} 秒后失效',
|
||||
waitforVerifyCode: '等待{timeout}秒',
|
||||
forgotPassword: '忘记密码',
|
||||
cannotForgotPassword: '未开启邮箱验证或未开启注册功能,无法重置密码,请联系管理员',
|
||||
resetPassword: '重置密码',
|
||||
pleaseInput: '请输入邮箱和密码',
|
||||
pleaseInputEmail: '请输入邮箱',
|
||||
pleaseInputCode: '请输入验证码',
|
||||
pleaseCompleteTurnstile: '请完成人机验证',
|
||||
pleaseLogin: '请登录',
|
||||
loginWithPasskey: '使用 Passkey 登录',
|
||||
loginWith: '使用 {provider} 登录',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const tabValue = ref("signin");
|
||||
const showModal = ref(false);
|
||||
const user = ref({
|
||||
email: "",
|
||||
password: "",
|
||||
code: ""
|
||||
});
|
||||
const cfToken = ref("")
|
||||
|
||||
const emailLogin = async () => {
|
||||
if (!user.value.email || !user.value.password) {
|
||||
message.error(t('pleaseInput'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/login`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
// hash password
|
||||
password: await hashPassword(user.value.password)
|
||||
})
|
||||
});
|
||||
userJwt.value = res.jwt;
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
message.error(error.message || "login failed");
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCodeExpire = ref(0);
|
||||
const verifyCodeTimeout = ref(0);
|
||||
|
||||
const getVerifyCodeTimeout = () => {
|
||||
if (!verifyCodeExpire.value || verifyCodeExpire.value < new Date().getTime()) return 0;
|
||||
return Math.round((verifyCodeExpire.value - new Date().getTime()) / 1000);
|
||||
};
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (!user.value.email) {
|
||||
message.error(t('pleaseInputEmail'));
|
||||
return;
|
||||
}
|
||||
if (openSettings.value.cfTurnstileSiteKey && !cfToken.value && userOpenSettings.value.enableMailVerify) {
|
||||
message.error(t('pleaseCompleteTurnstile'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/verify_code`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
cf_token: cfToken.value
|
||||
})
|
||||
});
|
||||
if (res && res.expirationTtl) {
|
||||
message.success(t('verifyCodeSent', { timeout: res.expirationTtl }));
|
||||
verifyCodeExpire.value = new Date().getTime() + res.expirationTtl * 1000;
|
||||
const intervalId = setInterval(() => {
|
||||
verifyCodeTimeout.value = getVerifyCodeTimeout();
|
||||
if (verifyCodeTimeout.value <= 0) {
|
||||
clearInterval(intervalId);
|
||||
verifyCodeTimeout.value = 0;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "send verification code failed");
|
||||
}
|
||||
};
|
||||
|
||||
const emailSignup = async () => {
|
||||
if (!user.value.email || !user.value.password) {
|
||||
message.error(t('pleaseInput'));
|
||||
return;
|
||||
}
|
||||
if (!user.value.code && userOpenSettings.value.enableMailVerify) {
|
||||
message.error(t('pleaseInputCode'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/register`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
// hash password
|
||||
password: await hashPassword(user.value.password),
|
||||
code: user.value.code
|
||||
}),
|
||||
message: message
|
||||
});
|
||||
if (res) {
|
||||
tabValue.value = "signin";
|
||||
message.success(t('pleaseLogin'));
|
||||
}
|
||||
showModal.value = false;
|
||||
} catch (error) {
|
||||
message.error(error.message || "register failed");
|
||||
}
|
||||
};
|
||||
|
||||
const passkeyLogin = async () => {
|
||||
try {
|
||||
const options = await api.fetch(`/user_api/passkey/authenticate_request`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain: location.hostname,
|
||||
})
|
||||
})
|
||||
const credential = await startAuthentication(options)
|
||||
|
||||
// Send the result to the server and return the promise.
|
||||
const res = await api.fetch(`/user_api/passkey/authenticate_response`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
origin: location.origin,
|
||||
domain: location.hostname,
|
||||
credential
|
||||
})
|
||||
})
|
||||
userJwt.value = res.jwt;
|
||||
location.reload();
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
message.error(e.message)
|
||||
}
|
||||
};
|
||||
|
||||
const oauth2Login = async (clientID) => {
|
||||
try {
|
||||
userOauth2SessionClientID.value = clientID;
|
||||
userOauth2SessionState.value = Math.random().toString(36).substring(2);
|
||||
const res = await api.fetch(`/user_api/oauth2/login_url?clientID=${clientID}&state=${userOauth2SessionState.value}`);
|
||||
// redirect to oauth2 login page
|
||||
location.href = res.url;
|
||||
} catch (error) {
|
||||
message.error(error.message || "login failed");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-tabs v-model:value="tabValue" size="large" v-if="userOpenSettings.fetched" justify-content="space-evenly">
|
||||
<n-tab-pane name="signin" :tab="t('login')">
|
||||
<n-form>
|
||||
<n-form-item-row :label="t('email')" required>
|
||||
<n-input v-model:value="user.email" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<n-button @click="emailLogin" type="primary" block secondary strong>
|
||||
{{ t('login') }}
|
||||
</n-button>
|
||||
<n-button @click="showModal = true" type="info" quaternary size="tiny">
|
||||
{{ t('forgotPassword') }}
|
||||
</n-button>
|
||||
<n-divider />
|
||||
<n-button @click="passkeyLogin" type="primary" block secondary strong>
|
||||
<template #icon>
|
||||
<n-icon :component="KeyFilled" />
|
||||
</template>
|
||||
{{ t('loginWithPasskey') }}
|
||||
</n-button>
|
||||
<n-button @click="oauth2Login(item.clientID)" v-for="item in userOpenSettings.oauth2ClientIDs"
|
||||
:key="item.clientID" block secondary strong>
|
||||
{{ t('loginWith', { provider: item.name }) }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="userOpenSettings.enable" name="signup" :tab="t('register')">
|
||||
<n-form>
|
||||
<n-form-item-row :label="t('email')" required>
|
||||
<n-input v-model:value="user.email" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile v-if="userOpenSettings.enableMailVerify" v-model:value="cfToken" />
|
||||
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="user.code" />
|
||||
<n-button @click="sendVerificationCode" style="margin-bottom: 0" type="primary" ghost
|
||||
:disabled="verifyCodeTimeout > 0">
|
||||
{{ verifyCodeTimeout > 0 ? t('waitforVerifyCode', { timeout: verifyCodeTimeout })
|
||||
: t('sendVerificationCode') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
<n-button @click="emailSignup" type="primary" block secondary strong>
|
||||
{{ t('register') }}
|
||||
</n-button>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<n-modal v-model:show="showModal" style="max-width: 600px;" preset="card" :title="t('forgotPassword')">
|
||||
<n-form v-if="userOpenSettings.enable && userOpenSettings.enableMailVerify">
|
||||
<n-form-item-row :label="t('email')" required>
|
||||
<n-input v-model:value="user.email" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<n-form-item-row :label="t('verifyCode')" required>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="user.code" />
|
||||
<n-button @click="sendVerificationCode" style="margin-bottom: 0" type="primary" ghost
|
||||
:disabled="verifyCodeTimeout > 0">
|
||||
{{ verifyCodeTimeout > 0 ? t('waitforVerifyCode', { timeout: verifyCodeTimeout })
|
||||
: t('sendVerificationCode') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-button @click="emailSignup" type="primary" block secondary strong>
|
||||
{{ t('resetPassword') }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
<n-alert v-else :show-icon="false" :bordered="false">
|
||||
<span>
|
||||
{{ t('cannotForgotPassword') }}
|
||||
</span>
|
||||
</n-alert>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
89
frontend/src/views/user/UserMailBox.vue
Normal file
89
frontend/src/views/user/UserMailBox.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { api } from '../../api'
|
||||
import MailBox from '../../components/MailBox.vue';
|
||||
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
addressQueryTip: 'Leave blank to query all addresses',
|
||||
keywordQueryTip: 'Leave blank to not query by keyword',
|
||||
query: 'Query',
|
||||
},
|
||||
zh: {
|
||||
addressQueryTip: '留空查询所有地址',
|
||||
keywordQueryTip: '留空不按关键字查询',
|
||||
query: '查询',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const mailBoxKey = ref("")
|
||||
const addressFilter = ref();
|
||||
const mailKeyword = ref("")
|
||||
const addressFilterOptions = ref([]);
|
||||
|
||||
const queryMail = () => {
|
||||
addressFilter.value = addressFilter.value ? addressFilter.value.trim() : addressFilter.value;
|
||||
mailKeyword.value = mailKeyword.value.trim();
|
||||
mailBoxKey.value = Date.now();
|
||||
}
|
||||
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
return await api.fetch(
|
||||
`/user_api/mails`
|
||||
+ `?limit=${limit}`
|
||||
+ `&offset=${offset}`
|
||||
+ (addressFilter.value ? `&address=${addressFilter.value}` : '')
|
||||
+ (mailKeyword.value ? `&keyword=${mailKeyword.value}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
const fetchAddresData = async () => {
|
||||
try {
|
||||
const { results } = await api.fetch(
|
||||
`/user_api/bind_address`
|
||||
);
|
||||
addressFilterOptions.value = results.map((item) => {
|
||||
return {
|
||||
label: item.name,
|
||||
value: item.name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/user_api/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
watch(addressFilter, async (newValue) => {
|
||||
queryMail();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchAddresData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="margin-top: 10px;">
|
||||
<n-input-group>
|
||||
<n-select v-model:value="addressFilter" :options="addressFilterOptions" clearable
|
||||
:placeholder="t('addressQueryTip')" />
|
||||
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" @keydown.enter="queryMail" />
|
||||
<n-button @click="queryMail" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div style="margin-top: 10px;"></div>
|
||||
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="true" :fetchMailData="fetchMailData"
|
||||
:deleteMail="deleteMail" />
|
||||
</div>
|
||||
</template>
|
||||
65
frontend/src/views/user/UserOauth2Callback.vue
Normal file
65
frontend/src/views/user/UserOauth2Callback.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api';
|
||||
|
||||
const {
|
||||
userJwt, userOauth2SessionState, userOauth2SessionClientID
|
||||
} = useGlobalState()
|
||||
|
||||
const message = useMessage();
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const errorInfo = ref('')
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
logging: 'Logging in...',
|
||||
stateNotMatch: 'state not match',
|
||||
},
|
||||
zh: {
|
||||
logging: '登录中...',
|
||||
stateNotMatch: 'state 不匹配',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const state = route.query.state;
|
||||
if (state != userOauth2SessionState.value) {
|
||||
console.error('state not match');
|
||||
message.error(t('stateNotMatch'));
|
||||
return;
|
||||
}
|
||||
const code = route.query.code;
|
||||
if (!code) {
|
||||
console.error('code not found');
|
||||
message.error('code not found');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/oauth2/callback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: code,
|
||||
clientID: userOauth2SessionClientID.value
|
||||
})
|
||||
});
|
||||
userJwt.value = res.jwt;
|
||||
router.push('/user');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-result status="info" :title="t('logging')" :description="errorInfo">
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
259
frontend/src/views/user/UserSettings.vue
Normal file
259
frontend/src/views/user/UserSettings.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { NButton, NPopconfirm } from 'naive-ui'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { userJwt, userSettings, } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const showLogout = ref(false)
|
||||
const showCreatePasskey = ref(false)
|
||||
const passkeyName = ref('')
|
||||
const showPasskeyList = ref(false)
|
||||
const showRenamePasskey = ref(false)
|
||||
const currentPasskeyId = ref(null)
|
||||
const currentPasskeyName = ref('')
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
logout: 'Logout',
|
||||
logoutConfirm: 'Are you sure you want to logout?',
|
||||
passordTip: 'The server will only receive the hash value of the password, and will not receive the plaintext password, so it cannot view or retrieve your password. If the administrator enables email verification, you can reset the password in incognito mode',
|
||||
createPasskey: 'Create Passkey',
|
||||
showPasskeyList: 'Show Passkey List',
|
||||
passkeyCreated: 'Passkey created successfully',
|
||||
passkeyNamePlaceholder: 'Please enter the passkey name or leave it empty to generate a random one',
|
||||
renamePasskey: 'Rename Passkey',
|
||||
deletePasskey: 'Delete Passkey',
|
||||
passkey_name: 'Passkey Name',
|
||||
created_at: 'Created At',
|
||||
updated_at: 'Updated At',
|
||||
actions: 'Actions',
|
||||
renamePasskey: 'Rename Passkey',
|
||||
renamePasskeyNamePlaceholder: 'Please enter the new passkey name',
|
||||
},
|
||||
zh: {
|
||||
logout: '退出登录',
|
||||
logoutConfirm: '确定要退出登录吗?',
|
||||
passordTip: '服务器只会接收到密码的哈希值,不会接收到明文密码,因此无法查看或者找回您的密码, 如果管理员启用了邮件验证您可以在无痕模式重置密码',
|
||||
createPasskey: '创建 Passkey',
|
||||
showPasskeyList: '查看 Passkey 列表',
|
||||
passkeyCreated: 'Passkey 创建成功',
|
||||
passkeyNamePlaceholder: '请输入 Passkey 名称或者留空自动生成',
|
||||
renamePasskey: '重命名 Passkey',
|
||||
deletePasskey: '删除 Passkey',
|
||||
passkey_name: 'Passkey 名称',
|
||||
created_at: '创建时间',
|
||||
updated_at: '更新时间',
|
||||
actions: '操作',
|
||||
renamePasskey: '重命名 Passkey',
|
||||
renamePasskeyNamePlaceholder: '请输入新的 Passkey 名称',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const logout = async () => {
|
||||
userJwt.value = '';
|
||||
location.reload()
|
||||
}
|
||||
|
||||
const createPasskey = async () => {
|
||||
try {
|
||||
const options = await api.fetch(`/user_api/passkey/register_request`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain: location.hostname,
|
||||
})
|
||||
})
|
||||
const credential = await startRegistration(options)
|
||||
|
||||
// Send the result to the server and return the promise.
|
||||
await api.fetch(`/user_api/passkey/register_response`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
origin: location.origin,
|
||||
passkey_name: passkeyName.value || (
|
||||
(window.navigator.userAgentData?.platform || "Unknown")
|
||||
+ ": " + Math.random().toString(36).substring(7)
|
||||
),
|
||||
credential
|
||||
})
|
||||
})
|
||||
message.success(t('passkeyCreated'));
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
message.error(e.message)
|
||||
} finally {
|
||||
passkeyName.value = ''
|
||||
showCreatePasskey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const passkeyColumns = [
|
||||
{
|
||||
title: "Passkey ID",
|
||||
key: "passkey_id"
|
||||
},
|
||||
{
|
||||
title: t('passkey_name'),
|
||||
key: "passkey_name"
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
},
|
||||
{
|
||||
title: t('updated_at'),
|
||||
key: "updated_at"
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
[
|
||||
h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
onClick: () => {
|
||||
showRenamePasskey.value = true;
|
||||
currentPasskeyId.value = row.passkey_id;
|
||||
}
|
||||
},
|
||||
{ default: () => t('renamePasskey') }
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await api.fetch(`/user_api/passkey/${row.passkey_id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await fetchPasskeyList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
message.error(e.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "error",
|
||||
},
|
||||
{ default: () => t('deletePasskey') }
|
||||
),
|
||||
default: () => `${t('deletePasskey')}?`
|
||||
}
|
||||
),
|
||||
]
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const passkeyData = ref([])
|
||||
|
||||
const fetchPasskeyList = async () => {
|
||||
try {
|
||||
const data = await api.fetch(`/user_api/passkey`)
|
||||
passkeyData.value = data
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
message.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const renamePasskey = async () => {
|
||||
try {
|
||||
await api.fetch(`/user_api/passkey/rename`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
passkey_name: currentPasskeyName.value,
|
||||
passkey_id: currentPasskeyId.value
|
||||
})
|
||||
})
|
||||
await fetchPasskeyList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
message.error(e.message)
|
||||
} finally {
|
||||
currentPasskeyName.value = ''
|
||||
showRenamePasskey.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="userSettings.user_email">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-button @click="showPasskeyList = true; fetchPasskeyList();" secondary block strong>
|
||||
{{ t('showPasskeyList') }}
|
||||
</n-button>
|
||||
<n-button @click="showCreatePasskey = true" type="primary" secondary block strong>
|
||||
{{ t('createPasskey') }}
|
||||
</n-button>
|
||||
<n-alert :show-icon="false" :bordered="false">
|
||||
<span>
|
||||
{{ t('passordTip') }}
|
||||
</span>
|
||||
</n-alert>
|
||||
<n-button @click="showLogout = true" secondary block strong>
|
||||
{{ t('logout') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
<n-modal v-model:show="showCreatePasskey" preset="dialog" :title="t('createPasskey')">
|
||||
<n-input v-model:value="passkeyName" :placeholder="t('passkeyNamePlaceholder')" />
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="createPasskey" size="small" tertiary type="primary">
|
||||
{{ t('createPasskey') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showRenamePasskey" preset="dialog" :title="t('renamePasskey')">
|
||||
<n-input v-model:value="currentPasskeyName" :placeholder="t('renamePasskeyNamePlaceholder')" />
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="renamePasskey" size="small" tertiary type="primary">
|
||||
{{ t('renamePasskey') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showPasskeyList" preset="card" :title="t('showPasskeyList')">
|
||||
<n-data-table :columns="passkeyColumns" :data="passkeyData" :bordered="false" embedded />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
|
||||
<p>{{ t('logoutConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="logout" size="small" tertiary type="warning">
|
||||
{{ t('logout') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
13
frontend/tsconfig.json
Normal file
13
frontend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { splitVendorChunkPlugin } from 'vite';
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||
@@ -13,19 +12,19 @@ import topLevelAwait from "vite-plugin-top-level-await";
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
outDir: './dist',
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
splitVendorChunkPlugin(),
|
||||
AutoImport({
|
||||
imports: [
|
||||
'vue',
|
||||
{
|
||||
'naive-ui': [
|
||||
'useMessage',
|
||||
'useNotification',
|
||||
'NButton',
|
||||
'NPopconfirm',
|
||||
'NIcon',
|
||||
@@ -37,13 +36,16 @@ export default defineConfig({
|
||||
resolvers: [NaiveUiResolver()]
|
||||
}),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
registerType: null,
|
||||
devOptions: {
|
||||
enabled: true
|
||||
enabled: false
|
||||
},
|
||||
workbox: {
|
||||
disableDevLogs: true,
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
globPatterns: [],
|
||||
runtimeCaching: [],
|
||||
navigateFallback: null,
|
||||
cleanupOutdatedCaches: true,
|
||||
},
|
||||
manifest: {
|
||||
name: 'Temp Email',
|
||||
@@ -64,5 +66,13 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.PACKAGE_VERSION': JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
esbuild: {
|
||||
supported: {
|
||||
'top-level-await': true
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
1
mail-parser-wasm/.gitignore
vendored
1
mail-parser-wasm/.gitignore
vendored
@@ -12,3 +12,4 @@ Cargo.lock
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
web/
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user