Compare commits

..

192 Commits

Author SHA1 Message Date
Dream Hunter
cf13236e7b fix: telegram mail page use iframe show email (#564) 2025-01-18 14:59:08 +08:00
Dream Hunter
36e9c611e6 feat: |Worker| add REMOVE_ALL_ATTACHMENT and REMOVE_EXCEED_SIZE_ATTAC… (#563)
feat: |Worker| add REMOVE_ALL_ATTACHMENT and REMOVE_EXCEED_SIZE_ATTACHMENT
2025-01-18 14:43:09 +08:00
Dream Hunter
047200c1c2 feat: |Worker| add REMOVE_ALL_ATTACHMENT and REMOVE_EXCEED_SIZE_ATTAC… (#562)
feat: |Worker| add REMOVE_ALL_ATTACHMENT and REMOVE_EXCEED_SIZE_ATTACHMENT
2025-01-18 14:12:01 +08:00
Dream Hunter
a22add0e14 fix: telegram mail page use iframe show email (#561) 2025-01-18 13:52:09 +08:00
Dream Hunter
7b1c4cc72a fix: mail-parser-wasm parsedEmailContext cache (#560) 2025-01-18 13:26:09 +08:00
刘志聪
3870727a08 fix: rpc headers covert & typo (#559)
Co-authored-by: liuzhicong <liuzhicong@dhgate.com>
2025-01-16 00:20:02 +08:00
Dream Hunter
2bb033964c feat: update doc (#557) 2025-01-11 18:56:36 +08:00
Dream Hunter
9db5a00b35 feat: v0.8.5 && update dependencies && fix deprecated warning for `… (#556)
feat: v0.8.5 && update dependencies && fix `deprecated` warning for `mail-parser-wasm-worker`
2025-01-11 18:46:46 +08:00
Dream Hunter
e161eb5d14 fix: telegram query email do not pass parsedEmailContext (#555) 2025-01-11 18:14:34 +08:00
Dream Hunter
b604f56d56 feat: |Github Action| Deploy Backend add DEBUG_MODE for logging && BA… (#554)
feat: |Github Action| Deploy Backend add DEBUG_MODE for logging && BACKEND_USE_MAIL_WASM_PARSER to enable mail-parser-wasm-worker
2025-01-11 18:04:53 +08:00
Dream Hunter
52caf811f5 feat: add JUNK_MAIL_CHECK_LIST for check exits and passed item && add ParsedEmailContext to cache the parsed Email (#553)
* feat: Junk mail only check JUNK_MAIL_FORCE_PASS_LIST

* feat: add `JUNK_MAIL_CHECK_LIST` for check exits and passed item && add `ParsedEmailContext` to cache the parsed Email
2025-01-11 17:42:20 +08:00
Dream Hunter
ee3884914b Update CHANGELOG.md 2025-01-09 22:50:22 +08:00
Dream Hunter
844fc52bbc feat: |UI| add configAutoRefreshInterval && autoRefresh useStorage (#549)
* feat: |UI| add configAutoRefreshInterval && autoRefresh useStorage

* Update MailBox.vue

* Update MailBox.vue
2025-01-09 22:49:25 +08:00
Dream Hunter
b87b49f09d Update CHANGELOG.md 2025-01-08 20:04:11 +08:00
刘志聪
5bfa588f70 feat: trigger another worker (#547) 2025-01-08 20:02:48 +08:00
Dream Hunter
92620cdedb feat: add DISABLE_ANONYMOUS_USER_CREATE_EMAIL which only allow logi… (#545)
feat: add `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` which only allow login user create email address
2025-01-05 18:51:48 +08:00
Dream Hunter
e9748be9fe Update vite.config.js (#544) 2025-01-05 02:05:40 +08:00
Dream Hunter
479322c430 feat: |Telegram Bot| add new command to clean invalid jwts (#543) 2025-01-05 01:52:07 +08:00
Dream Hunter
934e58e23b fix: |UI| admin mails unknown page call wrong api (#542) 2025-01-05 01:14:36 +08:00
Dream Hunter
c964d77a59 feat: |UI| add JUNK_MAIL_FORCE_PASS_LIST (#539) 2024-12-30 18:38:33 +08:00
Dream Hunter
8a03d3e57f feat: |UI| admin portal user oauth config support delete (#538) 2024-12-30 18:08:17 +08:00
Dream Hunter
6caba7c863 feat: add docs (#537) 2024-12-28 13:52:08 +08:00
Dream Hunter
43e5bdc764 feat: update dependencies (#536) 2024-12-28 00:32:07 +08:00
Dream Hunter
7bec0daba4 feat: update docs (#534) 2024-12-27 10:47:14 +08:00
Dream Hunter
13e5adef17 feat: update docs (#533) 2024-12-26 22:23:41 +08:00
Dream Hunter
440238133e feat: |Github Action| add upstream sync and auto deploy frontend&&bac… (#528)
feat: |Github Action| add upstream sync and auto deploy frontend&&backend
2024-12-23 22:55:10 +08:00
Dream Hunter
4a881e2d2b feat: upgrade dependencies (#527) 2024-12-23 21:10:45 +08:00
Dream Hunter
b0bf7a5f13 feat: add NO_LIMIT_SEND_ADDRESS_LIST_KEY in admin account settings page (#525) 2024-12-22 15:52:53 +08:00
Dream Hunter
a9bb8785ba feat: support send mail from admin portal(no balance limit) (#524) 2024-12-22 15:40:26 +08:00
Dream Hunter
0b48baff6d fix: frontend github actions cannot use branch param to deploy (#520)
* Update frontend_deploy.yaml

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-27 01:40:45 +08:00
Dream Hunter
a57a42b2a1 fix: name check bug (#434) 2024-08-25 16:39:55 +08:00
Dream Hunter
a24cc1f642 fix: bugs && release v0.7.4 (#432) 2024-08-24 15:07:07 +08:00
Dream Hunter
4c6fd3c2af feat: UI add min-width for table page (#428) 2024-08-19 22:53:13 +08:00
Dream Hunter
1cf38c1768 feat: UI: add WorkerConfig && release v0.7.3 (#421) 2024-08-18 14:58:57 +08:00
Dream Hunter
b5b59acdb3 feat: add Oauth2 Login (#420) 2024-08-18 14:39:50 +08:00
Dream Hunter
6d4783e1cd fix: UI admin page show modal when no need password (#419) 2024-08-17 23:54:03 +08:00
Dream Hunter
34e3e1b439 fix: UI admin page show modal when no need password (#418) 2024-08-17 23:14:35 +08:00
Dream Hunter
56104cd23a fix: UI tab active icon wrong position (#416) 2024-08-17 01:46:40 +08:00
Dream Hunter
3664028e06 feat: add ADDRESS_CHECK_REGEX (#415) 2024-08-17 00:11:28 +08:00
Dream Hunter
9888f98d74 feat: update dependencies (#411) 2024-08-15 01:05:05 +08:00
Dream Hunter
ac5605f17f release v0.7.2 doc (#410) 2024-08-15 01:02:15 +08:00
Dream Hunter
a9719cb3ec release v0.7.2 (#409) 2024-08-15 00:56:15 +08:00
Dream Hunter
5f4978645b release v0.7.2 (#408) 2024-08-15 00:52:18 +08:00
Dream Hunter
621476cb79 feat: update webhook to support global webhook (#407) 2024-08-15 00:23:31 +08:00
Dream Hunter
c969c4b082 fix: DISABLE_ADMIN_PASSWORD_CHECK still show admin password modal (#406) 2024-08-14 22:52:45 +08:00
Dream Hunter
d90f54345d feat: add ADDRESS_REGEX (#401) 2024-08-13 23:21:19 +08:00
Dream Hunter
797b8bb019 fix: NO_LIMIT_SEND_ROLE no access token (#400) 2024-08-13 01:38:20 +08:00
Dream Hunter
7e5d142924 fix: NO_LIMIT_SEND_ROLE when user settings not call (#396) 2024-08-11 23:45:24 +08:00
Dream Hunter
c6d0307eac Release v0.7.1 2024-08-11 22:46:40 +08:00
Dream Hunter
ac31042e69 feat: add EMAIL_KV_BLACK_LIST (#394) 2024-08-11 20:34:10 +08:00
Dream Hunter
c733d3bf4d fix: get user role before all requests (#393) 2024-08-11 19:29:49 +08:00
Dream Hunter
bf1243f4c4 release: v0.7.0 (#387) 2024-08-11 00:21:15 +08:00
Dream Hunter
15063b2e97 feat: add DISABLE_ADMIN_PASSWORD_CHECK (#386) 2024-08-11 00:10:16 +08:00
Dream Hunter
fc07f1cd87 feat: add passkey (#384) 2024-08-10 23:56:05 +08:00
Dream Hunter
9246550cc5 feat: add NO_LIMIT_SEND_ROLE (#373) 2024-08-04 21:02:11 +08:00
Dream Hunter
979b6eae1a feat: add SHOW_GITHUB config (#372) 2024-08-04 14:36:24 +08:00
Dream Hunter
10da337a9c feat: add SHOW_GITHUB config (#371) 2024-08-04 14:34:35 +08:00
Dream Hunter
9c5e8857af feat: add loading when process mails (#367) 2024-07-27 23:14:18 +08:00
Dream Hunter
84b4baa99e feat: add .github/workflows/pr_agent.yml (#366) 2024-07-27 23:06:54 +08:00
Dream Hunter
b57d46244a feat: add loading when process mails (#364) 2024-07-27 22:30:38 +08:00
Dream Hunter
5faae8796d feat: add ADMIN_USER_ROLE for user access admin panel (#363) 2024-07-27 22:04:18 +08:00
666-eth
a0805bc0ce Docs: Update new-address-api.md (#360) 2024-07-23 13:47:37 +08:00
Dream Hunter
d0ccc3ded1 v0.6.1 2024-07-22 13:09:42 +08:00
Dream Hunter
163d9451f7 feat: worker: newAddress if domain is not set, use the first domain (#358) 2024-07-22 13:05:50 +08:00
Dream Hunter
60dda7e3fe feat: add ANNOUNCEMENT (#357) 2024-07-22 13:01:38 +08:00
Dream Hunter
384eb9b041 fix: imap proxy do not support password && cleanup days translate (#356) 2024-07-19 22:40:53 +08:00
tqjason
38816cbf0f Add new workflow action and Fix cleanup bug (#355)
* Create frontend_pagefunction_deploy.yaml

* Update frontend_pagefunction_deploy.yaml

* Update cleanup_api.ts

* Update common.ts

* Update cleanup_api.ts

* Update common.ts
2024-07-19 22:34:01 +08:00
Dream Hunter
d7d1ba6b64 feat: wrangler d1 execute dev add --remote (#352) 2024-07-15 12:04:14 +08:00
Dream Hunter
14725e9e9f feat: add USER_DEFAULT_ROLE (#351) 2024-07-14 20:44:03 +08:00
Dream Hunter
2c1e63b8bc feat: add USER_DEFAULT_ROLE (#350) 2024-07-14 20:38:55 +08:00
Dream Hunter
f3a1d980c5 fix: roleDonotExist tip (#349) 2024-07-14 20:09:21 +08:00
Dream Hunter
75c48beb3b feat: add USER_ROLES && admin pages search when keybord enter && auto trim (#348)
* feat: add USER_ROLES

* feat: admin pages search when keybord enter && auto trim

* feat: update version to v0.6.0
2024-07-14 19:57:43 +08:00
Dream Hunter
26ccfdd6e0 feat: only allow address [a-z0-9] (#347) 2024-07-13 19:03:54 +08:00
刘志聪
aa8f3b4d46 fix: remove useless sql (#342) 2024-07-10 01:06:00 +08:00
Dream Hunter
a749c829d2 feat: update docs (#340) 2024-07-08 19:09:37 +08:00
Dream Hunter
4b2caf1a4b feat: update docs (#339) 2024-07-08 19:02:14 +08:00
Dream Hunter
80a8848ed8 feat: remove apiV1 and tables && update admin/statistics (#337) 2024-07-08 12:33:43 +08:00
Dream Hunter
dcfc1b3721 Update CHANGELOG.md 2024-07-07 12:55:29 +08:00
Dream Hunter
b0a0a6a1ef feat: updage dependencies (#336) 2024-07-06 20:26:54 +08:00
Dream Hunter
00c671cf14 feat: logo click 5 time to admin page && fix: 401 cannot show auth modal (#335) 2024-07-06 20:21:21 +08:00
Dream Hunter
0b78d1ff4a Update CHANGELOG.md 2024-07-04 13:38:00 +08:00
Dream Hunter
d152a7ce9f feat: allow admin and user delete mail, sendbox, send access(only admin) (#331) 2024-07-04 13:31:33 +08:00
Dream Hunter
21fed3fb00 feat: allow admin and user delete mail, sendbox, send access(only admin) (#329) 2024-07-04 13:25:14 +08:00
Dream Hunter
9448b3c754 fix: sendVerificationCode do not check cfToken when no TurnstileSiteKey (#325) 2024-06-29 01:01:46 +08:00
Dream Hunter
f1827f223a feat: docs: github actions add FRONTEND_BRANCH (#324) 2024-06-28 23:10:35 +08:00
Dream Hunter
2a0a34869e feat: github actions add FRONTEND_BRANCH env (#323) 2024-06-28 23:04:08 +08:00
Dream Hunter
881e66e484 feat: add DOMAIN_LABELS for chinese domain label (#322) 2024-06-28 22:25:06 +08:00
Dream Hunter
de7c3d5176 Update README.md 2024-06-12 14:52:16 +08:00
Dream Hunter
720d097ed7 Update README.md 2024-06-12 14:51:32 +08:00
Dream Hunter
53a03dc6a0 Update README.md 2024-06-12 14:35:22 +08:00
Dream Hunter
72b99e0c5e feat: upgeade npm packages (#311) 2024-06-12 13:57:26 +08:00
Dream Hunter
c4d9fe1fb9 feat: docs: add new-address-api (#309) 2024-06-12 13:53:40 +08:00
Dream Hunter
af9f46ba65 fix: smtp imap proxy sever: support senbox v2 (#306) 2024-06-09 13:35:26 +08:00
Dream Hunter
8bfd76bf71 Update README.md 2024-06-07 00:03:47 +08:00
Dream Hunter
dd477fe2c8 Update CHANGELOG.md 2024-06-06 21:45:57 +08:00
Dream Hunter
0db611bb3e feat: add MIN_ADDRESS_LEN && MAX_ADDRESS_LEN (#304) 2024-06-06 21:44:22 +08:00
Dream Hunter
6225f6521a fix: parseMail tg bot (#302) 2024-06-04 22:51:28 +08:00
Dream Hunter
da2e72e523 feat: add mail-parser-wasm-worker (#301) 2024-06-04 21:57:42 +08:00
Dream Hunter
c5d01e09e8 feat: change version (#294) 2024-06-01 21:31:30 +08:00
Dream Hunter
201c7658be feat: UI: admin mail page style add margin-top: 10px (#293) 2024-06-01 21:27:45 +08:00
Dream Hunter
77155299e0 feat: add mailbox multi delete and download (#292) 2024-06-01 21:23:17 +08:00
Dream Hunter
9725407c77 feat: add s3 attachment (#291) 2024-06-01 20:08:42 +08:00
Dream Hunter
e91bbe273a feat: UI i18n depends on router (#290) 2024-06-01 12:13:44 +08:00
Dream Hunter
b792c196c1 feat: UI i18n depends on router (#289) 2024-06-01 12:12:13 +08:00
Dream Hunter
7a368d7b23 feat: add global forward address list (#288) 2024-05-31 23:21:12 +08:00
Dream Hunter
f882e4cf97 feat: add Local Address Manage (#285) 2024-05-29 13:40:02 +08:00
Dream Hunter
00abf79417 fix: cannot delete addres when not bind KV (#284) 2024-05-29 12:08:56 +08:00
Dream Hunter
1f8edbc295 feat: add TITLE in worker for custom website title (#276) 2024-05-26 16:21:27 +08:00
Dream Hunter
268f3d6446 Update CHANGELOG.md 2024-05-26 15:35:18 +08:00
Dream Hunter
8dc9d32a7e feat: add resend for send mail (#275) 2024-05-26 15:30:18 +08:00
Dream Hunter
3b6736924b feat: add resend for send mail (#274) 2024-05-26 12:37:11 +08:00
Dream Hunter
dc14338b69 fix: telegram bot golbalPush (#273) 2024-05-25 17:37:39 +08:00
Dream Hunter
954ae2dfb1 fix: telegram bot golbalPush (#272) 2024-05-25 14:38:33 +08:00
Dream Hunter
6d55acdd42 fix: telegram bot golbalPush (#271) 2024-05-25 14:34:16 +08:00
Dream Hunter
03bb210016 fix: telegram bot golbalPush (#270) 2024-05-25 14:20:34 +08:00
Dream Hunter
bf3c372d8c feat: telegram bot global push (#269) 2024-05-25 14:07:00 +08:00
Dream Hunter
9414f7a977 Update README.md 2024-05-25 11:53:23 +08:00
Dream Hunter
32440706d2 feat: add sendmail sunset in readme (#267) 2024-05-23 12:32:07 +08:00
Dream Hunter
c976664f4e feat: UI: lazy load (#266) 2024-05-23 12:23:43 +08:00
Dream Hunter
aa04dc4efa feat: smtp_proxy_server use httpx (#265) 2024-05-22 22:24:59 +08:00
Dream Hunter
02e3e755e7 feat: docs: Telegram Mini App (#264) 2024-05-22 20:57:30 +08:00
Dream Hunter
37ed2955ff fix: webhook JSON.stringify (#263) 2024-05-22 20:48:03 +08:00
Dream Hunter
dd49768cfc feat: smtp_proxy_server update package (#262) 2024-05-21 23:53:32 +08:00
Dream Hunter
9ec11f7040 fix: telegram bot/miniapp bugs (#261) 2024-05-21 22:45:48 +08:00
Dream Hunter
2533257b68 fix: telegram bot/miniapp bugs (#259) 2024-05-21 13:32:47 +08:00
Dream Hunter
96ea81e055 fix: telegram bot/miniapp bugs (#258) 2024-05-21 13:28:02 +08:00
Dream Hunter
8459e0c306 fix: telegram bot/miniapp bugs (#257) 2024-05-21 13:18:15 +08:00
Dream Hunter
91d7896e65 feat: telegram mini app open mail from bot (#256) 2024-05-21 02:03:06 +08:00
Dream Hunter
69771fc1d1 feat: telegram bot unbind && delete address (#254) 2024-05-20 13:23:41 +08:00
Dream Hunter
c00382259a fix: telegram mini app pipeline (#253) 2024-05-19 11:37:06 +08:00
Dream Hunter
8ac96bff1f fix: telegram mini app pipeline (#252) 2024-05-19 11:34:30 +08:00
Dream Hunter
9f3ff7b980 fix: telegram mini app (#251) 2024-05-19 11:32:57 +08:00
Dream Hunter
870b7b9198 feat: add telegram mini app (#250) 2024-05-19 00:35:10 +08:00
Dream Hunter
46576316e6 Update CHANGELOG.md 2024-05-18 17:08:41 +08:00
Dream Hunter
a5ff4f2d90 feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view (#248) 2024-05-18 17:02:21 +08:00
Dream Hunter
745e36f838 feat: UI changes (#247) 2024-05-18 14:46:24 +08:00
Dream Hunter
a351839408 fix build (#245) 2024-05-18 14:07:52 +08:00
Dream Hunter
ca00a877ad feat: telegram bot TelegramSettings && webhook (#244)
* feat: telegram bot TelegramSettings

* feat: webhook
2024-05-18 14:02:18 +08:00
Dream Hunter
53a06fc9d6 Update CHANGELOG.md 2024-05-17 00:12:14 +08:00
Dream Hunter
607c04c810 fix: smtp_proxy: update raise imap4.NoSuchMailbox (#243) 2024-05-17 00:06:43 +08:00
Dream Hunter
243dac976b fix: smtp_proxy: cannot decode 8bit && tg bot new random address (#242) 2024-05-16 18:18:16 +08:00
Dream Hunter
4bd876a5f4 feat: docs: Telegram Bot (#241) 2024-05-16 13:27:26 +08:00
Dream Hunter
bbc4c05d69 fix: remove cleanup address due to many table need to be clean (#240) 2024-05-16 13:11:29 +08:00
Dream Hunter
78badf2eaa feat: telegram bot (#238) 2024-05-16 12:57:23 +08:00
Dream Hunter
6bb6fa8298 feat: remove mailV1Alert && fix mobile showSideMargin (#236) 2024-05-14 14:44:47 +08:00
Dream Hunter
a5b5335137 feat: add about page (#235) 2024-05-14 13:25:27 +08:00
Dream Hunter
f2685f9830 Update README.md 2024-05-14 12:56:34 +08:00
Dream Hunter
45bc5cad9e Update README.md 2024-05-14 12:52:43 +08:00
Dream Hunter
ea4ce9bf63 feat: add page functions proxy to make response faster (#234) 2024-05-14 12:43:03 +08:00
Dream Hunter
9de2d23be1 feat: add version for frontend && backend (#230) 2024-05-12 18:31:43 +08:00
Dream Hunter
62bec9ef90 fix: Maintenance wrong label (#229) 2024-05-12 18:09:42 +08:00
Dream Hunter
edc110b6ac fix: imap server (#227) 2024-05-12 17:47:01 +08:00
Dream Hunter
3fc8bba234 Update CHANGELOG.md 2024-05-12 11:58:48 +08:00
Dream Hunter
4b9d40d04b feat: UI show version (#226) 2024-05-12 11:52:55 +08:00
Dream Hunter
af027fd75e feat: add imap proxy server (#225) 2024-05-12 11:34:52 +08:00
Dream Hunter
386441a743 fix: smtp_proxy_server support decode from mail charset (#223) 2024-05-10 23:08:38 +08:00
Dream Hunter
46e04fd94a fix: name max 30 && /external/api/send_mail not return result (#222) 2024-05-10 22:57:31 +08:00
Sunset Mikoto
cdc5c5202b fix: typos (#221) 2024-05-10 21:23:59 +08:00
Dream Hunter
58c3fdb5b4 feat: use common function handleListQuery when query by page (#220) 2024-05-09 23:31:13 +08:00
Dream Hunter
fc6b0246b1 Update CHANGELOG.md 2024-05-09 20:26:22 +08:00
Dream Hunter
98cd6d9fcc feat: docs (#218) 2024-05-09 20:23:52 +08:00
Dream Hunter
45783c7494 feat: UI move AdminContact to common (#217) 2024-05-09 20:14:29 +08:00
Dream Hunter
9bfded4d1d feat: UI check openSettings in Login page (#215) 2024-05-09 19:06:00 +08:00
Dream Hunter
b7308587c6 feat: admin search mailbox && fix generateName multi dot && user jwt exp in 30 days && UI globalTabplacement && useSideMargin (#214)
* fix: generateName multi dot && user jwt exp in 30 days

* feat: support admin search mailbox

* fix: DELETE mail bug(should be raw_mails)

* feat: UI add globalTabplacement

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

* feat: update docs
2024-05-05 17:45:02 +08:00
Dream Hunter
66a6d40499 Update CHANGELOG.md 2024-05-05 00:03:56 +08:00
201 changed files with 22320 additions and 6296 deletions

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

View File

@@ -1,6 +1,9 @@
name: Deploy Backend Production
name: Deploy Backend
on:
workflow_run:
workflows: [Upstream Sync]
types: [completed]
push:
tags:
- "*"
@@ -29,14 +32,28 @@ jobs:
- name: Deploy Backend for ${{ github.ref_name }}
run: |
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
output=$(pnpm run deploy 2>&1)
if [ $? -ne 0 ]; then
code=$?
echo "Command failed with exit code $code"
exit $code
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 [ -n "$debug_mode" ]; 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:

View File

@@ -31,6 +31,22 @@ jobs:
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/

View File

@@ -1,9 +1,10 @@
name: Deploy Frontend
on:
workflow_run:
workflows: [Upstream Sync]
types: [completed]
push:
paths:
- "frontend/**"
tags:
- "*"
workflow_dispatch:
@@ -35,9 +36,29 @@ jobs:
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
export project_name=${{ secrets.FRONTEND_NAME }}
pnpm install --no-frozen-lockfile
pnpm run deploy --project-name=$project_name
echo "Deploying prodcution for ${{ github.ref_name }}"
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 }}

View 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: 18
- 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
View File

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

25
.github/workflows/sync.yaml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Upstream Sync
on:
schedule:
- cron: "0 0 * * 1"
workflow_dispatch:
jobs:
sync_latest_from_upstream:
name: Sync latest commits from upstream repo
runs-on: ubuntu-latest
if: ${{ github.event.repository.fork }}
steps:
- uses: actions/checkout@v4
- name: Sync upstream changes
id: sync
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
with:
upstream_sync_repo: dreamhunter2333/cloudflare_temp_email
upstream_sync_branch: main
target_sync_branch: main
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
test_mode: false

View File

@@ -30,7 +30,13 @@ jobs:
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:release
- name: Zip Frontend dist
run: cd frontend/dist/ && zip -r frontend.zip *
run: cd frontend/dist/ && zip -r frontend.zip * && mv frontend.zip ../
- name: Build Telegram Frontend
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:telegram:release
- name: Zip Telegram Frontend dist
run: cd frontend/dist/ && zip -r telegram-frontend.zip * && mv telegram-frontend.zip ../
- name: cp wrangler.toml
run: cd worker && cp wrangler.toml.template wrangler.toml
@@ -42,5 +48,6 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: |
frontend/dist/frontend.zip
frontend/frontend.zip
frontend/telegram-frontend.zip
worker/dist/worker.js

135
.gitignore vendored
View File

@@ -1,3 +1,138 @@
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
View File

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

View File

@@ -1,12 +1,281 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG
## main branch
## main(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`
![worker-runtime](vitepress-docs/docs/public/ui_install/worker-runtime.png)
### Changes
- feat: 支持提前设置 bot info, 降低 telegram 回调延迟 (#441)
- feat: 增加 telegram mini app 的 build 压缩包
- feat: 增加是否启用垃圾邮件检查 `ENABLE_CHECK_JUNK_MAIL` 配置
## v0.7.5
- fix: 修复 `name` 的校验检查
## v0.7.4
- feat: UI 列表页面增加最小宽度
- fix: 修复 `name` 的校验检查
- fix: 修复 `DEFAULT_DOMAINS` 配置为空不生效的问题
## v0.7.3
- feat: worker 增加 `ADDRESS_CHECK_REGEX`, address name 的正则表达式, 只用于检查,符合条件将通过检查
- fix: UI 修复登录页面 tab 激活图标错位
- fix: UI 修复 admin 页面刷新弹框输入密码的问题
- feat: support `Oath2` 登录, 可以通过 `Github` `Authentik` 等第三方登录, 详情查看 [OAuth2 第三方登录](https://temp-mail-docs.awsl.uk/zh/guide/feature/user-oauth2.html)
## v0.7.2
### Breaking Changes
`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: 回复邮件按钮, 引用原始邮件文本
- UI: 回复邮件按钮, 引用原始邮件文本 #186
- 添加发送邮件地址黑名单
- 添加 `CF Turnstile` 人机验证配置
- 添加 `/external/api/send_mail` 发送邮件 api, 使用 body 验证
- 添加 `/external/api/send_mail` 发送邮件 api, 使用 body 验证 #194
## v0.3.2
@@ -102,7 +371,6 @@ set
- 添加 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
@@ -158,7 +426,7 @@ The `mails` table will be discarded, and the `raw` text of the new `mail` will b
```bash
git checkout v0.2.0
cd worker
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql --remote
pnpm run deploy
cd ../frontend
pnpm run deploy

View File

@@ -1,10 +1,41 @@
# 使用 cloudflare 免费服务,搭建临时邮箱
<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="FeaturedHelloGitHub"/>
</a>
</p>
<p align="center">
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
<img alt="docs" src="https://img.shields.io/badge/docs-grey?style=for-the-badge&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?style=for-the-badge">
</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?style=for-the-badge">
</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?style=for-the-badge">
</a>
<a href="">
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
</a>
<a href="">
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
</a>
</p>
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
## [查看部署文档](https://temp-mail-docs.awsl.uk)
## [English Docs](https://temp-mail-docs.awsl.uk/en/)
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/dreamhunter2333/cloudflare_temp_email)
[Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/github-action.html)
[English Docs](https://temp-mail-docs.awsl.uk/en/)
## [CHANGELOG](CHANGELOG.md)
@@ -23,11 +54,11 @@
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
- [查看部署文档](#查看部署文档)
- [English Docs](#english-docs)
- [CHANGELOG](#changelog)
- [在线演示](#在线演示)
- [功能/TODO](#功能todo)
- [Reference](#reference)
- [Join Community](#join-community)
## 功能/TODO
@@ -41,7 +72,9 @@
- [x] 支持发送邮件
- [x] 支持 `DKIM`
- [x] `admin` 后台创建无前缀邮箱
- [x] 添加 `SMTP proxy server`,支持 SMTP 发送邮件
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件, `IMAP` 查看邮件
- [x] 添加完整的用户注册登录功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证切换不同邮箱
- [x] `Telegram Bot` 使用,以及 `Telegram` 推送
## Reference
@@ -49,3 +82,8 @@
- 使用 Cloudflare Pages 部署前端
- 使用 Cloudflare Workers 部署后端
- email 转发使用 Cloudflare Email Routing
## Join Community
- [Discord](https://discord.gg/dQEwTWhA6Q)
- [Telegram](https://t.me/cloudflare_temp_email)

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

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

9
db/2024-07-14-patch.sql Normal file
View 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
View File

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

View File

@@ -1,15 +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 INDEX IF NOT EXISTS idx_mails_address ON mails(address);
CREATE TABLE IF NOT EXISTS raw_mails (
id INTEGER PRIMARY KEY,
message_id TEXT,
@@ -22,7 +10,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY,
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -43,15 +31,6 @@ CREATE TABLE IF NOT EXISTS auto_reply_mails (
CREATE INDEX IF NOT EXISTS idx_auto_reply_mails_address ON auto_reply_mails(address);
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY,
source TEXT,
address TEXT,
message_id TEXT,
data TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS address_sender (
id INTEGER PRIMARY KEY,
address TEXT UNIQUE,
@@ -77,3 +56,50 @@ CREATE TABLE IF NOT EXISTS settings (
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);

View File

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

@@ -0,0 +1,2 @@
VITE_API_BASE=
VITE_CF_WEB_ANALY_TOKEN=

2
frontend/.gitignore vendored
View File

@@ -28,5 +28,7 @@ coverage
*.sw?
.env.*
!.env.example
!.env.pages
*-dist/
components.d.ts

View File

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

View File

@@ -1,41 +1,52 @@
{
"name": "cloudflare_temp_email",
"version": "0.0.0",
"version": "0.8.5",
"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:release": "VITE_IS_TELEGRAM=true vite build -m example --emptyOutDir",
"preview": "vite preview",
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
"deploy:actions:telegram": "npm run build:telegram && wrangler pages deploy ./dist",
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
"deploy": "npm run build && wrangler pages deploy ./dist --branch production"
"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",
"@simplewebauthn/browser": "10.0.0",
"@unhead/vue": "^1.11.15",
"@vueuse/core": "^12.4.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.6.8",
"mail-parser-wasm": "^0.1.6",
"naive-ui": "^2.38.2",
"postal-mime": "^2.2.5",
"axios": "^1.7.9",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.41.0",
"postal-mime": "^2.4.1",
"vooks": "^0.2.12",
"vue": "^3.4.26",
"vue": "^3.5.13",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^9.13.1",
"vue-router": "^4.3.2"
"vue-i18n": "^11.0.1",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vicons/fa": "^0.12.0",
"@vitejs/plugin-vue": "^5.0.4",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.27.0",
"vite": "^5.2.11",
"vite-plugin-pwa": "^0.19.8",
"vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-wasm": "^3.3.0",
"workbox-window": "^7.1.0",
"wrangler": "^3.53.1"
"@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0",
"@vitejs/plugin-vue": "^5.2.1",
"unplugin-auto-import": "^19.0.0",
"unplugin-vue-components": "^28.0.0",
"vite": "^6.0.7",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-top-level-await": "^1.4.4",
"vite-plugin-wasm": "^3.4.1",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0",
"wrangler": "^3.101.0"
}
}

6119
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,19 +6,26 @@ 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, isDark, loading } = useGlobalState()
const {
isDark, loading, useSideMargin, telegramApp, isTelegram
} = useGlobalState()
const { locale } = useI18n({});
const theme = computed(() => isDark.value ? darkTheme : null)
const localeConfig = computed(() => localeCache.value == 'zh' ? zhCN : null)
const localeConfig = computed(() => locale.value == 'zh' ? zhCN : null)
const isMobile = useIsMobile()
const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
const { locale } = useI18n({
useScope: 'global',
});
locale.value = localeCache.value;
onMounted(async () => {
try {
await api.getUserSettings();
} catch (error) {
console.error(error);
}
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
@@ -30,6 +37,23 @@ onMounted(async () => {
document.body.appendChild(script);
}
// 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>
@@ -37,10 +61,10 @@ onMounted(async () => {
<n-config-provider :locale="localeConfig" :theme="theme">
<n-global-style />
<n-spin description="loading..." :show="loading">
<n-message-provider>
<n-message-provider container-style="margin-top: 20px;">
<n-grid x-gap="12" :cols="12">
<n-gi v-if="!isMobile" span="1"></n-gi>
<n-gi :span="isMobile ? 12 : 10">
<n-gi v-if="showSideMargin" span="1"></n-gi>
<n-gi :span="!showSideMargin ? 12 : 10">
<div class="main">
<n-space vertical>
<n-layout style="min-height: 80vh;">
@@ -51,7 +75,7 @@ onMounted(async () => {
</n-space>
</div>
</n-gi>
<n-gi v-if="!isMobile" span="1"></n-gi>
<n-gi v-if="showSideMargin" span="1"></n-gi>
</n-grid>
<n-back-top />
</n-message-provider>

View File

@@ -2,12 +2,16 @@ import { useGlobalState } from '../store'
import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const { loading, auth, jwt, settings, openSettings } = useGlobalState();
const { showAuth, adminAuth, showAdminAuth } = useGlobalState();
const {
loading, auth, jwt, settings, openSettings,
userOpenSettings, userSettings, 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 = {}) => {
@@ -17,19 +21,21 @@ const apiFetch = async (path, options = {}) => {
method: options.method || 'GET',
data: options.body || null,
headers: {
'x-user-token': userJwt.value,
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`,
'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")
throw new Error("Unauthorized, your admin password is wrong")
}
if (response.status === 401 && openSettings.value.auth) {
showAuth.value = true;
throw new Error("Unauthorized, you access password is wrong")
}
if (response.status >= 300) {
throw new Error(`${response.status} ${response.data}` || "error");
@@ -49,27 +55,50 @@ const apiFetch = async (path, options = {}) => {
const getOpenSettings = async (message) => {
try {
const res = await api.fetch("/open_api/settings");
const domainLabels = res["domainLabels"] || [];
if (res["domains"]?.length < 1) {
message.error("No domains found, please check your worker settings");
}
Object.assign(openSettings.value, {
...res,
title: res["title"] || "",
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,
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,
});
if (openSettings.value.needAuth) {
showAuth.value = true;
}
if (openSettings.value.announcement && openSettings.value.announcement != announcement.value) {
announcement.value = openSettings.value.announcement;
message.info(announcement.value, {
showIcon: false,
duration: 0,
closable: true
});
}
} catch (error) {
message.error(error.message || "error");
} finally {
openSettings.value.fetched = true;
}
}
@@ -82,7 +111,6 @@ 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 {
@@ -90,10 +118,34 @@ const getSettings = async () => {
}
}
const adminShowPassword = async (id) => {
const getUserOpenSettings = async (message) => {
try {
const { password } = await apiFetch(`/admin/show_password/${id}`);
return password;
const res = await api.fetch(`/user_api/open_settings`);
Object.assign(userOpenSettings.value, res);
} catch (error) {
message.error(error.message || "fetch settings failed");
} 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)
} 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;
}
@@ -109,10 +161,24 @@ const adminDeleteAddress = async (id) => {
}
}
const bindUserAddress = async () => {
if (!userJwt.value) return;
try {
await apiFetch(`/user_api/bind_address`, {
method: 'POST',
});
} catch (error) {
throw error;
}
}
export const api = {
fetch: apiFetch,
getSettings: getSettings,
getOpenSettings: getOpenSettings,
adminShowPassword: adminShowPassword,
adminDeleteAddress: adminDeleteAddress,
getSettings,
getOpenSettings,
getUserOpenSettings,
getUserSettings,
adminShowAddressCredential,
adminDeleteAddress,
bindUserAddress,
}

View File

@@ -1,50 +1,59 @@
<script setup>
import { watch, onMounted, ref, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { CloudDownloadRound, ReplyFilled } from '@vicons/material'
import { CloudDownloadRound, ReplyFilled, ForwardFilled } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
import { utcToLocalDate } from '../utils';
const message = useMessage()
const isMobile = useIsMobile()
const router = useRouter()
const props = defineProps({
enableUserDeleteEmail: {
type: Boolean,
default: false,
requried: false
required: false
},
showEMailTo: {
type: Boolean,
default: true,
requried: false
required: false
},
fetchMailData: {
type: Function,
default: () => { },
requried: true
required: true
},
deleteMail: {
type: Function,
default: () => { },
requried: false
required: false
},
showReply: {
type: Boolean,
default: false,
requried: false
}
required: false
},
showSaveS3: {
type: Boolean,
default: false,
required: false
},
saveToS3: {
type: Function,
default: (mail_id, filename, blob) => { },
required: false
},
})
const {
localeCache, isDark, mailboxSplitSize, useIframeShowMail, sendMailModel
isDark, mailboxSplitSize, indexTab, loading, useUTCDate, autoRefresh, configAutoRefreshInterval,
useIframeShowMail, sendMailModel, preferShowTextMail
} = useGlobalState()
const autoRefresh = ref(false)
const autoRefreshInterval = ref(30)
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
const data = ref([])
const timer = ref(null)
@@ -55,9 +64,15 @@ const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
const curMail = ref(null);
const showTextMail = ref(preferShowTextMail.value)
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({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'Success',
@@ -66,10 +81,18 @@ const { t } = useI18n({
refresh: 'Refresh',
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
pleaseSelectMail: "Please select a mail to view.",
pleaseSelectMail: "Please select mail",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete this mail?',
reply: 'Reply'
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',
},
zh: {
success: '成功',
@@ -78,23 +101,33 @@ const { t } = useI18n({
refresh: '刷新',
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择一封邮件查看。",
pleaseSelectMail: "请选择邮件",
delete: '删除',
deleteMailTip: '确定要删除这封邮件吗?',
reply: '回复'
deleteMailTip: '确定要删除邮件吗?',
reply: '回复',
forwardMail: '转发',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件',
saveToS3: '保存到S3',
multiAction: '多选',
cancelMultiAction: '取消多选',
selectAll: '全选本页',
unselectAll: '取消全选',
}
}
});
const setupAutoRefresh = async (autoRefresh) => {
// auto refresh every 30 seconds
autoRefreshInterval.value = 30;
// 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 = 30;
await refresh();
autoRefreshInterval.value = configAutoRefreshInterval.value;
await backFirstPageAndRefresh();
}
}, 1000)
} else {
@@ -105,7 +138,7 @@ const setupAutoRefresh = async (autoRefresh) => {
watch(autoRefresh, async (autoRefresh, old) => {
setupAutoRefresh(autoRefresh)
})
}, { immediate: true })
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
@@ -118,22 +151,36 @@ const refresh = async () => {
const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value
);
loading.value = true;
data.value = await Promise.all(results.map(async (item) => {
item.checked = false;
return await processItem(item);
}));
if (totalCount > 0) {
count.value = totalCount;
}
if (!isMobile.value && !curMail.value && data.value.length > 0) {
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;
};
@@ -173,13 +220,108 @@ const replyMail = async () => {
contentType: 'rich',
content: curMail.value.text ? `<p><br></p><blockquote>${curMail.value.text}</blockquote><p><br></p>` : '',
});
await router.push('/send');
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 attachmentLoding = ref(false)
const saveToS3Proxy = async (filename, blob) => {
attachmentLoding.value = true
try {
await props.saveToS3(curMail.value.id, filename, blob);
} finally {
attachmentLoding.value = false
}
}
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();
});
@@ -191,14 +333,38 @@ onBeforeUnmount(() => {
<template>
<div>
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25"
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
<template #1>
<div class="center">
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small" :round="false">
<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>
@@ -206,87 +372,106 @@ onBeforeUnmount(() => {
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary">
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
</n-tag>
</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; 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>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail.raw)">
<template #icon>
<n-icon :component="CloudDownloadRound" />
</template>
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
</n-space>
<iframe v-if="useIframeShowMail" :srcdoc="curMail.message"
style="margin-top: 10px;width: 100%; height: 100%;">
</iframe>
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
<n-card class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
<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">
FROM: {{ row.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card :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.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail.raw)">
<template #icon>
<n-icon :component="CloudDownloadRound" />
</template>
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="forwardMail">
<template #icon>
<n-icon :component="ForwardFilled" />
</template>
{{ t('forwardMail') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
</n-button>
</n-space>
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
style="margin-top: 10px;width: 100%; height: 100%;">
</iframe>
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
<n-card :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-top: 10px; margin-bottom: 10px;">
<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">
@@ -297,10 +482,10 @@ onBeforeUnmount(() => {
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary">
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
</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)">
@@ -310,7 +495,7 @@ onBeforeUnmount(() => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
@@ -326,13 +511,13 @@ onBeforeUnmount(() => {
<n-drawer v-model:show="curMail" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 80vh;">
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
<n-card style="overflow: auto;">
<n-card :bordered="false" embedded style="overflow: auto;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
@@ -361,8 +546,21 @@ onBeforeUnmount(() => {
</template>
{{ t('reply') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="forwardMail">
<template #icon>
<n-icon :component="ForwardFilled" />
</template>
{{ t('forwardMail') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
</n-button>
</n-space>
<div v-html="curMail.message" style="margin-top: 10px;"></div>
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
style="margin-top: 10px;width: 100%; height: 100%;">
</iframe>
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
</n-drawer-content>
</n-drawer>
@@ -371,25 +569,51 @@ onBeforeUnmount(() => {
<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>
<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="saveToS3Proxy(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-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-list-item>
</n-list>
</n-spin>
</n-modal>
<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>
@@ -420,4 +644,9 @@ onBeforeUnmount(() => {
.mail-item {
height: 100%;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

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

View File

@@ -1,13 +1,12 @@
<script setup>
import { ref, watch, defineModel, onMounted } from "vue";
import { ref, watch, onMounted } from "vue";
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
const { localeCache, openSettings, isDark } = useGlobalState()
const { openSettings, isDark } = useGlobalState()
const cfToken = defineModel('value')
const { t } = useI18n({
locale: localeCache.value || 'zh',
const { locale, t } = useI18n({
messages: {
en: {
refresh: 'Refresh'
@@ -42,7 +41,7 @@ const checkCfTurnstile = async (remove) => {
"#cf-turnstile",
{
sitekey: openSettings.value.cfTurnstileSiteKey,
language: localeCache.value == 'zh' ? 'zh-CN' : 'en-US',
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
theme: isDark.value ? 'dark' : 'light',
callback: function (token) {
cfToken.value = token;
@@ -68,12 +67,15 @@ onMounted(() => {
<div v-if="openSettings.cfTurnstileSiteKey" class="center">
<n-spin description="loading..." :show="turnstileLoading">
<n-form-item-row>
<div id="cf-turnstile"></div>
<n-button text @click="checkCfTurnstile(true)">
{{ t('refresh') }}
</n-button>
<n-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>

View File

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

View File

@@ -0,0 +1,8 @@
const COMMOM_MAIL = [
"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com",
"icloud.com", "yahoo.com", "foxmail.com"
]
export default {
COMMOM_MAIL
}

View File

@@ -2,9 +2,8 @@ import { createApp } from 'vue'
import App from './App.vue'
import { createI18n } from 'vue-i18n'
import router from './router'
import { registerSW } from 'virtual:pwa-register'
import { createHead } from '@unhead/vue'
registerSW({ immediate: true })
const i18n = createI18n({
legacy: false, // you must set `false`, to use Composition API
locale: 'zh', // set locale
@@ -16,7 +15,19 @@ const i18n = createI18n({
messages: {}
}
})
router.beforeEach((to, from) => {
if (to.params.lang && ['en', 'zh'].includes(to.params.lang)) {
i18n.global.locale.value = to.params.lang
} else {
i18n.global.locale.value = 'zh'
}
});
const head = createHead()
const app = createApp(App)
app.use(i18n)
app.use(router)
app.use(head)
app.mount('#app')

View File

@@ -0,0 +1,15 @@
export type UserOauth2Settings = {
name: string;
clientID: string;
clientSecret: string;
authorizationURL: string;
accessTokenURL: string;
accessTokenFormat?: string;
userInfoURL: string;
redirectURL: string;
logoutURL?: string;
userEmailKey: string;
scope: string;
enableMailAllowList?: boolean | undefined;
mailAllowList?: string[] | undefined;
}

View File

@@ -1,27 +1,40 @@
import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue'
import User from '../views/User.vue'
import SendMail from '../views/send/SendMail.vue'
import Admin from '../views/Admin.vue'
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
alias: "/:lang/",
component: Index
},
{
path: '/user',
alias: "/:lang/user",
component: User
},
{
path: '/send',
component: SendMail
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: '/'
}
]
})

View File

@@ -1,25 +1,41 @@
import { ref } from "vue";
import { createGlobalState, useStorage, useDark, useToggle } 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 openSettings = ref({
fetched: false,
title: '',
announcement: '',
prefix: '',
addressRegex: '',
needAuth: false,
adminContact: '',
enableUserCreateEmail: false,
disableAnonymousUserCreateEmail: 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,
})
const settings = ref({
fetched: false,
has_v1_mails: false,
send_balance: 0,
address: '',
auto_reply: {
@@ -30,7 +46,7 @@ export const useGlobalState = createGlobalState(
name: '',
}
});
const sendMailModel = useStorage('sendMailModel', {
const sendMailModel = useSessionStorage('sendMailModel', {
fromName: "",
toName: "",
toMail: "",
@@ -39,29 +55,67 @@ export const useGlobalState = createGlobalState(
content: "",
});
const showAuth = ref(false);
const showPassword = ref(false);
const showAddressCredential = ref(false);
const showAdminAuth = ref(false);
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', '');
const localeCache = useStorage('locale', 'zh');
const adminTab = ref("account");
const adminTab = useSessionStorage('adminTab', "account");
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
const useIframeShowMail = useStorage('useIframeShowMail', false);
const preferShowTextMail = useStorage('preferShowTextMail', false);
const userJwt = useStorage('userJwt', '');
const userTab = useSessionStorage('userTab', 'user_settings');
const indexTab = useSessionStorage('indexTab', 'mailbox');
const globalTabplacement = useStorage('globalTabplacement', 'top');
const useSideMargin = useStorage('useSideMargin', true);
const useUTCDate = useStorage('useUTCDate', false);
const 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 {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
user_role: null,
});
const showAdminPage = computed(() =>
!!adminAuth.value
|| userSettings.value.is_admin
|| openSettings.value.disableAdminPasswordCheck
);
const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
return {
isDark,
toggleDark,
loading,
settings,
sendMailModel,
announcement,
openSettings,
showAuth,
showPassword,
showAddressCredential,
auth,
jwt,
localeCache,
adminAuth,
showAdminAuth,
adminTab,
@@ -69,6 +123,22 @@ export const useGlobalState = createGlobalState(
adminSendBoxTabAddress,
mailboxSplitSize,
useIframeShowMail,
preferShowTextMail,
userJwt,
userTab,
indexTab,
userOpenSettings,
userSettings,
globalTabplacement,
useSideMargin,
useUTCDate,
autoRefresh,
configAutoRefreshInterval,
telegramApp,
isTelegram,
showAdminPage,
userOauth2SessionState,
userOauth2SessionClientID,
}
},
)

View File

@@ -16,11 +16,11 @@ export async function processItem(item) {
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);
}
@@ -28,7 +28,8 @@ export async function processItem(item) {
id: a_item.content_id || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.content_id || "",
size: humanFileSize(a_item.content?.length || 0),
url: blob_url
url: blob_url,
blob: blob
}
}) || [];
} catch (error) {
@@ -49,11 +50,11 @@ export async function processItem(item) {
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);
}
@@ -61,7 +62,8 @@ export async function processItem(item) {
id: a_item.contentId || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.contentId || "",
size: humanFileSize(a_item.content?.length || 0),
url: blob_url
url: blob_url,
blob: blob
}
}) || [];
} catch (error) {

View File

@@ -0,0 +1,27 @@
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);
return date.toLocaleString();
} catch (e) {
console.error(e);
}
return utcDateString;
}

View File

@@ -1,8 +1,9 @@
<script setup>
import { onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { api } from '../api'
import SenderAccess from './admin/SenderAccess.vue'
import Statistics from "./admin/Statistics.vue"
@@ -10,17 +11,29 @@ 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 Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import About from './common/About.vue';
import Maintenance from './admin/Maintenance.vue';
import Appearance from './common/Appearance.vue';
import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
import MailWebhook from './admin/MailWebhook.vue';
import WorkerConfig from './admin/WorkerConfig.vue';
import SendMail from './admin/SendMail.vue';
const {
localeCache, adminAuth, showAdminAuth, adminTab, loading
adminAuth, showAdminAuth, adminTab, loading,
globalTabplacement, showAdminPage, userSettings
} = useGlobalState()
const message = useMessage()
const authFunc = async () => {
try {
adminAuth.value = tmpAdminAuth.value;
location.reload()
} catch (error) {
message.error(error.message || "error");
@@ -28,82 +41,169 @@ const authFunc = async () => {
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
accessHeader: 'Admin Password',
accessTip: 'Please enter the admin password',
mails: 'Emails',
sendMail: 'Send Mail',
qucickSetup: 'Quick Setup',
account: 'Account',
account_create: 'Create Account',
account_settings: 'Account Settings',
user: 'User',
user_management: 'User Management',
user_settings: 'User Settings',
userOauth2Settings: 'Oauth2 Settings',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
telegram: 'Telegram Bot',
webhookSettings: 'Webhook Settings',
statistics: 'Statistics',
maintenance: 'Maintenance',
workerconfig: 'Worker Config',
appearance: 'Appearance',
about: 'About',
ok: 'OK',
mailWebhook: 'Mail Webhook',
},
zh: {
accessHeader: 'Admin 密码',
accessTip: '请输入 Admin 密码',
mails: '邮件',
sendMail: '发送邮件',
qucickSetup: '快速设置',
account: '账号',
account_create: '创建账号',
account_settings: '账号设置',
user: '用户',
user_management: '用户管理',
user_settings: '用户设置',
userOauth2Settings: 'Oauth2 设置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
telegram: '电报机器人',
webhookSettings: 'Webhook 设置',
statistics: '统计',
maintenance: '维护',
workerconfig: 'Worker 配置',
appearance: '外观',
about: '关于',
ok: '确定',
mailWebhook: '邮件 Webhook',
}
}
});
const showAdminPasswordModal = computed(() => !showAdminPage.value || showAdminAuth.value)
const tmpAdminAuth = ref('')
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
})
</script>
<template>
<div>
<n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
:title="t('accessHeader')">
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
preset="dialog" :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="adminAuth" type="textarea" :autosize="{ minRows: 3 }" />
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" />
<template #action>
<n-button @click="authFunc" type="primary" :loading="loading">
{{ t('ok') }}
</n-button>
</template>
</n-modal>
<Statistics />
<n-tabs type="card" v-model:value="adminTab">
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettings />
</n-tab-pane>
<n-tab-pane name="workerconfig" :tab="t('workerconfig')">
<WorkerConfig />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="account" :tab="t('account')">
<Account />
<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="webhook" :tab="t('webhookSettings')">
<Webhook />
</n-tab-pane>
</n-tabs>
</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 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-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
<Mails />
<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')">
<MailsUnknow />
<n-tab-pane name="telegram" :tab="t('telegram')">
<Telegram />
</n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="sendBox" :tab="t('sendBox')">
<SendBox />
<n-tab-pane name="statistics" :tab="t('statistics')">
<Statistics />
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<Maintenance />
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="workerconfig" :tab="t('workerconfig')">
<WorkerConfig />
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<Maintenance />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="appearance" :tab="t('appearance')">
<Appearance />
</n-tab-pane>
<n-tab-pane name="about" :tab="t('about')">
<About />
</n-tab-pane>
</n-tabs>
</div>

View File

@@ -1,11 +1,10 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
const { localeCache, openSettings } = useGlobalState()
const { openSettings } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
copyright: "Copyright"

View File

@@ -1,32 +1,35 @@
<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, SendFilled
AdminPanelSettingsFilled
} from '@vicons/material'
import { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
import Login from './Login.vue'
import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
const { toClipboard } = useClipboard()
import { getRouterPathWithLang } from '../utils'
const message = useMessage()
const {
jwt, localeCache, toggleDark, isDark, settings, showPassword,
showAuth, adminAuth, auth, loading
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 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 {
@@ -36,32 +39,26 @@ 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',
accessHeader: 'Access Password',
accessTip: 'Please enter the correct password',
accessTip: 'Please enter the correct access password',
home: 'Home',
menu: 'Menu',
user: 'User',
sendMail: 'Send Mail',
yourAddress: 'Your email address is',
ok: 'OK',
copy: 'Copy',
copied: 'Copied',
fetchAddressError: 'Login password is invalid or account not exist, it may be network connection issue, please try again later.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
},
zh: {
title: 'Cloudflare 临时邮件',
@@ -72,36 +69,30 @@ const { t } = useI18n({
home: '主页',
menu: '菜单',
user: '用户',
sendMail: '发送邮件',
yourAddress: '你的邮箱地址是',
ok: '确定',
copy: '复制',
copied: '已复制',
fetchAddressError: '登录密码无效或账号不存在,也可能是网络连接异常,请稍后再尝试。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
}
}
});
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,
{
text: true,
size: "small",
type: menuValue.value == "home" ? "primary" : "default",
style: "width: 100%",
onClick: () => { router.push('/'); showMobileMenu.value = false; }
onClick: async () => {
await router.push(getRouterPathWithLang('/', locale.value));
showMobileMenu.value = false;
}
},
{
default: () => t('home'),
icon: () => h(NIcon, { component: Home })
}
),
}),
key: "home"
},
{
@@ -110,16 +101,20 @@ const menuOptions = computed(() => [
{
text: true,
size: "small",
type: menuValue.value == "user" ? "primary" : "default",
style: "width: 100%",
onClick: () => { router.push('/admin'); showMobileMenu.value = false; }
onClick: async () => {
await router.push(getRouterPathWithLang("/user", locale.value));
showMobileMenu.value = false;
}
},
{
default: () => "Admin",
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
default: () => t('user'),
icon: () => h(NIcon, { component: User }),
}
),
show: !!adminAuth.value,
key: "admin"
key: "user",
show: !isTelegram.value
},
{
label: () => h(
@@ -127,16 +122,20 @@ const menuOptions = computed(() => [
{
text: true,
size: "small",
type: menuValue.value == "admin" ? "primary" : "default",
style: "width: 100%",
onClick: () => { router.push("/user"); showMobileMenu.value = false; }
onClick: async () => {
await router.push(getRouterPathWithLang('/admin', locale.value));
showMobileMenu.value = false;
}
},
{
default: () => t('user'),
icon: () => h(NIcon, { component: User }),
default: () => "Admin",
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
}
),
show: showUserMenu.value,
key: "user",
show: showAdminPage.value,
key: "admin"
},
{
label: () => h(
@@ -163,13 +162,13 @@ const menuOptions = computed(() => [
text: true,
size: "small",
style: "width: 100%",
onClick: () => {
localeCache.value == 'zh' ? changeLocale('en') : changeLocale('zh');
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 }
)
@@ -189,26 +188,44 @@ const menuOptions = computed(() => [
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 copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
useHead({
title: () => openSettings.value.title || t('title'),
meta: [
{ name: "description", content: openSettings.value.description || t('title') },
]
});
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");
await router.push(getRouterPathWithLang('/admin', locale.value));
} else {
logoClickCount.value++;
}
if (logoClickCount.value > 0) {
message.info(`Click ${5 - logoClickCount.value + 1} times to enter the admin page`);
}
}
onMounted(async () => {
await api.getOpenSettings(message);
await api.getSettings();
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
});
</script>
@@ -216,14 +233,16 @@ onMounted(async () => {
<div>
<n-page-header>
<template #title>
<h3>{{ t('title') }}</h3>
<h3>{{ openSettings.title || t('title') }}</h3>
</template>
<template #avatar>
<n-avatar style="margin-left: 10px;" src="/logo.png" />
<div @click="logoClick">
<n-avatar style="margin-left: 10px;" src="/logo.png" />
</div>
</template>
<template #extra>
<n-space>
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" />
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" responsive />
<n-button v-else :text="true" @click="showMobileMenu = !showMobileMenu" style="margin-right: 10px;">
<template #icon>
<n-icon :component="MenuFilled" />
@@ -238,53 +257,10 @@ onMounted(async () => {
<n-menu :options="menuOptions" />
</n-drawer-content>
</n-drawer>
<div v-if="!isAdminRoute">
<n-card v-if="!settings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<div v-else-if="settings.address">
<n-alert v-if="settings.has_v1_mails" type="warning" show-icon closable>
<span>
<n-button tag="a" target="_blank" tertiary type="info" size="small"
href="https://mail-v1.awsl.uk">
<b>{{ t('mailV1Alert') }} </b>
</n-button>
</span>
</n-alert>
<n-alert type="info" show-icon>
<span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
<n-button style="margin-left: 10px" @click="router.push('/send')" size="small" tertiary
type="primary">
<n-icon :component="SendFilled" /> {{ t('sendMail') }}
</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 class="center">
<n-card style="max-width: 600px;">
<n-alert v-if="jwt" type="warning" show-icon>
<span>{{ t('fetchAddressError') }}</span>
</n-alert>
<Login />
</n-card>
</div>
</div>
<n-modal v-model:show="showPassword" preset="dialog" :title="t('password')">
<span>
<p>{{ t("passwordTip") }}</p>
</span>
<n-card>
<b>{{ jwt }}</b>
</n-card>
</n-modal>
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
:title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="auth" type="textarea" :autosize="{ minRows: 3 }" />
<n-input v-model:value="auth" type="password" show-password-on="click" />
<template #action>
<n-button :loading="loading" @click="authFunc" type="primary">
{{ t('ok') }}

View File

@@ -1,22 +1,159 @@
<script setup>
import MailBox from '../components/MailBox.vue';
import { defineAsyncComponent, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useGlobalState } from '../store'
import { api } from '../api'
const { settings, openSettings } = useGlobalState()
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 Webhook from './index/Webhook.vue';
import Attachment from './index/Attachment.vue';
import About from './common/About.vue';
const SendMail = defineAsyncComponent(() => import('./index/SendMail.vue'));
const { settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
const message = useMessage()
const route = useRoute()
const { t } = useI18n({
messages: {
en: {
mailbox: 'Mail Box',
sendbox: 'Send Box',
sendmail: 'Send Mail',
auto_reply: 'Auto Reply',
accountSettings: 'Account Settings',
about: 'About',
s3Attachment: 'S3 Attachment',
saveToS3Success: 'save to s3 success',
webhookSettings: 'Webhook Settings',
query: 'Query',
},
zh: {
mailbox: '收件箱',
sendbox: '发件箱',
sendmail: '发送邮件',
auto_reply: '自动回复',
accountSettings: '账户设置',
about: '关于',
s3Attachment: 'S3附件',
saveToS3Success: '保存到s3成功',
webhookSettings: 'Webhook 设置',
query: '查询',
}
}
});
const fetchMailData = async (limit, offset) => {
if (mailIdQuery.value > 0) {
const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`);
if (singleMail) return { results: [singleMail], count: 1 };
return { results: [], count: 0 };
}
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
};
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");
}
}
const mailBoxKey = ref("")
const mailIdQuery = ref("")
const showMailIdQuery = ref(false)
const queryMail = () => {
mailBoxKey.value = Date.now();
}
watch(route, () => {
if (!route.query.mail_id) {
showMailIdQuery.value = false;
mailIdQuery.value = "";
queryMail();
}
})
onMounted(() => {
if (route.query.mail_id) {
showMailIdQuery.value = true;
mailIdQuery.value = route.query.mail_id;
queryMail();
}
})
</script>
<template>
<div v-if="settings.address">
<MailBox :showEMailTo="false" :showReply="true" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
<div>
<AddressBar />
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
<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 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>
</template>

View File

@@ -1,174 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import AdminContact from './admin/AdminContact.vue'
import Turnstile from '../components/Turnstile.vue'
import { useGlobalState } from '../store'
import { api } from '../api'
const message = useMessage()
const router = useRouter()
const {
jwt, localeCache, loading, openSettings, showPassword
} = useGlobalState()
const tabValue = ref('signin')
const password = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const login = async () => {
if (!password.value) {
message.error(t('passwordInput'));
return;
}
try {
jwt.value = password.value;
await api.getSettings()
location.reload()
} catch (error) {
message.error(error.message || "error");
}
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
login: 'Login',
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
getNewEmail: 'Get New Email',
getNewEmailTip1: 'Please input the email you want to use. only allow ., a-z, A-Z and 0-9',
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
password: 'Password',
ok: 'OK',
generateName: 'Generate Fake Name',
help: 'Help',
passwordInput: 'Please input the password',
},
zh: {
login: '登录',
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '注册新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
password: '密码',
ok: '确定',
generateName: '生成随机名字',
help: '帮助',
passwordInput: '请输入密码',
}
}
});
const generateNameLoading = ref(false);
const generateName = async () => {
try {
generateNameLoading.value = true;
const { faker } = await import('https://esm.sh/@faker-js/faker');
emailName.value = faker.person
.fullName()
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9.]/g, '')
.toLowerCase();
} catch (error) {
message.error(error.message || "error");
} finally {
generateNameLoading.value = false;
}
};
const newEmail = async () => {
try {
const res = await api.fetch(`/api/new_address`, {
method: "POST",
body: JSON.stringify({
name: emailName.value,
domain: emailDomain.value,
cf_token: cfToken.value,
}),
});
jwt.value = res["jwt"];
await api.getSettings();
showPassword.value = true;
} catch (error) {
message.error(error.message || "error");
}
};
onMounted(async () => {
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0]?.value : "";
});
</script>
<template>
<div>
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tab-pane name="signin" :tab="t('login')">
<n-form>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="password" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-button @click="login" :loading="loading" type="primary" block secondary strong>
{{ t('login') }}
</n-button>
<n-button v-if="openSettings.enableUserCreateEmail" @click="tabValue = 'register'" block secondary
strong>
{{ t('getNewEmail') }}
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableUserCreateEmail" name="register" :tab="t('getNewEmail')">
<n-spin :show="generateNameLoading">
<n-form>
<span>
<p>{{ t("getNewEmailTip1") }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>
<n-button @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
<Turnstile v-model:value="cfToken" />
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
{{ t('ok') }}
</n-button>
</n-form>
</n-spin>
</n-tab-pane>
<n-tab-pane name="help" :tab="t('help')">
<n-alert type="info" show-icon>
<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;
}
</style>

View File

@@ -1,28 +1,28 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useStorage } from '@vueuse/core'
import { useGlobalState } from '../store'
import AutoReply from './user/AutoReply.vue';
import SendBox from './send/SendBox.vue';
import Account from './user/Account.vue';
import AddressMangement from './user/AddressManagement.vue';
import UserSettingsPage from './user/UserSettings.vue';
import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue';
const { localeCache, settings, openSettings } = useGlobalState()
const userTab = useStorage('userTab', 'account')
const {
userTab, globalTabplacement, userSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
sendbox: 'Send Box',
auto_reply: 'Auto Reply',
account: 'Account',
address_management: 'Address Management',
user_settings: 'User Settings',
bind_address: 'Bind Mail Address',
},
zh: {
sendbox: '发件箱',
auto_reply: '自动回复',
account: '账户',
address_management: '地址管理',
user_settings: '用户设置',
bind_address: '绑定邮箱地址',
}
}
});
@@ -30,16 +30,17 @@ const { t } = useI18n({
</script>
<template>
<div v-if="settings.address">
<n-tabs type="card" v-model:value="userTab">
<n-tab-pane name="account" :tab="t('account')">
<Account />
<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="sendbox" :tab="t('sendbox')">
<SendBox />
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettingsPage />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
<AutoReply />
<n-tab-pane name="bind_address" :tab="t('bind_address')">
<BindAddress />
</n-tab-pane>
</n-tabs>
</div>

View File

@@ -9,13 +9,12 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
localeCache, adminAuth, showAdminAuth, loading,
adminTab, adminMailTabAddress, adminSendBoxTabAddress
loading, adminTab,
adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
name: 'Name',
@@ -23,9 +22,9 @@ const { t } = useI18n({
updated_at: 'Update At',
mail_count: 'Mail Count',
send_count: 'Send Count',
showPass: 'Show Passwrod',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
showCredential: 'Show Mail Address Credential',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
delete: 'Delete',
deleteTip: 'Are you sure to delete this email?',
delteAccount: 'Delete Account',
@@ -42,9 +41,9 @@ const { t } = useI18n({
updated_at: '更新时间',
mail_count: '邮件数量',
send_count: '发送数量',
showPass: '显示密码',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
showCredential: '查看邮箱地址凭证',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
delteAccount: '删除邮箱',
@@ -58,8 +57,8 @@ const { t } = useI18n({
}
});
const showEmailPassword = ref(false)
const curEmailPassword = ref("")
const showEmailCredential = ref(false)
const curEmailCredential = ref("")
const curDeleteAddressId = ref(0);
const addressQuery = ref("")
@@ -68,16 +67,16 @@ const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showDelteAccount = ref(false)
const showDeleteAccount = ref(false)
const showPassword = async (id) => {
const showCredential = async (id) => {
try {
curEmailPassword.value = await api.adminShowPassword(id)
showEmailPassword.value = true
curEmailCredential.value = await api.adminShowAddressCredential(id)
showEmailCredential.value = true
} catch (error) {
message.error(error.message || "error");
showEmailPassword.value = false
curEmailPassword.value = ""
showEmailCredential.value = false
curEmailCredential.value = ""
}
}
@@ -89,12 +88,13 @@ const deleteEmail = async () => {
} catch (error) {
message.error(error.message || "error");
} finally {
showDelteAccount.value = false
showDeleteAccount.value = false
}
}
const fetchData = async () => {
try {
addressQuery.value = addressQuery.value.trim()
const { results, count: addressCount } = await api.fetch(
`/admin/address`
+ `?limit=${pageSize.value}`
@@ -197,9 +197,9 @@ const columns = [
label: () => h(NButton,
{
text: true,
onClick: () => showPassword(row.id)
onClick: () => showCredential(row.id)
},
{ default: () => t('showPass') }
{ default: () => t('showCredential') }
),
},
{
@@ -232,7 +232,7 @@ const columns = [
text: true,
onClick: () => {
curDeleteAddressId.value = row.id;
showDelteAccount.value = true;
showDeleteAccount.value = true;
}
},
{ default: () => t('delete') }
@@ -252,30 +252,26 @@ watch([page, pageSize], async () => {
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showEmailPassword" preset="dialog" title="Dialog">
<div style="margin-top: 10px;">
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("password") }}</div>
<div>{{ t("addressCredential") }}</div>
</template>
<span>
<p>{{ t("passwordTip") }}</p>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card>
<b>{{ curEmailPassword }}</b>
<n-card :bordered="false" embedded>
<b>{{ curEmailCredential }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('delteAccount')">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
@@ -284,20 +280,23 @@ onMounted(async () => {
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
<n-button @click="fetchData" type="primary" ghost>
<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>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
<div style="overflow: auto;">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
@@ -306,4 +305,8 @@ onMounted(async () => {
margin-top: 10px;
margin-bottom: 10px;
}
.n-data-table {
min-width: 1000px;
}
</style>

View File

@@ -5,37 +5,50 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
tip: 'You can manually input the following multiple select input and enter',
save: 'Save',
successTip: 'Save Success',
address_block_list: 'Address Block Keywords for Users(Admin can skip)',
address_block_list_placeholder: 'Please enter the keywords you want to block',
send_address_block_list: 'Address Block Keywords for send email',
noLimitSendAddressList: 'No Balance Limit Send Address List',
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
fromBlockList: 'Block Keywords for receive email',
},
zh: {
tip: '您可以手动输入以下多选输入框, 回车增加',
save: '保存',
successTip: '保存成功',
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
send_address_block_list: '发送邮件地址屏蔽关键词',
noLimitSendAddressList: '无余额限制发送地址列表',
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
fromBlockList: '接收邮件地址屏蔽关键词',
}
}
});
const addressBlockList = ref([])
const sendAddressBlockList = ref([])
const noLimitSendAddressList = ref([])
const verifiedAddressList = ref([])
const fromBlockList = ref([])
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 || []
} catch (error) {
message.error(error.message || "error");
}
@@ -47,7 +60,10 @@ const save = async () => {
method: 'POST',
body: JSON.stringify({
blockList: addressBlockList.value || [],
sendBlockList: sendAddressBlockList.value || []
sendBlockList: sendAddressBlockList.value || [],
verifiedAddressList: verifiedAddressList.value || [],
fromBlockList: fromBlockList.value || [],
noLimitSendAddressList: noLimitSendAddressList.value || [],
})
})
message.success(t('successTip'))
@@ -64,7 +80,10 @@ onMounted(async () => {
<template>
<div class="center">
<n-card style="max-width: 600px;">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert :show-icon="false" type="warning" style="margin-bottom: 10px;">
{{ t("tip") }}
</n-alert>
<n-form-item-row :label="t('address_block_list')">
<n-select v-model:value="addressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" />
@@ -73,6 +92,17 @@ onMounted(async () => {
<n-select v-model:value="sendAddressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" />
</n-form-item-row>
<n-form-item-row :label="t('noLimitSendAddressList')">
<n-select v-model:value="noLimitSendAddressList" filterable multiple tag
:placeholder="t('noLimitSendAddressList')" />
</n-form-item-row>
<n-form-item-row :label="t('verified_address_list')">
<n-select v-model:value="verifiedAddressList" filterable multiple tag
:placeholder="t('verified_address_list')" />
</n-form-item-row>
<n-form-item-row :label="t('fromBlockList')">
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')" />
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>

View File

@@ -6,12 +6,11 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
const {
localeCache, loading, openSettings,
loading, openSettings,
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
@@ -19,7 +18,7 @@ const { t } = useI18n({
creatNewEmail: 'Get New Email',
fillInAllFields: 'Please fill in all fields',
successTip: 'Success Created',
password: 'Password',
addressCredential: 'Mail Address Credential',
},
zh: {
address: '地址',
@@ -27,7 +26,7 @@ const { t } = useI18n({
creatNewEmail: '创建新邮箱',
fillInAllFields: '请填写完整信息',
successTip: '创建成功',
password: '密码',
addressCredential: '邮箱地址凭证',
}
}
});
@@ -70,13 +69,13 @@ onMounted(async () => {
<template>
<div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('password')">
<p>{{ t('password') }}</p>
<n-card>
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
<p>{{ t('addressCredential') }}</p>
<n-card :bordered="false" embedded>
<b>{{ result }}</b>
</n-card>
</n-modal>
<n-card style="max-width: 600px;">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
<n-checkbox v-model:checked="enablePrefix" />
</n-form-item-row>

View File

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

View File

@@ -1,34 +1,35 @@
<script setup>
import { ref, onMounted } from 'vue';
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 {
localeCache, adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const { adminMailTabAddress } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
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 queryAddress = () => {
mailBoxKey.value = adminMailTabAddress.value;
const queryMail = () => {
adminMailTabAddress.value = adminMailTabAddress.value.trim();
mailKeyword.value = mailKeyword.value.trim();
mailBoxKey.value = Date.now();
}
const fetchMailData = async (limit, offset) => {
@@ -37,25 +38,27 @@ const fetchMailData = async (limit, offset) => {
+ `?limit=${limit}`
+ `&offset=${offset}`
+ (adminMailTabAddress.value ? `&address=${adminMailTabAddress.value}` : '')
+ (mailKeyword.value ? `&keyword=${mailKeyword.value}` : '')
);
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
const deleteMail = async (curMailId) => {
await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' });
};
</script>
<template>
<div>
<div style="margin-top: 10px;">
<n-input-group>
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" />
<n-button @click="queryAddress" type="primary" ghost>
<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>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="false" :fetchMailData="fetchMailData" />
<div style="margin-top: 10px;"></div>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="true" :fetchMailData="fetchMailData"
:deleteMail="deleteMail" />
</div>
</template>

View File

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

View File

@@ -1,12 +1,10 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { CleaningServicesFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
const message = useMessage()
const cleanupModel = ref({
enableMailsAutoCleanup: false,
@@ -20,29 +18,28 @@ const cleanupModel = ref({
})
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
tip: 'Please input the cleanup days',
mailBoxLabel: 'Clean up days for mailbox',
mailUnknowLabel: "Clean up days for unknow receiver",
addressUnActiveLabel: "Clean up days for unactive address",
sendBoxLabel: "Clean up days for sendbox",
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",
cleanupNow: "Cleanup now",
autoCleanup: "Auto cleanup",
cleanupSuccess: "Cleanup success",
save: "Save",
cronTip: "Enable cron cleanup, need to configure [crons] in worker, please refer to the document",
},
zh: {
tip: '请输入清理天数',
mailBoxLabel: '收件箱清理天数',
mailUnknowLabel: "无收件人邮件清理天数",
addressUnActiveLabel: "未活跃地址清理天数",
sendBoxLabel: "发件箱清理天数",
tip: '请输入天数',
mailBoxLabel: '清理 n 天前的收件箱',
mailUnknowLabel: "清理 n 天前的无收件人邮件",
sendBoxLabel: "清理 n 天前的发件箱",
autoCleanup: "自动清理",
cleanupSuccess: "清理成功",
cleanupNow: "立即清理",
save: "保存",
cronTip: "启用定时清理, 需在 worker 配置 [crons] 参数, 请参考文档",
}
}
});
@@ -81,10 +78,6 @@ const save = async () => {
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData();
})
</script>
@@ -92,7 +85,10 @@ onMounted(async () => {
<template>
<div class="center">
<n-card>
<n-card :bordered="false" embedded>
<n-alert :show-icon="false" :bordered="false">
<span>{{ t('cronTip') }}</span>
</n-alert>
<n-form :model="cleanupModel">
<n-form-item-row :label="t('mailBoxLabel')">
<n-checkbox v-model:checked="cleanupModel.enableMailsAutoCleanup">
@@ -118,19 +114,7 @@ onMounted(async () => {
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('addressUnActiveLabel')">
<n-checkbox v-model:checked="cleanupModel.enableAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('address', cleanupModel.cleanAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('mailBoxLabel')">
<n-form-item-row :label="t('sendBoxLabel')">
<n-checkbox v-model:checked="cleanupModel.enableSendBoxAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
@@ -162,6 +146,10 @@ onMounted(async () => {
justify-content: center;
}
.n-alert {
margin-bottom: 20px;
}
.item {
display: flex;
margin: 10px;

View File

@@ -1,153 +1,48 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import SendBox from '../../components/SendBox.vue';
const { localeCache, adminAuth, adminSendBoxTabAddress } = useGlobalState()
const message = useMessage()
const { adminSendBoxTabAddress } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
success: 'Success',
to_mail: 'To Mail',
subject: 'Subject',
created_at: 'Created At',
action: 'Action',
query: 'Query',
itemCount: 'itemCount',
view: 'View',
queryTip: 'Please input address to query, leave blank to query all',
},
zh: {
address: '地址',
success: '成功',
to_mail: '收件人邮箱',
subject: '主题',
created_at: '创建时间',
action: '操作',
query: '查询',
itemCount: '总数',
view: '查看',
queryTip: '请输入地址查询, 留空则查询所有',
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curRow = ref({})
const showModal = ref(false)
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/admin/sendbox`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
);
data.value = results.map((item) => {
try {
const data = JSON.parse(item.raw);
item.to_mail = data?.personalizations?.map(
(p) => p.to?.map((t) => t.email).join(',')
).join(';');
item.subject = data.subject;
item.raw = JSON.stringify(data, null, 2);
} catch (error) {
console.log(error);
}
return item;
});
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
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 columns = [
{
title: "ID",
key: "id"
},
{
title: t('address'),
key: "address"
},
{
title: t('to_mail'),
key: "to_mail"
},
{
title: t('subject'),
key: "subject"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
ghost: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
}
},
{ default: () => t('view') }
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
const deleteSenboxMail = async (curMailId) => {
await api.fetch(`/admin/sendbox/${curMailId}`, { method: 'DELETE' });
};
</script>
<template>
<div>
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
</n-modal>
<n-input-group>
<n-input v-model:value="adminSendBoxTabAddress" />
<n-button @click="fetchData" type="primary" ghost>
<n-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>
<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" />
<SendBox style="margin-top: 10px;" :enableUserDeleteEmail="true" :deleteMail="deleteSenboxMail"
:fetchMailData="fetchData" :showEMailFrom="true" />
</div>
</template>

View File

@@ -0,0 +1,199 @@
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useI18n } from 'vue-i18n'
import { onBeforeUnmount, ref, shallowRef } from 'vue'
import { useSessionStorage } from '@vueuse/core'
import { api } from '../../api'
const message = useMessage()
const isPreview = ref(false)
const editorRef = shallowRef()
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
fromName: "",
fromMail: "",
toName: "",
toMail: "",
subject: "",
contentType: 'text',
content: "",
});
const { t } = useI18n({
locale: 'zh',
messages: {
en: {
successSend: 'Please check your sendbox. If failed, please try again later.',
fromName: 'Your Name and Address, leave Name blank to use email address',
toName: 'Recipient Name and Address, leave Name blank to use email address',
subject: 'Subject',
options: 'Options',
edit: 'Edit',
preview: 'Preview',
content: 'Content',
send: 'Send',
text: 'Text',
html: 'HTML',
'rich text': 'Rich Text',
tooLarge: 'Too large file, please upload file less than 1MB.',
},
zh: {
successSend: '请查看您的发件箱, 如果失败, 请检查稍后重试。',
fromName: '你的名称和地址,名称不填写则使用邮箱地址',
toName: '收件人名称和地址,名称不填写则使用邮箱地址',
subject: '主题',
options: '选项',
edit: '编辑',
preview: '预览',
content: '内容',
send: '发送',
text: '文本',
html: 'HTML',
'rich text': '富文本',
tooLarge: '文件过大, 请上传小于1MB的文件。',
}
}
});
const contentTypes = [
{ label: t('text'), value: 'text' },
{ label: t('html'), value: 'html' },
{ label: t('rich text'), value: 'rich' },
]
const send = async () => {
try {
await api.fetch(`/admin/send_mail`,
{
method: 'POST',
body:
JSON.stringify({
from_name: sendMailModel.value.fromName,
from_mail: sendMailModel.value.fromMail,
to_name: sendMailModel.value.toName,
to_mail: sendMailModel.value.toMail,
subject: sendMailModel.value.subject,
is_html: sendMailModel.value.contentType != 'text',
content: sendMailModel.value.content,
})
})
sendMailModel.value = {
fromName: "",
fromMail: "",
toName: "",
toMail: "",
subject: "",
contentType: 'text',
content: "",
}
} catch (error) {
message.error(error.message || "error");
} finally {
message.success(t("successSend"));
}
}
const toolbarConfig = {
excludeKeys: ["uploadVideo"]
}
const editorConfig = {
MENU_CONF: {
'uploadImage': {
async customUpload() {
message.error(t('tooLarge'))
},
maxFileSize: 1 * 1024 * 1024,
base64LimitSize: 1 * 1024 * 1024,
}
}
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor;
}
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded>
<n-flex justify="end">
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
</n-flex>
<div class="left">
<n-form :model="sendMailModel">
<n-form-item :label="t('fromName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.fromName" />
<n-input v-model:value="sendMailModel.fromMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('toName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.toName" />
<n-input v-model:value="sendMailModel.toMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('subject')" label-placement="top">
<n-input v-model:value="sendMailModel.subject" />
</n-form-item>
<n-form-item :label="t('options')" label-placement="top">
<n-radio-group v-model:value="sendMailModel.contentType">
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
:label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
style="margin-left: 10px;">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<n-card :bordered="false" embedded v-if="isPreview">
<div v-html="sendMailModel.content" />
</n-card>
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
:editor="editorRef" mode="default" />
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
</div>
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
minRows: 3
}" />
</n-form-item>
</n-form>
</div>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.n-button {
text-align: left;
margin-right: 10px;
}
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
.left {
text-align: left;
place-items: left;
justify-content: left;
}
</style>

View File

@@ -5,18 +5,20 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
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',
@@ -28,9 +30,12 @@ const { t } = useI18n({
zh: {
address: '地址',
success: '成功',
is_enabled: '是否启用',
enable: '启用',
disable: '禁用',
modify: '修改',
delete: '删除',
deleteTip: '确定删除吗?',
created_at: '创建时间',
action: '操作',
itemCount: '总数',
@@ -74,6 +79,7 @@ const updateData = async () => {
const fetchData = async () => {
try {
addressQuery.value = addressQuery.value.trim();
const { results, count: addressCount } = await api.fetch(
`/admin/address_sender`
+ `?limit=${pageSize.value}`
@@ -108,7 +114,7 @@ const columns = [
key: "balance"
},
{
title: "Enabled",
title: t('is_enabled'),
key: "enabled",
render(row) {
return h('div', [
@@ -124,7 +130,7 @@ const columns = [
h(NButton,
{
type: 'success',
ghost: true,
tertiary: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
@@ -133,7 +139,25 @@ const columns = [
}
},
{ 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')
}
),
])
}
}
@@ -169,20 +193,22 @@ onMounted(async () => {
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" />
<n-button @click="fetchData" type="primary" ghost>
<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="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
<div style="overflow: auto;">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
@@ -191,4 +217,8 @@ onMounted(async () => {
margin-top: 10px;
margin-bottom: 10px;
}
.n-data-table {
min-width: 700px;
}
</style>

View File

@@ -4,24 +4,25 @@ import { useI18n } from 'vue-i18n'
import { User, UserCheck, MailBulk } from '@vicons/fa'
import { SendOutlined } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, adminAuth } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
userCount: 'Account Count',
activeUser: '7 days Active Mail Account',
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: '地址总数',
activeUser: '周活跃邮箱地址',
userCount: '用户总数',
addressCount: '邮箱地址总数',
activeAddressCount7days: '7天活跃邮箱地址总数',
activeAddressCount30days: '30天活跃邮箱地址总数',
mailCount: '邮件总数',
sendMailCount: '发送邮件总数'
}
@@ -29,21 +30,27 @@ const { t } = useI18n({
});
const statistics = ref({
addressCount: 0,
userCount: 0,
mailCount: 0,
activeUserCount7days: 0,
activeAddressCount7days: 0,
activeAddressCount30days: 0,
sendMailCount: 0,
})
const fetchStatistics = async () => {
try {
const {
userCount, activeUserCount7days, mailCount, sendMailCount
userCount, mailCount, sendMailCount,
addressCount, activeAddressCount7days,
activeAddressCount30days,
} = await api.fetch(`/admin/statistics`);
statistics.value.mailCount = mailCount || 0;
statistics.value.userCount = userCount || 0;
statistics.value.activeUserCount7days = activeUserCount7days || 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");
@@ -51,44 +58,68 @@ const fetchStatistics = async () => {
}
onMounted(async () => {
if (!adminAuth.value) {
return;
}
await fetchStatistics()
})
</script>
<template>
<n-card>
<n-row>
<n-col :span="6">
<n-statistic :label="t('userCount')" :value="statistics.userCount">
<template #prefix>
<n-icon :component="User" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('activeUser')" :value="statistics.activeUserCount7days">
<template #prefix>
<n-icon :component="UserCheck" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('mailCount')" :value="statistics.mailCount">
<template #prefix>
<n-icon :component="MailBulk" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('sendMailCount')" :value="statistics.sendMailCount">
<template #prefix>
<n-icon :component="SendOutlined" />
</template>
</n-statistic>
</n-col>
</n-row>
</n-card>
<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>

View File

@@ -0,0 +1,164 @@
<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 user ID)',
enable: 'Enable',
telegramAllowList: 'Telegram Allow List(Manually input telegram user ID)',
save: 'Save',
miniAppUrl: 'Telegram Mini App URL',
enableGlobalMailPush: 'Enable Global Mail Push(Manually input telegram user ID)',
globalMailPushList: 'Global Mail Push List',
},
zh: {
init: '初始化',
successTip: '成功',
status: '查看状态',
enableTelegramAllowList: '启用 Telegram 白名单(手动输入用户 ID, 回车增加)',
enable: '启用',
telegramAllowList: 'Telegram 白名单(手动输入用户 ID, 回车增加)',
save: '保存',
miniAppUrl: '电报小程序 URL(请输入你部署的电报小程序网页地址)',
enableGlobalMailPush: '启用全局邮件推送(手动输入邮箱管理员的 telegram 用户 ID, 回车增加)',
globalMailPushList: '全局邮件推送用户列表',
}
}
});
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-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')" />
</n-input-group>
</n-form-item-row>
<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')" />
</n-input-group>
</n-form-item-row>
<n-form-item-row :label="t('miniAppUrl')">
<n-input v-model:value="settings.miniAppUrl"></n-input>
</n-form-item-row>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</n-card>
<n-button @click="init" type="primary" block>
{{ t('init') }}
</n-button>
<n-button @click="fetchStatus" secondary block>
{{ t('status') }}
</n-button>
<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;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,400 @@
<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';
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',
},
zh: {
success: '成功',
user_email: '用户邮箱',
role: '角色',
address_count: '地址数量',
created_at: '创建时间',
actions: '操作',
query: '查询',
itemCount: '总数',
deleteUser: '删除用户',
delete: '删除',
deleteUserTip: '确定要删除此用户吗?',
resetPassword: '重置密码',
pleaseInput: '请输入完整信息',
createUser: '创建用户',
email: '邮箱',
password: '密码',
changeRole: '更改角色',
prefix: '前缀',
domains: '域名',
roleDonotExist: '当前角色不存在',
}
}
});
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 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(NBadge, {
value: row.address_count,
'show-zero': true,
max: 99,
type: "success"
})
}
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
h(NMenu, {
mode: "horizontal",
options: [
{
label: t('actions'),
icon: () => h(MenuFilled),
key: "action",
children: [
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curUserId.value = row.id;
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-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>

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
<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',
webhookAllowList: 'Webhook Allow List(Enter the address that is allowed to use webhook and enter)',
save: 'Save',
notEnabled: 'Webhook is not enabled',
},
zh: {
successTip: '成功',
webhookAllowList: 'Webhook 白名单(请输入允许使用webhook 的地址, 回车增加)',
save: '保存',
notEnabled: 'Webhook 未开启',
}
}
});
class WebhookSettings {
allowList: string[];
constructor(allowList: string[]) {
this.allowList = allowList;
}
}
const webhookSettings = ref(new WebhookSettings([]))
const webhookEnabled = ref(false)
const errorInfo = ref('')
const getSettings = async () => {
try {
const res = await api.fetch(`/admin/webhook/settings`)
Object.assign(webhookSettings.value, res)
webhookEnabled.value = true
} catch (error) {
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-form-item-row :label="t('webhookAllowList')">
<n-select v-model:value="webhookSettings.allowList" filterable multiple tag
:placeholder="t('webhookAllowList')" />
</n-form-item-row>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</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>

View File

@@ -0,0 +1,42 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { loading } = useGlobalState()
const message = useMessage()
const settings = ref({})
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/worker/configs`)
Object.assign(settings.value, res)
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded style="max-width: 600px;">
<pre>{{ JSON.stringify(settings, null, 2) }}</pre>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup>
import { GithubAlt, Discord, Telegram } from '@vicons/fa'
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded>
<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>

View File

@@ -1,10 +1,9 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
const { localeCache, openSettings } = useGlobalState()
const { openSettings } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
adminContact: 'If you need help, please contact the administrator ({msg})',
@@ -17,7 +16,7 @@ const { t } = useI18n({
</script>
<template>
<n-alert v-if="openSettings.adminContact" type="info" show-icon>
<n-alert v-if="openSettings.adminContact" :show-icon="false" :bordered="false">
<span>{{ t('adminContact', { msg: openSettings.adminContact }) }}</span>
</n-alert>
</template>

View File

@@ -0,0 +1,95 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useIsMobile } from '../../utils/composables'
import { useGlobalState } from '../../store'
const {
mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
globalTabplacement, useSideMargin, useUTCDate
} = useGlobalState()
const isMobile = useIsMobile()
const { t } = useI18n({
messages: {
en: {
mailboxSplitSize: 'Mailbox Split Size',
useIframeShowMail: 'Use iframe Show HTML Mail',
preferShowTextMail: 'Display text Mail by default',
useSideMargin: 'Turn on the side margins on the left and right sides of the page',
globalTabplacement: 'Global Tab Placement',
left: 'left',
top: 'top',
right: 'right',
bottom: 'bottom',
useUTCDate: 'Use UTC Date',
autoRefreshInterval: 'Auto Refresh Interval(Sec)',
},
zh: {
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 :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>

View File

@@ -0,0 +1,285 @@
<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 } 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 router = useRouter()
const {
jwt, loading, openSettings,
showAddressCredential, userSettings
} = useGlobalState()
const tabValue = ref('signin')
const credential = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const login = async () => {
if (!credential.value) {
message.error(t('credentialInput'));
return;
}
try {
jwt.value = credential.value;
await api.getSettings();
try {
await 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',
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',
},
zh: {
login: '登录',
pleaseGetNewEmail: '请"登录"或点击 "注册新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '创建新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许: ',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
credential: '邮箱地址凭据',
ok: '确定',
generateName: '生成随机名字',
help: '帮助',
credentialInput: '请输入邮箱地址凭据',
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
bindUserAddressError: '绑定邮箱地址到用户时错误',
}
}
});
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 {
const res = await props.newAddressPath(
emailName.value,
emailDomain.value,
cfToken.value
);
jwt.value = res["jwt"];
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();
}
emailDomain.value = domainsOptions.value ? domainsOptions.value[0]?.value : "";
});
</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="t('login')">
<n-form>
<n-form-item-row :label="t('credential')" required>
<n-input v-model:value="credential" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-button @click="login" :loading="loading" type="primary" block secondary strong>
<template #icon>
<n-icon :component="EmailOutlined" />
</template>
{{ t('login') }}
</n-button>
<n-button v-if="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>{{ t("getNewEmailTip1") + addressRegex.source }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>
<n-button @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="addressPrefix">
{{ addressPrefix }}
</n-input-group-label>
<n-input v-model:value="emailName" show-count :minlength="openSettings.minAddressLen"
:maxlength="openSettings.maxAddressLen" />
<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;
}
.n-form {
text-align: left;
}
</style>

View File

@@ -5,35 +5,31 @@ import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import Appearance from '../common/Appearance.vue'
import { getRouterPathWithLang } from '../../utils'
const {
jwt, localeCache, settings, showPassword, loading,
mailboxSplitSize, useIframeShowMail
jwt, settings, showAddressCredential, loading
} = useGlobalState()
const router = useRouter()
const message = useMessage()
const showLogout = ref(false)
const showDelteAccount = ref(false)
const { t } = useI18n({
locale: localeCache.value || 'zh',
const { locale, t } = useI18n({
messages: {
en: {
mailboxSplitSize: 'Mailbox Split Size',
useIframeShowMail: 'Use iframe Show Mail',
logout: "Logout",
delteAccount: "Delete Account",
showPassword: 'Show Password',
showAddressCredential: 'Show Address Credential',
logoutConfirm: 'Are you sure to logout?',
delteAccount: "Delete Account",
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
},
zh: {
mailboxSplitSize: '邮箱界面分栏大小',
useIframeShowMail: '使用iframe显示邮件',
logout: '退出登录',
delteAccount: "删除账户",
showPassword: '查看密码',
showAddressCredential: '查看邮箱地址凭证',
logoutConfirm: '确定要退出登录吗?',
delteAccount: "删除账户",
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
@@ -43,7 +39,7 @@ const { t } = useI18n({
const logout = async () => {
jwt.value = '';
await router.push('/')
await router.push(getRouterPathWithLang("/", locale.value))
location.reload()
}
@@ -53,7 +49,7 @@ const deleteAccount = async () => {
method: 'DELETE'
});
jwt.value = '';
await router.push('/')
await router.push(getRouterPathWithLang("/", locale.value))
location.reload()
} catch (error) {
message.error(error.message || "error");
@@ -63,21 +59,10 @@ const deleteAccount = async () => {
<template>
<div class="center" v-if="settings.address">
<n-card>
<n-card>
<n-form-item-row :label="t('mailboxSplitSize')">
<n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{
0.25: '0.25',
0.5: '0.5',
0.75: '0.75'
}" />
</n-form-item-row>
<n-form-item-row :label="t('useIframeShowMail')">
<n-switch v-model:value="useIframeShowMail" :round="false" />
</n-form-item-row>
</n-card>
<n-button @click="showPassword = true" type="primary" secondary block strong>
{{ t('showPassword') }}
<n-card :bordered="false" embedded>
<Appearance />
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }}
</n-button>
<n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }}

View File

@@ -0,0 +1,168 @@
<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
} = 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',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
userLogin: 'User Login',
},
zh: {
addressManage: '地址管理',
changeAddress: '更换地址',
ok: '确定',
copy: '复制',
copied: '已复制',
fetchAddressError: '邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
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 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 :bordered="false" embedded>
<b>{{ jwt }}</b>
</n-card>
</n-modal>
</div>
</template>
<style scoped>
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.n-card {
margin-top: 10px;
}
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

View File

@@ -0,0 +1,91 @@
<script setup>
import { ref, h, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { api } from '../../api'
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
download: 'Download',
action: 'Action',
},
zh: {
download: '下载',
action: '操作',
}
}
});
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') }
)
])
}
}
]
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>

View File

@@ -81,7 +81,7 @@ onMounted(async () => {
<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="saveData">{{ t('save') }}</n-button>
</div>

View 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',
bind: 'Bind',
bindAddressSuccess: 'Bind Address Success',
},
zh: {
tip: '这些地址存储在您的浏览器中,如果您清除浏览器缓存,可能会丢失。',
success: '成功',
address: '地址',
actions: '操作',
changeMailAddress: '切换邮箱地址',
unbindMailAddress: '解绑邮箱地址',
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="bind" :tab="t('bind')">
<Login :bindUserAddress="bindAddress" />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

@@ -3,18 +3,17 @@ 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 '../admin/AdminContact.vue'
import AdminContact from '../common/AdminContact.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import router from '../../router'
const message = useMessage()
const isPreview = ref(false)
const editorRef = shallowRef()
const { settings, sendMailModel } = useGlobalState()
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
const { t } = useI18n({
locale: 'zh',
@@ -91,7 +90,7 @@ const send = async () => {
message.error(error.message || "error");
} finally {
message.success(t("successSend"));
router.push('/user');
indexTab.value = 'sendbox'
}
}
@@ -137,28 +136,30 @@ const handleCreated = (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>
<n-card :bordered="false" embedded>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<n-alert type="warning" show-icon>
<n-alert type="warning" :show-icon="false" :bordered="false">
{{ t('requestAccessTip') }}
<n-button type="primary" ghost @click="requestAccess">{{ t('requestAccess') }}</n-button>
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
}}</n-button>
</n-alert>
<br />
<AdminContact />
</div>
<div v-else>
<n-alert type="info" show-icon>
<n-alert type="info" :show-icon="false" :bordered="false" closable>
{{ t('send_balance') }}: {{ settings.send_balance }}
</n-alert>
<div class="right">
<n-flex justify="end">
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
</div>
</n-flex>
<div class="left">
<n-form :model="sendMailModel">
<n-form-item :label="t('fromName')" label-placement="top">
@@ -187,7 +188,7 @@ onMounted(async () => {
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<n-card v-if="isPreview">
<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">
@@ -230,9 +231,7 @@ onMounted(async () => {
justify-content: left;
}
.right {
text-align: right;
place-items: right;
justify-content: right;
.n-alert {
margin-bottom: 10px;
}
</style>

View File

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

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

View File

@@ -1,156 +0,0 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, settings } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
success: 'Success',
to_mail: 'To Mail',
subject: 'Subject',
created_at: 'Created At',
action: 'Action',
refresh: 'Refresh',
itemCount: 'itemCount',
view: 'View',
ok: 'OK'
},
zh: {
address: '地址',
success: '成功',
to_mail: '收件人邮箱',
subject: '主题',
created_at: '创建时间',
action: '操作',
refresh: '刷新',
itemCount: '总数',
view: '查看',
ok: '确定'
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curRow = ref({})
const showModal = ref(false)
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/api/sendbox`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
);
data.value = results.map((item) => {
try {
const data = JSON.parse(item.raw);
item.to_mail = data?.personalizations?.map(
(p) => p.to?.map((t) => t.email).join(',')
).join(';');
item.subject = data.subject;
item.raw = JSON.stringify(data, null, 2);
} catch (error) {
console.log(error);
}
return item;
});
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('to_mail'),
key: "to_mail"
},
{
title: t('subject'),
key: "subject"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
ghost: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
}
},
{ default: () => t('view') }
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div v-if="settings.address">
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
</n-modal>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
<template #suffix>
<n-button @click="fetchData" type="primary" size="small" ghost>
{{ t('refresh') }}
</n-button>
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

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

View File

@@ -0,0 +1,235 @@
<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'
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?'
},
zh: {
success: '成功',
name: '名称',
mail_count: '邮件数量',
send_count: '发送数量',
actions: '操作',
changeMailAddress: '切换地址',
unbindAddress: '解绑地址',
unbindAddressTip: '解绑前请切换到此邮箱地址并保存邮箱地址凭证。',
transferAddress: '转移地址',
targetUserEmail: '目标用户邮箱',
transferAddressTip: '转移地址到其他用户将会从你的账户中移除此地址并转移给其他用户。确定要转移地址吗?'
}
}
});
const data = ref([])
const showTranferAddress = ref(false)
const currentAddress = ref("")
const currentAddressId = ref(0)
const targetUserEmail = ref('')
const changeMailAddress = async (address_id) => {
try {
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, count: addressCount } = await api.fetch(
`/user_api/bind_address`
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: 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>
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
<span>
<p>{{ t("transferAddressTip") }}</p>
<p>{{ t('transferAddress') + ": " + currentAddress }}</p>
<n-input v-model:value="targetUserEmail" :placeholder="t('targetUserEmail')" />
</span>
<template #action>
<n-button :loading="loading" @click="transferAddress" size="small" tertiary type="error">
{{ t('transferAddress') }}
</n-button>
</template>
</n-modal>
<div style="overflow: auto;">
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
</template>
<style scoped>
.n-data-table {
min-width: 700px;
}
</style>

View File

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

View File

@@ -0,0 +1,76 @@
<script setup>
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import UserLogin from './UserLogin.vue'
const message = useMessage()
const router = useRouter()
const {
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>

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

View File

@@ -0,0 +1,65 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router';
import { useGlobalState } from '../../store'
import { api } from '../../api';
const {
userJwt, userOauth2SessionState, userOauth2SessionClientID
} = useGlobalState()
const message = useMessage();
const route = useRoute()
const router = useRouter()
const errorInfo = ref('')
const { t } = useI18n({
messages: {
en: {
logging: 'Logging in...',
stateNotMatch: 'state not match',
},
zh: {
logging: '登录中...',
stateNotMatch: 'state 不匹配',
}
}
});
onMounted(async () => {
const state = route.query.state;
if (state != userOauth2SessionState.value) {
console.error('state not match');
message.error(t('stateNotMatch'));
return;
}
const code = route.query.code;
if (!code) {
console.error('code not found');
message.error('code not found');
return;
}
try {
const res = await api.fetch(`/user_api/oauth2/callback`, {
method: 'POST',
body: JSON.stringify({
code: code,
clientID: userOauth2SessionClientID.value
})
});
userJwt.value = res.jwt;
router.push('/user');
} catch (error) {
console.error(error);
message.error(error.message || 'error');
}
});
</script>
<template>
<n-card :bordered="false" embedded>
<n-result status="info" :title="t('logging')" :description="errorInfo">
</n-result>
</n-card>
</template>

View File

@@ -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="primary">
{{ t('logout') }}
</n-button>
</template>
</n-modal>
</div>
</template>
<style scoped>
.center {
display: flex;
justify-content: center;
}
.n-card {
max-width: 800px;
text-align: left;
}
.n-button {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

13
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": [
"ESNext"
],
"types": []
},
}

View File

@@ -13,15 +13,6 @@ import topLevelAwait from "vite-plugin-top-level-await";
export default defineConfig({
build: {
outDir: './dist',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('wangeditor')) {
return 'vendor-wangeditor';
}
}
}
}
},
plugins: [
vue(),
@@ -44,13 +35,16 @@ export default defineConfig({
resolvers: [NaiveUiResolver()]
}),
VitePWA({
registerType: 'autoUpdate',
registerType: null,
devOptions: {
enabled: true
enabled: false
},
workbox: {
disableDevLogs: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
globPatterns: [],
runtimeCaching: [],
navigateFallback: null,
cleanupOutdatedCaches: true,
},
manifest: {
name: 'Temp Email',
@@ -71,5 +65,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
},
}
})

View File

@@ -12,3 +12,4 @@ Cargo.lock
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
web/

View File

@@ -1,6 +1,6 @@
[package]
name = "mail-parser-wasm"
version = "0.1.6"
version = "0.2.1"
edition = "2021"
description = "A simple mail parser for wasm"
license = "MIT"
@@ -9,5 +9,5 @@ license = "MIT"
crate-type = ["cdylib"]
[dependencies]
mail-parser = "0.9.3"
wasm-bindgen = "0.2.92"
mail-parser = "0.9.4"
wasm-bindgen = "0.2.99"

View File

@@ -1,16 +1,45 @@
# mail-parser-wasm
# mail-parser-wasm web and cf worker
## usage
## [mail-parser-wasm](https://www.npmjs.com/package/mail-parser-wasm)
### mail-parser-wasm usage
```bash
pnpm add mail-parser-wasm
```
```js
import { parse_message } from 'mail-parser-wasm'
const parsedEmail = parse_message(item.raw);
const parsedEmail = parse_message(rawEmail);
```
## build
### mail-parser-wasm build
```bash
wasm-pack build --release
wasm-pack publish
```
## [mail-parser-wasm-worker](https://www.npmjs.com/package/mail-parser-wasm-worker)
### mail-parser-wasm-worker usage
```bash
pnpm add mail-parser-wasm-worker
```
```js
import { parse_message_wrapper } from 'mail-parser-wasm-worker'
const parsedEmail = parse_message_wrapper(rawEmail);
```
### mail-parser-wasm-worker build
```bash
wasm-pack build --out-dir web --target web --release
find web/ -type f ! -name '*.json' ! -name '.gitignore' -exec cp {} worker/ \;
# modify worker/package.json version or whatever
pnpm publish worker --no-git-checks
```

View File

@@ -35,10 +35,31 @@ impl AttachmentResult {
}
}
#[derive(Clone)]
#[wasm_bindgen]
pub struct MessageHeader {
key: String,
value: String,
}
#[wasm_bindgen]
impl MessageHeader {
#[wasm_bindgen(getter)]
pub fn key(&self) -> String {
self.key.clone()
}
#[wasm_bindgen(getter)]
pub fn value(&self) -> String {
self.value.clone()
}
}
#[wasm_bindgen]
pub struct MessageResult {
sender: String,
subject: String,
headers: Vec<MessageHeader>,
body_html: String,
text: String,
attachments: Vec<AttachmentResult>,
@@ -56,6 +77,11 @@ impl MessageResult {
self.subject.clone()
}
#[wasm_bindgen(getter)]
pub fn headers(&self) -> Vec<MessageHeader> {
self.headers.clone()
}
#[wasm_bindgen(getter)]
pub fn body_html(&self) -> String {
self.body_html.clone()
@@ -119,6 +145,7 @@ pub fn parse_message(raw_message: &str) -> MessageResult {
return MessageResult {
sender: String::new(),
subject: String::new(),
headers: Vec::new(),
body_html: String::new(),
text: String::new(),
attachments: Vec::new(),
@@ -146,6 +173,14 @@ pub fn parse_message(raw_message: &str) -> MessageResult {
.subject()
.map(|subject| subject.to_owned())
.unwrap_or(String::new()),
headers: message
.headers()
.iter()
.map(|header| MessageHeader {
key: header.name().to_owned(),
value: header.value().as_text().unwrap_or("").to_owned(),
})
.collect(),
body_html: message
.body_html(0)
.map(|html| html.into_owned())

9
mail-parser-wasm/worker/index.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import initAsync, { MessageResult } from './mail_parser_wasm';
import MODULE from './mail_parser_wasm_bg.wasm';
export { initAsync, MODULE };
export * from './mail_parser_wasm';
/**
* @param {string} raw_message
* @returns {MessageResult}
*/
export function parse_message_wrapper(raw_message: string): MessageResult;

View File

@@ -0,0 +1,12 @@
import initAsync, { initSync, parse_message } from './mail_parser_wasm';
import MODULE from './mail_parser_wasm_bg.wasm';
initSync({ module: MODULE });
export { initAsync, MODULE };
export * from './mail_parser_wasm';
export const parse_message_wrapper = (raw_message) => {
initSync({ module: MODULE });
return parse_message(raw_message);
}

View File

@@ -0,0 +1,24 @@
{
"name": "mail-parser-wasm-worker",
"description": "A simple mail parser for worker",
"homepage": "https://github.com/dreamhunter2333/cloudflare_temp_email/tree/main/mail-parser-wasm",
"repository": {
"type": "git",
"url": "https://github.com/dreamhunter2333/cloudflare_temp_email",
"directory": "mail-parser-wasm"
},
"version": "0.2.1",
"license": "MIT",
"files": [
"mail_parser_wasm_bg.wasm",
"mail_parser_wasm.js",
"mail_parser_wasm.d.ts",
"index.js",
"index.d.ts"
],
"module": "index.js",
"types": "index.d.ts",
"sideEffects": [
"./snippets/*"
]
}

34
pages/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env.*
*-dist/
components.d.ts
.wrangler/
pnpm-lock.yaml

View File

@@ -0,0 +1,15 @@
const API_PATHS = [
"/api/",
"/open_api/",
"/user_api/",
"/admin/",
"/telegram/"
];
export async function onRequest(context) {
const reqPath = new URL(context.request.url).pathname;
if (API_PATHS.map(path => reqPath.startsWith(path)).some(Boolean)) {
return context.env.BACKEND.fetch(context.request);
}
return await context.next();
}

16
pages/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "temp-email-pages",
"version": "0.8.5",
"description": "",
"main": "index.js",
"scripts": {
"dev": "wrangler pages dev",
"deploy": "wrangler pages deploy --branch production"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^3.101.0"
}
}

8
pages/wrangler.toml Normal file
View File

@@ -0,0 +1,8 @@
name = "temp-email-pages"
pages_build_output_dir = "../frontend/dist"
compatibility_date = "2024-05-13"
[[services]]
binding = "BACKEND"
service = "cloudflare_temp_email"
environment = "production"

View File

@@ -0,0 +1,22 @@
import logging
from pydantic_settings import BaseSettings
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
class Settings(BaseSettings):
proxy_url: str = "http://localhost:8787"
port: int = 8025
imap_port: int = 11143
basic_password: str = ""
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -7,6 +7,8 @@ services:
container_name: "smtp_proxy_server"
ports:
- "8025:8025"
- "11143:11143"
environment:
- proxy_url=https://temp-email-api.xxx.xxx
- port=8025
- imap_port=11143

View File

@@ -4,4 +4,4 @@ WORKDIR /app
COPY requirements.txt /requirements.txt
RUN python3 -m pip install -r /requirements.txt
COPY . /app
ENTRYPOINT [ "python3", "server.py" ]
ENTRYPOINT [ "python3", "main.py" ]

View File

@@ -0,0 +1,251 @@
import json
import logging
import httpx
from io import BytesIO
from twisted.mail import imap4
from zope.interface import implementer
from twisted.cred.portal import Portal, IRealm
from twisted.internet import protocol, reactor, defer
from twisted.cred.checkers import ICredentialsChecker, IUsernamePassword
from config import settings
from parse_email import generate_email_model, parse_email
from models import EmailModel
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
@implementer(imap4.IMessage)
class SimpleMessage:
def __init__(self, uid=None, email_model: EmailModel = None):
self.uid = uid
self.email = email_model
self.subparts = self.email.subparts
def getUID(self):
return self.uid
def getHeaders(self, negate, *names):
self.got_headers = negate, names
return {
k.lower(): v
for k, v in self.email.headers.items()
}
def isMultipart(self):
return len(self.subparts) > 0
def getSubPart(self, part):
self.got_subpart = part
return SimpleMessage(email_model=self.subparts[part])
def getBodyFile(self):
return BytesIO(self.email.body.encode("utf-8"))
def getSize(self):
return self.email.size
def getFlags(self):
return ["\\Seen"]
def getInternalDate(self):
return self.email.headers.get("Date", "Mon, 1 Jan 1900 00:00:00 +0000")
@implementer(imap4.IMailboxInfo, imap4.IMailbox)
class SimpleMailbox:
def __init__(self, name, password):
self.name = name
self.password = password
self.listeners = []
self.addListener = self.listeners.append
self.removeListener = self.listeners.remove
self.message_count = 0
def getFlags(self):
return ["\\Seen"]
def getUIDValidity(self):
return 0
def getMessageCount(self):
return self.message_count or 1000
def getRecentCount(self):
return 0
def getUnseenCount(self):
return 0
def isWriteable(self):
return 0
def destroy(self):
pass
def getHierarchicalDelimiter(self):
return "/"
def requestStatus(self, names):
r = {}
if "MESSAGES" in names:
r["MESSAGES"] = self.getMessageCount()
if "RECENT" in names:
r["RECENT"] = self.getRecentCount()
if "UIDNEXT" in names:
r["UIDNEXT"] = self.getMessageCount() + 1
if "UIDVALIDITY" in names:
r["UIDVALIDITY"] = self.getUIDValidity()
if "UNSEEN" in names:
r["UNSEEN"] = self.getUnseenCount()
return defer.succeed(r)
def fetch(self, messages, uid):
if self.name == "INBOX":
return self.fetchINBOX(messages)
if self.name == "SENT":
return self.fetchSENT(messages)
return []
def fetchINBOX(self, messages):
start, end = messages.ranges[0]
start = max(start, 1)
limit = min(20, end - start + 1) if end and end >= start else 20
if self.message_count > 0 and start > self.message_count:
return []
res = httpx.get(
f"{settings.proxy_url}/api/mails?limit={limit}&offset={start - 1}",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)
if res.status_code != 200:
_logger.error(
"Failed: "
f"code=[{res.status_code}] text=[{res.text}]"
)
raise Exception("Failed to fetch emails")
if res.json()["count"] > 0:
self.message_count = res.json()["count"]
return [
(start + uid, SimpleMessage(start + uid, parse_email(item["raw"])))
for uid, item in enumerate(reversed(res.json()["results"]))
]
def fetchSENT(self, messages):
start, end = messages.ranges[0]
start = max(start, 1)
limit = min(20, end - start + 1) if end and end >= start else 20
if self.message_count > 0 and start > self.message_count:
return []
res = httpx.get(
f"{settings.proxy_url}/api/sendbox?limit={limit}&offset={start - 1}",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)
if res.status_code != 200:
_logger.error(
"Failed: "
f"code=[{res.status_code}] text=[{res.text}]"
)
raise Exception("Failed to fetch emails")
if res.json()["count"] > 0:
self.message_count = res.json()["count"]
return [
(start + uid, SimpleMessage(start + uid, generate_email_model(item)))
for uid, item in enumerate(reversed(res.json()["results"]))
]
def getUID(self, message):
return message.uid
def store(self, messages, flags, mode, uid):
# IMailboxIMAP.store
raise NotImplementedError
class Account(imap4.MemoryAccount):
def __init__(self, user, password):
self.password = password
super().__init__(user)
def isSubscribed(self, name):
return name.upper() in ["INBOX", "SENT"]
def _emptyMailbox(self, name, id):
_logger.info(f"New mailbox: {name}, {id}")
if name == "INBOX":
return SimpleMailbox(name, self.password)
if name == "SENT":
return SimpleMailbox(name, self.password)
raise imap4.NoSuchMailbox(name.encode("utf-8"))
def select(self, name, rw=1):
return imap4.MemoryAccount.select(self, name)
class SimpleIMAPServer(imap4.IMAP4Server):
def __init__(self, factory):
imap4.IMAP4Server.__init__(self)
self.factory = factory
def lineReceived(self, line):
# _logger.info(f"Received: {line}")
super().lineReceived(line)
def sendLine(self, line):
# _logger.info(f"Sent: {line}")
super().sendLine(line)
@implementer(IRealm)
class SimpleRealm:
def requestAvatar(self, avatarId, mind, *interfaces):
res = json.loads(avatarId)
account = Account(res["username"], res["password"])
account.addMailbox("INBOX")
account.addMailbox("SENT")
return imap4.IAccount, account, lambda: None
class IMAPFactory(protocol.Factory):
def __init__(self, portal):
self.portal = portal
def buildProtocol(self, addr):
p = SimpleIMAPServer(self)
p.portal = self.portal
return p
@implementer(ICredentialsChecker)
class CustomChecker:
credentialInterfaces = (IUsernamePassword,)
def requestAvatarId(self, credentials):
return defer.succeed(json.dumps({
"username": credentials.username.decode(),
"password": credentials.password.decode(),
}))
def start_imap_server():
_logger.info(f"Starting IMAP server on port {settings.imap_port}")
portal = Portal(SimpleRealm(), [CustomChecker()])
reactor.listenTCP(settings.imap_port, IMAPFactory(portal))
reactor.run()
if __name__ == "__main__":
_logger.info(f"Starting server settings[{settings}]")
start_imap_server()

24
smtp_proxy_server/main.py Normal file
View File

@@ -0,0 +1,24 @@
import logging
import multiprocessing
from smtp_server import start_smtp_server
from imap_server import start_imap_server
from config import settings
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
if __name__ == '__main__':
_logger.info(f"Starting server settings[{settings}]")
process_list = [
multiprocessing.Process(target=start_smtp_server, args=()),
multiprocessing.Process(target=start_imap_server, args=()),
]
try:
for p in process_list:
p.start()
for p in process_list:
p.join()
except KeyboardInterrupt:
for p in process_list:
p.terminate()

View File

@@ -0,0 +1,10 @@
from typing import Dict, List
from pydantic import BaseModel
class EmailModel(BaseModel):
headers: Dict[str, str]
body: str
content_type: str
subparts: List["EmailModel"]
size: int

View File

@@ -0,0 +1,72 @@
import datetime
import json
import logging
import email
from email.message import Message
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from models import EmailModel
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
def get_email_model(msg: Message):
subparts = [
get_email_model(subpart)
for subpart in msg.get_payload()
] if msg.is_multipart() else []
body = "" if msg.is_multipart() else msg._payload
return EmailModel(
headers={k: v for k, v in msg.items()},
body=body,
content_type=msg.get_content_type(),
size=len(body) + sum(subpart.size for subpart in subparts),
subparts=subparts,
)
def parse_email(raw: str) -> EmailModel:
try:
msg = email.message_from_string(raw)
return get_email_model(msg)
except Exception as e:
_logger.error(f"Could not parse email: {e}")
return EmailModel(
headers={},
body="could not parse email",
content_type="text/plain",
size=len("could not parse email"),
subparts=[],
)
def generate_email_model(item: dict) -> EmailModel:
email_json = json.loads(item["raw"])
message = MIMEMultipart()
if email_json.get("version") == "v2":
message['From'] = f"{email_json["from_name"]} <{item["address"]}>" if email_json.get(
"from_name") else item["address"]
message['To'] = f"{email_json["to_name"]} <{email_json["to_mail"]}>" if email_json.get(
"to_name") else email_json["to_mail"]
message.attach(MIMEText(
email_json["content"],
"html" if email_json.get("is_html") else "plain"
))
else:
message['From'] = f"{email_json["from"]['name']} <{
email_json["from"]['email']}>"
message['To'] = ", ".join(
[f"{to['name']} <{to['email']}>" for to in email_json["personalizations"][0]["to"]])
message.attach(MIMEText(
email_json["content"][0]["value"],
"html" if "html" in email_json["content"][0]["type"] else "plain"
))
message['Subject'] = email_json["subject"]
message["Date"] = datetime.datetime.strptime(
item["created_at"], "%Y-%m-%d %H:%M:%S"
).strftime("%a, %d %b %Y %H:%M:%S +0000")
return parse_email(message.as_string())

View File

@@ -1,3 +1,5 @@
aiosmtpd==1.4.5
aiosmtpd==1.4.6
pydantic-settings==2.2.1
requests==2.31.0
requests==2.32.0
twisted==24.7.0
httpx==0.27.0

View File

@@ -1,28 +1,17 @@
import asyncio
import logging
import email
import requests
import httpx
from pydantic_settings import BaseSettings
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import SMTP, Session, Envelope, AuthResult, LoginPassword
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
from config import settings
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
class Settings(BaseSettings):
proxy_url: str = "http://localhost:8787"
port: int = 8025
class Config:
env_file = ".env"
class CustomSMTPHandler:
def authenticator(self, server, session, envelope, mechanism, auth_data):
@@ -51,20 +40,34 @@ class CustomSMTPHandler:
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
payload = part.get_payload(decode=True)
charset = part.get_content_charset()
cte = str(part.get('content-transfer-encoding', '')).lower()
if content_type not in ["text/plain", "text/html"]:
_logger.warning(f"Skipping {content_type}")
continue
if not payload:
if cte == "8bit":
value = part.get_payload(decode=False)
else:
payload = part.get_payload(decode=True)
value = payload.decode(charset) if charset else payload
if not value:
continue
content_list.append({
"type": content_type,
"value": payload.decode()
"value": value
})
elif msg.get_content_type() in ["text/plain", "text/html"] and msg.get_payload(decode=True):
cte = str(msg.get('content-transfer-encoding', '')).lower()
charset = msg.get_content_charset()
if cte == "8bit":
value = msg.get_payload(decode=False)
else:
payload = msg.get_payload(decode=True)
value = payload.decode(charset) if charset else payload
_logger.info(f"Payload {msg._payload} charset {charset}")
content_list.append({
"type": msg.get_content_type(),
"value": msg.get_payload(decode=True).decode()
"value": value
})
if not content_list:
@@ -97,9 +100,9 @@ class CustomSMTPHandler:
"is_html": body["type"] == "text/html",
"content": body["value"],
}
_logger.info(f"Send mail {send_body}")
_logger.info(f"Send mail {dict(send_body, token='***')}")
try:
res = requests.post(
res = httpx.post(
f"{settings.proxy_url}/external/api/send_mail",
json=send_body, headers={
"Content-Type": "application/json"
@@ -118,7 +121,6 @@ class CustomSMTPHandler:
return '250 OK'
settings = Settings()
handler = CustomSMTPHandler()
server = Controller(
handler,
@@ -132,11 +134,11 @@ server = Controller(
async def start():
_logger.info(f"Starting server settings[{settings}]")
_logger.info(f"Starting server on port {settings.port}")
server.start()
if __name__ == "__main__":
def start_smtp_server():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task = loop.create_task(start())
@@ -145,3 +147,8 @@ if __name__ == "__main__":
except KeyboardInterrupt:
_logger.info("Got KeyboardInterrupt, stopping")
server.stop()
if __name__ == "__main__":
_logger.info(f"Starting server settings[{settings}]")
start_smtp_server()

Some files were not shown because too many files have changed in this diff Show More