129 Commits

Author SHA1 Message Date
Sebastian 539284d5b5 chore(deps): update dependency phpunit/phpunit to v12.5.35
Build Test / test (pull_request) Successful in 1m13s
JS Unit Tests / test (pull_request) Successful in 1m16s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m45s
PHP Unit Tests / test (pull_request) Failing after 1m56s
2026-09-10 03:07:34 +00:00
Sebastian 5c9e13f87b refactor(module): register browser integration through context
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian 9508106d4f feat(preview): disable attachment previews when unavailable
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian da1e8e52b0 feat: use new variant type
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-29 19:13:11 -04:00
Sebastian 74d3654515 feat: implement attachment preview
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-29 16:21:18 -04:00
Sebastian 704e01fece feat: implement format toggle
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-28 22:29:22 -04:00
Sebastian 45cb0bd817 feat: wire mailbox search to server-side filtering
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-28 21:36:55 -04:00
Sebastian 33c44d62db feat(core): add CLI management for tenant system stores
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-28 21:23:50 -04:00
Sebastian a5eb104bd7 fix: reuse synced remote draft when sending a composition
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-25 22:39:33 -04:00
Sebastian 03862ed0cf fix: purge local composition after successful send
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-25 22:14:57 -04:00
Sebastian 0c7f103353 fix: increment composition revision on attachment add and remove
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-25 22:10:19 -04:00
Sebastian 684d50062c feat: implement save mechanisim
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-18 22:30:42 -04:00
Sebastian 67024b5bfd feat: add logic to sync drafts to server
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-18 21:57:38 -04:00
Sebastian bd5965f600 feat: add remote sync state
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-17 23:39:09 -04:00
Sebastian 8fb3086792 feat: Atomically read, mutate, and replace a composition snapshot.
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-17 23:20:35 -04:00
Sebastian c95e503251 chore: update git ignore list
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-17 21:57:28 -04:00
Sebastian 4839f822fa feat: add attach from documents
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-15 12:55:59 -04:00
Sebastian 8a18494883 feat: show disabled accounts
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-28 20:52:13 -04:00
Sebastian 0a2a87acfa fix: auto selected sending account when only once available
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-28 13:10:01 -04:00
Sebastian 436197fbfe refactor: migrate to scoped execution contexts
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-27 00:52:28 -04:00
Sebastian 75d034f478 Merge pull request 'chore(deps): update tiptap monorepo to v3.29.0' (#83) from renovate/tiptap-monorepo into main
Reviewed-on: #83
2026-07-25 23:50:06 +00:00
Sebastian 44fb30b74d chore(deps): update tiptap monorepo to v3.29.0
JS Unit Tests / test (pull_request) Successful in 1m28s
Build Test / test (pull_request) Successful in 1m30s
PHP Unit Tests / test (pull_request) Successful in 2m23s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m36s
2026-07-25 03:05:33 +00:00
Sebastian 671bad6f22 Merge pull request 'fix: checkout companion module in CI for cross-module type imports' (#82) from chore/js-test-suites into main
Reviewed-on: #82
2026-07-24 05:04:21 +00:00
Sebastian 05e6171b31 fix: regenerate package-lock.json to fix corrupted vue dependency tree
Build Test / test (pull_request) Successful in 48s
JS Unit Tests / test (pull_request) Successful in 50s
PHP Unit Tests / test (pull_request) Successful in 1m25s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m26s
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-24 01:02:36 -04:00
Sebastian 415cd96450 fix: checkout companion module in CI for cross-module type imports
JS Unit Tests / test (pull_request) Failing after 1m1s
Build Test / test (pull_request) Failing after 1m2s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m45s
PHP Unit Tests / test (pull_request) Successful in 1m57s
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-24 00:51:26 -04:00
Sebastian c9f720d46e Merge pull request 'chore: implement js test suites' (#81) from chore/js-test-suites into main
Reviewed-on: #81
2026-07-24 04:25:37 +00:00
Sebastian ac0d8de3fb chore: implement js test suites
JS Unit Tests / test (pull_request) Successful in 58s
Build Test / test (pull_request) Successful in 1m2s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m54s
PHP Unit Tests / test (pull_request) Successful in 3m5s
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-24 00:13:52 -04:00
Sebastian 3d0016bf3e Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.8' (#80) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #80
2026-07-24 03:19:29 +00:00
Sebastian 121014cd20 Merge pull request 'chore(deps): update dependency vue to v3.5.40' (#79) from renovate/vue-monorepo into main
Reviewed-on: #79
2026-07-24 03:19:21 +00:00
Sebastian 3178b2e234 chore(deps): update dependency vue-tsc to v3.3.8
PHP Unit Tests / test (pull_request) Successful in 1m3s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m44s
2026-07-24 03:10:31 +00:00
Sebastian e9ce62fc3f chore(deps): update dependency vue to v3.5.40
PHP Unit Tests / test (pull_request) Successful in 1m6s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m36s
2026-07-24 03:10:29 +00:00
Sebastian f285c7b51e Merge pull request 'Chore/php83 test suites' (#78) from chore/php83-test-suites into main
Reviewed-on: #78
2026-07-24 02:51:58 +00:00
Sebastian 9826913c91 Merge pull request 'chore(deps): update dependency typescript to v7' (#74) from renovate/typescript-7.x into main
Reviewed-on: #74
2026-07-24 02:47:23 +00:00
Sebastian 0242f00d40 Merge pull request 'chore(deps): update dependency vuetify to v4.1.5' (#75) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #75
2026-07-24 02:47:10 +00:00
Sebastian 946b20ce5b Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.8' (#77) from renovate/vitejs-plugin-vue-6.x-lockfile into main
Reviewed-on: #77
2026-07-24 02:47:05 +00:00
Sebastian b914e9b2df Merge pull request 'chore(deps): update tiptap monorepo to v3.28.0' (#76) from renovate/tiptap-monorepo into main
Reviewed-on: #76
2026-07-24 02:46:37 +00:00
Sebastian f02d141be0 fix: install module dependencies before running integration tests
PHP Unit Tests / test (pull_request) Successful in 47s
PHP Integration Tests / Integration Tests (pull_request) Successful in 58s
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-23 22:32:05 -04:00
Sebastian f87503d139 chore: implement php test suites
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-23 22:20:36 -04:00
Sebastian b43753665b Merge pull request 'chore(deps): update dependency vite to v8.1.5' (#73) from renovate/vite-8.x-lockfile into main
Reviewed-on: #73
2026-07-23 19:59:01 +00:00
Sebastian 2aa8e7b74c chore(deps): update dependency vite to v8.1.5 2026-07-23 03:08:25 +00:00
Sebastian 53d2d41cf1 chore(deps): update tiptap monorepo to v3.28.0 2026-07-18 03:07:43 +00:00
Sebastian ab705e3612 chore(deps): update dependency vuetify to v4.1.5 2026-07-18 03:07:39 +00:00
Sebastian c63b516c2e chore(deps): update dependency @vitejs/plugin-vue to v6.0.8 2026-07-18 03:07:33 +00:00
Sebastian dff46de730 refactor: rename main entry point
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-14 17:40:12 -04:00
Sebastian f7210ff8b0 refactor: add navigaton panel component
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-14 17:19:43 -04:00
Sebastian f96e2b029d refactor: standardize module tool bar
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-13 22:03:30 -04:00
Sebastian acb8c14fb2 chore(deps): update dependency typescript to v7 2026-07-10 03:02:35 +00:00
Sebastian 8b02392603 Track Composer dependencies
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-09 22:56:21 -04:00
Sebastian 93a8157166 fix(mail): fit workspace within private layout
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-09 21:20:30 -04:00
Sebastian 7d9bd8491c fix(renovate): install renovate in runner temp
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-09 20:12:07 -04:00
Sebastian 392881d56e Merge pull request 'chore(deps): update dependency vite to v8.1.3' (#69) from renovate/vite-8.x-lockfile into main
Reviewed-on: #69
2026-07-09 23:46:05 +00:00
Sebastian 63f38919f0 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.7' (#70) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #70
2026-07-09 23:46:00 +00:00
Sebastian db0d2bbc46 Merge pull request 'chore(deps): update dependency vuetify to v4.1.4' (#71) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #71
2026-07-09 23:45:54 +00:00
Sebastian 6112dff5d9 Merge pull request 'chore(deps): update tiptap monorepo to v3.27.3' (#72) from renovate/tiptap-monorepo into main
Reviewed-on: #72
2026-07-09 23:45:46 +00:00
Sebastian e90d71e424 chore(deps): update tiptap monorepo to v3.27.3 2026-07-09 03:05:28 +00:00
Sebastian d5d91ed1f5 chore(deps): update dependency vuetify to v4.1.4 2026-07-09 03:05:23 +00:00
Sebastian 7fb6f364ab chore(deps): update dependency vue-tsc to v3.3.7 2026-07-09 03:05:21 +00:00
Sebastian dd7bff6caa chore(deps): update dependency vite to v8.1.3 2026-07-03 03:02:44 +00:00
Sebastian 36f69fd72d Merge pull request 'chore(deps): update dependency vue to v3.5.39' (#67) from renovate/vue-monorepo into main
Reviewed-on: #67
2026-06-28 14:42:06 +00:00
Sebastian 4a3fd8abe5 Merge pull request 'chore(deps): update dependency vite to v8.1.0' (#68) from renovate/vite-8.x-lockfile into main
Reviewed-on: #68
2026-06-28 14:41:58 +00:00
Sebastian 0973291462 fix: use seen insstead of read flag
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-06-25 23:38:26 -04:00
Sebastian 382091e3d1 feat: configurable message list width
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-06-25 23:38:10 -04:00
Sebastian 88741802c2 chore(deps): update dependency vite to v8.1.0 2026-06-26 03:04:55 +00:00
Sebastian 5a523c61d4 chore(deps): update dependency vue to v3.5.39 2026-06-26 03:04:46 +00:00
Sebastian 7e1a7679c8 feat: message list density
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-06-23 21:34:02 -04:00
Sebastian b419af09eb feat: recipient search
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-06-22 22:54:02 -04:00
Sebastian 949bbfa538 Merge pull request 'chore(deps): update dependency vuetify to v4.1.2' (#65) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #65
2026-06-20 23:00:30 +00:00
Sebastian 5257c39227 Merge pull request 'chore(deps): update tiptap monorepo to v3.27.1' (#66) from renovate/tiptap-monorepo into main
Reviewed-on: #66
2026-06-20 23:00:22 +00:00
Sebastian 75ff60a04d refactor: rename mailStore to mailOperationsStore
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-06-20 15:41:47 -04:00
Sebastian 12ee277d29 chore(deps): update tiptap monorepo to v3.27.1 2026-06-19 03:02:58 +00:00
Sebastian 7f65539241 chore(deps): update dependency vuetify to v4.1.2 2026-06-19 03:02:55 +00:00
Sebastian 27816efb0c Merge pull request 'feat: attachment preview' (#64) from feat/attachment-preview into main
Reviewed-on: #64
2026-06-18 23:08:05 +00:00
Sebastian 34dc636d13 feat: attachment preview
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-06-18 19:07:39 -04:00
Sebastian 0af38cf50c fix: composition send responses and notifications
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-06-16 23:41:02 -04:00
Sebastian 27cba6ecff Merge pull request 'chore(deps): update dependency vite to v8.0.16' (#55) from renovate/vite-8.x-lockfile into main
Reviewed-on: #55
2026-06-16 17:38:57 +00:00
Sebastian bbdb417997 Merge pull request 'chore(deps): update dependency vuetify to v4.1.1' (#59) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #59
2026-06-16 17:38:52 +00:00
Sebastian d8c4bcd29a Merge pull request 'chore(deps): update vue monorepo to v3.5.38' (#62) from renovate/vue-monorepo into main
Reviewed-on: #62
2026-06-16 17:38:40 +00:00
Sebastian 6ff6304dd5 Merge pull request 'chore(deps): update tiptap monorepo to v3.26.1' (#61) from renovate/tiptap-monorepo into main
Reviewed-on: #61
2026-06-16 17:38:33 +00:00
Sebastian 84d71bc1b7 Merge pull request 'chore(deps): update vue-language-tools monorepo to v3.3.5' (#60) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #60
2026-06-16 17:38:00 +00:00
Sebastian f06f936434 Merge pull request 'feat: mail composition' (#63) from feat/mail-composition into main
Reviewed-on: #63
2026-06-16 17:25:25 +00:00
Sebastian f1cff5441a feat: mail composition
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-06-16 13:24:54 -04:00
Sebastian 822e791768 chore(deps): update vue-language-tools monorepo to v3.3.5 2026-06-15 03:03:18 +00:00
Sebastian 9bf30507e1 chore(deps): update tiptap monorepo to v3.26.1 2026-06-13 03:03:37 +00:00
Sebastian 63b0ad823f chore(deps): update vue monorepo to v3.5.38 2026-06-13 03:03:30 +00:00
Sebastian 7736d26f49 chore(deps): update dependency vite to v8.0.16 2026-06-11 03:04:50 +00:00
Sebastian 307a2e3ef0 chore(deps): update dependency vuetify to v4.1.1 2026-06-09 03:04:17 +00:00
Sebastian f379e40de4 Merge pull request 'chore(deps): update vue-language-tools monorepo to v3.3.3' (#56) from renovate/vue-language-tools-monorepo into main
Renovate / renovate (push) Failing after 1m34s
Reviewed-on: #56
2026-06-06 04:08:05 +00:00
Sebastian 19464beba1 Merge pull request 'chore(deps): update dependency vue-router to v5.1.0' (#57) from renovate/vue-router-5.x-lockfile into main
Reviewed-on: #57
2026-06-06 04:07:48 +00:00
Sebastian 6e7592d39d Merge pull request 'chore(deps): update dependency vuetify to v4.1.0' (#58) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #58
2026-06-06 04:07:38 +00:00
Sebastian 37d723cbdb chore(deps): update dependency vuetify to v4.1.0 2026-06-06 03:02:54 +00:00
Sebastian 461cd5dd92 chore(deps): update dependency vue-router to v5.1.0 2026-06-06 03:02:51 +00:00
Sebastian 805863f084 chore(deps): update dependency vue-tsc to v3.3.3 2026-06-02 03:01:55 +00:00
Sebastian 7a3d90d0cd Merge pull request 'feat: message list message menu' (#54) from feat/message-list-message-menu into main
Renovate / renovate (push) Successful in 2m50s
Reviewed-on: #54
2026-05-30 22:30:59 +00:00
Sebastian d24e6110b9 feat: message list message menu
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-30 18:30:43 -04:00
Sebastian ec776bbb78 Merge pull request 'chore(deps): update dependency vue to v3.5.35' (#52) from renovate/vue-monorepo into main
Renovate / renovate (push) Failing after 1m49s
Reviewed-on: #52
2026-05-29 03:11:03 +00:00
Sebastian 4f9a6cc5bd Merge pull request 'chore(deps): update dependency vuetify to v4.0.8' (#53) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #53
2026-05-29 03:10:56 +00:00
Sebastian 09da7b4770 chore(deps): update dependency vuetify to v4.0.8 2026-05-29 03:02:26 +00:00
Sebastian 41c6c82546 chore(deps): update dependency vue to v3.5.35 2026-05-29 03:02:20 +00:00
Sebastian 967dadf864 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.2' (#51) from renovate/vue-tsc-3.x-lockfile into main
Renovate / renovate (push) Successful in 2m21s
Reviewed-on: #51
2026-05-27 03:20:53 +00:00
Sebastian c1c334fe22 chore(deps): update dependency vue-tsc to v3.3.2 2026-05-27 03:20:01 +00:00
Sebastian 5a355c1536 Merge pull request 'fix: recipient click' (#50) from fix/recipient-click into main
Reviewed-on: #50
2026-05-27 03:17:28 +00:00
Sebastian 3a3cf77da1 fix: recipient click
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-26 23:17:03 -04:00
Sebastian 800d6bce83 Merge pull request 'chore(deps): update dependency vite to v8.0.14' (#49) from renovate/vite-8.x-lockfile into main
Renovate / renovate (push) Failing after 1m45s
Reviewed-on: #49
2026-05-24 03:21:35 +00:00
Sebastian 6745361a5c chore(deps): update dependency vite to v8.0.14 2026-05-24 03:12:00 +00:00
Sebastian 9a98042fa0 Merge pull request 'chore(deps): update tiptap monorepo to v3.23.6' (#47) from renovate/tiptap-monorepo into main
Reviewed-on: #47
2026-05-24 03:10:36 +00:00
Sebastian 42c24642ce Merge pull request 'feat: message and attachment download' (#48) from feat/download into main
Reviewed-on: #48
2026-05-24 03:10:08 +00:00
Sebastian b66ebbd078 feat: message and attachment download
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-23 23:09:40 -04:00
Sebastian 4b9a3b7945 chore(deps): update tiptap monorepo to v3.23.6 2026-05-22 15:59:15 +00:00
Sebastian cdff4d0d3f Merge pull request 'feat: recipient details' (#46) from feat/address-menu into main
Renovate / renovate (push) Failing after 1m34s
Reviewed-on: #46
2026-05-22 15:57:50 +00:00
Sebastian bafb294e1a Merge pull request 'fix: read and unread' (#45) from fix/read-unread into main
Reviewed-on: #45
2026-05-22 15:57:27 +00:00
Sebastian f1d0511cbb feat: recipient details
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-22 11:56:29 -04:00
Sebastian 897a03578e fix: read and unread
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-21 18:52:21 -04:00
Sebastian 4367fcfe9a Merge pull request 'chore(deps): update tiptap monorepo to v3.23.5' (#43) from renovate/tiptap-monorepo into main
Renovate / renovate (push) Failing after 2m19s
Reviewed-on: #43
2026-05-21 03:41:11 +00:00
Sebastian b7fcc0a368 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.1' (#44) from renovate/vue-tsc-3.x-lockfile into main
Reviewed-on: #44
2026-05-21 03:40:48 +00:00
Sebastian 0db23271e5 chore(deps): update dependency vue-tsc to v3.3.1 2026-05-20 02:29:54 +00:00
Sebastian 3f4aeb99c7 chore(deps): update tiptap monorepo to v3.23.5 2026-05-20 02:29:52 +00:00
Sebastian 7e544d16f4 Merge pull request 'feat: implement patch and settings store' (#42) from feat/patch-and-settings into main
Renovate / renovate (push) Failing after 1m29s
Reviewed-on: #42
2026-05-20 02:28:14 +00:00
Sebastian 8ba40a971e feat: implement patch and settings store
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-19 22:27:51 -04:00
Sebastian c3c41f7ba0 Merge pull request 'refactor: clean up event methods' (#41) from chore/clean-up-methods into main
Renovate / renovate (push) Successful in 1m18s
Reviewed-on: #41
2026-05-17 21:52:34 +00:00
Sebastian 5a58c3c7ac refactor: clean up event methods
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-17 17:52:16 -04:00
Sebastian 7853a21288 Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.7' (#40) from renovate/vitejs-plugin-vue-6.x-lockfile into main
Renovate / renovate (push) Successful in 2m5s
Reviewed-on: #40
2026-05-15 13:40:08 +00:00
Sebastian f1823a246c chore(deps): update dependency @vitejs/plugin-vue to v6.0.7 2026-05-15 13:39:23 +00:00
Sebastian 820c2c812f Merge pull request 'chore(deps): update tiptap monorepo to v3.23.4' (#39) from renovate/tiptap-monorepo into main
Reviewed-on: #39
2026-05-15 13:25:07 +00:00
Sebastian 6e76bec190 chore(deps): update tiptap monorepo to v3.23.4 2026-05-15 13:21:52 +00:00
Sebastian c304ab6b6e Merge pull request 'chore(deps): update dependency vue-tsc to v3.2.9' (#38) from renovate/vue-tsc-3.x-lockfile into main
Reviewed-on: #38
2026-05-15 03:37:58 +00:00
Sebastian 12e0c7b428 Merge pull request 'chore(deps): update dependency vite to v8.0.13' (#37) from renovate/vite-8.x-lockfile into main
Reviewed-on: #37
2026-05-15 03:09:53 +00:00
Sebastian 7c366fdd96 Merge pull request 'fix(deps): update dependency vue-router to v5' (#30) from renovate/vue-router-5.x into main
Reviewed-on: #30
2026-05-15 03:09:42 +00:00
Sebastian 1d62412407 chore(deps): update dependency vue-tsc to v3.2.9 2026-05-15 03:04:15 +00:00
Sebastian d76166addd chore(deps): update dependency vite to v8.0.13 2026-05-15 03:04:13 +00:00
68 changed files with 10956 additions and 1587 deletions
+58
View File
@@ -0,0 +1,58 @@
name: Build Test
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Install Mail Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/mail_manager
ref: main
path: server/modules/mail_manager
github-server-url: https://git.ktrix.dev
- name: Install People Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/people_manager
ref: main
path: server/modules/people_manager
github-server-url: https://git.ktrix.dev
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/mail
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/mail
- name: Build
run: npm run build
working-directory: server/modules/mail
+58
View File
@@ -0,0 +1,58 @@
name: JS Unit Tests
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Install Mail Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/mail_manager
ref: main
path: server/modules/mail_manager
github-server-url: https://git.ktrix.dev
- name: Install People Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/people_manager
ref: main
path: server/modules/people_manager
github-server-url: https://git.ktrix.dev
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/mail
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/mail
- name: Run tests
run: npm run test:unit
working-directory: server/modules/mail
@@ -0,0 +1,59 @@
name: PHP Integration Tests
on:
pull_request:
workflow_dispatch:
jobs:
test:
name: Integration Tests
runs-on: ubuntu-latest
services:
mongo:
image: mongo:7
options: >-
--health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\""
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install server
uses: ./action-server-install
with:
install-php: 'true'
php-version: '8.5'
server-path: './server'
database-uri: 'mongodb://mongo:27017/?tls=false'
database-name: 'ktrix_ci'
app-environment: 'test'
- name: Checkout module under test
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha || github.sha }}
path: server/modules/mail
github-server-url: https://git.ktrix.dev
- name: Install module dependencies
run: composer install --prefer-dist --no-progress
working-directory: server/modules/mail
- name: Install and enable module
working-directory: server
run: |
php bin/console module:install mail
php bin/console module:enable mail
- name: Run integration tests
working-directory: server/modules/mail
run: composer test:integration
+42
View File
@@ -0,0 +1,42 @@
name: PHP Unit Tests
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'true'
install-node: 'false'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/mail
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: composer install --prefer-dist --no-progress
working-directory: server/modules/mail
- name: Run tests
run: composer test:unit
working-directory: server/modules/mail
+8 -2
View File
@@ -25,11 +25,17 @@ jobs:
tools: composer:v2 tools: composer:v2
- name: Install Renovate - name: Install Renovate
run: npm install -g renovate run: |
npm install --global --no-audit --fund=false \
--prefix "${{ runner.temp }}/renovate-npm" \
--cache "${{ runner.temp }}/renovate-npm-cache" \
renovate
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
- name: Run Renovate - name: Run Renovate
env: env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1 RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
run: renovate ${{ gitea.repository }} run: |
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
+3 -4
View File
@@ -12,18 +12,17 @@ node_modules/
/static/ /static/
# Backend development # Backend development
/vendor/
/lib/vendor/ /lib/vendor/
coverage/ coverage/
phpunit.xml.cache *.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
.phpactor/ .phpactor/
# Editors # Editors
.DS_Store .DS_Store
.vscode/ .vscode/
.idea/ .idea/
.ideas/
# Logs # Logs
*.log *.log
+13 -1
View File
@@ -9,6 +9,18 @@
} }
}, },
"require": { "require": {
"php": ">=8.2" "php": ">=8.3"
},
"require-dev": {
"phpunit/phpunit": "^12.0"
},
"autoload-dev": {
"psr-4": {
"KTXT\\Mail\\Tests\\": "tests/php/"
}
},
"scripts": {
"test:unit": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --colors=always --testdox",
"test:integration": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Integration Tests\" --colors=always --testdox"
} }
} }
Generated
+1714
View File
File diff suppressed because it is too large Load Diff
+904
View File
@@ -0,0 +1,904 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail;
use InvalidArgumentException;
use KTXC\Module\ModuleManager;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Mail\Collection\CollectionBaseInterface;
use KTXF\Mail\Collection\CollectionRoles;
use KTXF\Mail\Object\Address;
use KTXF\Mail\Object\AddressInterface;
use KTXF\Mail\Object\MessagePart;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Service\ServiceEntityMutableInterface;
use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifier;
use KTXF\Utile\UUID;
use KTXM\Mail\Stores\CompositionStore;
use KTXM\MailManager\Manager as MailManager;
use KTXM\DocumentsManager\Manager as DocumentsManager;
use Psr\Container\ContainerInterface;
use RuntimeException;
use Throwable;
class CompositionManager {
private const DOCUMENTS_MODULE_HANDLE = 'documents_manager';
public function __construct(
private readonly ContainerInterface $container,
private readonly ModuleManager $moduleManager,
private readonly CompositionStore $compositionStore,
private readonly MailManager $mailManager,
private readonly EventDispatcherInterface $events,
) {}
public function stage(string $tenantId, string $userId, string $identifier, string $action, array $sender, array $message, ?string $source = null): array {
if ($identifier === '') {
throw new InvalidArgumentException('Draft identifier is required');
}
$snapshot = [
'identifier' => $identifier,
'action' => $action,
'source' => $source,
'revision' => 1,
'disposition' => 'staged',
'sender' => $sender,
'message' => $message,
'attachments' => [],
'remote' => [
'status' => 'dirty',
'entity' => null,
'error' => null,
],
];
// Construct the snapshot mutation that will run while the composition is locked.
$saveComposition = function () use ($tenantId, $userId, $identifier, $action, $source, $snapshot): array {
if ($action !== 'forward') {
return $snapshot;
}
$sourceIndentifier = ResourceIdentifier::fromString($source);
if ($sourceIndentifier === null) {
throw new InvalidArgumentException('Invalid source identifier');
}
$entities = $this->mailManager->entityFetchBulk($tenantId, $userId, $sourceIndentifier);
if (count($entities) === 0) {
throw new InvalidArgumentException('Source message not found');
}
// retrieve the attachments from the source message
$attachments = reset($entities)->getProperties()->getAttachments();
// For each attachment, we download the content and stage it in the composition store
foreach ($attachments as $attachment) {
$contentId = $attachment->getContentId();
$isInline = $attachment->getDisposition() === 'inline';
// target part
$targetPart = [
'partId' => $attachment->getId(),
'blobId' => $attachment->getBlobId(),
'cid' => $contentId,
'cId' => $contentId,
];
// retrieve the attachment content as a stream
$data = $this->mailManager->entityDownload($tenantId, $userId, $sourceIndentifier, $targetPart);
// stage the attachment in the composition store
$meta = $this->compositionStore->attachmentStageFromStream(
tenantId: $tenantId,
userId: $userId,
compositionId: $identifier,
attachmentId: UUID::v4(),
data: $data,
);
// supplement the attachment metadata with source information
$meta['origin'] = 'message';
$meta['source'] = $source;
$meta['partId'] = $attachment->getId();
$meta['blobId'] = $attachment->getBlobId();
$meta['cid'] = $contentId;
$meta['contentId'] = $contentId;
$meta['disposition'] = $attachment->getDisposition();
$meta['inline'] = $isInline;
$meta['name'] = $attachment->getName() ?? $meta['name'];
$meta['type'] = $attachment->getType() ?? $meta['type'];
$meta['size'] = $attachment->getSize() ?? $meta['size'];
$snapshot['attachments'][$meta['identifier']] = $meta;
}
return $snapshot;
};
// Execute the mutation and atomically persist the snapshot it returns.
$snapshot = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$identifier,
$saveComposition,
) ?? $snapshot;
$event = new CompositionSavedEvent($tenantId, $userId, $identifier, (int)$snapshot['revision']);
$this->events->dispatch($event);
$response = $snapshot;
unset($response['remote']);
return $response;
}
public function patch(string $tenantId, string $userId, string $identifier, array $data): array {
$result = [];
// Construct the snapshot mutation that will run while the composition is locked.
$patchComposition = function (?array $composed) use ($data, $identifier, &$result): ?array {
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return null;
}
if (!isset($data['revision']) || !is_int($data['revision']) || $data['revision'] < $composed['revision']) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'revision_mismatch',
'message' => 'Revision mismatch',
'currentRevision' => $composed['revision'],
];
return null;
}
$composed['revision'] = $data['revision'];
$composed['sender'] = $data['sender'] ?? [];
$composed['message'] = $data['message'] ?? [];
$composed = $this->markRemoteDirty($composed);
$result = [
'identifier' => $identifier,
'disposition' => 'patched',
];
return $composed;
};
// Execute the mutation and atomically persist the snapshot it returns.
$composed = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$identifier,
$patchComposition,
);
if (($result['disposition'] ?? null) === 'patched' && $composed !== null) {
$event = new CompositionSavedEvent($tenantId, $userId, $identifier, (int)($composed['revision'] ?? 0));
$this->events->dispatch($event);
}
return $result;
}
public function save(string $tenantId, string $userId, string $identifier, array $data): array {
// Construct the snapshot mutation that will run while the composition is locked.
$saveComposition = function (?array $composed) use ($data): ?array {
if ($composed === null) {
return null;
}
if (isset($data['revision']) && is_int($data['revision'])) {
$composed['revision'] = $data['revision'];
}
$composed['sender'] = $data['sender'];
$composed['message'] = $data['message'];
return $this->markRemoteDirty($composed);
};
// Execute the mutation and atomically persist the snapshot it returns.
$composed = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$identifier,
$saveComposition,
);
if ($composed === null) {
return [
'identifier' => $identifier,
'disposition' => 'error',
'error' => [
'type' => 'composition_not_found',
'message' => 'Composition not found',
],
];
}
$synchronized = $this->synchronize($tenantId, $userId, $identifier);
if ($synchronized['disposition'] !== 'saved') {
return [
'identifier' => $identifier,
'disposition' => 'error',
'error' => [
'type' => 'composition_save_failed',
'message' => (string)($synchronized['error'] ?? 'Draft could not be saved to the server'),
],
];
}
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
return ['identifier' => $identifier, 'disposition' => 'saved'];
}
public function discard(string $tenantId, string $userId, string $identifier): array {
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
if ($composed === null) {
return ['identifier' => $identifier, 'disposition' => 'discarded'];
}
try {
$remoteEntity = $this->remoteEntityIdentifier($composed['remote']['entity'] ?? null);
if ($remoteEntity !== null) {
$outcomes = $this->mailManager->entityDelete($tenantId, $userId, $remoteEntity);
$outcome = $outcomes[(string)$remoteEntity] ?? reset($outcomes);
if (!is_array($outcome) || ($outcome['disposition'] ?? 'error') === 'error') {
return [
'identifier' => $identifier,
'disposition' => 'error',
'error' => [
'type' => 'composition_discard_failed',
'message' => is_array($outcome)
? (string)($outcome['error'] ?? 'Remote draft could not be discarded')
: 'Remote draft could not be discarded',
],
];
}
}
} catch (Throwable $throwable) {
return [
'identifier' => $identifier,
'disposition' => 'error',
'error' => [
'type' => 'composition_discard_failed',
'message' => $throwable->getMessage(),
],
];
}
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
return ['identifier' => $identifier, 'disposition' => 'discarded'];
}
public function synchronize(string $tenantId, string $userId, string $identifier): array {
$result = [
'identifier' => $identifier,
'disposition' => 'staged',
];
// Construct the synchronization operation that will run while the composition is locked.
$synchronizeComposition = function (?array $composed) use ($tenantId, $userId, $identifier, &$result): ?array {
if ($composed === null) {
$result['error'] = 'Composition not found';
return null;
}
$remote = isset($composed['remote']) && is_array($composed['remote'])
? $composed['remote']
: ['status' => 'dirty', 'entity' => null, 'error' => null];
if (($remote['status'] ?? 'dirty') === 'synced') {
$result['disposition'] = 'saved';
return null;
}
try {
$sender = isset($composed['sender']) && is_array($composed['sender'])
? $composed['sender']
: [];
$senderAddress = (string)($sender['address'] ?? '');
if ($senderAddress === '' || !filter_var($senderAddress, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Composition sender is invalid');
}
$service = $this->mailManager->serviceFindByAddress($tenantId, $userId, $senderAddress);
if ($service === null || $service->getEnabled() === false) {
throw new RuntimeException("No enabled mail service handles '$senderAddress'");
}
if (!$service instanceof ServiceEntityMutableInterface) {
throw new RuntimeException("Mail service '{$service->identifier()}' does not support draft mutations");
}
$target = $this->resolveDraftCollection($service);
$attachments = isset($composed['attachments']) && is_array($composed['attachments'])
? array_values($composed['attachments'])
: [];
$properties = $this->buildMessageProperties(
service: $service,
sender: Address::fromArray($sender),
message: is_array($composed['message'] ?? null) ? $composed['message'] : [],
attachments: $attachments,
composed: $composed,
tenantId: $tenantId,
userId: $userId,
compositionId: $identifier,
);
$properties->setFlag('draft', true);
$properties->setFlag('seen', true);
$remoteEntity = $this->remoteEntityIdentifier($remote['entity'] ?? null);
if ($remoteEntity === null) {
$entity = $this->mailManager->entityCreate($tenantId, $userId, $target, $properties);
} else {
if ($remoteEntity->provider() !== $service->provider()
|| (string)$remoteEntity->service() !== (string)$service->identifier()) {
throw new RuntimeException('Changing the draft service is not supported yet');
}
$entity = $this->mailManager->entityModify($tenantId, $userId, $remoteEntity, $properties);
}
if ($entity->collection() === null || $entity->identifier() === null) {
throw new RuntimeException('Provider returned an incomplete draft identifier');
}
$composed['remote'] = [
...$remote,
'status' => 'synced',
'entity' => (string)new EntityIdentifier(
$entity->provider(),
(string)$entity->service(),
(string)$entity->collection(),
(string)$entity->identifier(),
),
'error' => null,
];
$result['disposition'] = 'saved';
} catch (Throwable $throwable) {
$composed['remote'] = [
...$remote,
'status' => 'failed',
'entity' => $remote['entity'] ?? null,
'error' => $throwable->getMessage(),
];
$result['error'] = $throwable->getMessage();
}
return $composed;
};
// Synchronize and atomically persist the resulting remote state.
$this->compositionStore->compositionSave(
$tenantId,
$userId,
$identifier,
$synchronizeComposition,
);
return $result;
}
public function send(string $tenantId, string $userId, string $identifier, array $sender, array $message, array $attachments): array {
// Construct the snapshot mutation that will run while the composition is locked.
$saveComposition = function (?array $composed) use ($sender, $message): ?array {
if ($composed === null) {
return null;
}
$composed['sender'] = $sender;
$composed['message'] = $message;
return $this->markRemoteDirty($composed);
};
// Execute the mutation and atomically persist the snapshot it returns.
$composed = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$identifier,
$saveComposition,
);
if ($composed === null) {
return [
'disposition' => 'error',
'error' => [
'type' => 'composition_not_found',
'message' => 'Composition not found',
],
];
}
// validate the sender address before attempting to send the message
if ($sender['address'] === '' || !filter_var($sender['address'], FILTER_VALIDATE_EMAIL)) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_invalid_sender',
'message' => 'Invalid sender address',
];
return $result;
}
$senderAddress = $sender['address'];
$senderObject = Address::fromArray($sender);
// resolve submit-capable service for sender
$service = $this->mailManager->serviceFindByAddress($tenantId, $userId, $senderAddress);
if ($service === null || $service->getEnabled() === false) {
return [
'disposition' => 'error',
'error' => [
'type' => 'service_not_found',
'message' => "Service not found for sender '$senderAddress' or service is disabled",
],
];
}
if ($service instanceof ServiceEntitySubmitInterface === false) {
return [
'disposition' => 'error',
'error' => [
'type' => 'service_not_supported',
'message' => "Service '{$service->identifier()}' does not support entity submission",
],
];
}
// Reuse the synced remote draft (if any) so the provider can natively clean it up
$remoteEntity = $this->remoteEntityIdentifier($composed['remote']['entity'] ?? null);
$source = ($remoteEntity !== null
&& $remoteEntity->provider() === $service->provider()
&& (string)$remoteEntity->service() === (string)$service->identifier())
? $remoteEntity
: null;
$properties = $this->buildMessageProperties(
service: $service,
sender: $senderObject,
message: $message,
attachments: $attachments,
composed: $composed,
tenantId: $tenantId,
userId: $userId,
compositionId: $identifier,
);
$sendResult = $service->entitySubmit($senderObject, $source, $properties);
if ($sendResult->disposition === EntitySubmitResult::DISPOSITION_ERROR) {
return [
'identifier' => $identifier,
'disposition' => 'error',
'error' => [
'type' => 'service_submission_error',
'message' => $sendResult->errorMessage ?? 'An unknown error occurred during submission',
],
];
}
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
return [
'identifier' => $identifier,
'disposition' => 'sent'
];
}
private function buildMessageProperties(
ServiceEntitySubmitInterface|ServiceEntityMutableInterface $service,
AddressInterface $sender,
array $message,
array $attachments,
array $composed,
string $tenantId,
string $userId,
string $compositionId,
): MessagePropertiesMutableInterface {
$properties = $service->entityFresh()->getProperties();
$bodyTextPlain = isset($message['body']['text']) ? (string)$message['body']['text'] : '';
$bodyTextHtml = isset($message['body']['html']) ? (string)$message['body']['html'] : '';
$properties->setFrom($sender);
$to = $this->mapAddresses($message['to'] ?? []);
if ($to !== []) {
$properties->setTo(...$to);
}
$cc = $this->mapAddresses($message['cc'] ?? []);
if ($cc !== []) {
$properties->setCc(...$cc);
}
$bcc = $this->mapAddresses($message['bcc'] ?? []);
if ($bcc !== []) {
$properties->setBcc(...$bcc);
}
$replyTo = $this->mapAddresses($message['replyTo'] ?? []);
if ($replyTo !== []) {
$properties->setReplyTo(...$replyTo);
}
$properties->setSubject((string)($message['subject'] ?? ''));
$properties->setBodyTextPlain($bodyTextPlain);
$properties->setBodyTextHtml($bodyTextHtml);
if (isset($message['flags']) && is_array($message['flags'])) {
$properties->setFlags($message['flags']);
}
$attachmentObjects = [];
foreach ($attachments as $attachment) {
if (!isset($attachment['identifier']) || !is_string($attachment['identifier']) || $attachment['identifier'] === '') {
continue;
}
$composedAttachment = $composed['attachments'][$attachment['identifier']] ?? null;
if (!is_array($composedAttachment)) {
continue;
}
$attachmentObjects[] = MessagePart::fromArray([
'partId' => $attachment['identifier'],
'blobId' => $attachment['blobId'] ?? $composedAttachment['blobId'] ?? null,
'size' => $composedAttachment['size'] ?? null,
'name' => $composedAttachment['name'] ?? 'unknown.bin',
'type' => $composedAttachment['type'] ?? 'application/octet-stream',
'disposition' => (($attachment['inline'] ?? $composedAttachment['inline'] ?? false) === true) ? 'inline' : 'attachment',
'content' => $this->compositionStore->attachmentFetchData($tenantId, $userId, $compositionId, $attachment['identifier']),
'cid' => $attachment['contentId'] ?? $attachment['cid'] ?? $composedAttachment['contentId'] ?? $composedAttachment['cid'] ?? null,
]);
}
if ($attachmentObjects !== []) {
$properties->setAttachments(...$attachmentObjects);
}
return $properties;
}
public function attachmentAdd(string $tenantId, string $userId, string $composition, array ...$attachments): array {
$result = [
'composition' => $composition,
'attachments' => [],
];
$failed = [];
$compositionChanged = false;
// Construct the snapshot mutation that will run while the composition is locked.
$addAttachments = function (?array $composed) use ($tenantId, $userId, $composition, $attachments, &$result, &$failed, &$compositionChanged): ?array {
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return null;
}
$uploads = [];
$documents = [];
foreach ($attachments as $attachment) {
if (isset($attachment['origin']) && $attachment['origin'] === 'documents') {
$documents[] = $attachment;
} elseif (isset($attachment['origin']) && $attachment['origin'] === 'device') {
$uploads[] = $attachment;
}
}
if ($uploads !== []) {
$this->attachmentAddFromDevice($tenantId, $userId, $composition, $uploads, $composed);
}
if ($documents !== []) {
$failed = $this->attachmentAddFromDocuments($tenantId, $userId, $composition, $documents, $composed);
}
$deviceAttachmentsChanged = $uploads !== [];
$documentAttachmentsChanged = count($failed) < count($documents);
if ($deviceAttachmentsChanged || $documentAttachmentsChanged) {
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
$composed = $this->markRemoteDirty($composed);
$compositionChanged = true;
}
return $composed;
};
// Execute the mutation and atomically persist the snapshot it returns.
$composed = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$composition,
$addAttachments,
);
if ($composed === null) {
return $result;
}
// construct the result
$result['attachments'] = $composed['attachments'];
$result['failed'] = $failed;
$result['disposition'] = match (true) {
$failed === [] => 'added',
$result['attachments'] !== [] => 'partial',
default => 'error',
};
if ($result['disposition'] === 'error') {
$result['error'] = [
'type' => 'attachment_add_failed',
'message' => 'None of the requested attachments could be added',
];
}
if ($compositionChanged) {
$result['revision'] = (int)($composed['revision'] ?? 0);
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
$this->events->dispatch($event);
}
return $result;
}
/**
* Stage device-uploaded (base64 payload) attachment entries
*/
private function attachmentAddFromDevice(string $tenantId, string $userId, string $composition, array $uploads, array &$composed): void {
foreach ($uploads as $attachment) {
$identifier = (isset($attachment['identifier']) && is_string($attachment['identifier']) && $attachment['identifier'] !== '')
? $attachment['identifier']
: UUID::v4();
$decoded = base64_decode((string) ($attachment['data'] ?? ''), true);
if ($decoded === false) {
throw new InvalidArgumentException('Attachment payload is not valid base64');
}
$data = new BinaryResource(
$attachment['name'] ?? 'unknown.bin',
$attachment['type'] ?? 'application/octet-stream',
$this->stringToGenerator($decoded),
);
$meta = $this->compositionStore->attachmentStageFromStream(
tenantId: $tenantId,
userId: $userId,
compositionId: $composition,
attachmentId: $identifier,
data: $data,
);
$meta['origin'] = 'device';
$composed['attachments'][$meta['identifier']] = $meta;
}
}
private function resolveDocumentsManager(): ?DocumentsManager {
$module = $this->moduleManager->fetch(self::DOCUMENTS_MODULE_HANDLE);
if ($module === null || !$module->enabled()) {
return null;
}
if (!$this->container->has(DocumentsManager::class)) {
return null;
}
return $this->container->get(DocumentsManager::class);
}
/**
* Stage documents-sourced attachment entries
*/
private function attachmentAddFromDocuments(string $tenantId, string $userId, string $composition, array $sourced, array &$composed): array {
$failed = [];
// The documents module is an optional dependency, so we must resolve it
// lazily and handle the case where it is not installed or enabled.
$documentsManager = $this->resolveDocumentsManager();
if ($documentsManager === null) {
foreach ($sourced as $attachment) {
$failed[] = [
'identifier' => (string) ($attachment['source'] ?? ''),
'message' => 'Documents module is not installed or enabled',
];
}
return $failed;
}
$requested = [];
foreach ($sourced as $attachment) {
$rawIdentifier = (string) $attachment['source'];
try {
$parsed = ResourceIdentifier::fromString($rawIdentifier);
} catch (InvalidArgumentException) {
$failed[] = ['identifier' => $rawIdentifier, 'message' => 'Invalid document identifier'];
continue;
}
if (!$parsed instanceof EntityIdentifier) {
$failed[] = ['identifier' => $rawIdentifier, 'message' => 'Invalid document identifier'];
continue;
}
$requested[] = ['attachment' => $attachment, 'identifier' => $parsed];
}
if ($requested === []) {
return $failed;
}
$entities = $documentsManager->entityFetchBulk($tenantId, $userId, ...array_column($requested, 'identifier'));
$entitiesByUrn = [];
foreach ($entities as $entity) {
$entitiesByUrn[$entity->urn()] = $entity;
}
foreach ($requested as ['attachment' => $attachment, 'identifier' => $entityIdentifier]) {
$urn = (string) $entityIdentifier;
$entity = $entitiesByUrn[$urn] ?? null;
if ($entity === null) {
$failed[] = ['identifier' => $urn, 'message' => 'Document not found or access denied'];
continue;
}
$resource = $documentsManager->entityReadStream($tenantId, $userId, $entityIdentifier);
if ($resource === null) {
$failed[] = ['identifier' => $urn, 'message' => 'Document content could not be read'];
continue;
}
$identifier = (isset($attachment['identifier']) && is_string($attachment['identifier']) && $attachment['identifier'] !== '')
? $attachment['identifier']
: UUID::v4();
$properties = $entity->getProperties();
$data = new BinaryResource($properties->getLabel(), $properties->getMime(), $this->streamToGenerator($resource));
$meta = $this->compositionStore->attachmentStageFromStream(
tenantId: $tenantId,
userId: $userId,
compositionId: $composition,
attachmentId: $identifier,
data: $data,
);
$meta['origin'] = 'documents';
$meta['source'] = $urn;
$composed['attachments'][$meta['identifier']] = $meta;
}
return $failed;
}
public function attachmentRemove(string $tenantId, string $userId, string $composition, string $identifier): array {
$result = [
'composition' => $composition,
'identifier' => $identifier,
];
// Construct the snapshot mutation that will run while the composition is locked.
$removeAttachment = function (?array $composed) use ($identifier, &$result): ?array {
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return null;
}
if (!isset($composed['attachments'][$identifier])) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'attachment_not_found',
'message' => 'Attachment not found',
];
return null;
}
unset($composed['attachments'][$identifier]);
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
$composed = $this->markRemoteDirty($composed);
$result['disposition'] = 'removed';
$result['revision'] = $composed['revision'];
return $composed;
};
// Execute the mutation and atomically persist the snapshot it returns.
$composed = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$composition,
$removeAttachment,
);
if (($result['disposition'] ?? null) === 'removed' && $composed !== null) {
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
$this->events->dispatch($event);
}
return $result;
}
private function markRemoteDirty(array $composed): array {
$remote = isset($composed['remote']) && is_array($composed['remote'])
? $composed['remote']
: [];
$remote['status'] = 'dirty';
$remote['entity'] ??= null;
$remote['error'] = null;
$composed['remote'] = $remote;
return $composed;
}
private function resolveDraftCollection(ServiceBaseInterface $service): CollectionIdentifier {
$configuredTarget = $service->getAuxiliary()['draftTarget'] ?? null;
if ($configuredTarget !== null && (string)$configuredTarget !== '') {
return new CollectionIdentifier(
$service->provider(),
(string)$service->identifier(),
(string)$configuredTarget,
);
}
$filter = $service->collectionListFilter();
$filter->condition('role', CollectionRoles::Drafts->value);
$collections = $service->collectionList('', $filter);
$collection = reset($collections);
if (!$collection instanceof CollectionBaseInterface || $collection->identifier() === null) {
throw new RuntimeException("Mail service '{$service->identifier()}' has no Drafts collection");
}
return new CollectionIdentifier(
$service->provider(),
(string)$service->identifier(),
(string)$collection->identifier(),
);
}
private function remoteEntityIdentifier(mixed $value): ?EntityIdentifier {
if (!is_string($value) || $value === '') {
return null;
}
$identifier = ResourceIdentifier::fromString($value);
if (!$identifier instanceof EntityIdentifier) {
throw new RuntimeException("Invalid remote draft identifier '$value'");
}
return $identifier;
}
/**
* @param array<int,array|string> $entries
* @return array<int,Address>
*/
private function mapAddresses(array $entries): array {
$addresses = [];
foreach ($entries as $entry) {
if (is_array($entry)) {
$address = Address::fromArray($entry);
if ($address->getAddress() !== '') {
$addresses[] = $address;
}
continue;
}
if (is_string($entry) && $entry !== '') {
$address = Address::fromString($entry);
if ($address->getAddress() !== '') {
$addresses[] = $address;
}
}
}
return $addresses;
}
/**
* Wrap an in-memory byte string in a single-chunk Generator.
*/
private function stringToGenerator(string $bytes): \Generator {
yield $bytes;
}
/**
* Wrap a raw PHP stream resource in a Generator yielding chunks, closing it once exhausted.
*/
private function streamToGenerator($resource): \Generator {
while (!feof($resource)) {
yield fread($resource, 65536);
}
fclose($resource);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail;
use InvalidArgumentException;
use KTXF\Event\Event;
final class CompositionSavedEvent extends Event {
public function __construct(
string $tenantId,
private readonly string $userId,
private readonly string $compositionId,
private readonly int $revision,
) {
if ($tenantId === '' || $this->userId === '' || $this->compositionId === '') {
throw new InvalidArgumentException('Composition save events require tenant, user, and composition identifiers');
}
if ($this->revision < 0) {
throw new InvalidArgumentException('Composition save event revision cannot be negative');
}
parent::__construct(
self::class,
[
'userId' => $this->userId,
'compositionId' => $this->compositionId,
'revision' => $this->revision,
],
$tenantId,
$this->userId,
);
}
public function userIdentifier(): string {
return $this->userId;
}
public function compositionIdentifier(): string {
return $this->compositionId;
}
public function revision(): int {
return $this->revision;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail;
final class CompositionSyncListener {
public function __construct(
private readonly CompositionManager $compositionManager,
) {}
public function onCompositionSaved(CompositionSavedEvent $event): void {
$this->compositionManager->synchronize(
$event->tenantIdentifier(),
$event->userIdentifier(),
$event->compositionIdentifier(),
);
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail\Controllers;
use InvalidArgumentException;
use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\Response;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
use KTXM\Mail\CompositionManager;
use Psr\Log\LoggerInterface;
use Throwable;
final class CompositionController extends ControllerAbstract {
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly CompositionManager $compositionManager,
private readonly LoggerInterface $logger,
) {}
#[AuthenticatedRoute('/compose/v1', name: 'mail.compose.v1', methods: ['POST'])]
public function index(
int $version,
string $transaction,
string|null $operation = null,
array|null $data = null,
): Response {
$tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->identifier();
try {
if ($operation === null) {
throw new InvalidArgumentException('Operation must be provided');
}
$result = match ($operation) {
'stage' => $this->stage($tenantId, $userId, $data),
'patch' => $this->patch($tenantId, $userId, $data),
'save' => $this->save($tenantId, $userId, $data),
'discard' => $this->discard($tenantId, $userId, $data),
'send' => $this->send($tenantId, $userId, $data),
'attachment.add' => $this->attachmentAdd($tenantId, $userId, $data),
'attachment.remove' => $this->attachmentRemove($tenantId, $userId, $data),
default => throw new InvalidArgumentException('Invalid operation: ' . $operation),
};
return new JsonResponse([
'version' => $version,
'transaction' => $transaction,
'operation' => $operation,
'status' => 'success',
'data' => $result,
], JsonResponse::HTTP_OK);
} catch (Throwable $throwable) {
$this->logger->error('Mail draft request failed', ['exception' => $throwable]);
return new JsonResponse([
'version' => $version,
'transaction' => $transaction,
'operation' => $operation,
'status' => 'error',
'data' => [
'code' => $throwable->getCode(),
'message' => $throwable->getMessage(),
],
], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
}
}
private function stage(string $tenantId, string $userId, array $data): array {
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
if (!isset($data['action']) || !is_string($data['action'])) {
throw new InvalidArgumentException('Invalid parameter: action must be a string');
}
if (!in_array($data['action'], ['fresh', 'reply', 'replyAll', 'forward'], true)) {
throw new InvalidArgumentException('Invalid parameter: action must be one of fresh, reply, replyAll, forward');
}
if (!isset($data['sender']) || !is_array($data['sender'])) {
throw new InvalidArgumentException('Invalid parameter: sender must be an array');
}
if (!isset($data['message']) || !is_array($data['message'])) {
throw new InvalidArgumentException('Invalid parameter: message must be an array');
}
if (($data['action'] === 'reply' || $data['action'] === 'replyAll' || $data['action'] === 'forward') && (!isset($data['source']) || !is_string($data['source']) || $data['source'] === '')) {
throw new InvalidArgumentException('Invalid parameter: source must be a non-empty string for reply, replyAll, or forward action');
}
return $this->compositionManager->stage($tenantId, $userId, $data['identifier'], $data['action'], $data['sender'], $data['message'], $data['source'] ?? null);
}
private function patch(string $tenantId, string $userId, array $data): array {
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
if (!isset($data['sender']) || !is_array($data['sender'])) {
throw new InvalidArgumentException('Invalid parameter: sender must be an array');
}
if (!isset($data['message']) || !is_array($data['message'])) {
throw new InvalidArgumentException('Invalid parameter: message must be an array');
}
return $this->compositionManager->patch($tenantId, $userId, $data['identifier'], $data);
}
private function save(string $tenantId, string $userId, array $data): array {
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
if (!isset($data['sender']) || !is_array($data['sender'])) {
throw new InvalidArgumentException('Invalid parameter: sender must be an array');
}
if (!isset($data['message']) || !is_array($data['message'])) {
throw new InvalidArgumentException('Invalid parameter: message must be an array');
}
return $this->compositionManager->save($tenantId, $userId, $data['identifier'], $data);
}
private function discard(string $tenantId, string $userId, array $data): array {
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
return $this->compositionManager->discard($tenantId, $userId, $data['identifier']);
}
private function send(string $tenantId, string $userId, array $data): array {
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
if (!isset($data['sender']) || !is_array($data['sender'])) {
throw new InvalidArgumentException('Invalid parameter: sender must be an array');
}
if (!isset($data['message']) || !is_array($data['message'])) {
throw new InvalidArgumentException('Invalid parameter: message must be an array');
}
if (!isset($data['attachments']) || !is_array($data['attachments'])) {
throw new InvalidArgumentException('Invalid parameter: attachments must be an array');
}
return $this->compositionManager->send($tenantId, $userId, $data['identifier'], $data['sender'], $data['message'], $data['attachments']);
}
private function attachmentAdd(string $tenantId, string $userId, array $data): array {
if (!isset($data['composition']) || !is_string($data['composition']) || $data['composition'] === '') {
throw new InvalidArgumentException('Invalid parameter: composition must be a non-empty string');
}
if (!isset($data['attachments']) || !is_array($data['attachments'])) {
throw new InvalidArgumentException('Invalid parameter: attachments must be an array');
}
return $this->compositionManager->attachmentAdd($tenantId, $userId, $data['composition'], ...$data['attachments']);
}
private function attachmentRemove(string $tenantId, string $userId, array $data): array {
if (!isset($data['composition']) || !is_string($data['composition']) || $data['composition'] === '') {
throw new InvalidArgumentException('Invalid parameter: composition must be a non-empty string');
}
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
return $this->compositionManager->attachmentRemove($tenantId, $userId, $data['composition'], $data['identifier']);
}
}
+23 -14
View File
@@ -2,17 +2,32 @@
namespace KTXM\Mail; namespace KTXM\Mail;
use KTXF\Module\ModuleBrowserInterface; use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXF\Module\Configuration\BrowserModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextInterface;
use KTXF\Module\ModuleInstanceAbstract; use KTXF\Module\ModuleInstanceAbstract;
/** /**
* Mail Module - Email Client * Mail Module - Email Client
*/ */
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface class Module extends ModuleInstanceAbstract
{ {
public function __construct() public function __construct(
{ } private readonly EventListenerRegistrarInterface $events,
) {}
public function boot(): void
{
$this->events->listen(
$this->handle(),
CompositionSavedEvent::class,
CompositionSyncListener::class,
'onCompositionSaved',
DeliveryMode::Deferred,
);
}
public function handle(): string public function handle(): string
{ {
@@ -65,16 +80,10 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
]; ];
} }
public function registerBI(): array public function configure(ModuleContextInterface $context): void
{ {
return [ if ($context instanceof BrowserModuleContextInterface) {
'handle' => $this->handle(), $context->registerModule($this, 'Mail', 'static/module.mjs');
'namespace' => 'Mail', }
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
} }
} }
+224
View File
@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail\Stores;
use DI\Attribute\Inject;
use KTXF\Resource\BinaryResource;
use RuntimeException;
use UnexpectedValueException;
final class CompositionStore {
private const COMPOSITION_FILENAME = 'composition.json';
private string $storagePath;
public function __construct(
#[Inject('rootDir')] private readonly string $rootDir,
) {
$this->storagePath = $this->rootDir . '/var/cache/mail/composer';
}
public function compositionFetch(string $tenantId, string $userId, string $draftId): ?array {
return $this->withCompositionLock(
$tenantId,
$userId,
$draftId,
LOCK_SH,
fn(): ?array => $this->compositionRead($tenantId, $userId, $draftId),
);
}
/**
* Atomically read, mutate, and replace a composition snapshot.
*
* Returning null from the callback leaves the stored snapshot unchanged.
* The callback receives null when the composition does not exist.
*
* @param callable(?array): ?array $update
*/
public function compositionSave(string $tenantId, string $userId, string $draftId, callable $update): ?array {
return $this->withCompositionLock(
$tenantId,
$userId,
$draftId,
LOCK_EX,
function () use ($tenantId, $userId, $draftId, $update): ?array {
$snapshot = $update($this->compositionRead($tenantId, $userId, $draftId));
if ($snapshot === null) {
return null;
}
if (!is_array($snapshot)) {
throw new UnexpectedValueException('Composition save callback must return an array or null');
}
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
$this->ensureDirectory($draftDir);
$temporaryPath = tempnam($draftDir, '.composition-');
if ($temporaryPath === false) {
throw new RuntimeException("Unable to create a temporary composition file for '$draftId'");
}
try {
$written = file_put_contents($temporaryPath, $json);
if ($written !== strlen($json)) {
throw new RuntimeException("Unable to write composition '$draftId'");
}
if (!rename($temporaryPath, $draftDir . '/' . self::COMPOSITION_FILENAME)) {
throw new RuntimeException("Unable to replace composition '$draftId'");
}
} finally {
if (is_file($temporaryPath)) {
unlink($temporaryPath);
}
}
return $snapshot;
},
);
}
public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool {
return $this->withCompositionLock(
$tenantId,
$userId,
$draftId,
LOCK_EX,
function () use ($tenantId, $userId, $draftId): bool {
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
if (!is_dir($draftDir)) {
return false;
}
$this->deleteDir($draftDir);
return true;
},
);
}
private function compositionRead(string $tenantId, string $userId, string $draftId): ?array {
$messagePath = $this->draftDir($tenantId, $userId, $draftId) . '/' . self::COMPOSITION_FILENAME;
if (!is_file($messagePath)) {
return null;
}
$contents = file_get_contents($messagePath);
if ($contents === false) {
throw new RuntimeException("Unable to read composition '$draftId'");
}
$decoded = json_decode($contents, true);
return is_array($decoded) ? $decoded : null;
}
private function withCompositionLock(string $tenantId, string $userId, string $draftId, int $operation, callable $callback): mixed {
$lockDir = $this->storagePath . '/.locks';
$this->ensureDirectory($lockDir);
$lockId = hash('sha256', $tenantId . "\0" . $userId . "\0" . $draftId);
$handle = fopen($lockDir . '/' . $lockId . '.lock', 'c+b');
if ($handle === false) {
throw new RuntimeException("Unable to open composition lock for '$draftId'");
}
try {
if (!flock($handle, $operation)) {
throw new RuntimeException("Unable to lock composition '$draftId'");
}
return $callback();
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
public function attachmentStageFromStream(string $tenantId, string $userId, string $compositionId, string $attachmentId, BinaryResource $data): array {
$attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId);
if (!is_dir($attachmentDir)) {
mkdir($attachmentDir, 0755, true);
}
$contentPath = $attachmentDir . '/' . $attachmentId . '.blob';
$handle = fopen($contentPath, 'wb');
$size = 0;
foreach ($data->stream() as $chunk) {
$chunkString = (string)$chunk;
$size += strlen($chunkString);
fwrite($handle, $chunkString);
}
fclose($handle);
$meta = [
'identifier' => $attachmentId,
'composition' => $compositionId,
'name' => $data->filename(),
'type' => $data->mimeType(),
'size' => $size,
];
file_put_contents($attachmentDir . '/' . $attachmentId . '.meta', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return $meta;
}
public function attachmentFetchData(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?string {
$path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.blob';
if (!file_exists($path)) {
return null;
}
return file_get_contents($path) ?: null;
}
public function fetchAttachmentMeta(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?array {
$path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.meta';
if (!file_exists($path)) {
return null;
}
$decoded = json_decode((string)file_get_contents($path), true);
return is_array($decoded) ? $decoded : null;
}
private function attachmentDir(string $tenantId, string $userId, string $draftId): string {
return $this->draftDir($tenantId, $userId, $draftId) . '/attachments';
}
private function draftDir(string $tenantId, string $userId, string $draftId): string {
return $this->storagePath . '/' . $tenantId . '/' . $userId . '/' . $draftId;
}
private function ensureDirectory(string $path): void {
if (!is_dir($path) && !mkdir($path, 0755, true) && !is_dir($path)) {
throw new RuntimeException("Unable to create directory '$path'");
}
}
private function deleteDir(string $path): void {
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$child = $path . '/' . $entry;
if (is_dir($child)) {
$this->deleteDir($child);
continue;
}
unlink($child);
}
rmdir($path);
}
}
+2379 -615
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -11,7 +11,11 @@
"dev": "vite build --mode development --config vite.config.ts", "dev": "vite build --mode development --config vite.config.ts",
"watch": "vite build --mode development --watch --config vite.config.ts", "watch": "vite build --mode development --watch --config vite.config.ts",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"test": "vitest run --config tests/js/vitest.config.ts",
"test:unit": "vitest run --config tests/js/vitest.config.ts",
"test:watch": "vitest watch --config tests/js/vitest.config.ts",
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
}, },
"dependencies": { "dependencies": {
"@tiptap/vue-3": "^3.0.0", "@tiptap/vue-3": "^3.0.0",
@@ -28,8 +32,12 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.9.0", "@vue/tsconfig": "^0.9.0",
"typescript": "^6.0.0", "typescript": "~6.0.0",
"vite": "^8.0.0", "vite": "^8.0.0",
"vue-tsc": "^3.0.0" "vue-tsc": "^3.0.0",
"@vitest/coverage-v8": "^4.1.6",
"@vue/test-utils": "^2.4.10",
"jsdom": "^29.1.1",
"vitest": "^4.1.6"
} }
} }
+1 -1
View File
@@ -91,7 +91,7 @@ const handleCancel = () => {
<div class="mb-4"> <div class="mb-4">
<div class="text-caption text-medium-emphasis">Account</div> <div class="text-caption text-medium-emphasis">Account</div>
<div class="text-body-2"> <div class="text-body-2">
{{ service.label || service.primaryAddress || 'Mail Account' }} {{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
</div> </div>
</div> </div>
+1 -1
View File
@@ -64,7 +64,7 @@ const handleCancel = () => {
<div class="mb-4"> <div class="mb-4">
<div class="text-caption text-medium-emphasis">Account</div> <div class="text-caption text-medium-emphasis">Account</div>
<div class="text-body-2"> <div class="text-body-2">
{{ service.label || service.primaryAddress || 'Mail Account' }} {{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
</div> </div>
</div> </div>
+2 -2
View File
@@ -187,7 +187,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
v-bind="activatorProps" v-bind="activatorProps"
class="account-header-item" class="account-header-item"
:title="group.service.label || 'Mail Account'" :title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress || undefined" :subtitle="group.service.primaryAddress?.address || undefined"
> >
<template v-slot:prepend> <template v-slot:prepend>
<v-icon icon="mdi-email-outline" /> <v-icon icon="mdi-email-outline" />
@@ -348,7 +348,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
<v-list-item <v-list-item
class="account-header-item" class="account-header-item"
:title="group.service.label || 'Mail Account'" :title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress || undefined" :subtitle="group.service.primaryAddress?.address || undefined"
> >
<template v-slot:prepend> <template v-slot:prepend>
<v-icon icon="mdi-email-outline" /> <v-icon icon="mdi-email-outline" />
+6 -6
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useMailStore } from '@/stores/mailStore' import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore' import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import type { ServiceObject, CollectionObject } from '@MailManager/models' import type { ServiceObject, CollectionObject } from '@MailManager/models'
import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue' import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue'
@@ -30,7 +30,7 @@ const emit = defineEmits<{
cancel: [] cancel: []
}>() }>()
const mailStore = useMailStore() const mailOperationsStore = useMailOperationsStore()
const collectionsStore = useCollectionsStore() const collectionsStore = useCollectionsStore()
const selectedFolderKey = ref<string | null>(null) const selectedFolderKey = ref<string | null>(null)
@@ -59,9 +59,9 @@ const serviceGroups = computed<ServiceGroup[]>(() => {
return [{ return [{
service, service,
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier), loading: mailOperationsStore.isServiceFolderLoading(service.provider, service.identifier),
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier), loaded: mailOperationsStore.hasServiceFoldersLoaded(service.provider, service.identifier),
error: mailStore.getServiceFolderError(service.provider, service.identifier), error: mailOperationsStore.getServiceFolderError(service.provider, service.identifier),
}] }]
}) })
@@ -151,7 +151,7 @@ const handleConfirm = () => {
<v-list-item <v-list-item
class="account-header-item account-header-static" class="account-header-item account-header-static"
:title="group.service.label || 'Mail Account'" :title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress || undefined" :subtitle="group.service.primaryAddress?.address || undefined"
> >
<template #prepend> <template #prepend>
<v-icon icon="mdi-email-outline" /> <v-icon icon="mdi-email-outline" />
+2 -2
View File
@@ -46,7 +46,7 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
v-bind="activatorProps" v-bind="activatorProps"
class="account-header-item" class="account-header-item"
:title="group.service.label || 'Mail Account'" :title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress || undefined" :subtitle="group.service.primaryAddress?.address || undefined"
> >
<template v-slot:prepend> <template v-slot:prepend>
<v-icon icon="mdi-email-outline" /> <v-icon icon="mdi-email-outline" />
@@ -116,7 +116,7 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
<v-list-item <v-list-item
class="account-header-item account-header-static" class="account-header-item account-header-static"
:title="group.service.label || 'Mail Account'" :title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress || undefined" :subtitle="group.service.primaryAddress?.address || undefined"
> >
<template v-slot:prepend> <template v-slot:prepend>
<v-icon icon="mdi-email-outline" /> <v-icon icon="mdi-email-outline" />
+9 -14
View File
@@ -1,16 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useServicesStore } from '@MailManager/stores/servicesStore' import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailStore } from '@/stores/mailStore' import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { useMailUiStore } from '@/stores/mailUiStore' import { useMailUiStore } from '@/stores/mailUiStore'
import { useUser } from '@KTXC'
import FolderTreeView from './FolderTreeView.vue' import FolderTreeView from './FolderTreeView.vue'
import FolderPageView from './FolderPageView.vue' import FolderPageView from './FolderPageView.vue'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models' import type { ServiceObject } from '@MailManager/models'
type FolderViewMode = 'tree' | 'page'
interface ServiceGroup { interface ServiceGroup {
service: ServiceObject service: ServiceObject
loading: boolean loading: boolean
@@ -29,14 +28,10 @@ const emit = defineEmits<{
// Stores // Stores
const servicesStore = useServicesStore() const servicesStore = useServicesStore()
const mailStore = useMailStore() const mailOperationsStore = useMailOperationsStore()
const mailUiStore = useMailUiStore() const mailUiStore = useMailUiStore()
const { settings } = useUser() const mailSettingsStore = useMailSettingsStore()
const { folderViewMode } = storeToRefs(mailSettingsStore)
// Computed
const folderViewMode = computed(() => {
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
})
const serviceGroups = computed(() => { const serviceGroups = computed(() => {
const groups: ServiceGroup[] = [] const groups: ServiceGroup[] = []
@@ -48,9 +43,9 @@ const serviceGroups = computed(() => {
groups.push({ groups.push({
service, service,
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier), loading: mailOperationsStore.isServiceFolderLoading(service.provider, service.identifier),
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier), loaded: mailOperationsStore.hasServiceFoldersLoaded(service.provider, service.identifier),
error: mailStore.getServiceFolderError(service.provider, service.identifier), error: mailOperationsStore.getServiceFolderError(service.provider, service.identifier),
}) })
}) })
+239 -141
View File
@@ -8,16 +8,22 @@ import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align' import TextAlign from '@tiptap/extension-text-align'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import { EntityObject } from '@MailManager/models/entity' import { EntityObject } from '@MailManager/models/entity'
import type { CollectionObject } from '@MailManager/models' import type { CollectionObject, MessageAddressObject } from '@MailManager/models'
import { useMailStore } from '@/stores/mailStore' import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useMailCompositionStore } from '@/stores/mailCompositionStore'
import { ComposerMode } from '@/types/composer'
import type { ComposerFormat } from '@/types/composer'
import { documentsSelectorComponent } from '@/services/documentsSelector'
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue' import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
import ComposerSender from '@/components/composer/ComposerSender.vue'
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue' import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
import ComposerAttachments from '@/components/composer/ComposerAttachments.vue'
import ComposerEditor from '@/components/composer/ComposerEditor.vue' import ComposerEditor from '@/components/composer/ComposerEditor.vue'
// Props // Props
interface Props { interface Props {
mode: 'new' | 'reply' | 'forward' mode: ComposerMode
source?: EntityObject | null source?: EntityObject | MessageAddressObject | null
folder?: CollectionObject | null folder?: CollectionObject | null
} }
@@ -26,26 +32,69 @@ const props = defineProps<Props>()
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
sent: []
}>() }>()
const mailStore = useMailStore() const mailOperationsStore = useMailOperationsStore()
const compositionStore = useMailCompositionStore()
const { const {
composerSending: sending, composerSending: sending,
composerSaving: saving, } = storeToRefs(mailOperationsStore)
composerLastSaved: lastSaved,
} = storeToRefs(mailStore) const {
activeDraft,
saving,
stageStatus,
} = storeToRefs(compositionStore)
// State // State
const to = ref<string[]>([])
const cc = ref<string[]>([])
const bcc = ref<string[]>([])
const subject = ref('')
const showCc = ref(false) const showCc = ref(false)
const showBcc = ref(false) const showBcc = ref(false)
const format = ref<ComposerFormat>('rich')
const textEdited = ref(false)
const applyingDraftToEditor = ref(false)
const showDocumentSelector = ref(false)
const showCloseDialog = ref(false)
const closeAction = ref<'save' | 'discard' | null>(null)
const documentSelector = computed(() => documentsSelectorComponent())
// Auto-save timer const sender = computed({
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null get: () => activeDraft.value?.sender ?? null,
set: value => {
if (value) {
compositionStore.updateSender(value)
}
},
})
const to = computed({
get: () => activeDraft.value?.message.to ?? [],
set: value => compositionStore.updateRecipients('to', value),
})
const cc = computed({
get: () => activeDraft.value?.message.cc ?? [],
set: value => compositionStore.updateRecipients('cc', value),
})
const bcc = computed({
get: () => activeDraft.value?.message.bcc ?? [],
set: value => compositionStore.updateRecipients('bcc', value),
})
const subject = computed({
get: () => activeDraft.value?.message.subject ?? '',
set: value => compositionStore.updateSubject(value),
})
const attachments = computed(() => activeDraft.value?.attachments ?? {})
const text = computed({
get: () => activeDraft.value?.message.body.text ?? '',
set: value => {
textEdited.value = true
compositionStore.updateBody({ text: value, html: textToHtml(value) })
},
})
// Initialize Tiptap editor // Initialize Tiptap editor
const editor = useEditor({ const editor = useEditor({
@@ -70,53 +119,6 @@ const editor = useEditor({
}, },
}) })
function resetComposerFields() {
to.value = []
cc.value = []
bcc.value = []
subject.value = ''
showCc.value = false
showBcc.value = false
editor.value?.commands.setContent('')
}
function initializeComposerFromProps() {
mailStore.resetComposerState()
resetComposerFields()
if (!props.source) {
return
}
const sourceMessage = props.source.properties
const originalSubject = sourceMessage.subject || ''
const originalBody = sourceMessage.getHtmlContent() || sourceMessage.getTextContent() || ''
const senderName = sourceMessage.from?.label || sourceMessage.from?.address || 'Unknown'
const sentAt = sourceMessage.sent || props.source.created || ''
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
if (props.mode === 'reply') {
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address
to.value = fromEmail ? [fromEmail] : []
subject.value = /^Re:/i.test(originalSubject)
? originalSubject
: `Re: ${originalSubject}`
editor.value?.commands.setContent(
`<p><br></p><p>On ${sentLabel}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`,
)
return
}
if (props.mode === 'forward') {
subject.value = /^Fwd:/i.test(originalSubject)
? originalSubject
: `Fwd: ${originalSubject}`
editor.value?.commands.setContent(
`<p><br></p><p>---------- Forwarded message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
)
}
}
watch( watch(
[() => props.mode, () => props.source, () => editor.value], [() => props.mode, () => props.source, () => editor.value],
([, , currentEditor]) => { ([, , currentEditor]) => {
@@ -124,7 +126,15 @@ watch(
return return
} }
initializeComposerFromProps() compositionStore.openDraft(props.mode, props.source)
showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0
showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0
format.value = 'rich'
textEdited.value = false
applyingDraftToEditor.value = true
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
applyingDraftToEditor.value = false
}, },
{ immediate: true }, { immediate: true },
) )
@@ -134,98 +144,90 @@ const canSend = computed(() => {
return to.value.length > 0 && subject.value.trim().length > 0 return to.value.length > 0 && subject.value.trim().length > 0
}) })
const saveStatus = computed(() => { // Watch editor content changes
if (saving.value) return 'Saving...' watch(editor, currentEditor => {
if (lastSaved.value) { if (!currentEditor) {
const seconds = Math.floor((Date.now() - lastSaved.value.getTime()) / 1000)
if (seconds < 60) return 'Saved just now'
if (seconds < 3600) return `Saved ${Math.floor(seconds / 60)} min ago`
return `Saved at ${lastSaved.value.toLocaleTimeString()}`
}
return ''
})
// Auto-save function
const saveDraft = async () => {
if (saving.value || sending.value) return
if (!props.folder) return
// Don't save if completely empty
if (to.value.length === 0 && subject.value.trim().length === 0 && !editor.value?.getText().trim()) {
return return
} }
try { const handleUpdate = () => {
await mailStore.saveComposerDraft(props.folder, { if (applyingDraftToEditor.value) {
to: to.value, return
cc: cc.value, }
bcc: bcc.value,
subject: subject.value, compositionStore.updateBody({
body: { html: currentEditor.getHTML(),
html: editor.value?.getHTML() || '', text: currentEditor.getText(),
text: editor.value?.getText() || '',
},
}) })
} catch (error) {
console.error('[Mail][Composer] Failed to save draft:', error)
}
}
// Watch for changes and trigger auto-save
const scheduleAutoSave = () => {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
} }
autoSaveTimer = setTimeout(() => { currentEditor.on('update', handleUpdate)
saveDraft()
}, 30000) // 30 seconds
}
watch([to, cc, bcc, subject], () => { return () => {
scheduleAutoSave() currentEditor.off('update', handleUpdate)
}, { deep: true }) }
})
// Watch editor content changes
if (editor.value) {
editor.value.on('update', () => {
scheduleAutoSave()
})
}
// Cleanup // Cleanup
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (autoSaveTimer) { void compositionStore.flushSave()
clearTimeout(autoSaveTimer)
}
mailStore.resetComposerState()
editor.value?.destroy() editor.value?.destroy()
}) })
// Handlers // Handlers
const handleClose = () => { const handleClose = () => {
mailStore.resetComposerState() showCloseDialog.value = true
emit('close') }
const handleSave = async () => {
closeAction.value = 'save'
try {
if (await compositionStore.saveDraft()) {
showCloseDialog.value = false
emit('close')
}
} finally {
closeAction.value = null
}
}
const handleDiscard = async () => {
closeAction.value = 'discard'
try {
if (await compositionStore.discardDraft()) {
showCloseDialog.value = false
emit('close')
}
} finally {
closeAction.value = null
}
} }
const handleSend = async () => { const handleSend = async () => {
if (!canSend.value || sending.value) return await compositionStore.sendDraft()
}
try { const handleAttachFromDevice = async (files: File[]) => {
await mailStore.sendComposerMessage({ await compositionStore.addAttachmentsFromDevice(files)
to: to.value, }
cc: cc.value,
bcc: bcc.value, const handleAttachFromDocuments = () => {
subject: subject.value, showDocumentSelector.value = true
body: { }
html: editor.value?.getHTML() || '',
text: editor.value?.getText() || '', const handleDocumentsSelected = async (items: { kind: 'entity' | 'collection'; identifier: string; name: string; mimeType?: string; size?: number }[]) => {
}, // The dialog defaults to entities-only (no `selectable` prop passed below), but
}) // filter defensively since mail can't attach a folder as a single file blob.
emit('sent') const files = items.filter(item => item.kind === 'entity')
} catch (error) { if (files.length === 0) {
console.error('[Mail][Composer] Failed to send message:', error) return
} }
await compositionStore.addAttachmentsFromDocuments(files)
showDocumentSelector.value = false
}
const handleDetach = async (identifier: string) => {
await compositionStore.removeAttachment(identifier)
} }
const toggleCc = () => { const toggleCc = () => {
@@ -249,11 +251,6 @@ const setLink = () => {
} }
} }
const removeLink = () => editor.value?.chain().focus().unsetLink().run() const removeLink = () => editor.value?.chain().focus().unsetLink().run()
const isActive = (name: string, attrs?: any) => {
return editor.value?.isActive(name, attrs) || false
}
const toggleLink = () => { const toggleLink = () => {
if (isActive('link')) { if (isActive('link')) {
removeLink() removeLink()
@@ -262,20 +259,66 @@ const toggleLink = () => {
setLink() setLink()
} }
const isActive = (name: string, attrs?: any) => {
return editor.value?.isActive(name, attrs) || false
}
const toggleFormat = () => {
if (format.value === 'rich') {
if (editor.value) {
compositionStore.updateBody({
html: activeDraft.value?.message.body.html ?? '',
text: editor.value.getText(),
})
}
textEdited.value = false
format.value = 'plain'
return
}
format.value = 'rich'
if (textEdited.value && editor.value) {
applyingDraftToEditor.value = true
editor.value.commands.setContent(activeDraft.value?.message.body.html || '')
applyingDraftToEditor.value = false
}
}
function textToHtml(text: string): string {
if (!text) {
return ''
}
return text
.split(/\r?\n/)
.map(line => `<p>${escapeHtml(line) || '<br>'}</p>`)
.join('')
}
function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
</script> </script>
<template> <template>
<div class="message-composer"> <div class="message-composer">
<ComposerToolbar <ComposerToolbar
:mode="mode" :mode="mode"
:save-status="saveStatus" :status="stageStatus"
:can-send="canSend" :can-send="canSend"
:sending="sending" :sending="sending"
:saving="saving"
@close="handleClose" @close="handleClose"
@save="handleSave"
@send="handleSend" @send="handleSend"
/> />
<div class="composer-content"> <div class="composer-content">
<ComposerSender
v-model="sender"
:options="compositionStore.senderIdentities"
/>
<ComposerRecipients <ComposerRecipients
:to="to" :to="to"
:cc="cc" :cc="cc"
@@ -293,8 +336,18 @@ const toggleLink = () => {
<v-divider /> <v-divider />
<ComposerAttachments
v-if="Object.keys(attachments).length > 0"
:attachments="attachments"
@remove="handleDetach"
/>
<v-divider v-if="Object.keys(attachments).length > 0" />
<ComposerEditor <ComposerEditor
:editor="editor" :editor="editor"
:format="format"
:text="text"
:is-bold-active="isActive('bold')" :is-bold-active="isActive('bold')"
:is-italic-active="isActive('italic')" :is-italic-active="isActive('italic')"
:is-underline-active="isActive('underline')" :is-underline-active="isActive('underline')"
@@ -307,8 +360,52 @@ const toggleLink = () => {
@bullet-list="toggleBulletList" @bullet-list="toggleBulletList"
@ordered-list="toggleOrderedList" @ordered-list="toggleOrderedList"
@link="toggleLink" @link="toggleLink"
@toggle-format="toggleFormat"
@update:text="text = $event"
@attach-from-device="handleAttachFromDevice"
@attach-from-documents="handleAttachFromDocuments"
/> />
</div> </div>
<component
:is="documentSelector"
v-if="documentSelector"
v-model="showDocumentSelector"
:selectable="['entity']"
@select="handleDocumentsSelected"
/>
<v-dialog v-model="showCloseDialog" max-width="480" persistent>
<v-card>
<v-card-title>Save this draft?</v-card-title>
<v-card-text>
Save the draft to your mail server, or discard it permanently.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn :disabled="closeAction !== null" @click="showCloseDialog = false">
Cancel
</v-btn>
<v-btn
color="error"
variant="text"
:loading="closeAction === 'discard'"
:disabled="closeAction !== null || saving"
@click="handleDiscard"
>
Discard
</v-btn>
<v-btn
color="primary"
:loading="closeAction === 'save'"
:disabled="closeAction !== null || saving"
@click="handleSave"
>
Save and close
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div> </div>
</template> </template>
@@ -331,4 +428,5 @@ const toggleLink = () => {
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
</style> </style>
+284 -123
View File
@@ -1,8 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import { storeToRefs } from 'pinia'
import type { EntityIdentifier } from '@MailManager/types/common' import type { EntityIdentifier } from '@MailManager/types/common'
import type { EntityObject } from '@MailManager/models' import type { EntityObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import { messageListDensityConfig, useMailSettingsStore } from '@/stores/mailSettingsStore'
import RecipientDetails from '@/components/common/RecipientDetails.vue'
import MessageListItemMenu from '@/components/MessageListItemMenu.vue'
// Props // Props
interface Props { interface Props {
@@ -12,83 +16,56 @@ interface Props {
selectionMode?: boolean selectionMode?: boolean
selectedCollection?: CollectionObject | null selectedCollection?: CollectionObject | null
loading?: boolean loading?: boolean
searchActive?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
loading: false, loading: false,
selectionList: () => [], selectionList: () => [],
selectionMode: false, selectionMode: false,
searchActive: false,
}) })
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
open: [message: EntityObject] open: [message: EntityObject]
toggleSelection: [message: EntityObject] reply: [message: EntityObject]
activateSelectionMode: [message: EntityObject] forward: [message: EntityObject]
toggleSelectAll: [value: boolean] move: [message: EntityObject]
clearSelection: [] delete: [message: EntityObject]
moveSelection: [] flag: [message: EntityObject, flag: string, value: boolean]
deleteSelection: [] selectionMode: [message: EntityObject]
selectionToggleOne: [message: EntityObject]
selectionToggleAll: [value: boolean]
selectionClear: []
selectionMove: []
selectionDelete: []
selectionFlag: [flag: string, value: boolean]
}>() }>()
const mailSettingsStore = useMailSettingsStore()
const { messageListDensity } = storeToRefs(mailSettingsStore)
const densityConfig = computed(() => messageListDensityConfig[messageListDensity.value])
const longPressTimer = ref<number | null>(null) const longPressTimer = ref<number | null>(null)
const longPressActivated = ref(false) const longPressActivated = ref(false)
const suppressNextClick = ref(false) const suppressNextClick = ref(false)
const LONG_PRESS_MS = 450 const contextMenuVisible = ref(false)
const contextMenuTarget = ref<[number, number] | undefined>(undefined)
const contextMenuMessage = ref<EntityObject | null>(null)
const LONG_PRESS_MS = 400
const selectedIdSet = computed(() => new Set(props.selectionList)) const selectedIdSet = computed(() => new Set(props.selectionList))
const selectionCount = computed(() => props.selectionList.length ?? 0)
const currentMessages = computed(() => props.messages ?? [])
const getMessageTimestamp = (message: EntityObject): string | null => {
return message.properties.received
|| message.properties.sent
|| message.modified
|| message.created
|| null
}
const getMessageTimeValue = (message: EntityObject): number => {
const timestamp = getMessageTimestamp(message)
if (!timestamp) {
return 0
}
const timeValue = new Date(timestamp).getTime()
return Number.isNaN(timeValue) ? 0 : timeValue
}
const selectionCount = computed(() => props.selectionList.length)
const hasSelection = computed(() => selectionCount.value > 0)
const allCurrentMessagesSelected = computed(() => {
return currentMessages.value.length > 0 && currentMessages.value.every(message => isSelected(message))
})
// Sorted messages (newest first) // Sorted messages (newest first)
const sortedMessages = computed(() => { const sortedMessages = computed(() => {
return [...currentMessages.value].sort((a, b) => { return [...props.messages].sort((a, b) => {
const dateA = getMessageTimeValue(a) const dateA = timeStamp(a) ?? 0
const dateB = getMessageTimeValue(b) const dateB = timeStamp(b) ?? 0
return dateB - dateA return dateB - dateA
}) })
}) })
// Read/Unread counts from collection properties
const unreadCount = computed(() => {
return props.selectedCollection?.properties.unread ?? 0
})
const totalCount = computed(() => {
return props.selectedCollection?.properties.total ?? 0
})
// True only when the collection explicitly provides total/unread counts
const hasCountData = computed(() => {
return props.selectedCollection?.properties.total != null
})
const isOpened = (message: EntityObject): boolean => { const isOpened = (message: EntityObject): boolean => {
if (!props.selectedMessage) return false if (!props.selectedMessage) return false
return (message.identifier === props.selectedMessage.identifier) return (message.identifier === props.selectedMessage.identifier)
@@ -98,8 +75,23 @@ const isSelected = (message: EntityObject): boolean => {
return selectedIdSet.value.has(message.identifier) return selectedIdSet.value.has(message.identifier)
} }
const timeStamp = (message: EntityObject): number | null => {
const timestamp = message.properties.received
|| message.properties.sent
|| message.modified
|| message.created
|| null
if (!timestamp) {
return null
}
const timeValue = new Date(timestamp).getTime()
return Number.isNaN(timeValue) ? null : timeValue
}
// Format date for display // Format date for display
const formatDate = (date: Date | string | null | undefined): string => { const formatDate = (date: Date | string | number | null | undefined): string => {
if (!date) return '' if (!date) return ''
const messageDate = new Date(date) const messageDate = new Date(date)
@@ -141,21 +133,19 @@ const formatDate = (date: Date | string | null | undefined): string => {
}) })
} }
// Truncate text
const truncate = (text: string | null | undefined, length: number = 100): string => {
if (!text) return ''
return text.length > length ? text.substring(0, length) + '...' : text
}
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => { const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
return event.target instanceof Element && event.target.closest('.message-selection-checkbox') !== null return event.target instanceof Element && event.target.closest('.message-selection-checkbox, .message-item-menu-trigger, .message-item-menu-content') !== null
} }
const handleSelectionToggle = (message: EntityObject) => { const handleSelectionToggleOne = (message: EntityObject) => {
emit('toggleSelection', message) emit('selectionToggleOne', message)
} }
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => { const handleSelectionToggleAll = (value: boolean | null) => {
emit('selectionToggleAll', value === true)
}
const handleMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) { if (isSelectionControlClick(event)) {
return return
} }
@@ -168,7 +158,7 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
if (event.shiftKey && !props.selectionMode) { if (event.shiftKey && !props.selectionMode) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
emit('activateSelectionMode', message) emit('selectionMode', message)
return return
} }
@@ -178,14 +168,14 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
} }
if (props.selectionMode) { if (props.selectionMode) {
emit('toggleSelection', message) emit('selectionToggleOne', message)
return return
} }
emit('open', message) emit('open', message)
} }
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => { const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) { if (isSelectionControlClick(event)) {
return return
} }
@@ -197,14 +187,44 @@ const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
suppressNextClick.value = true suppressNextClick.value = true
emit('activateSelectionMode', message) emit('selectionMode', message)
} }
const clearLongPressTimer = () => { const openContextMenu = (event: MouseEvent, message: EntityObject) => {
if (longPressTimer.value !== null) { if (isSelectionControlClick(event)) {
window.clearTimeout(longPressTimer.value) return
longPressTimer.value = null
} }
event.preventDefault()
event.stopPropagation()
contextMenuMessage.value = message
contextMenuTarget.value = [event.clientX, event.clientY]
contextMenuVisible.value = true
}
const handleContextMenuReply = (message: EntityObject) => {
emit('reply', message)
}
const handleContextMenuForward = (message: EntityObject) => {
emit('forward', message)
}
const handleContextMenuMove = (message: EntityObject) => {
emit('move', message)
}
const handleContextMenuDelete = (message: EntityObject) => {
emit('delete', message)
}
const handleContextMenuFlag = (message: EntityObject, flag: string, value: boolean) => {
emit('flag', message, flag, value)
}
const getContextMenuMessage = (): EntityObject => {
return contextMenuMessage.value as EntityObject
} }
const handleTouchStart = (message: EntityObject) => { const handleTouchStart = (message: EntityObject) => {
@@ -213,9 +233,9 @@ const handleTouchStart = (message: EntityObject) => {
longPressTimer.value = window.setTimeout(() => { longPressTimer.value = window.setTimeout(() => {
if (!props.selectionMode) { if (!props.selectionMode) {
emit('activateSelectionMode', message) emit('selectionMode', message)
} else { } else {
emit('toggleSelection', message) emit('selectionToggleOne', message)
} }
longPressActivated.value = true longPressActivated.value = true
@@ -231,13 +251,32 @@ const handleTouchMove = () => {
clearLongPressTimer() clearLongPressTimer()
} }
const clearLongPressTimer = () => {
if (longPressTimer.value !== null) {
window.clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
}
const handleFlag = (flag: string, value: boolean) => {
if (props.selectionMode && selectionCount.value > 0) {
emit('selectionFlag', flag, value)
}
}
const handleRecipientClick = (message: EntityObject) => {
if (props.selectionMode) {
emit('selectionToggleOne', message)
return
}
emit('open', message)
}
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearLongPressTimer() clearLongPressTimer()
}) })
const handleSelectAllToggle = (value: boolean | null) => {
emit('toggleSelectAll', value === true)
}
</script> </script>
<template> <template>
@@ -245,12 +284,12 @@ const handleSelectAllToggle = (value: boolean | null) => {
<!-- Header with folder name and counts --> <!-- Header with folder name and counts -->
<div v-if="selectedCollection" class="message-list-header"> <div v-if="selectedCollection" class="message-list-header">
<div class="message-list-heading"> <div class="message-list-heading">
<h2 class="text-h6">{{ selectedCollection.properties.label || 'Folder' }}</h2> <h2 class="text-h6">{{ selectedCollection?.properties.label || 'Folder' }}</h2>
<div class="folder-counts text-caption text-medium-emphasis"> <div class="folder-counts text-caption text-medium-emphasis">
<span v-if="hasCountData"> <span v-if="selectedCollection?.properties.total != null">
<span class="unread-count">{{ unreadCount }}</span> <span class="unread-count">{{ selectedCollection?.properties.unread ?? 0 }}</span>
<span class="mx-1">/</span> <span class="mx-1">/</span>
<span>{{ totalCount }}</span> <span>{{ selectedCollection?.properties.total ?? 0 }}</span>
</span> </span>
<span v-else-if="messages.length > 0"> <span v-else-if="messages.length > 0">
{{ messages.length }} loaded {{ messages.length }} loaded
@@ -261,15 +300,12 @@ const handleSelectAllToggle = (value: boolean | null) => {
<div v-if="selectionMode && messages.length > 0" class="selection-summary"> <div v-if="selectionMode && messages.length > 0" class="selection-summary">
<div class="selection-controls"> <div class="selection-controls">
<v-checkbox-btn <v-checkbox-btn
:model-value="allCurrentMessagesSelected" :model-value="selectionCount !== 0"
:indeterminate="hasSelection && !allCurrentMessagesSelected" :indeterminate="selectionCount > 0 && selectionCount !== messages.length"
density="compact" density="compact"
hide-details hide-details
@update:model-value="handleSelectAllToggle" @update:model-value="handleSelectionToggleAll"
/> />
<span class="text-caption text-medium-emphasis">
{{ selectionCount > 0 ? `${selectionCount} selected` : 'Select all loaded' }}
</span>
</div> </div>
<div class="selection-actions"> <div class="selection-actions">
@@ -277,8 +313,8 @@ const handleSelectAllToggle = (value: boolean | null) => {
size="small" size="small"
icon="mdi-folder-move-outline" icon="mdi-folder-move-outline"
variant="text" variant="text"
:disabled="!hasSelection" :disabled="selectionCount === 0"
@click="emit('moveSelection')" @click="emit('selectionMove')"
> >
<v-icon>mdi-folder-move-outline</v-icon> <v-icon>mdi-folder-move-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Move Selected</v-tooltip> <v-tooltip activator="parent" location="bottom">Move Selected</v-tooltip>
@@ -287,17 +323,37 @@ const handleSelectAllToggle = (value: boolean | null) => {
size="small" size="small"
icon="mdi-delete-outline" icon="mdi-delete-outline"
variant="text" variant="text"
:disabled="!hasSelection" :disabled="selectionCount === 0"
@click="emit('deleteSelection')" @click="emit('selectionDelete')"
> >
<v-icon>mdi-delete-outline</v-icon> <v-icon>mdi-delete-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Delete Selected</v-tooltip> <v-tooltip activator="parent" location="bottom">Delete Selected</v-tooltip>
</v-btn> </v-btn>
<v-btn
size="small"
icon="mdi-email-open-outline"
variant="text"
:disabled="selectionCount === 0"
@click="handleFlag('seen', true)"
>
<v-icon>mdi-email-open-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Mark as Read</v-tooltip>
</v-btn>
<v-btn
size="small"
icon="mdi-email-outline"
variant="text"
:disabled="selectionCount === 0"
@click="handleFlag('seen', false)"
>
<v-icon>mdi-email-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Mark as Unread</v-tooltip>
</v-btn>
<v-btn <v-btn
size="small" size="small"
icon="mdi-close" icon="mdi-close"
variant="text" variant="text"
@click="emit('clearSelection')" @click="emit('selectionClear')"
> >
<v-icon>mdi-close</v-icon> <v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="bottom">Clear Selected</v-tooltip> <v-tooltip activator="parent" location="bottom">Clear Selected</v-tooltip>
@@ -317,11 +373,11 @@ const handleSelectAllToggle = (value: boolean | null) => {
</div> </div>
<!-- Empty state --> <!-- Empty state -->
<div v-else-if="currentMessages.length === 0" class="pa-8 text-center"> <div v-else-if="messages.length === 0" class="pa-8 text-center">
<v-icon size="64" color="grey-lighten-1">mdi-email-outline</v-icon> <v-icon size="64" color="grey-lighten-1">{{ searchActive ? 'mdi-email-search-outline' : 'mdi-email-outline' }}</v-icon>
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div> <div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
This folder is empty {{ searchActive ? 'No messages match your search' : 'This folder is empty' }}
</div> </div>
</div> </div>
@@ -329,7 +385,7 @@ const handleSelectAllToggle = (value: boolean | null) => {
<v-virtual-scroll <v-virtual-scroll
v-else v-else
:items="sortedMessages" :items="sortedMessages"
:item-height="80" :item-height="densityConfig.itemHeight"
class="message-virtual-scroll" class="message-virtual-scroll"
> >
<template v-slot:default="{ item: message }"> <template v-slot:default="{ item: message }">
@@ -342,13 +398,14 @@ const handleSelectAllToggle = (value: boolean | null) => {
'selection-mode': selectionMode, 'selection-mode': selectionMode,
'unread': !message.properties.isRead 'unread': !message.properties.isRead
}" }"
@mousedown="handleMessageMouseDown($event, message)" @mousedown="handleMouseDown($event, message)"
@click="handleMessageMouseClick($event, message)" @click="handleMouseClick($event, message)"
@contextmenu="openContextMenu($event, message)"
@touchstart.passive="handleTouchStart(message)" @touchstart.passive="handleTouchStart(message)"
@touchend="handleTouchEnd" @touchend="handleTouchEnd"
@touchcancel="handleTouchEnd" @touchcancel="handleTouchEnd"
@touchmove="handleTouchMove" @touchmove="handleTouchMove"
lines="three" :lines="densityConfig.lines"
> >
<template v-slot:prepend> <template v-slot:prepend>
<div class="message-item-prepend"> <div class="message-item-prepend">
@@ -358,7 +415,7 @@ const handleSelectAllToggle = (value: boolean | null) => {
density="compact" density="compact"
hide-details hide-details
@click.stop @click.stop
@update:model-value="handleSelectionToggle(message)" @update:model-value="handleSelectionToggleOne(message)"
/> />
<v-avatar size="40" color="primary"> <v-avatar size="40" color="primary">
@@ -370,45 +427,82 @@ const handleSelectAllToggle = (value: boolean | null) => {
</template> </template>
<v-list-item-title class="d-flex align-center"> <v-list-item-title class="d-flex align-center">
<span class="flex-grow-1 text-truncate"> <span class="message-sender text-truncate">
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }} <RecipientDetails
:address="message.properties.from"
@clicked="handleRecipientClick(message)"
>
<template #default="{ label }">
<span class="message-person-link text-truncate">{{ label }}</span>
</template>
</RecipientDetails>
</span> </span>
<span class="text-caption text-medium-emphasis ml-2"> <span
{{ formatDate(getMessageTimestamp(message)) }} v-if="!densityConfig.showSubjectLine"
class="message-inline-subject text-medium-emphasis text-truncate ml-2"
>
{{ message.properties.subject || '(No subject)' }}
</span>
<span class="message-date text-caption text-medium-emphasis ml-2">
{{ formatDate(timeStamp(message)) }}
</span> </span>
</v-list-item-title> </v-list-item-title>
<v-list-item-subtitle class="text-truncate"> <v-list-item-subtitle v-if="densityConfig.showSubjectLine" class="text-truncate">
{{ message.properties.subject || '(No subject)' }} {{ message.properties.subject || '(No subject)' }}
</v-list-item-subtitle> </v-list-item-subtitle>
<v-list-item-subtitle class="text-caption text-truncate"> <v-list-item-subtitle v-if="densityConfig.showThirdLine" class="text-caption text-truncate">
{{ '' }} {{ '' }}
</v-list-item-subtitle> </v-list-item-subtitle>
<template v-slot:append> <template v-slot:append>
<div class="d-flex flex-column align-center"> <div class="message-item-append">
<v-icon <div class="d-flex flex-column align-center">
v-if="message.properties.isFlagged" <v-icon
size="small" v-if="message.properties.isFlagged"
color="warning" size="small"
class="mb-1" color="warning"
> class="mb-1"
mdi-star >
</v-icon> mdi-star
<v-icon </v-icon>
v-if="message.properties.hasAttachments" <v-icon
size="small" v-if="message.properties.hasAttachments"
color="grey" size="small"
> color="grey"
mdi-paperclip >
</v-icon> mdi-paperclip
</v-icon>
</div>
<MessageListItemMenu
:message="message"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
</div> </div>
</template> </template>
</v-list-item> </v-list-item>
<v-divider /> <v-divider />
</template> </template>
</v-virtual-scroll> </v-virtual-scroll>
<MessageListItemMenu
v-if="contextMenuMessage"
v-model="contextMenuVisible"
:message="getContextMenuMessage()"
:target="contextMenuTarget"
:show-activator="false"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
</div> </div>
</template> </template>
@@ -493,6 +587,31 @@ const handleSelectAllToggle = (value: boolean | null) => {
min-width: 72px; min-width: 72px;
} }
.message-item-append {
display: flex;
align-items: center;
gap: 4px;
}
@media (hover: hover) {
.message-item :deep(.message-item-menu-trigger) {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.message-item:hover :deep(.message-item-menu-trigger),
.message-item:focus-within :deep(.message-item-menu-trigger),
.message-item.opened :deep(.message-item-menu-trigger),
.message-item.selected :deep(.message-item-menu-trigger),
.message-item :deep(.message-item-menu-trigger[aria-expanded='true']) {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
.message-item:hover { .message-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.04); background-color: rgba(var(--v-theme-on-surface), 0.04);
} }
@@ -511,6 +630,48 @@ const handleSelectAllToggle = (value: boolean | null) => {
:deep(.v-list-item-subtitle:first-of-type) { :deep(.v-list-item-subtitle:first-of-type) {
font-weight: 600; font-weight: 600;
} }
:deep(.v-list-item-title),
:deep(.v-list-item-subtitle:first-of-type),
:deep(.v-list-item-title .text-caption) {
color: rgb(var(--v-theme-on-surface));
}
}
.message-item.unread:hover {
background-color: rgba(var(--v-theme-primary), 0.1);
}
.message-item.unread.selected:not(.opened) {
background-color: rgba(var(--v-theme-primary), 0.14);
}
.message-sender {
flex: 0 1 auto;
min-width: 0;
}
.message-inline-subject {
flex: 1 1 auto;
min-width: 0;
}
.message-date {
flex-shrink: 0;
margin-left: auto;
}
.message-person-link {
display: inline-block;
max-width: 100%;
border-radius: 4px;
padding: 1px 4px;
margin: -1px -4px;
transition: background-color 0.2s ease;
}
.message-person-link:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
} }
@media (max-width: 960px) { @media (max-width: 960px) {
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, getCurrentInstance, ref } from 'vue'
import type { EntityObject } from '@MailManager/models'
interface Props {
message: EntityObject
modelValue?: boolean
target?: [number, number] | undefined
showActivator?: boolean
}
const props = withDefaults(defineProps<Props>(), {
target: undefined,
showActivator: true,
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
reply: [message: EntityObject]
forward: [message: EntityObject]
move: [message: EntityObject]
delete: [message: EntityObject]
flag: [message: EntityObject, flag: string, value: boolean]
}>()
const isRead = computed(() => props.message.properties.isRead === true)
const isFlagged = computed(() => props.message.properties.isFlagged === true)
const localIsOpen = ref(false)
const instance = getCurrentInstance()
const isControlled = computed(() => {
const vnodeProps = instance?.vnode.props ?? {}
return Object.prototype.hasOwnProperty.call(vnodeProps, 'modelValue')
|| Object.prototype.hasOwnProperty.call(vnodeProps, 'onUpdate:modelValue')
})
const isOpen = computed({
get: () => (isControlled.value ? props.modelValue ?? false : localIsOpen.value),
set: (value: boolean) => {
if (!isControlled.value) {
localIsOpen.value = value
}
emit('update:modelValue', value)
},
})
const handleFlag = (flag: string, value: boolean) => {
emit('flag', props.message, flag, value)
isOpen.value = false
}
const handleReply = () => {
emit('reply', props.message)
isOpen.value = false
}
const handleForward = () => {
emit('forward', props.message)
isOpen.value = false
}
const handleMove = () => {
emit('move', props.message)
isOpen.value = false
}
const handleDelete = () => {
emit('delete', props.message)
isOpen.value = false
}
</script>
<template>
<v-menu
v-model="isOpen"
:target="target"
:absolute="target !== undefined"
:close-on-content-click="true"
location="bottom end"
content-class="message-item-menu-content"
>
<template v-if="showActivator" #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
class="message-item-menu-trigger"
icon="mdi-dots-vertical"
size="small"
variant="text"
@click.stop
@mousedown.stop
>
<v-icon>mdi-dots-vertical</v-icon>
<v-tooltip activator="parent" location="bottom">More Actions</v-tooltip>
</v-btn>
</template>
<v-list class="message-item-menu" density="compact" min-width="220">
<v-list-item
:prepend-icon="isRead ? 'mdi-email-outline' : 'mdi-email-open-outline'"
@click="handleFlag('seen', !isRead)"
>
<v-list-item-title>
{{ isRead ? 'Mark as unread' : 'Mark as read' }}
</v-list-item-title>
</v-list-item>
<v-list-item
:prepend-icon="isFlagged ? 'mdi-star' : 'mdi-star-outline'"
@click="handleFlag('flagged', !isFlagged)"
>
<v-list-item-title>
{{ isFlagged ? 'Unstar' : 'Star' }}
</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item prepend-icon="mdi-folder-move-outline" @click="handleMove">
<v-list-item-title>Move to...</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-reply" @click="handleReply">
<v-list-item-title>Reply</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-share" @click="handleForward">
<v-list-item-title>Forward</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item prepend-icon="mdi-delete-outline" @click="handleDelete">
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
+86 -3
View File
@@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useUser } from '@KTXC' import { usePreviewStore, useUser } from '@KTXC'
import { useMailUiStore } from '@/stores/mailUiStore'
import type { EntityObject, MessageObject } from '@MailManager/models' import type { EntityObject, MessageObject } from '@MailManager/models'
import { SecurityLevel } from '@/utile/emailSanitizer' import { SecurityLevel } from '@/utile/emailSanitizer'
import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer'
import type { MediaViewerItem } from '@/types/mediaViewer'
import ReaderEmpty from './reader/ReaderEmpty.vue' import ReaderEmpty from './reader/ReaderEmpty.vue'
import ReaderToolbar from './reader/ReaderToolbar.vue' import ReaderToolbar from './reader/ReaderToolbar.vue'
import ReaderHeader from './reader/ReaderHeader.vue' import ReaderHeader from './reader/ReaderHeader.vue'
@@ -16,6 +19,7 @@ const props = defineProps<{
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
reply: [entity: EntityObject] reply: [entity: EntityObject]
replyAll: [entity: EntityObject]
forward: [entity: EntityObject] forward: [entity: EntityObject]
move: [entity: EntityObject] move: [entity: EntityObject]
delete: [entity: EntityObject] delete: [entity: EntityObject]
@@ -25,11 +29,17 @@ const emit = defineEmits<{
// User settings // User settings
const { getSetting } = useUser() const { getSetting } = useUser()
const previewStore = usePreviewStore()
const mailUiStore = useMailUiStore()
// Per-message overrides // Security overrides
const allowImages = ref(false) const allowImages = ref(false)
const overrideSecurityLevel = ref<SecurityLevel | null>(null) const overrideSecurityLevel = ref<SecurityLevel | null>(null)
// Viewer state
const showViewer = ref(false)
const viewerIndex = ref(0)
// Computed // Computed
const message = computed<MessageObject | null>(() => { const message = computed<MessageObject | null>(() => {
return props.entity?.properties ?? null return props.entity?.properties ?? null
@@ -53,12 +63,36 @@ const effectiveSecurityLevel = computed(() => {
return overrideSecurityLevel.value ?? securityLevel.value return overrideSecurityLevel.value ?? securityLevel.value
}) })
// Preview state
const previewDialog = computed(() => mediaViewerComponent('dialog'))
const previewItems = computed<MediaViewerItem[]>(() => {
if (!previewStore.availability.storage) return []
const attachments = message.value?.attachments ?? []
const items: MediaViewerItem[] = []
attachments.forEach((attachment, index) => {
if (!mediaViewerCanDisplay(attachment.type)) return
items.push({
id: attachment.partId || attachment.blobId || attachment.cid || `att-${index}`,
title: attachment.name || 'Attachment',
mime: attachment.type || '',
size: attachment.size ?? null,
meta: { index, attachment },
})
})
return items
})
// Reset overrides when message changes // Reset overrides when message changes
watch(() => props.entity, () => { watch(() => props.entity, () => {
allowImages.value = allowImagesDefault.value allowImages.value = allowImagesDefault.value
overrideSecurityLevel.value = null overrideSecurityLevel.value = null
}) })
// Close the viewer when switching messages.
watch(() => props.entity, () => {
showViewer.value = false
})
// Toggle images for current message only // Toggle images for current message only
const toggleImages = () => { const toggleImages = () => {
allowImages.value = !allowImages.value allowImages.value = !allowImages.value
@@ -76,6 +110,12 @@ const handleReply = () => {
} }
} }
const handleReplyAll = () => {
if (props.entity) {
emit('replyAll', props.entity)
}
}
const handleForward = () => { const handleForward = () => {
if (props.entity) { if (props.entity) {
emit('forward', props.entity) emit('forward', props.entity)
@@ -94,6 +134,14 @@ const handleMove = () => {
} }
} }
const handleDownload = async (partIndex?: number) => {
if (!props.entity) {
return
}
await mailUiStore.downloadMessage(props.entity, partIndex)
}
const handleFlag = () => { const handleFlag = () => {
if (props.entity) { if (props.entity) {
emit('flag', props.entity) emit('flag', props.entity)
@@ -103,6 +151,22 @@ const handleFlag = () => {
const handleCompose = () => { const handleCompose = () => {
emit('compose') emit('compose')
} }
const handlePreview = (index: number) => {
const pos = previewItems.value.findIndex(item => item.meta.index === index)
if (pos < 0) return
viewerIndex.value = pos
showViewer.value = true
}
const handleViewerRetrieve = (item: MediaViewerItem): Promise<Blob> => {
if (!props.entity) return Promise.reject(new Error('No message selected'))
return mailUiStore.attachmentPreviewBlob(props.entity, item.meta.attachment, 'fullscreen')
}
const handleViewerDownload = (item: MediaViewerItem) => {
if (props.entity) mailUiStore.downloadMessage(props.entity, item.meta.index)
}
</script> </script>
<template> <template>
@@ -121,17 +185,23 @@ const handleCompose = () => {
:security-level="effectiveSecurityLevel" :security-level="effectiveSecurityLevel"
:is-security-overridden="overrideSecurityLevel !== null" :is-security-overridden="overrideSecurityLevel !== null"
@reply="handleReply" @reply="handleReply"
@reply-all="handleReplyAll"
@forward="handleForward" @forward="handleForward"
@move="handleMove" @move="handleMove"
@delete="handleDelete" @delete="handleDelete"
@flag="handleFlag" @flag="handleFlag"
@download="handleDownload"
@toggle-images="toggleImages" @toggle-images="toggleImages"
@set-security-level="setSecurityLevel" @set-security-level="setSecurityLevel"
/> />
<!-- Message content --> <!-- Message content -->
<div class="message-content"> <div class="message-content">
<ReaderHeader :message="message!" /> <ReaderHeader
:entity="props.entity"
@download="handleDownload"
@preview="handlePreview"
/>
<v-divider /> <v-divider />
@@ -141,6 +211,19 @@ const handleCompose = () => {
:allow-images="allowImages" :allow-images="allowImages"
/> />
</div> </div>
<!-- Inline attachment viewer (provided by the media_viewer module) -->
<component
:is="previewDialog"
v-if="previewDialog"
v-model="showViewer"
:items="previewItems"
:index="viewerIndex"
:source="handleViewerRetrieve"
:download="handleViewerDownload"
:navigation="true"
@update:index="viewerIndex = $event"
/>
</template> </template>
</div> </div>
</template> </template>
+1 -1
View File
@@ -91,7 +91,7 @@ const handleCancel = () => {
<div class="mb-4"> <div class="mb-4">
<div class="text-caption text-medium-emphasis">Account</div> <div class="text-caption text-medium-emphasis">Account</div>
<div class="text-body-2"> <div class="text-body-2">
{{ service.label || service.primaryAddress || 'Mail Account' }} {{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
</div> </div>
</div> </div>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify'
import { useMailUiStore } from '@/stores/mailUiStore'
import FolderView from '@/components/FolderView.vue'
import type { CollectionObject } from '@MailManager/models/collection'
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
const mailUiStore = useMailUiStore()
const { sidebarVisible, selectedFolder } = storeToRefs(mailUiStore)
const handleCompose = () => mailUiStore.openComposer()
const handleFolderSelect = (folder: CollectionObject) => mailUiStore.selectFolder(folder)
const handleSettingsOpen = () => mailUiStore.settingsOpen()
</script>
<template>
<v-navigation-drawer
v-model="sidebarVisible"
contained
:permanent="sidebarVisible && !isMobile"
:temporary="isMobile"
width="280"
class="mail-sidebar"
>
<div class="pa-3 pb-0">
<!-- Primary action -->
<v-btn
block
color="primary"
variant="tonal"
prepend-icon="mdi-pencil"
class="justify-start"
@click="handleCompose"
>
Compose
</v-btn>
</div>
<!-- Folders -->
<FolderView
:selected-folder="selectedFolder"
@select="handleFolderSelect"
/>
<!-- Pinned utility footer -->
<template #append>
<v-divider />
<div class="pa-2">
<v-btn
block
variant="text"
size="small"
prepend-icon="mdi-cog"
class="justify-start text-medium-emphasis"
@click="handleSettingsOpen"
>
Settings
</v-btn>
</div>
</template>
</v-navigation-drawer>
</template>
<style scoped>
.mail-sidebar {
border-right: 1px solid rgb(var(--v-border-color));
overflow-y: auto;
}
</style>
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { MessageAddressInterface } from '@MailManager/types/message'
import { useMailUiStore } from '@/stores/mailUiStore'
import { ComposerMode } from '@/types/composer'
interface Props {
address?: MessageAddressInterface | null
}
const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
clicked: []
}>()
const mailUiStore = useMailUiStore()
const recipientLabel = computed(() => props.address?.label?.trim() || '')
const recipientAddress = computed(() => props.address?.address?.trim() || '')
const displayLabel = computed(() => recipientLabel.value || recipientAddress.value || 'Unknown Sender')
const formattedAddress = computed(() => {
if (recipientLabel.value && recipientAddress.value && recipientLabel.value !== recipientAddress.value) {
return `${recipientLabel.value} <${recipientAddress.value}>`
}
return recipientAddress.value || recipientLabel.value
})
const hasEmail = computed(() => recipientAddress.value.length > 0)
const showCopyResult = (message: string, color: 'success' | 'error') => {
mailUiStore.notify(message, color)
}
const copy = async (value: string, label: string) => {
if (!value) {
return
}
try {
await navigator.clipboard.writeText(value)
showCopyResult(`${label} copied`, 'success')
} catch (error) {
console.error('[Mail][RecipientDetails] Failed to copy text:', error)
showCopyResult(`Unable to copy ${label.toLowerCase()}`, 'error')
}
}
const handleCompose = () => {
if (!props.address) {
return
}
mailUiStore.openComposer(props.address, ComposerMode.Fresh)
}
const handleClick = () => {
emit('clicked')
}
</script>
<template>
<v-menu
:open-on-hover="true"
:open-on-click="false"
:open-delay="1000"
location="bottom start"
transition="slide-y-transition"
>
<template #activator="{ props: activatorProps }">
<span
v-bind="activatorProps"
class="address-activator"
@click="handleClick"
>
<slot
:label="displayLabel"
:name="recipientLabel"
:email="recipientAddress"
>
{{ displayLabel }}
</slot>
</span>
</template>
<v-card class="address-card" elevation="8" rounded="lg">
<div class="address-card-header">
<v-avatar size="40" color="primary">
<span class="text-white text-subtitle-2">
{{ displayLabel[0]?.toUpperCase() || 'U' }}
</span>
</v-avatar>
<div class="address-card-meta">
<div class="text-subtitle-2 font-weight-medium">{{ displayLabel }}</div>
<div v-if="recipientAddress" class="text-body-2 text-medium-emphasis">{{ recipientAddress }}</div>
</div>
</div>
<v-divider class="my-3" />
<div class="address-card-actions">
<v-btn
class="address-action-button"
size="small"
variant="tonal"
prepend-icon="mdi-pencil"
:disabled="!hasEmail"
@click="handleCompose"
>
Send Email
</v-btn>
<v-btn
class="address-action-button"
size="small"
variant="text"
:disabled="!hasEmail"
@click="copy(recipientAddress, 'Email address')"
>
<v-icon>mdi-content-copy</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Email</v-tooltip>
</v-btn>
<v-btn
class="address-action-button"
size="small"
variant="text"
:disabled="!formattedAddress"
@click="copy(formattedAddress, 'Address')"
>
<v-icon>mdi-card-account-details-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Address</v-tooltip>
</v-btn>
<v-btn
v-if="recipientLabel"
class="address-action-button"
size="small"
variant="text"
@click="copy(recipientLabel, 'Name')"
>
<v-icon>mdi-account-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Name</v-tooltip>
</v-btn>
</div>
</v-card>
</v-menu>
</template>
<style scoped lang="scss">
.address-activator {
display: inline-flex;
align-items: center;
max-width: 100%;
}
.address-card {
width: min(320px, calc(100vw - 32px));
padding: 16px;
}
.address-card-header {
display: flex;
align-items: center;
gap: 12px;
}
.address-card-meta {
min-width: 0;
}
.address-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.address-card-actions :deep(.address-action-button) {
height: 36px;
min-height: 36px;
width: 36px;
min-width: 36px;
padding: 0;
}
.address-card-actions :deep(.address-action-button:first-child) {
width: auto;
min-width: 0;
padding-inline: 12px;
}
</style>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { ComposerDraftAttachment } from '@/types/composer'
import { formatFileSize } from '@/utile/format'
interface Props {
attachments: Record<string, ComposerDraftAttachment>
}
defineProps<Props>()
defineEmits<{
remove: [identifier: string]
}>()
</script>
<template>
<div class="composer-attachments px-4 py-3">
<div class="text-caption text-medium-emphasis mb-2">Attachments</div>
<div class="composer-attachments-list">
<v-chip
v-for="attachment in Object.values(attachments)"
:key="attachment.identifier"
size="small"
closable
class="mr-2 mb-2"
@click:close="$emit('remove', attachment.identifier)"
>
{{ attachment.name }} ({{ formatFileSize(attachment.size) }})
</v-chip>
</div>
</div>
</template>
<style scoped lang="scss">
.composer-attachments {
border-bottom: 1px solid rgb(var(--v-border-color));
}
.composer-attachments-list {
display: flex;
flex-wrap: wrap;
}
</style>
+139 -60
View File
@@ -1,12 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import type { PropType } from 'vue' import type { PropType } from 'vue'
import { EditorContent, type Editor } from '@tiptap/vue-3' import { EditorContent, type Editor } from '@tiptap/vue-3'
import { documentsSelectorAvailable } from '@/services/documentsSelector'
defineProps({ defineProps({
editor: { editor: {
type: Object as PropType<Editor | null>, type: Object as PropType<Editor | null>,
default: null, default: null,
}, },
format: {
type: String as PropType<'rich' | 'plain'>,
required: true,
},
text: {
type: String,
default: '',
},
isBoldActive: { isBoldActive: {
type: Boolean, type: Boolean,
required: true, required: true,
@@ -33,94 +43,154 @@ defineProps({
}, },
}) })
defineEmits<{ const emit = defineEmits<{
bold: [] bold: []
italic: [] italic: []
underline: [] underline: []
bulletList: [] bulletList: []
orderedList: [] orderedList: []
link: [] link: []
toggleFormat: []
'update:text': [value: string]
attachFromDevice: [files: File[]]
attachFromDocuments: []
}>() }>()
const fileInput = ref<HTMLInputElement | null>(null)
function openFilePicker() {
fileInput.value?.click()
}
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement | null
const files = input?.files ? Array.from(input.files) : []
if (files.length > 0) {
emit('attachFromDevice', files)
}
if (input) {
input.value = ''
}
}
</script> </script>
<template> <template>
<v-toolbar density="compact" elevation="0" class="editor-toolbar"> <v-toolbar density="compact" elevation="0" class="editor-toolbar">
<v-btn <template v-if="format === 'rich'">
icon <v-btn
size="small" icon
:class="{ 'v-btn--active': isBoldActive }" size="small"
@click="$emit('bold')" :class="{ 'v-btn--active': isBoldActive }"
> @click="$emit('bold')"
<v-icon>mdi-format-bold</v-icon> >
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip> <v-icon>mdi-format-bold</v-icon>
</v-btn> <v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isItalicActive }"
@click="$emit('italic')"
>
<v-icon>mdi-format-italic</v-icon>
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isUnderlineActive }"
@click="$emit('underline')"
>
<v-icon>mdi-format-underline</v-icon>
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isBulletListActive }"
@click="$emit('bulletList')"
>
<v-icon>mdi-format-list-bulleted</v-icon>
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isOrderedListActive }"
@click="$emit('orderedList')"
>
<v-icon>mdi-format-list-numbered</v-icon>
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isLinkActive }"
@click="$emit('link')"
>
<v-icon>mdi-link</v-icon>
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
</template>
<v-btn <v-btn
icon icon
size="small" size="small"
:class="{ 'v-btn--active': isItalicActive }" :class="{ 'v-btn--active': format === 'plain' }"
@click="$emit('italic')" @click="$emit('toggleFormat')"
> >
<v-icon>mdi-format-italic</v-icon> <v-icon>{{ format === 'plain' ? 'mdi-format-text' : 'mdi-code-tags' }}</v-icon>
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip> <v-tooltip activator="parent" location="bottom">
</v-btn> {{ format === 'plain' ? 'Rich Text' : 'Plain Text' }}
</v-tooltip>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isUnderlineActive }"
@click="$emit('underline')"
>
<v-icon>mdi-format-underline</v-icon>
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isBulletListActive }"
@click="$emit('bulletList')"
>
<v-icon>mdi-format-list-bulleted</v-icon>
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isOrderedListActive }"
@click="$emit('orderedList')"
>
<v-icon>mdi-format-list-numbered</v-icon>
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isLinkActive }"
@click="$emit('link')"
>
<v-icon>mdi-link</v-icon>
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
</v-btn> </v-btn>
<v-spacer /> <v-spacer />
<v-btn icon size="small"> <input
ref="fileInput"
type="file"
multiple
class="sr-only"
@change="handleFileChange"
>
<v-btn icon size="small" @click="openFilePicker">
<v-icon>mdi-paperclip</v-icon> <v-icon>mdi-paperclip</v-icon>
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip> <v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
</v-btn> </v-btn>
<v-btn v-if="documentsSelectorAvailable()" icon size="small" @click="$emit('attachFromDocuments')">
<v-icon>mdi-folder-search-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Attach from Documents</v-tooltip>
</v-btn>
</v-toolbar> </v-toolbar>
<v-divider /> <v-divider />
<div class="editor-container"> <div class="editor-container">
<EditorContent :editor="editor" /> <EditorContent v-if="format === 'rich'" :editor="editor ?? undefined" />
<v-textarea
v-else
:model-value="text"
variant="plain"
auto-grow
hide-details
placeholder="Write your message..."
class="text-editor pa-4"
@update:model-value="$emit('update:text', $event)"
/>
</div> </div>
</template> </template>
@@ -136,11 +206,20 @@ defineEmits<{
background-color: rgb(var(--v-theme-background)); background-color: rgb(var(--v-theme-background));
} }
.sr-only {
display: none;
}
.v-btn--active { .v-btn--active {
background-color: rgba(var(--v-theme-primary), 0.12); background-color: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary)); color: rgb(var(--v-theme-primary));
} }
.text-editor :deep(textarea) {
min-height: 300px;
font-family: inherit;
}
:deep(.tiptap-editor) { :deep(.tiptap-editor) {
outline: none; outline: none;
min-height: 300px; min-height: 300px;
+73 -12
View File
@@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRecipientLookupStore } from '@/stores/mailRecipientsStore'
import type { MessageAddressInterface } from '@MailManager/types/message'
interface Props { interface Props {
to: string[] to: MessageAddressInterface[]
cc: string[] cc: MessageAddressInterface[]
bcc: string[] bcc: MessageAddressInterface[]
subject: string subject: string
showCc: boolean showCc: boolean
showBcc: boolean showBcc: boolean
@@ -10,14 +13,34 @@ interface Props {
defineProps<Props>() defineProps<Props>()
defineEmits<{ const emit = defineEmits<{
'update:to': [value: string[]] 'update:to': [value: MessageAddressInterface[]]
'update:cc': [value: string[]] 'update:cc': [value: MessageAddressInterface[]]
'update:bcc': [value: string[]] 'update:bcc': [value: MessageAddressInterface[]]
'update:subject': [value: string] 'update:subject': [value: string]
'toggle:cc': [] 'toggle:cc': []
'toggle:bcc': [] 'toggle:bcc': []
}>() }>()
const recipientLookup = useRecipientLookupStore()
/**
* Normalize combobox entries: typed strings become bare address objects;
* picked suggestions are already MessageAddressInterface and pass through.
*/
function normalize(entries: (MessageAddressInterface | string)[]): MessageAddressInterface[] {
return entries.map(entry =>
typeof entry === 'string' ? { address: entry } : entry,
)
}
function chipLabel(item: MessageAddressInterface): string {
return item.label ? `${item.label} <${item.address}>` : item.address
}
function itemTitle(item: MessageAddressInterface): string {
return item.label || item.address
}
</script> </script>
<template> <template>
@@ -28,11 +51,23 @@ defineEmits<{
chips chips
multiple multiple
closable-chips closable-chips
return-object
no-filter
variant="outlined" variant="outlined"
density="compact" density="compact"
class="mb-2" class="mb-2"
@update:model-value="$emit('update:to', $event)" :items="recipientLookup.suggestions"
:loading="recipientLookup.searching"
:item-title="itemTitle"
@update:search="recipientLookup.search($event)"
@update:model-value="emit('update:to', normalize($event))"
> >
<template #chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
</template>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
</template>
<template #append-inner> <template #append-inner>
<v-btn <v-btn
size="x-small" size="x-small"
@@ -59,11 +94,24 @@ defineEmits<{
chips chips
multiple multiple
closable-chips closable-chips
return-object
no-filter
variant="outlined" variant="outlined"
density="compact" density="compact"
class="mb-2" class="mb-2"
@update:model-value="$emit('update:cc', $event)" :items="recipientLookup.suggestions"
/> :loading="recipientLookup.searching"
:item-title="itemTitle"
@update:search="recipientLookup.search($event)"
@update:model-value="emit('update:cc', normalize($event))"
>
<template #chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
</template>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
</template>
</v-combobox>
<v-combobox <v-combobox
v-if="showBcc" v-if="showBcc"
@@ -72,11 +120,24 @@ defineEmits<{
chips chips
multiple multiple
closable-chips closable-chips
return-object
no-filter
variant="outlined" variant="outlined"
density="compact" density="compact"
class="mb-2" class="mb-2"
@update:model-value="$emit('update:bcc', $event)" :items="recipientLookup.suggestions"
/> :loading="recipientLookup.searching"
:item-title="itemTitle"
@update:search="recipientLookup.search($event)"
@update:model-value="emit('update:bcc', normalize($event))"
>
<template #chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
</template>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
</template>
</v-combobox>
<v-text-field <v-text-field
:model-value="subject" :model-value="subject"
@@ -0,0 +1,83 @@
<script setup lang="ts">
import type { ComposerSenderIdentity } from '@/types/composer';
import { computed, watch } from 'vue'
interface Props {
modelValue: ComposerSenderIdentity | null
options: ComposerSenderIdentity[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: ComposerSenderIdentity | null]
}>()
type SenderListItem = {
title: string
value: string
}
const items = computed<SenderListItem[]>(() => {
return props.options.map(option => ({
title: formatSenderLabel(option.label, option.address),
value: option.address,
}))
})
const selectedValue = computed(() => props.modelValue?.address ?? null)
const errorMessages = computed(() => {
if (props.options.length === 0) {
return ['No send-capable account is available.']
}
return []
})
// Auto-select (and keep selected) the only send-capable account, since
// there's no "from" UI shown for the user to pick it themselves.
watch(
() => props.options,
options => {
if (options.length === 1 && props.modelValue?.address !== options[0].address) {
emit('update:modelValue', options[0])
}
},
{ immediate: true },
)
function handleUpdate(value: string | null) {
const match = value ? props.options.find(o => o.address === value) ?? null : null
emit('update:modelValue', match)
}
function formatSenderLabel(label: string | null | undefined, address: string): string {
return label ? `${label} <${address}>` : address
}
</script>
<template>
<div v-if="options.length !== 1" class="composer-sender px-4 pt-4 pb-0">
<v-select
:model-value="selectedValue"
:items="items"
item-title="title"
item-value="value"
label="From"
variant="outlined"
density="compact"
:error="errorMessages.length > 0"
:error-messages="errorMessages"
:disabled="options.length === 0"
class="mb-2"
@update:model-value="handleUpdate"
/>
</div>
</template>
<style scoped lang="scss">
.composer-sender {
flex-shrink: 0;
}
</style>
+21 -6
View File
@@ -1,15 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { ComposerMode } from '@/types/composer'
interface Props { interface Props {
mode: 'new' | 'reply' | 'forward' mode: ComposerMode
saveStatus: string status: string
canSend: boolean canSend: boolean
sending: boolean sending: boolean
saving: boolean
} }
defineProps<Props>() defineProps<Props>()
defineEmits<{ defineEmits<{
close: [] close: []
save: []
send: [] send: []
}>() }>()
</script> </script>
@@ -26,18 +30,29 @@ defineEmits<{
</v-btn> </v-btn>
<v-toolbar-title> <v-toolbar-title>
{{ mode === 'reply' ? 'Reply' : mode === 'forward' ? 'Forward' : 'New Message' }} {{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.ReplyAll ? 'Reply All' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
</v-toolbar-title> </v-toolbar-title>
<v-spacer /> <v-spacer />
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4"> <span v-if="status" class="text-caption text-medium-emphasis mr-4">
{{ saveStatus }} {{ status }}
</span> </span>
<v-btn
class="mr-2"
variant="text"
:loading="saving"
:disabled="sending"
prepend-icon="mdi-content-save-outline"
@click="$emit('save')"
>
Save
</v-btn>
<v-btn <v-btn
color="primary" color="primary"
:disabled="!canSend" :disabled="!canSend || saving"
:loading="sending" :loading="sending"
prepend-icon="mdi-send" prepend-icon="mdi-send"
@click="$emit('send')" @click="$emit('send')"
+134 -26
View File
@@ -1,12 +1,72 @@
<script setup lang="ts"> <script setup lang="ts">
import { MessageObject } from '@MailManager/models/message' import { computed } from 'vue'
import { usePreviewStore } from '@KTXC'
import RecipientDetails from '@/components/common/RecipientDetails.vue'
import { formatFileSize } from '@/utile/format'
import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer'
import { useMailUiStore } from '@/stores/mailUiStore'
import type { EntityObject } from '@MailManager/models';
import type { MessagePartInterface } from '@MailManager/types/message';
interface AttachmentItem {
id: string
title: string
mime: string
size?: number | null
meta: { index: number; attachment: MessagePartInterface }
}
interface Props { interface Props {
message: MessageObject entity: EntityObject | null
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{
download: [index: number]
preview: [index: number]
}>()
const mailUiStore = useMailUiStore()
const previewStore = usePreviewStore()
const previewsAvailable = computed(() => previewStore.availability.storage)
const previewPopover = computed(() => previewsAvailable.value ? mediaViewerComponent('popover') : null)
const message = computed(() => {
return props.entity?.properties ?? null
})
const randomKey = computed(() => {
return Math.random().toString(36).substring(2, 15)
})
const attachmentItems = computed<AttachmentItem[]>(() =>
(message.value?.attachments ?? []).map((attachment, index) => ({
id: attachment.partId || attachment.blobId || attachment.cid || `att-${index}`,
title: attachment.name || 'Attachment',
mime: attachment.type || '',
size: attachment.size ?? null,
meta: { index, attachment },
})),
)
const isPreviewable = (attachment: MessagePartInterface): boolean => {
return previewsAvailable.value && mediaViewerCanDisplay(attachment.type)
}
const handleAttachmentClick = (attachment: MessagePartInterface, index: number): void => {
if (isPreviewable(attachment)) {
emit('preview', index)
} else {
emit('download', index)
}
}
const handleViewerRetrieve = (item: AttachmentItem): Promise<Blob> => {
if (!props.entity) return Promise.reject(new Error('No message selected'))
return mailUiStore.attachmentPreviewBlob(props.entity, item.meta.attachment, 'inline')
}
// Format date for display // Format date for display
const formatDate = (date: Date | string | null | undefined): string => { const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return '' if (!date) return ''
@@ -22,15 +82,6 @@ const formatDate = (date: Date | string | null | undefined): string => {
hour12: true hour12: true
}) })
} }
// Format file size for display
const formatFileSize = (bytes: number | undefined): string => {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script> </script>
<template> <template>
@@ -46,10 +97,14 @@ const formatFileSize = (bytes: number | undefined): string => {
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="text-body-1 font-weight-medium"> <div class="text-body-1 font-weight-medium">
{{ message?.from?.label || message?.from?.address || 'Unknown Sender' }} <RecipientDetails :address="message?.from">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
</div> </div>
<div class="text-caption text-medium-emphasis"> <div class="text-caption text-medium-emphasis">
{{ formatDate(message?.date) }} {{ formatDate(message?.received || message?.sent) }}
</div> </div>
</div> </div>
</div> </div>
@@ -57,12 +112,26 @@ const formatFileSize = (bytes: number | undefined): string => {
<!-- Recipients --> <!-- Recipients -->
<div v-if="message?.to && message?.to.length > 0" class="text-body-2 mb-1"> <div v-if="message?.to && message?.to.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">To:</span> <span class="text-medium-emphasis">To:</span>
{{ message?.to.map(t => t.label || t.address).join(', ') }} <template v-for="(recipient, index) in message.to" :key="randomKey">
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.to.length - 1">, </span>
</template>
</div> </div>
<div v-if="message?.cc && message?.cc.length > 0" class="text-body-2 mb-1"> <div v-if="message?.cc && message?.cc.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">Cc:</span> <span class="text-medium-emphasis">Cc:</span>
{{ message?.cc.map(c => c.label || c.address).join(', ') }} <template v-for="(recipient, index) in message.cc" :key="randomKey">
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.cc.length - 1">, </span>
</template>
</div> </div>
<!-- Attachments --> <!-- Attachments -->
@@ -71,19 +140,34 @@ const formatFileSize = (bytes: number | undefined): string => {
Attachments ({{ message?.attachments.length }}) Attachments ({{ message?.attachments.length }})
</div> </div>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<v-chip <div
v-for="(attachment, index) in message?.attachments" v-for="(attachment, index) in message?.attachments"
:key="index" :key="attachment.partId || attachment.blobId || attachment.cid || index"
prepend-icon="mdi-paperclip" class="attachment-item"
size="small"
variant="outlined"
class="attachment-chip"
> >
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span> <v-chip
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1"> :prepend-icon="isPreviewable(attachment) ? 'mdi-eye-outline' : 'mdi-paperclip'"
({{ formatFileSize(attachment.size) }}) size="small"
</span> variant="outlined"
</v-chip> class="attachment-chip"
:title="isPreviewable(attachment) ? 'Preview' : 'Download'"
@click="handleAttachmentClick(attachment, index)"
>
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
<span v-if="attachment.size != null" class="text-caption text-medium-emphasis ml-1">
({{ formatFileSize(attachment.size) }})
</span>
</v-chip>
<!-- Hover preview (provided by the media_viewer module) -->
<component
:is="previewPopover"
v-if="previewPopover && isPreviewable(attachment)"
:item="attachmentItems[index]"
:source="handleViewerRetrieve"
@expand="emit('preview', index)"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -98,8 +182,15 @@ const formatFileSize = (bytes: number | undefined): string => {
gap: 0.5rem; gap: 0.5rem;
} }
.attachment-item {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.attachment-chip { .attachment-chip {
max-width: 300px; max-width: 300px;
cursor: pointer;
.attachment-name { .attachment-name {
overflow: hidden; overflow: hidden;
@@ -107,4 +198,21 @@ const formatFileSize = (bytes: number | undefined): string => {
white-space: nowrap; white-space: nowrap;
} }
} }
.attachment-error {
color: rgb(var(--v-theme-error));
margin-top: 0.25rem;
}
.contact-link {
display: inline-block;
border-radius: 4px;
padding: 1px 4px;
margin: -1px -4px;
transition: background-color 0.2s ease;
}
.contact-link:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
</style> </style>
+25 -1
View File
@@ -13,10 +13,12 @@ const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
reply: [] reply: []
replyAll: []
forward: [] forward: []
move: [] move: []
delete: [] delete: []
flag: [] flag: []
download: []
toggleImages: [] toggleImages: []
setSecurityLevel: [level: SecurityLevel] setSecurityLevel: [level: SecurityLevel]
}>() }>()
@@ -46,7 +48,7 @@ const currentSecurityLevel = computed(() => {
<v-btn <v-btn
icon="mdi-reply-all" icon="mdi-reply-all"
variant="text" variant="text"
@click="emit('reply')" @click="emit('replyAll')"
> >
<v-icon>mdi-reply-all</v-icon> <v-icon>mdi-reply-all</v-icon>
<v-tooltip activator="parent" location="bottom">Reply All</v-tooltip> <v-tooltip activator="parent" location="bottom">Reply All</v-tooltip>
@@ -161,6 +163,28 @@ const currentSecurityLevel = computed(() => {
<v-icon>mdi-delete-outline</v-icon> <v-icon>mdi-delete-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Delete</v-tooltip> <v-tooltip activator="parent" location="bottom">Delete</v-tooltip>
</v-btn> </v-btn>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-dots-vertical"
variant="text"
>
<v-icon>mdi-dots-vertical</v-icon>
<v-tooltip activator="parent" location="bottom">More Actions</v-tooltip>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
prepend-icon="mdi-download"
@click="emit('download')"
>
<v-list-item-title>Download</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar> </v-toolbar>
</template> </template>
+4 -3
View File
@@ -40,15 +40,16 @@ const handleAccountSaved = async () => {
<v-list-item <v-list-item
v-for="service in servicesStore.services" v-for="service in servicesStore.services"
:key="`${service.provider}:${service.identifier}`" :key="`${service.provider}:${service.identifier}`"
:class="{ 'opacity-60': !service.enabled }"
> >
<template #prepend> <template #prepend>
<v-avatar color="primary"> <v-avatar :color="service.enabled ? 'primary' : 'grey'">
<v-icon>mdi-email</v-icon> <v-icon>{{ service.enabled ? 'mdi-email' : 'mdi-email-off' }}</v-icon>
</v-avatar> </v-avatar>
</template> </template>
<v-list-item-title>{{ service.label || 'Unnamed Account' }}</v-list-item-title> <v-list-item-title>{{ service.label || 'Unnamed Account' }}</v-list-item-title>
<v-list-item-subtitle>{{ service.primaryAddress || service.identifier }}</v-list-item-subtitle> <v-list-item-subtitle>{{ service.primaryAddress?.address || service.identifier }}</v-list-item-subtitle>
<template #append> <template #append>
<v-btn <v-btn
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { messageReadDelayOptions, useMailSettingsStore } from '@/stores/mailSettingsStore'
const mailSettingsStore = useMailSettingsStore()
const { messageReadEnabled, messageReadDelay } = storeToRefs(mailSettingsStore)
</script>
<template>
<div class="pa-4">
<h3 class="text-h6 mb-4">Behaviours</h3>
<v-list>
<v-list-item>
<v-list-item-title>Mark messages as read automatically</v-list-item-title>
<v-list-item-subtitle>
Mark a message as read after it stays open for the configured delay
</v-list-item-subtitle>
<template #append>
<v-switch v-model="messageReadEnabled" color="primary" hide-details />
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Read delay</v-list-item-title>
<v-list-item-subtitle>
Choose how long a message must stay open before it is marked as read
</v-list-item-subtitle>
<template #append>
<v-select
v-model="messageReadDelay"
:items="messageReadDelayOptions"
item-title="title"
item-value="value"
density="compact"
variant="outlined"
:disabled="!messageReadEnabled"
style="width: 180px"
/>
</template>
</v-list-item>
</v-list>
</div>
</template>
+36 -43
View File
@@ -1,34 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { storeToRefs } from 'pinia'
import { useUser } from '@KTXC' import {
folderViewModeOptions,
messageListDensityOptions,
messageListWidthOptions,
useMailSettingsStore,
} from '@/stores/mailSettingsStore'
type FolderViewMode = 'tree' | 'page' const mailSettingsStore = useMailSettingsStore()
const { folderViewMode, messageListDensity, messageListWidth } = storeToRefs(mailSettingsStore)
const { settings, setSetting } = useUser()
const theme = ref('Auto')
const showPreview = ref(true)
const compactMode = ref(false)
const folderViewMode = computed({
get: () => {
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
},
set: (value: FolderViewMode) => setSetting('mail.folderViewMode', value)
})
</script> </script>
<template> <template>
<div class="pa-4"> <div class="pa-4">
<h3 class="text-h6 mb-4">Display Settings</h3> <h3 class="text-h6 mb-4">Display Settings</h3>
<v-list> <v-list>
<v-list-item> <v-list-item>
<v-list-item-title>Theme</v-list-item-title> <v-list-item-title>Folder navigation style</v-list-item-title>
<v-list-item-subtitle>Choose your preferred theme</v-list-item-subtitle> <v-list-item-subtitle>Choose how folders are displayed</v-list-item-subtitle>
<template #append> <template #append>
<v-select <v-select
v-model="theme" v-model="folderViewMode"
:items="['Light', 'Dark', 'Auto']" :items="folderViewModeOptions"
item-value="value"
item-title="title"
density="compact" density="compact"
variant="outlined" variant="outlined"
style="width: 150px" style="width: 150px"
@@ -37,31 +33,28 @@ const folderViewMode = computed({
</v-list-item> </v-list-item>
<v-list-item> <v-list-item>
<v-list-item-title>Message preview</v-list-item-title> <v-list-item-title>Message list density</v-list-item-title>
<v-list-item-subtitle>Show message preview in list</v-list-item-subtitle> <v-list-item-subtitle>Control row height and how much detail is shown</v-list-item-subtitle>
<template #append>
<v-switch v-model="showPreview" color="primary" hide-details />
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Compact mode</v-list-item-title>
<v-list-item-subtitle>Use compact message list layout</v-list-item-subtitle>
<template #append>
<v-switch v-model="compactMode" color="primary" hide-details />
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Folder navigation style</v-list-item-title>
<v-list-item-subtitle>Choose how folders are displayed</v-list-item-subtitle>
<template #append> <template #append>
<v-select <v-select
v-model="folderViewMode" v-model="messageListDensity"
:items="[ :items="messageListDensityOptions"
{ value: 'tree', title: 'Tree' }, item-value="value"
{ value: 'page', title: 'Page' } item-title="title"
]" density="compact"
variant="outlined"
style="width: 150px"
/>
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Message list width</v-list-item-title>
<v-list-item-subtitle>Set how wide the message list panel is</v-list-item-subtitle>
<template #append>
<v-select
v-model="messageListWidth"
:items="messageListWidthOptions"
item-value="value" item-value="value"
item-title="title" item-title="title"
density="compact" density="compact"
@@ -2,6 +2,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import DisplaySettings from './DisplaySettings.vue' import DisplaySettings from './DisplaySettings.vue'
import AccountsSettings from './AccountsSettings.vue' import AccountsSettings from './AccountsSettings.vue'
import BehaviorSettings from './BehaviorSettings.vue'
import SecuritySettings from './SecuritySettings.vue' import SecuritySettings from './SecuritySettings.vue'
interface Props { interface Props {
@@ -51,6 +52,10 @@ const handleClose = () => {
<v-icon start>mdi-palette</v-icon> <v-icon start>mdi-palette</v-icon>
Display Display
</v-tab> </v-tab>
<v-tab value="behaviour">
<v-icon start>mdi-timer-cog-outline</v-icon>
Behaviours
</v-tab>
<v-tab value="security"> <v-tab value="security">
<v-icon start>mdi-shield-account</v-icon> <v-icon start>mdi-shield-account</v-icon>
Security Security
@@ -68,6 +73,10 @@ const handleClose = () => {
<DisplaySettings /> <DisplaySettings />
</v-window-item> </v-window-item>
<v-window-item value="behaviour">
<BehaviorSettings />
</v-window-item>
<v-window-item value="security"> <v-window-item value="security">
<SecuritySettings /> <SecuritySettings />
</v-window-item> </v-window-item>
+175 -138
View File
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, unref } from 'vue' import { computed, onMounted, ref, unref, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC' import { useModuleStore } from '@KTXC'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore' import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore' import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { useMailUiStore } from '@/stores/mailUiStore' import { useMailUiStore } from '@/stores/mailUiStore'
import type { CollectionObject, EntityObject } from '@MailManager/models' import type { CollectionObject, EntityObject } from '@MailManager/models'
import { ComposerMode } from '@/types/composer'
import MessageList from '@/components/MessageList.vue' import MessageList from '@/components/MessageList.vue'
import MessageReader from '@/components/MessageReader.vue' import MessageReader from '@/components/MessageReader.vue'
import MessageComposer from '@/components/MessageComposer.vue' import MessageComposer from '@/components/MessageComposer.vue'
@@ -15,7 +17,7 @@ import DeleteFolderDialog from '@/components/DeleteFolderDialog.vue'
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue' import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
import RenameFolderDialog from '@/components/RenameFolderDialog.vue' import RenameFolderDialog from '@/components/RenameFolderDialog.vue'
import SettingsDialog from '@/components/settings/SettingsDialog.vue' import SettingsDialog from '@/components/settings/SettingsDialog.vue'
import FolderView from '@/components/FolderView.vue' import Sidebar from '@/components/Sidebar.vue'
// Vuetify display for responsive behavior // Vuetify display for responsive behavior
const display = useDisplay() const display = useDisplay()
@@ -29,30 +31,32 @@ const isManagerAvailable = computed(() => {
const collectionsStore = useCollectionsStore() const collectionsStore = useCollectionsStore()
// Mail module store // Mail module store
const mailStore = useMailStore() const mailOperationsStore = useMailOperationsStore()
const mailUiStore = useMailUiStore() const mailUiStore = useMailUiStore()
const mailSettingsStore = useMailSettingsStore()
const { messageListWidth } = storeToRefs(mailSettingsStore)
// storeToRefs preserves reactivity for state and computed properties // storeToRefs preserves reactivity for state and computed properties
const { const {
loading, loading,
selectedFolder,
selectedMessage, selectedMessage,
currentMessages, currentMessages,
} = storeToRefs(mailStore) searchResults,
searching,
} = storeToRefs(mailOperationsStore)
const { const {
sidebarVisible,
settingsDialogVisible, settingsDialogVisible,
composeMode, selectedFolder,
composeSource, composerMode,
composeVisible, composerSource,
composerVisible,
selectionList, selectionList,
selectionMode, selectionMode,
moveMessagesDialogVisible, moveMessagesDialogVisible,
moveMessagesDialogService, moveMessagesDialogService,
createFolderDialogVisible, createFolderDialogVisible,
createFolderDialogService, createFolderDialogService,
createFolderDialogParent,
createFolderDialogLoading, createFolderDialogLoading,
createFolderDialogError, createFolderDialogError,
renameFolderDialogVisible, renameFolderDialogVisible,
@@ -62,7 +66,6 @@ const {
renameFolderDialogError, renameFolderDialogError,
moveFolderDialogVisible, moveFolderDialogVisible,
moveFolderDialogService, moveFolderDialogService,
moveFolderDialogSource,
deleteFolderDialogVisible, deleteFolderDialogVisible,
deleteFolderDialogService, deleteFolderDialogService,
deleteFolderDialogFolder, deleteFolderDialogFolder,
@@ -71,7 +74,20 @@ const {
} = storeToRefs(mailUiStore) } = storeToRefs(mailUiStore)
// Complex store/composable objects accessed directly (not simple refs) // Complex store/composable objects accessed directly (not simple refs)
const { mailSync, entitiesStore } = mailStore const { mailSync, entitiesStore } = mailOperationsStore
// Message search — debounced server-side search of the selected folder
const searchQuery = ref('')
watch(searchQuery, query => mailOperationsStore.searchMessages(query))
// Reset the search box when the folder changes so it doesn't show a stale
// query for a folder whose results are no longer being filtered.
watch(selectedFolder, () => {
searchQuery.value = ''
})
const filteredMessages = computed(() => searchResults.value ?? currentMessages.value)
const lastSyncLabel = computed(() => { const lastSyncLabel = computed(() => {
const lastSync = unref(unref(mailSync.lastSync)) const lastSync = unref(unref(mailSync.lastSync))
@@ -83,7 +99,7 @@ const lastSyncLabel = computed(() => {
// Initialize // Initialize
onMounted(async () => { onMounted(async () => {
if (!isManagerAvailable.value) return if (!isManagerAvailable.value) return
await mailStore.initialize() await mailUiStore.initialize()
}) })
// Handlers — thin wrappers that delegate to the store // Handlers — thin wrappers that delegate to the store
@@ -92,54 +108,9 @@ const {
validateRenameFolderName, validateRenameFolderName,
} = mailUiStore } = mailUiStore
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder) const sidebarToggle = () => mailUiStore.sidebarToggle()
const handleMessageOpen = (message: EntityObject) => { const handleFolderSelect = (folder: CollectionObject) => mailUiStore.selectFolder(folder)
mailStore.selectMessage(message)
if (isMobile.value) {
mailUiStore.closeSidebar()
}
}
const handleMessageSelectionToggle = (message: EntityObject) => mailUiStore.toggleMessageSelection(message)
const handleSelectionModeActivate = (message: EntityObject) => mailUiStore.activateSelectionMode(message)
const handleSelectAllToggle = (value: boolean) => {
if (value) {
mailUiStore.selectAllCurrentMessages()
return
}
mailUiStore.clearSelection()
}
const handleSelectionClear = () => mailUiStore.deactivateSelectionMode()
const handleSelectionMove = () => mailUiStore.openMoveMessagesDialog()
const handleSelectionDelete = () => mailUiStore.deleteSelectedMessages()
const handleCompose = () => mailUiStore.openCompose()
const handleComposeReply = (message: EntityObject) => mailUiStore.openCompose(message, 'reply')
const handleComposeForward = (message: EntityObject) => mailUiStore.openCompose(message, 'forward')
const handleComposeClose = () => mailUiStore.closeCompose()
const handleComposeSent = () => mailUiStore.afterSent()
const handleDelete = (message: EntityObject) => {
mailStore.deleteMessages([message.identifier])
}
const handleMove = (message: EntityObject) => mailUiStore.openMoveMessagesDialog(message)
const handleMoveConfirm = async (target: CollectionObject) => { await mailUiStore.confirmMoveMessages(target) }
const handleMoveCancel = () => mailUiStore.closeMoveMessagesDialog()
const handleFolderCreateConfirm = async (folderName: string) => { const handleFolderCreateConfirm = async (folderName: string) => {
try { try {
@@ -149,11 +120,11 @@ const handleFolderCreateConfirm = async (folderName: string) => {
handleFolderSelect(mutatedFolder) handleFolderSelect(mutatedFolder)
} }
} catch (error: unknown) { } catch (error: unknown) {
console.error('[MailPage] Failed to create folder:', error) console.error('[Mail] Failed to create folder:', error)
} }
} }
const handleFolderRenameConfirm = async (folderName: string) => { const handleFolderEditConfirm = async (folderName: string) => {
try { try {
const mutatedFolder = await mailUiStore.confirmRenameFolder(folderName) const mutatedFolder = await mailUiStore.confirmRenameFolder(folderName)
@@ -161,7 +132,15 @@ const handleFolderRenameConfirm = async (folderName: string) => {
handleFolderSelect(mutatedFolder) handleFolderSelect(mutatedFolder)
} }
} catch (error: unknown) { } catch (error: unknown) {
console.error('[MailPage] Failed to rename folder:', error) console.error('[Mail] Failed to rename folder:', error)
}
}
const handleFolderDeleteConfirm = async () => {
try {
await mailUiStore.confirmDeleteFolder()
} catch (error: unknown) {
console.error('[Mail] Failed to delete folder:', error)
} }
} }
@@ -169,23 +148,60 @@ const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
try { try {
await mailUiStore.confirmMoveFolder(targetFolder) await mailUiStore.confirmMoveFolder(targetFolder)
} catch (error: unknown) { } catch (error: unknown) {
console.error('[MailPage] Failed to move folder:', error) console.error('[Mail] Failed to move folder:', error)
} }
} }
const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog() const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog()
const handleFolderDeleteConfirm = async () => {
try { const handleMessageOpen = (message: EntityObject) => {
await mailUiStore.confirmDeleteFolder() mailOperationsStore.selectMessage(message)
} catch (error: unknown) {
console.error('[MailPage] Failed to delete folder:', error) if (isMobile.value) {
mailUiStore.sidebarHide()
} }
} }
const toggleSidebar = () => mailUiStore.toggleSidebar() const handleMessageComposeFresh = () => mailUiStore.openComposer()
const handleSettingsOpen = () => mailUiStore.openSettings() const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Reply)
const handleMessageComposeReplyAll = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.ReplyAll)
const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Forward)
const handleMessageComposeClose = () => mailUiStore.closeComposer()
const handleMessageFlag = async (message: EntityObject, flag: string, value: boolean) => {
await mailUiStore.flagMessage(message, flag, value)
}
const handleMessageDelete = async (message: EntityObject) => {
await mailUiStore.deleteMessages([message.identifier])
}
const handleMessageMove = (message: EntityObject) => mailUiStore.openMoveMessagesDialog(message)
const handleMessageMoveConfirm = async (target: CollectionObject) => { await mailUiStore.confirmMoveMessages(target) }
const handleMessageMoveCancel = () => mailUiStore.closeMoveMessagesDialog()
const handleMessageSelectionMode = (message: EntityObject) => mailUiStore.messageSelectionModeActivate(message)
const handleMessageSelectionToggleOne = (message: EntityObject) => mailUiStore.messageSelectionToggleOne(message)
const handleMessageSelectionToggleAll = (value: boolean) => {
mailUiStore.messageSelectionToggleAll(value)
}
const handleMessageSelectionClear = () => mailUiStore.messageSelectionModeDeactivate()
const handleMessageSelectionMove = () => mailUiStore.openMoveMessagesDialog()
const handleMessageSelectionFlag = (flag: string, value: boolean) => mailUiStore.flagSelectedMessages(flag, value)
const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
</script> </script>
@@ -213,19 +229,39 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
<!-- Normal UI --> <!-- Normal UI -->
<div v-else class="mail-container"> <div v-else class="mail-container">
<!-- Top toolbar --> <!-- Top toolbar -->
<v-app-bar class="mail-toolbar" elevation="0" density="compact"> <v-toolbar class="mail-toolbar" elevation="0" density="compact">
<v-app-bar-nav-icon <template #prepend>
v-if="isMobile" <v-btn
@click="toggleSidebar" icon="mdi-menu"
/> variant="text"
@click="sidebarToggle"
/>
</template>
<v-app-bar-title>Mail</v-app-bar-title> <v-toolbar-title class="d-flex align-center">
<v-icon size="24" color="primary" class="mr-2">mdi-email</v-icon>
<span class="text-subtitle-1 font-weight-bold">Mail</span>
</v-toolbar-title>
<v-spacer /> <v-spacer />
<!-- Search -->
<v-text-field
v-model="searchQuery"
density="compact"
variant="outlined"
placeholder="Search messages..."
prepend-inner-icon="mdi-magnify"
:loading="searching"
hide-details
single-line
clearable
class="mail-toolbar-search mx-4"
/>
<v-btn <v-btn
icon="mdi-pencil" icon="mdi-pencil"
@click="handleCompose()" @click="handleMessageComposeFresh()"
color="primary" color="primary"
variant="text" variant="text"
> >
@@ -253,79 +289,64 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
> >
mdi-sync mdi-sync
</v-icon> </v-icon>
</v-app-bar> </v-toolbar>
<!-- Main content area --> <!-- Main content area -->
<div class="mail-content"> <div class="mail-content">
<!-- Folder tree sidebar --> <!-- Folder tree sidebar -->
<v-navigation-drawer <Sidebar />
v-model="sidebarVisible"
:permanent="!isMobile"
:temporary="isMobile"
width="280"
class="mail-sidebar"
>
<FolderView
:selected-folder="selectedFolder"
@select="handleFolderSelect"
/>
<template #append>
<div class="pa-2">
<v-btn
block
variant="text"
prepend-icon="mdi-cog"
@click="handleSettingsOpen"
>
Settings
</v-btn>
</div>
</template>
</v-navigation-drawer>
<!-- Main area with message list and reader --> <!-- Main area with message list and reader -->
<div class="mail-main"> <div class="mail-main">
<div class="mail-wrapper"> <div class="mail-wrapper">
<!-- Message list panel --> <!-- Message list panel -->
<div class="mail-list-panel"> <div
class="mail-list-panel"
:style="{ '--mail-list-width': messageListWidth + '%' }"
>
<MessageList <MessageList
:messages="currentMessages" :messages="filteredMessages"
:selected-collection="selectedFolder" :selected-collection="selectedFolder"
:selected-message="selectedMessage" :selected-message="selectedMessage"
:selection-list="selectionList" :selection-list="selectionList"
:selection-mode="selectionMode" :selection-mode="selectionMode"
:loading="loading" :loading="loading"
:search-active="searchResults !== null"
@open="handleMessageOpen" @open="handleMessageOpen"
@toggle-selection="handleMessageSelectionToggle" @reply="handleMessageComposeReply"
@activate-selection-mode="handleSelectionModeActivate" @forward="handleMessageComposeForward"
@toggle-select-all="handleSelectAllToggle" @move="handleMessageMove"
@clear-selection="handleSelectionClear" @delete="handleMessageDelete"
@move-selection="handleSelectionMove" @flag="handleMessageFlag"
@delete-selection="handleSelectionDelete" @selection-mode="handleMessageSelectionMode"
@selection-toggle-one="handleMessageSelectionToggleOne"
@selection-toggle-all="handleMessageSelectionToggleAll"
@selection-clear="handleMessageSelectionClear"
@selection-flag="handleMessageSelectionFlag"
@selection-move="handleMessageSelectionMove"
@selection-delete="handleMessageSelectionDelete"
/> />
</div> </div>
<!-- Reader/Composer panel --> <!-- Reader/Composer panel -->
<div class="mail-reader-panel"> <div class="mail-reader-panel">
<MessageComposer <MessageComposer
v-if="composeVisible" v-if="composerVisible"
:mode="composeMode" :mode="composerMode"
:source="composeSource" :source="composerSource"
:folder="selectedFolder" :folder="selectedFolder"
@close="handleComposeClose" @close="handleMessageComposeClose"
@sent="handleComposeSent"
/> />
<MessageReader <MessageReader
v-else v-else
:entity="selectedMessage" :entity="selectedMessage"
@compose="handleCompose" @compose="handleMessageComposeFresh"
@reply="handleComposeReply" @reply="handleMessageComposeReply"
@forward="handleComposeForward" @reply-all="handleMessageComposeReplyAll"
@move="handleMove" @forward="handleMessageComposeForward"
@delete="handleDelete" @move="handleMessageMove"
@delete="handleMessageDelete"
/> />
</div> </div>
</div> </div>
@@ -336,19 +357,19 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
<SettingsDialog v-model="settingsDialogVisible" /> <SettingsDialog v-model="settingsDialogVisible" />
<FolderSelectionDialog <FolderSelectionDialog
v-if="moveMessagesDialogService && moveFolderDialogSource" v-if="moveMessagesDialogService"
v-model="moveMessagesDialogVisible" v-model="moveMessagesDialogVisible"
:service="moveMessagesDialogService" :service="moveMessagesDialogService"
:loading="loading" :loading="loading"
title="Move Messages To" title="Move Messages To"
confirm-text="Move" confirm-text="Move"
empty-text="No other folders are available in this account." empty-text="No other folders are available in this account."
@select="handleMoveConfirm" @select="handleMessageMoveConfirm"
@cancel="handleMoveCancel" @cancel="handleMessageMoveCancel"
/> />
<FolderSelectionDialog <FolderSelectionDialog
v-if="moveFolderDialogService && moveFolderDialogSource" v-if="moveFolderDialogService"
v-model="moveFolderDialogVisible" v-model="moveFolderDialogVisible"
:service="moveFolderDialogService" :service="moveFolderDialogService"
:loading="collectionsStore.transceiving" :loading="collectionsStore.transceiving"
@@ -380,7 +401,7 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
:validate-name="validateRenameFolderName" :validate-name="validateRenameFolderName"
:loading="renameFolderDialogLoading" :loading="renameFolderDialogLoading"
:error-message="renameFolderDialogError" :error-message="renameFolderDialogError"
@confirm="handleFolderRenameConfirm" @confirm="handleFolderEditConfirm"
/> />
<DeleteFolderDialog <DeleteFolderDialog
@@ -401,7 +422,8 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100vh; height: 100%;
min-height: 0;
padding: 48px; padding: 48px;
text-align: center; text-align: center;
width: 100%; width: 100%;
@@ -415,28 +437,38 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
.mail-container { .mail-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100%;
min-height: 0;
isolation: isolate; isolation: isolate;
} }
.mail-toolbar { .mail-toolbar {
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
}
.mail-toolbar-search {
max-width: 300px;
}
@media (max-width: 960px) {
.mail-toolbar-search {
max-width: 180px;
margin-inline: 8px !important;
}
} }
.mail-content { .mail-content {
display: flex; display: flex;
flex: 1; flex: 1;
min-height: 0;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
.mail-sidebar {
border-right: 1px solid rgb(var(--v-border-color));
overflow-y: auto;
}
.mail-main { .mail-main {
flex: 1; flex: 1;
min-height: 0;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
min-width: 0; min-width: 0;
@@ -444,18 +476,22 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
.mail-wrapper { .mail-wrapper {
flex: 1; flex: 1;
min-height: 0;
min-width: 0;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
.mail-list-panel { .mail-list-panel {
width: 320px; flex: 0 0 var(--mail-list-width, 35%);
width: var(--mail-list-width, 35%);
min-width: 280px; min-width: 280px;
max-width: 450px; max-width: 70%;
border-right: 1px solid rgb(var(--v-border-color)); border-right: 1px solid rgb(var(--v-border-color));
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
} }
.mail-reader-panel { .mail-reader-panel {
@@ -473,6 +509,7 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
} }
.mail-list-panel { .mail-list-panel {
flex: 0 0 auto;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
border-right: none; border-right: none;
+3 -2
View File
@@ -2,10 +2,11 @@ const routes = [
{ {
name: 'mail', name: 'mail',
path: '/', path: '/',
component: () => import('@/pages/MailPage.vue'), component: () => import('@/pages/Main.vue'),
meta: { meta: {
title: 'Mail', title: 'Mail',
requiresAuth: true requiresAuth: true,
layout: 'fixed'
} }
}, },
] ]
+77
View File
@@ -0,0 +1,77 @@
import { createFetchWrapper } from '@KTXC'
import type { ApiRequest, ApiResponse } from '@MailManager/types/common'
import type {
CompositionAttachmentAddRequest,
CompositionAttachmentAddResponse,
CompositionAttachmentRemoveRequest,
CompositionAttachmentRemoveResponse,
CompositionDiscardRequest,
CompositionDiscardResponse,
CompositionPatchRequest,
CompositionPatchResponse,
CompositionSaveRequest,
CompositionSaveResponse,
CompositionSendRequest,
CompositionSendResponse,
CompositionStageRequest,
CompositionStageResponse,
} from '@/types/composition'
const fetchWrapper = createFetchWrapper()
const API_URL = '/m/mail/compose/v1'
const API_VERSION = 1
function generateTransactionId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
async function post<TRequest, TResponse>(operation: string, data: TRequest): Promise<TResponse> {
const request: ApiRequest<TRequest> = {
version: API_VERSION,
transaction: generateTransactionId(),
operation,
data,
}
const response: ApiResponse<TResponse> = await fetchWrapper.post(API_URL, request)
if (response.status === 'error') {
const errorMessage = `[${operation}] ${response.data.message}${response.data.code ? ` (code: ${response.data.code})` : ''}`
throw new Error(errorMessage)
}
return response.data
}
const compositionService = {
async stage(request: CompositionStageRequest): Promise<CompositionStageResponse> {
return await post<CompositionStageRequest, CompositionStageResponse>('stage', request)
},
async patch(request: CompositionPatchRequest): Promise<CompositionPatchResponse> {
return await post<CompositionPatchRequest, CompositionPatchResponse>('patch', request)
},
async save(request: CompositionSaveRequest): Promise<CompositionSaveResponse> {
return await post<CompositionSaveRequest, CompositionSaveResponse>('save', request)
},
async discard(request: CompositionDiscardRequest): Promise<CompositionDiscardResponse> {
return await post<CompositionDiscardRequest, CompositionDiscardResponse>('discard', request)
},
async send(request: CompositionSendRequest): Promise<CompositionSendResponse> {
return await post<CompositionSendRequest, CompositionSendResponse>('send', request)
},
async attachmentAdd(request: CompositionAttachmentAddRequest): Promise<CompositionAttachmentAddResponse> {
return await post<CompositionAttachmentAddRequest, CompositionAttachmentAddResponse>('attachment.add', request)
},
async attachmentRemove(request: CompositionAttachmentRemoveRequest): Promise<CompositionAttachmentRemoveResponse> {
return await post<CompositionAttachmentRemoveRequest, CompositionAttachmentRemoveResponse>('attachment.remove', request)
}
}
export default compositionService
+27
View File
@@ -0,0 +1,27 @@
/**
* documentsSelector — bridges the Mail composer to documents_manager's
* document/collection picker dialog, resolved at runtime via the integration
* store. documents_manager (not the end-user `documents` app) registers this,
* since it's the module designed to be depended on by other feature modules;
* this mirrors CompositionManager's own ModuleManager-based check on the
* backend, which also targets documents_manager.
*/
import { defineAsyncComponent } from 'vue'
import { useIntegrationStore } from '@KTXC'
const DOCUMENTS_SELECTOR_POINT = 'documents_selector'
/** True if a document selector dialog has been registered (i.e. documents_manager is enabled). */
export function documentsSelectorAvailable(): boolean {
return useIntegrationStore().getItems(DOCUMENTS_SELECTOR_POINT).length > 0
}
export function documentsSelectorComponent() {
// The integration store prefixes ids with the module handle
// (e.g. 'documents_manager.dialog'), so match on the suffix.
const entry = useIntegrationStore().getItems(DOCUMENTS_SELECTOR_POINT).find(i => i.id.endsWith('.dialog'))
return entry?.component
? defineAsyncComponent(entry.component as () => Promise<unknown>)
: null
}
+48
View File
@@ -0,0 +1,48 @@
/**
* mediaViewer — bridges the Mail UI to the standalone media_viewer module
*/
import { defineAsyncComponent } from 'vue'
import { useIntegrationStore } from '@KTXC'
const MEDIA_VIEWER_FORMAT_POINT = 'media_viewer_format'
const MEDIA_VIEWER_POINT = 'media_viewer'
function mimeMatchesPattern(mime: string, pattern: string): boolean {
if (pattern.endsWith('/*')) {
return mime.startsWith(pattern.slice(0, -1))
}
return mime === pattern
}
function mimeMatchesViewer(mime: string, mimeTypes?: string[], mimePatterns?: string[]): boolean {
if (mimeTypes?.includes(mime)) return true
if (mimePatterns) {
for (const pattern of mimePatterns) {
if (mimeMatchesPattern(mime, pattern)) return true
}
}
return false
}
/** True if a leaf viewer is registered for this MIME type. */
export function mediaViewerCanDisplay(mime: string | null | undefined): boolean {
if (!mime) return false
const viewers = useIntegrationStore().getItems(MEDIA_VIEWER_FORMAT_POINT)
return viewers.some(viewer =>
mimeMatchesViewer(
mime,
viewer.meta?.mimeTypes as string[] | undefined,
viewer.meta?.mimePatterns as string[] | undefined,
),
)
}
export function mediaViewerComponent(type: 'dialog' | 'popover' = 'dialog') {
// The integration store prefixes ids with the module handle
// (e.g. 'media_viewer.dialog'), so match on the suffix.
const entry = useIntegrationStore().getItems(MEDIA_VIEWER_POINT).find(i => i.id.endsWith(`.${type}`))
return entry?.component
? defineAsyncComponent(entry.component as () => Promise<unknown>)
: null
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Recipient service
*/
import { entityService } from '@PeopleManager/services'
import { GroupObject, IndividualObject, OrganizationObject, type EntityObject } from '@PeopleManager/models'
import type { MessageAddressInterface } from '@MailManager/types/message'
/** Minimum query length before a lookup is worthwhile. */
const MIN_QUERY_LENGTH = 3
/** Default cap on the number of matches returned. */
const DEFAULT_LIMIT = 10
/**
* Derive a human-readable display name from a contact's properties.
*/
function displayName(properties: IndividualObject | OrganizationObject | GroupObject): string {
if (properties.label) {
return properties.label
}
if (properties instanceof IndividualObject) {
const parts = [properties.names?.given, properties.names?.family].filter(Boolean)
if (parts.length > 0) {
return parts.join(' ')
}
} else if (properties instanceof OrganizationObject) {
if (properties.names?.full) {
return properties.names.full
}
}
return ''
}
/**
* Flatten a contact entity into one recipient per email address.
* Groups (no addressable email) are skipped.
*/
function toRecipients(entity: EntityObject): MessageAddressInterface[] {
const properties = entity.properties
if (properties instanceof GroupObject) {
return []
}
const emails = properties.emails
if (!emails) {
return []
}
const label = displayName(properties)
const recipients: MessageAddressInterface[] = []
for (const email of Object.values(emails)) {
const address = email?.address?.trim()
if (!address) {
continue
}
recipients.push({ address, label: label || address })
}
return recipients
}
export const recipientService = {
/**
* Search the user's contacts for recipients matching a query.
*
* @param query - free-text fragment matched against contact names
* @param limit - maximum number of recipients to return
*
* @returns De-duplicated recipients (one per email address)
*/
async search(query: string, limit: number = DEFAULT_LIMIT): Promise<MessageAddressInterface[]> {
const trimmed = query.trim()
if (trimmed.length < MIN_QUERY_LENGTH) {
return []
}
const recipients: MessageAddressInterface[] = []
const seen = new Set<string>()
try {
// The "*" attribute defaults to LIKE, so a scalar string is all that is
// needed; range caps the scan to the first `limit` matches.
await entityService.listStream(
{ filter: { '*': trimmed }, range: { type: 'tally', anchor: 'absolute', position: 0, tally: limit } },
(entity: EntityObject) => {
for (const recipient of toRecipients(entity)) {
const key = recipient.address.toLowerCase()
if (seen.has(key)) {
continue
}
seen.add(key)
recipients.push(recipient)
}
},
)
} catch (error: any) {
console.error('[Mail][Recipient Service] - Search failed:', error)
return []
}
return recipients.slice(0, limit)
},
}
export default recipientService
+701
View File
@@ -0,0 +1,701 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailUiStore } from './mailUiStore'
import compositionService from '@/services/compositionService'
import type {
CompositionAttachmentAddRequest,
CompositionAttachmentInterface,
CompositionAttachmentRemoveRequest,
CompositionPatchRequest,
CompositionSaveRequest,
CompositionStageRequest,
CompositionStageResponse,
} from '@/types/composition'
import { ComposerMode } from '@/types/composer'
import type { ComposerDraft, ComposerDraftAttachment, ComposerDraftMessage, ComposerSenderIdentity } from '@/types/composer'
import { EntityObject, MessageAddressObject, ServiceObject } from '@MailManager/models'
import type { EntityIdentifier } from '@MailManager/types/common'
import type { MessageAddressInterface } from '@MailManager/types/message'
export const useMailCompositionStore = defineStore('mailCompositionStore', () => {
const servicesStore = useServicesStore()
const mailUiStore = useMailUiStore()
const activeDraft = ref<ComposerDraft | null>(null)
const saving = ref(false)
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
const stageStatus = computed(() => {
if (!activeDraft.value) {
return ''
}
if (saving.value) {
return 'Saving...'
}
if (activeDraft.value.stageStatus === 'local') {
return 'Local draft only'
}
if (!activeDraft.value.stagedAt) {
return ''
}
const seconds = Math.floor((Date.now() - activeDraft.value.stagedAt.getTime()) / 1000)
if (seconds < 60) {
return 'Staged just now'
}
if (seconds < 3600) {
return `Staged ${Math.floor(seconds / 60)} min ago`
}
return `Staged at ${activeDraft.value.stagedAt.toLocaleTimeString()}`
})
const senderIdentities = computed(() => {
const identities: ComposerSenderIdentity[] = []
servicesStore.servicesEnabled.forEach(service => {
if (!service.capable('EntityTransmit') || service.identifier === null) {
return
}
if (service.primaryAddress && !service.primaryAddress.empty) {
identities.push({
service: service,
address: service.primaryAddress.address,
label: service.primaryAddress.label ?? null,
})
}
service.secondaryAddresses.forEach(addr => {
identities.push({
service: service,
address: addr.address,
label: addr.label,
})
})
})
return identities
})
function openDraft(mode: ComposerMode, source?: EntityObject | MessageAddressObject | null) {
let sender = null
// find sending identity from source message recipients if possible
if (source instanceof EntityObject) {
const sourceMessage = source.properties
const recipients = [...(sourceMessage.to || []), ...(sourceMessage.cc || []), ...(sourceMessage.bcc || [])]
const matchingRecipient = recipients.find(recipient => {
return senderIdentities.value.some(identity => identity.address === recipient.address)
})
if (matchingRecipient) {
sender = senderIdentities.value.find(identity => identity.address === matchingRecipient.address) ?? null
}
}
// find sending identity from currently selected folder service if possible
if (sender === null) {
const serviceId = mailUiStore.selectedFolder?.service
sender = senderIdentities.value.find(identity =>
serviceId === `${identity.service.provider}:${identity.service.identifier}`
) ?? null
}
// fallback to first available identity
if (sender === null) {
sender = senderIdentities.value[0]
}
if (sender === null) {
return
}
const ownAddresses = senderIdentities.value.map(identity => identity.address)
activeDraft.value = buildDraft(mode, sender, source, ownAddresses)
activeDraft.value.action = mode
stageDraft()
}
async function stageDraft() {
if (!activeDraft.value) {
return null
}
saving.value = true
try {
const request: CompositionStageRequest = {
identifier: activeDraft.value.identifier,
action: activeDraft.value.action,
sender: {
provider: activeDraft.value.sender.service.provider,
service: activeDraft.value.sender.service.identifier,
address: activeDraft.value.sender.address,
name: activeDraft.value.sender.label || null,
},
source: activeDraft.value.source,
message: activeDraft.value.message,
}
const response = await compositionService.stage(request)
applyStageResponse(response)
} catch (error) {
console.error('[Mail][Composer] Failed to stage draft:', error)
throw error
} finally {
saving.value = false
}
}
async function patchDraft() {
if (!activeDraft.value) {
return null
}
saving.value = true
try {
const request: CompositionPatchRequest = {
identifier: activeDraft.value.identifier,
revision: activeDraft.value.revision,
sender: {
provider: activeDraft.value.sender.service.provider,
service: activeDraft.value.sender.service.identifier,
address: activeDraft.value.sender.address,
name: activeDraft.value.sender.label || null,
},
message: activeDraft.value.message,
}
const response = await compositionService.patch(request)
return response
} catch (error) {
console.error('[Mail][Composer] Failed to patch draft:', error)
throw error
} finally {
saving.value = false
}
}
async function discardDraft() {
if (!activeDraft.value) {
return true
}
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
autoSaveTimer = null
}
const draftId = activeDraft.value.identifier
saving.value = true
try {
const response = await compositionService.discard({ identifier: draftId })
if (response.disposition === 'error') {
mailUiStore.notify(`Failed to discard draft: ${response.error?.message || 'Unknown error'}`, 'error')
return false
}
activeDraft.value = null
return true
} catch (error) {
console.error('[Mail][Composer] Failed to discard staged draft:', error)
mailUiStore.notify('Failed to discard draft', 'error')
return false
} finally {
saving.value = false
}
}
async function saveDraft() {
if (!activeDraft.value) {
return true
}
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
autoSaveTimer = null
}
const draft = activeDraft.value
const request: CompositionSaveRequest = {
identifier: draft.identifier,
revision: draft.revision,
sender: {
provider: draft.sender.service.provider,
service: draft.sender.service.identifier,
address: draft.sender.address,
name: draft.sender.label || null,
},
message: draft.message,
}
saving.value = true
try {
const response = await compositionService.save(request)
if (response.disposition === 'error') {
mailUiStore.notify(`Failed to save draft: ${response.error?.message || 'Unknown error'}`, 'error')
return false
}
activeDraft.value = null
mailUiStore.notify('Draft saved', 'success')
return true
} catch (error) {
console.error('[Mail][Composer] Failed to save draft:', error)
mailUiStore.notify('Failed to save draft', 'error')
return false
} finally {
saving.value = false
}
}
async function sendDraft() {
if (!activeDraft.value) {
return
}
try {
const response = await compositionService.send({
identifier: activeDraft.value.identifier,
revision: activeDraft.value.revision,
sender: {
provider: activeDraft.value.sender.service.provider,
service: activeDraft.value.sender.service.identifier,
address: activeDraft.value.sender.address,
name: activeDraft.value.sender.label || null,
},
message: activeDraft.value.message,
attachments: activeDraft.value.attachments,
})
if (response.disposition === 'error') {
console.error('[Mail][Composer] Failed to send message:', response.error)
mailUiStore.notify(`Failed to send message: ${response.error?.message || 'Unknown error'}`, 'error')
return
}
activeDraft.value = null
mailUiStore.notify('Message sent successfully', 'success')
mailUiStore.closeComposer()
} catch (error) {
console.error('[Mail][Composer] Failed to send draft:', error)
throw error
}
}
function updateRecipients(field: 'to' | 'cc' | 'bcc', values: MessageAddressInterface[]) {
if (!activeDraft.value) {
return
}
activeDraft.value.message[field] = [...values]
queueSave(false)
}
function updateSubject(subject: string) {
if (!activeDraft.value) {
return
}
activeDraft.value.message.subject = subject
queueSave(false)
}
function updateBody(body: ComposerDraftMessage['body']) {
if (!activeDraft.value) {
return
}
activeDraft.value.message.body = {
html: body.html,
text: body.text,
}
queueSave(false)
}
function updateSender(sender: ComposerSenderIdentity) {
if (!activeDraft.value) {
return
}
activeDraft.value.sender = sender
queueSave(false)
}
async function addAttachmentsFromDevice(files: File[]) {
if (!activeDraft.value || files.length === 0) {
return
}
const composition = activeDraft.value.identifier
const attachments = Object.fromEntries(
await Promise.all(files.map(async file => {
const identifier = createIdentifier()
const data = await fileToBase64(file)
return [identifier, {
identifier,
composition,
origin: 'device',
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
source: null,
data,
} satisfies CompositionAttachmentInterface]
})),
)
const request: CompositionAttachmentAddRequest = {
composition,
attachments,
}
const response = await compositionService.attachmentAdd(request)
if (!activeDraft.value || response.composition !== activeDraft.value.identifier) {
return
}
if (response.disposition === 'error') {
console.error('[Mail][Composer] Failed to add attachments:', response.error)
return
}
Object.values(response.attachments).forEach(attachment => {
activeDraft.value!.attachments[attachment.identifier] = {
identifier: attachment.identifier,
composition: attachment.composition,
origin: attachment.origin,
name: attachment.name,
type: attachment.type,
size: attachment.size,
source: attachment.source,
}
})
if (response.revision !== undefined) {
activeDraft.value.revision = response.revision
}
}
async function addAttachmentsFromDocuments(items: { identifier: string; name: string; mimeType?: string; size?: number }[]) {
if (!activeDraft.value || items.length === 0) {
return
}
const composition = activeDraft.value.identifier
const attachments = Object.fromEntries(
items.map(item => {
const identifier = createIdentifier()
return [identifier, {
identifier,
composition,
origin: 'documents',
name: item.name,
type: item.mimeType || 'application/octet-stream',
size: item.size ?? 0,
source: item.identifier as EntityIdentifier,
} satisfies CompositionAttachmentInterface]
}),
)
const request: CompositionAttachmentAddRequest = {
composition,
attachments,
}
const response = await compositionService.attachmentAdd(request)
if (!activeDraft.value || response.composition !== activeDraft.value.identifier) {
return
}
if (response.disposition === 'error') {
console.error('[Mail][Composer] Failed to add attachments from documents:', response.error)
mailUiStore.notify(`Failed to attach documents: ${response.error?.message || 'Unknown error'}`, 'error')
return
}
Object.values(response.attachments).forEach(attachment => {
activeDraft.value!.attachments[attachment.identifier] = {
identifier: attachment.identifier,
composition: attachment.composition,
origin: attachment.origin,
name: attachment.name,
type: attachment.type,
size: attachment.size,
source: attachment.source,
}
})
if (response.revision !== undefined) {
activeDraft.value.revision = response.revision
}
if (response.failed?.length) {
mailUiStore.notify(
response.failed.length === 1
? '1 document could not be attached'
: `${response.failed.length} documents could not be attached`,
'warning',
)
}
}
async function removeAttachment(identifier: string) {
if (!activeDraft.value) {
return
}
const attachment = activeDraft.value.attachments[identifier]
if (!attachment) {
return
}
const request: CompositionAttachmentRemoveRequest = {
composition: attachment.composition,
identifier: attachment.identifier,
}
const response = await compositionService.attachmentRemove(request)
if (response.disposition === 'error') {
console.error('[Mail][Composer] Failed to remove attachment:', response.error)
}
delete activeDraft.value.attachments[attachment.identifier]
if (response.revision !== undefined) {
activeDraft.value.revision = response.revision
}
}
function queueSave(immediate: boolean) {
if (!activeDraft.value) {
return
}
activeDraft.value.revision += 1
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
}
autoSaveTimer = setTimeout(() => {
void flushSave()
}, immediate ? 0 : 15000)
}
async function flushSave() {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
autoSaveTimer = null
}
if (!activeDraft.value) {
return
}
await patchDraft()
}
function applyStageResponse(response: CompositionStageResponse) {
const draft = activeDraft.value
if (!draft) {
return
}
if (response.identifier !== draft.identifier) {
console.warn('[Mail][Composer] Stage response identifier mismatch:', response.identifier, draft.identifier)
return
}
if (response.revision < draft.revision) {
console.warn('[Mail][Composer] Stage response revision is older than current draft:', response.revision, draft.revision)
return
}
draft.attachments = {}
Object.values(response.attachments).forEach(attachment => {
draft.attachments[attachment.identifier] = {
identifier: attachment.identifier,
composition: attachment.composition,
origin: attachment.origin,
name: attachment.name,
type: attachment.type,
size: attachment.size,
source: attachment.source,
}
})
draft.revision = response.revision
draft.stageStatus = response.disposition
draft.stagedAt = new Date()
}
return {
activeDraft,
saving,
stageStatus,
senderIdentities,
openDraft,
saveDraft,
discardDraft,
sendDraft,
updateSender,
updateRecipients,
updateSubject,
updateBody,
addAttachmentsFromDevice,
addAttachmentsFromDocuments,
removeAttachment,
flushSave,
}
})
function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source: EntityObject | MessageAddressObject | null | undefined, ownAddresses: string[]): ComposerDraft {
const composition = createIdentifier()
const freshMessage = buildMessage(mode, source, ownAddresses)
const freshAttachments = buildAttachments(composition, mode, source)
const sourceIdentifier = source instanceof EntityObject ? source.identifier : null
return {
action: mode,
identifier: composition,
revision: 1,
sender: sender,
source: sourceIdentifier,
stageStatus: 'local',
stagedAt: null,
message: freshMessage,
attachments: freshAttachments,
}
}
function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressObject | null | undefined, ownAddresses: string[] = []): ComposerDraftMessage {
if (!source) {
return emptyMessage()
}
if (mode === ComposerMode.Fresh) {
const freshMessage = emptyMessage()
if (source && source instanceof MessageAddressObject) {
freshMessage.to = [{ address: source.address, label: source.label }]
}
return freshMessage
}
if (!(source instanceof EntityObject)) {
return emptyMessage()
}
const sourceMessage = source.properties
const originalSubject = sourceMessage.subject || ''
const originalBody = sourceMessage.getHtmlContent() || sourceMessage.getTextContent() || ''
const senderName = sourceMessage.from?.label || sourceMessage.from?.address || 'Unknown'
const sentAt = sourceMessage.sent || source.created || ''
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
if (mode === ComposerMode.Reply || mode === ComposerMode.ReplyAll) {
const replyToAddr = sourceMessage.replyTo?.[0] ?? sourceMessage.from ?? null
const replyToAddress = replyToAddr?.address || ''
const to: MessageAddressInterface[] = replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : []
let cc: MessageAddressInterface[] = []
if (mode === ComposerMode.ReplyAll) {
const excluded = new Set([replyToAddress.toLowerCase(), ...ownAddresses.map(addr => addr.toLowerCase())])
const seen = new Set<string>()
cc = [...(sourceMessage.to || []), ...(sourceMessage.cc || [])].filter(recipient => {
const address = recipient.address?.toLowerCase() || ''
if (!address || excluded.has(address) || seen.has(address)) {
return false
}
seen.add(address)
return true
})
}
return {
to,
cc,
bcc: [],
subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`,
body: {
html: `<p><br></p><p>---------- Original message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
text: '',
},
}
}
return {
to: [],
cc: [],
bcc: [],
subject: /^Fwd:/i.test(originalSubject) ? originalSubject : `Fwd: ${originalSubject}`,
body: {
html: `<p><br></p><p>---------- Forwarded message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
text: '',
},
}
}
function buildAttachments(composition: string, mode: ComposerMode, source?: EntityObject | MessageAddressObject | null): Record<string, ComposerDraftAttachment> {
if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || mode === ComposerMode.ReplyAll || !(source instanceof EntityObject)) {
return {}
}
const sourceAttachments = source.properties.attachments || []
const attachments: Record<string, ComposerDraftAttachment> = {}
sourceAttachments.forEach(attachment => {
const id = createIdentifier()
attachments[id] = {
identifier: id,
composition: composition,
origin: 'message',
name: attachment.name || '',
type: attachment.type || 'application/octet-stream',
size: attachment.size || 0,
source: source.identifier,
}
})
return attachments
}
function emptyMessage(): ComposerDraftMessage {
return {
to: [],
cc: [],
bcc: [],
subject: '',
body: {
html: '',
text: '',
},
}
}
function createIdentifier(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
async function fileToBase64(file: File): Promise<string> {
const buffer = await file.arrayBuffer()
let binary = ''
const bytes = new Uint8Array(buffer)
for (let index = 0; index < bytes.byteLength; index += 1) {
binary += String.fromCharCode(bytes[index])
}
return btoa(binary)
}
@@ -4,9 +4,9 @@ import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore' import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
import { useServicesStore } from '@MailManager/stores/servicesStore' import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync' import { useMailSync } from '@MailManager/composables/useMailSync'
import { useSnackbar } from '@KTXC' import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier, ListFilter } from '@MailManager/types/common'
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common' import { ListFilterComparisonOperator, ListFilterConjunctionOperator } from '@MailManager/types/common'
import type { EntityTransmitRequest } from '@MailManager/types/entity' import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity'
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message' import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models' import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
import { CollectionPropertiesObject } from '@MailManager/models/collection' import { CollectionPropertiesObject } from '@MailManager/models/collection'
@@ -22,11 +22,10 @@ interface ComposerMessageInput {
} }
} }
export const useMailStore = defineStore('mailStore', () => { export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
const servicesStore = useServicesStore() const servicesStore = useServicesStore()
const collectionsStore = useCollectionsStore() const collectionsStore = useCollectionsStore()
const entitiesStore = useEntitiesStore() const entitiesStore = useEntitiesStore()
const { showSnackbar } = useSnackbar()
// Background mail sync // Background mail sync
const mailSyncController = useMailSync({ const mailSyncController = useMailSync({
@@ -52,6 +51,8 @@ export const useMailStore = defineStore('mailStore', () => {
const selectedFolder = shallowRef<CollectionObject | null>(null) const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null) const selectedMessage = shallowRef<EntityObject | null>(null)
const searchResults = shallowRef<EntityObject[] | null>(null)
const searching = ref(false)
const composerSaving = ref(false) const composerSaving = ref(false)
const composerSending = ref(false) const composerSending = ref(false)
const composerLastSaved = ref<Date | null>(null) const composerLastSaved = ref<Date | null>(null)
@@ -78,20 +79,15 @@ export const useMailStore = defineStore('mailStore', () => {
await servicesStore.list() await servicesStore.list()
const services = [...servicesStore.servicesEnabled] const services = [...servicesStore.servicesEnabled]
services.forEach(service => { await Promise.all(services.map(service => loadFoldersForService(service)))
void loadFoldersForService(service,{ selectInbox: true })
})
} catch (error) { } catch (error) {
console.error('[Mail] Failed to initialize:', error) console.error('[Mail][Operations] Failed to initialize:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function loadFoldersForService( async function loadFoldersForService(service: ServiceObject) {
service: ServiceObject,
options: { selectInbox?: boolean } = {},
) {
if (service.identifier === null) { if (service.identifier === null) {
return return
@@ -102,32 +98,15 @@ export const useMailStore = defineStore('mailStore', () => {
try { try {
// retrieve folders for service // retrieve folders for service
const collections = await collectionsStore.collectionsForService(service.provider, service.identifier, true) await collectionsStore.collectionsForService(service.provider, service.identifier, true)
_setServiceFolderLoaded(service.provider, service.identifier, true) _setServiceFolderLoaded(service.provider, service.identifier, true)
if (options.selectInbox && !selectedFolder.value) {
const inbox = Object.values(collections).find(
folder =>
folder.provider === service.provider &&
String(folder.service) === String(service.identifier) &&
(folder.properties.role === 'inbox' ||
String(folder.identifier).toLowerCase() === 'inbox'),
)
if (inbox) {
await selectFolder(inbox)
}
}
_updateSyncSources() _updateSyncSources()
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load folders' const message = error instanceof Error ? error.message : 'Failed to load folders'
_setServiceFolderError(service.provider, service.identifier, message) _setServiceFolderError(service.provider, service.identifier, message)
console.error( console.error(`[Mail][Operations] Failed to load folders for ${service.provider}:${String(service.identifier)}:`, error)
`[Mail] Failed to load folders for ${service.provider}:${String(service.identifier)}:`,
error,
)
_updateSyncSources() _updateSyncSources()
return {} return {}
} finally { } finally {
@@ -180,11 +159,7 @@ export const useMailStore = defineStore('mailStore', () => {
// Track the currently selected folder // Track the currently selected folder
if (selectedFolder.value) { if (selectedFolder.value) {
//mailSyncController.addSource({ mailSyncController.addSource(selectedFolder.value.identifier)
// provider: selectedFolder.value.provider,
// service: selectedFolder.value.service,
// collections: [selectedFolder.value.identifier],
//})
} }
// Always track inboxes for each account (for new-mail notifications) // Always track inboxes for each account (for new-mail notifications)
@@ -199,13 +174,7 @@ export const useMailStore = defineStore('mailStore', () => {
String(c.identifier).toLowerCase() === 'inbox'), String(c.identifier).toLowerCase() === 'inbox'),
) )
if (inboxes.length > 0) { inboxes.forEach(inbox => mailSyncController.addSource(inbox.identifier))
//mailSyncController.addSource({
// provider: service.provider,
// service: service.identifier as string | number,
// collections: inboxes.map(inbox => inbox.identifier),
//})
}
}) })
if (mailSyncController.sources.value.length > 0 && !mailSyncController.isRunning.value) { if (mailSyncController.sources.value.length > 0 && !mailSyncController.isRunning.value) {
@@ -313,54 +282,100 @@ export const useMailStore = defineStore('mailStore', () => {
try { try {
service = await servicesStore.serviceByIdentifier(identifier, true) service = await servicesStore.serviceByIdentifier(identifier, true)
} catch (error) { } catch (error) {
console.error(`[Mail] Failed to retrieve service ${identifier}:`, error) console.error(`[Mail][Operations] Failed to retrieve service ${identifier}:`, error)
throw error throw error
} }
if (!service) { if (!service) {
const message = `Service ${identifier} not found` const message = `Service ${identifier} not found`
console.error(`[Mail] ${message}`) console.error(`[Mail][Operations] ${message}`)
throw new Error(message) throw new Error(message)
} }
return service return service
} }
async function selectFolder(folder: CollectionObject) { async function selectFolder(folder: CollectionObject | null) {
clearSearch()
selectedFolder.value = folder selectedFolder.value = folder
selectedMessage.value = null selectedMessage.value = null
try { if (folder) {
await entitiesStore.list([folder.identifier]) try {
} catch (error) { await entitiesStore.list([folder.identifier])
console.error('[Mail] Failed to load messages:', error) } catch (error) {
console.error('[Mail][Operations] Failed to load messages:', error)
}
} }
_updateSyncSources() _updateSyncSources()
} }
function clearSelectedFolder() { const SEARCH_DEBOUNCE_MS = 300
selectedFolder.value = null let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
selectedMessage.value = null // Monotonic token so out-of-order/stale search responses are discarded.
let searchToken = 0
_updateSyncSources() /**
* Debounced server-side search of the selected folder. Clearing the query
* (or the folder) reverts the message list back to `currentMessages`.
*/
function searchMessages(query: string) {
const trimmed = (query ?? '').trim()
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
if (!trimmed || !selectedFolder.value) {
searchToken++
searchResults.value = null
searching.value = false
return
}
const folder = selectedFolder.value
// 'from'/'to'/'subject'/'body' are the attributes both IMAP and JMAP
// backends whitelist for entity search (see their entityListFilter
// capability); OR them together for a single-box search.
const filter: ListFilter = {
from: [trimmed, ListFilterComparisonOperator.LIKE],
to: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
subject: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
body: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
}
searching.value = true
const token = ++searchToken
searchDebounceTimer = setTimeout(async () => {
try {
const results = await entitiesStore.list([folder.identifier], filter)
if (token !== searchToken) return
searchResults.value = Object.values(results)
} catch (error) {
if (token === searchToken) searchResults.value = []
console.error('[Mail][Operations] Failed to search messages:', error)
} finally {
if (token === searchToken) searching.value = false
}
}, SEARCH_DEBOUNCE_MS)
} }
function selectMessage(entity: EntityObject) { function clearSearch() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
searchToken++
searchResults.value = null
searching.value = false
}
function selectMessage(entity: EntityObject | null) {
selectedMessage.value = entity selectedMessage.value = entity
} }
function clearSelectedMessage() {
selectedMessage.value = null
}
async function reloadSelectedFolder() {
// Reload the current folder so the sent message appears in Sent
if (selectedFolder.value) {
await selectFolder(selectedFolder.value)
}
}
async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) { async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) {
composerSaving.value = true composerSaving.value = true
@@ -376,13 +391,31 @@ export const useMailStore = defineStore('mailStore', () => {
return draft return draft
} catch (error) { } catch (error) {
console.error('[Mail] Failed to save draft:', error) console.error('[Mail][Operations] Failed to save draft:', error)
throw error throw error
} finally { } finally {
composerSaving.value = false composerSaving.value = false
} }
} }
function findFoldersByRole(role: string): CollectionObject[] {
const normalizedRole = role.toLowerCase()
return servicesStore.servicesEnabled.flatMap(service => {
if (service.identifier === null) {
return []
}
return collectionsStore.collectionsForService(service.provider, service.identifier).filter(
folder =>
folder.provider === service.provider &&
String(folder.service) === String(service.identifier) &&
(folder.properties.role === normalizedRole ||
String(folder.identifier).toLowerCase() === normalizedRole),
)
})
}
async function sendComposerMessage(message: ComposerMessageInput) { async function sendComposerMessage(message: ComposerMessageInput) {
composerSending.value = true composerSending.value = true
@@ -414,28 +447,21 @@ export const useMailStore = defineStore('mailStore', () => {
try { try {
await entitiesStore.delete([composerDraftIdentifier.value]) await entitiesStore.delete([composerDraftIdentifier.value])
} catch (error) { } catch (error) {
console.error('[Mail] Failed to delete draft after send:', error) console.error('[Mail][Operations] Failed to delete draft after send:', error)
} }
} }
notify('Message sent', 'success')
resetComposerState() resetComposerState()
return response return response
} catch (error) { } catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to send message' console.error('[Mail][Operations] Failed to send message:', error)
console.error('[Mail] Failed to send message:', error)
notify(messageText, 'error')
throw error throw error
} finally { } finally {
composerSending.value = false composerSending.value = false
} }
} }
async function createFolder( async function createFolder(service: ServiceObject, label: string, parentFolder: CollectionObject | null = null): Promise<CollectionObject> {
service: ServiceObject,
label: string,
parentFolder: CollectionObject | null = null,
): Promise<CollectionObject> {
if (service.identifier === null) { if (service.identifier === null) {
throw new Error('Cannot create folder for a service without an identifier') throw new Error('Cannot create folder for a service without an identifier')
} }
@@ -452,11 +478,6 @@ export const useMailStore = defineStore('mailStore', () => {
parentFolder?.identifier, parentFolder?.identifier,
) )
notify(
`Folder "${newFolder.properties.label || properties.label}" created`,
'success',
)
return newFolder return newFolder
} }
@@ -472,11 +493,6 @@ export const useMailStore = defineStore('mailStore', () => {
selectedFolder.value = updatedFolder selectedFolder.value = updatedFolder
} }
notify(
`Folder "${folder.properties.label || String(folder.identifier)}" renamed to "${updatedFolder.properties.label || properties.label}"`,
'success',
)
return updatedFolder return updatedFolder
} }
@@ -487,11 +503,6 @@ export const useMailStore = defineStore('mailStore', () => {
selectedFolder.value = movedFolder selectedFolder.value = movedFolder
} }
notify(
`Folder "${source.properties.label || String(source.identifier)}" moved to "${target.properties.label || String(target.identifier)}"`,
'success',
)
return movedFolder return movedFolder
} }
@@ -499,17 +510,45 @@ export const useMailStore = defineStore('mailStore', () => {
const deletedFolder = await collectionsStore.delete(folder.identifier) const deletedFolder = await collectionsStore.delete(folder.identifier)
if (_sameCollection(selectedFolder.value, folder)) { if (_sameCollection(selectedFolder.value, folder)) {
clearSelectedFolder() await selectFolder(null)
} }
notify(
`Folder "${folder.properties.label || String(folder.identifier)}" deleted`,
'success',
)
return deletedFolder return deletedFolder
} }
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
if (entityIdentifiers.length === 0) {
return null
}
loading.value = true
try {
return await entitiesStore.delete(entityIdentifiers)
} catch (error) {
console.error('[Mail][Operations] Failed to delete messages:', error)
throw error
} finally {
loading.value = false
}
}
async function flagMessages(entityIdentifiers: EntityIdentifier[], flags: Partial<NonNullable<MessageInterface['flags']>>) {
if (entityIdentifiers.length === 0) {
return null
}
try {
const patch = entitiesStore.fresh().properties
patch.flags = flags
return await entitiesStore.patch(patch, entityIdentifiers)
} catch (error) {
console.error('[Mail][Operations] Failed to update messages:', error)
throw error
}
}
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) { async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce( const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
(accumulator, identifier) => { (accumulator, identifier) => {
@@ -545,7 +584,7 @@ export const useMailStore = defineStore('mailStore', () => {
) )
if (movableIdentifiers.length === 0) { if (movableIdentifiers.length === 0) {
return return null
} }
loading.value = true loading.value = true
@@ -553,72 +592,70 @@ export const useMailStore = defineStore('mailStore', () => {
try { try {
const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers) const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
if (failures.length === 0) {
notify(
successes.length === 1 ? 'Message moved' : `${successes.length} messages moved`,
'success',
)
}
if (failures.length > 0) {
notify(
successes.length === 0
? `Move failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
: `Moved ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
successes.length === 0 ? 'error' : 'warning',
)
}
// update source collections to reflect moved messages // update source collections to reflect moved messages
sourceCollections.push(target.identifier) sourceCollections.push(target.identifier)
await collectionsStore.fetch(sourceCollections) await collectionsStore.fetch(sourceCollections)
return { successes, failures }
} catch (error) { } catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move messages' console.error('[Mail][Operations] Failed to move messages:', error)
console.error('[Mail] Failed to move messages:', error)
notify(messageText, 'error')
throw error throw error
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) { async function downloadMessage(entity: EntityObject, index?: number) {
if (entityIdentifiers.length === 0) { const target = entity.identifier
return let part = null
}
loading.value = true if (index !== undefined) {
part = entity.properties.attachments?.[Number(index)] ?? null
}
try { try {
const { successes, failures } = await entitiesStore.delete(entityIdentifiers) await entitiesStore.download(target, part)
if (failures.length === 0) {
notify(
successes.length === 1 ? 'Message deleted' : `${successes.length} messages deleted`,
'success',
)
}
if (failures.length > 0) {
notify(
successes.length === 0
? `Delete failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
: `Deleted ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
successes.length === 0 ? 'error' : 'warning',
)
}
} catch (error) { } catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to delete messages' console.error(
console.error('[Mail] Failed to delete messages:', error) index === undefined
notify(messageText, 'error') ? '[Mail][Operations] Failed to download message:'
: '[Mail][Operations] Failed to download attachment:',
error,
)
throw error throw error
} finally {
loading.value = false
} }
} }
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') { async function attachmentBlob(entity: EntityObject, part: MessagePartInterface): Promise<Blob> {
showSnackbar({ message, color }) const selector: EntityBlobSelector = {
blobId: part.blobId ?? undefined,
partId: part.partId ?? undefined,
cid: part.cid ?? undefined,
}
const results = await entitiesStore.blobs(entity.identifier, [selector])
const blob = results[0]?.blob
if (!blob) {
throw new Error('Attachment content unavailable')
}
return blob
}
async function attachmentPreviewBlob(
entity: EntityObject,
part: MessagePartInterface,
variant: 'inline' | 'fullscreen' = 'inline',
): Promise<Blob> {
return entitiesStore.preview({
target: entity.identifier,
part: {
blobId: part.blobId ?? undefined,
partId: part.partId ?? undefined,
cid: part.cid ?? undefined,
type: part.type || 'application/octet-stream',
size: part.size ?? null,
},
variant,
})
} }
// ── Exports ─────────────────────────────────────────────────────────────── // ── Exports ───────────────────────────────────────────────────────────────
@@ -632,8 +669,9 @@ export const useMailStore = defineStore('mailStore', () => {
// State // State
loading, loading,
selectedFolder,
selectedMessage, selectedMessage,
searchResults,
searching,
composerSaving, composerSaving,
composerSending, composerSending,
composerLastSaved, composerLastSaved,
@@ -648,23 +686,26 @@ export const useMailStore = defineStore('mailStore', () => {
// Actions // Actions
retrieveService, retrieveService,
selectFolder, selectFolder,
clearSelectedFolder,
selectMessage, selectMessage,
clearSelectedMessage, searchMessages,
clearSearch,
createFolder, createFolder,
reloadSelectedFolder,
saveComposerDraft, saveComposerDraft,
sendComposerMessage, sendComposerMessage,
resetComposerState, resetComposerState,
flagMessages,
deleteMessages, deleteMessages,
deleteFolder, deleteFolder,
moveMessages, moveMessages,
downloadMessage,
attachmentBlob,
attachmentPreviewBlob,
moveFolder, moveFolder,
renameFolder, renameFolder,
notify,
isServiceFolderLoading, isServiceFolderLoading,
hasServiceFoldersLoaded, hasServiceFoldersLoaded,
getServiceFolderError, getServiceFolderError,
findFoldersByRole,
loadFoldersForService, loadFoldersForService,
initialize, initialize,
} }
+85
View File
@@ -0,0 +1,85 @@
/**
* Recipients Store
*/
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { recipientService } from '../services/recipientService'
import type { MessageAddressInterface } from '@MailManager/types/message'
/** Minimum characters before a lookup fires. */
const MIN_QUERY_LENGTH = 3
/** Debounce window for keystroke-driven searches. */
const DEBOUNCE_MS = 250
export const useRecipientLookupStore = defineStore('mailRecipientsStore', () => {
const suggestions = ref<MessageAddressInterface[]>([])
const searching = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// Monotonic token so out-of-order/stale responses are discarded.
let requestToken = 0
function cancelPending() {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
/**
* Clear suggestions and cancel any in-flight lookup.
*/
function clear() {
cancelPending()
requestToken++
suggestions.value = []
searching.value = false
}
/**
* Debounced contact search. Queries shorter than MIN_QUERY_LENGTH clear the
* suggestion list without hitting the backend.
*/
function search(query: string) {
const trimmed = (query ?? '').trim()
cancelPending()
if (trimmed.length < MIN_QUERY_LENGTH) {
requestToken++
suggestions.value = []
searching.value = false
return
}
searching.value = true
const token = ++requestToken
debounceTimer = setTimeout(async () => {
try {
const matches = await recipientService.search(trimmed)
if (token !== requestToken) {
return
}
suggestions.value = matches
} catch (error: any) {
if (token === requestToken) {
suggestions.value = []
}
console.error('[Mail][Recipient Lookup] - Search failed:', error)
} finally {
if (token === requestToken) {
searching.value = false
}
}
}, DEBOUNCE_MS)
}
return {
suggestions,
searching,
search,
clear,
}
})
+133
View File
@@ -0,0 +1,133 @@
import { computed } from 'vue'
import { defineStore } from 'pinia'
import { useUserStore } from '@KTXC'
const MESSAGE_READ_ENABLED_KEY = 'mail.behaviour.messageReadEnabled'
const MESSAGE_READ_DELAY_KEY = 'mail.behaviour.messageReadDelay'
const FOLDER_VIEW_MODE_KEY = 'mail.folderViewMode'
const MESSAGE_LIST_DENSITY_KEY = 'mail.messageListDensity'
const MESSAGE_LIST_WIDTH_KEY = 'mail.messageListWidth'
const DEFAULT_MESSAGE_READ_ENABLED = false
const DEFAULT_MESSAGE_READ_DELAY = 5
const DEFAULT_FOLDER_VIEW_MODE = 'tree'
const DEFAULT_MESSAGE_LIST_DENSITY = 'cozy'
const DEFAULT_MESSAGE_LIST_WIDTH = 35
export type FolderViewMode = 'tree' | 'page'
export type MessageListDensity = 'compact' | 'cozy' | 'comfortable'
export const messageListDensityOptions = [
{ value: 'compact', title: 'Compact' },
{ value: 'cozy', title: 'Cozy' },
{ value: 'comfortable', title: 'Comfortable' },
]
export const messageListDensityConfig: Record<MessageListDensity, {
itemHeight: number
lines: 'one' | 'two' | 'three'
showSubjectLine: boolean
showThirdLine: boolean
}> = {
compact: { itemHeight: 56, lines: 'one', showSubjectLine: false, showThirdLine: false },
cozy: { itemHeight: 72, lines: 'two', showSubjectLine: true, showThirdLine: false },
comfortable: { itemHeight: 96, lines: 'three', showSubjectLine: true, showThirdLine: true },
}
// Width of the message list panel, as a percentage of the split area. Only applies to
// the side-by-side (vertical reading pane) view; future layout views own their own sizing.
export type MessageListWidth = 25 | 35 | 50
export const messageListWidthOptions = [
{ value: 25, title: 'Narrow' },
{ value: 35, title: 'Medium' },
{ value: 50, title: 'Wide' },
]
export const messageReadDelayOptions = [
{ value: 2, title: '2 seconds' },
{ value: 5, title: '5 seconds' },
{ value: 10, title: '10 seconds' },
{ value: 30, title: '30 seconds' },
]
export const folderViewModeOptions = [
{ value: 'tree', title: 'Tree' },
{ value: 'page', title: 'Page' },
]
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') {
return value
}
return fallback
}
function normalizePositiveNumber(value: unknown, fallback: number): number {
const normalized = Number(value)
return Number.isFinite(normalized) && normalized > 0 ? normalized : fallback
}
function normalizeFolderViewMode(value: unknown, fallback: FolderViewMode): FolderViewMode {
return value === 'tree' || value === 'page' ? value : fallback
}
function normalizeMessageListDensity(value: unknown, fallback: MessageListDensity): MessageListDensity {
return value === 'compact' || value === 'cozy' || value === 'comfortable' ? value : fallback
}
function normalizeMessageListWidth(value: unknown, fallback: MessageListWidth): MessageListWidth {
return value === 25 || value === 35 || value === 50 ? value : fallback
}
export const useMailSettingsStore = defineStore('mailSettingsStore', () => {
const userStore = useUserStore()
const messageReadEnabled = computed({
get: () => normalizeBoolean(userStore.getSetting(MESSAGE_READ_ENABLED_KEY), DEFAULT_MESSAGE_READ_ENABLED),
set: (value: boolean) => userStore.setSetting(MESSAGE_READ_ENABLED_KEY, value),
})
const messageReadDelay = computed({
get: () => normalizePositiveNumber(userStore.getSetting(MESSAGE_READ_DELAY_KEY), DEFAULT_MESSAGE_READ_DELAY),
set: (value: number) => userStore.setSetting(
MESSAGE_READ_DELAY_KEY,
normalizePositiveNumber(value, DEFAULT_MESSAGE_READ_DELAY),
),
})
const folderViewMode = computed({
get: () => normalizeFolderViewMode(userStore.getSetting(FOLDER_VIEW_MODE_KEY), DEFAULT_FOLDER_VIEW_MODE),
set: (value: FolderViewMode) => userStore.setSetting(
FOLDER_VIEW_MODE_KEY,
normalizeFolderViewMode(value, DEFAULT_FOLDER_VIEW_MODE),
),
})
const messageListDensity = computed({
get: () => normalizeMessageListDensity(userStore.getSetting(MESSAGE_LIST_DENSITY_KEY), DEFAULT_MESSAGE_LIST_DENSITY),
set: (value: MessageListDensity) => userStore.setSetting(
MESSAGE_LIST_DENSITY_KEY,
normalizeMessageListDensity(value, DEFAULT_MESSAGE_LIST_DENSITY),
),
})
const messageListWidth = computed({
get: () => normalizeMessageListWidth(userStore.getSetting(MESSAGE_LIST_WIDTH_KEY), DEFAULT_MESSAGE_LIST_WIDTH),
set: (value: MessageListWidth) => userStore.setSetting(
MESSAGE_LIST_WIDTH_KEY,
normalizeMessageListWidth(value, DEFAULT_MESSAGE_LIST_WIDTH),
),
})
return {
folderViewMode,
messageListDensity,
messageListWidth,
messageReadEnabled,
messageReadDelay,
}
})
+468 -202
View File
@@ -1,20 +1,29 @@
import { computed, ref, shallowRef, watch } from 'vue' import { computed, ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore' import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore' import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { ComposerMode } from '@/types/composer'
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { EntityObject, ServiceObject } from '@MailManager/models' import { EntityObject, type ServiceObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import type { MessageInterface } from '@MailManager/types/message'
import type { MessageAddressInterface } from '@MailManager/types/message'
import { useSnackbar } from '@KTXC'
export const useMailUiStore = defineStore('mailUiStore', () => { export const useMailUiStore = defineStore('mailUiStore', () => {
const collectionsStore = useCollectionsStore() const collectionsStore = useCollectionsStore()
const mailStore = useMailStore() const mailOperationsStore = useMailOperationsStore()
const mailSettingsStore = useMailSettingsStore()
const { showSnackbar } = useSnackbar()
const sidebarVisible = ref(true) const sidebarVisible = ref(true)
const settingsDialogVisible = ref(false) const settingsDialogVisible = ref(false)
const composeMode = ref<'new' | 'reply' | 'forward'>('new') const selectedFolder = shallowRef<CollectionObject | null>(null)
const composeSource = shallowRef<EntityObject | null>(null) const selectedMessage = shallowRef<EntityObject | null>(null)
const composeVisible = ref(false) const composerMode = ref<ComposerMode>(ComposerMode.Fresh)
const composerSource = shallowRef<EntityObject | MessageAddressInterface | null>(null)
const composerVisible = ref(false)
const selectionMode = ref(false) const selectionMode = ref(false)
const selectionList = ref<EntityIdentifier[]>([]) const selectionList = ref<EntityIdentifier[]>([])
const moveMessagesDialogVisible = ref(false) const moveMessagesDialogVisible = ref(false)
@@ -38,6 +47,8 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
const deleteFolderDialogFolder = shallowRef<CollectionObject | null>(null) const deleteFolderDialogFolder = shallowRef<CollectionObject | null>(null)
const deleteFolderDialogLoading = ref(false) const deleteFolderDialogLoading = ref(false)
const deleteFolderDialogError = ref('') const deleteFolderDialogError = ref('')
const messageReadIdentifier = ref<EntityIdentifier | null>(null)
const messageReadTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const createFolderDialogParentLabel = computed(() => { const createFolderDialogParentLabel = computed(() => {
return createFolderDialogParent.value?.properties.label || 'Root' return createFolderDialogParent.value?.properties.label || 'Root'
@@ -87,29 +98,335 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
}) })
watch( watch(
() => mailStore.selectedFolder, () => mailOperationsStore.selectedMessage,
() => { message => {
closeCompose() if (message) {
deactivateSelectionMode() closeComposer()
},
)
watch(
() => mailStore.selectedMessage,
selectedMessage => {
if (selectedMessage) {
closeCompose()
} }
selectMessage(message)
}, },
) )
watch( watch(
() => mailStore.currentMessages, () => mailOperationsStore.currentMessages,
() => { () => {
reconcileSelection() messageSelectionReconcile()
}, },
) )
async function initialize() {
await mailOperationsStore.initialize()
if (!selectedFolder.value) {
const inbox = mailOperationsStore.findFoldersByRole('inbox')[0] ?? null
if (inbox) {
await selectFolder(inbox)
}
}
}
function sidebarToggle() {
sidebarVisible.value = !sidebarVisible.value
}
function sidebarHide() {
sidebarVisible.value = false
}
function settingsOpen() {
settingsDialogVisible.value = true
}
function settingsClose() {
settingsDialogVisible.value = false
}
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
showSnackbar({ message, color })
}
function notifyDeleteMessagesResult(successes: number, failures: number) {
if (failures === 0) {
notify(successes === 1 ? 'Message deleted' : `${successes} messages deleted`, 'success')
return
}
notify(
successes === 0
? `Delete failed for ${failures === 1 ? '1 message' : `${failures} messages`}`
: `Deleted ${successes} ${successes === 1 ? 'message' : 'messages'}. ${failures} failed.`,
successes === 0 ? 'error' : 'warning',
)
}
function notifyFlagMessagesResult(successes: number, failures: number) {
if (failures === 0) {
notify(successes === 1 ? 'Message updated' : `${successes} messages updated`, 'success')
return
}
notify(
successes === 0
? `Update failed for ${failures === 1 ? '1 message' : `${failures} messages`}`
: `Updated ${successes} ${successes === 1 ? 'message' : 'messages'}. ${failures} failed.`,
successes === 0 ? 'error' : 'warning',
)
}
function notifyMoveMessagesResult(successes: number, failures: number) {
if (failures === 0) {
notify(successes === 1 ? 'Message moved' : `${successes} messages moved`, 'success')
return
}
notify(
successes === 0
? `Move failed for ${failures === 1 ? '1 message' : `${failures} messages`}`
: `Moved ${successes} ${successes === 1 ? 'message' : 'messages'}. ${failures} failed.`,
successes === 0 ? 'error' : 'warning',
)
}
function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
if (!left || !right) {
return false
}
return left.provider === right.provider &&
String(left.service) === String(right.service) &&
String(left.identifier) === String(right.identifier)
}
async function selectFolder(folder: CollectionObject | null) {
closeComposer()
messageSelectionModeDeactivate()
clearMessageReadTimer()
selectedMessage.value = null
selectedFolder.value = folder
await mailOperationsStore.selectFolder(folder)
}
async function selectMessage(message: EntityObject | null) {
messageSelectionModeDeactivate()
createMessageReadTimer(message)
selectedMessage.value = message
}
function createMessageReadTimer(entity: EntityObject | null) {
clearMessageReadTimer()
if (!entity) {
return
}
if (entity.properties.isRead || !mailSettingsStore.messageReadEnabled) {
return
}
const delayMilliseconds = mailSettingsStore.messageReadDelay * 1000
if (delayMilliseconds <= 0) {
return
}
messageReadIdentifier.value = entity.identifier
messageReadTimer.value = setTimeout(() => {
void completeMessageRead(entity.identifier)
}, delayMilliseconds)
}
function clearMessageReadTimer() {
if (messageReadTimer.value !== null) {
clearTimeout(messageReadTimer.value)
}
messageReadTimer.value = null
messageReadIdentifier.value = null
}
async function completeMessageRead(identifier: EntityIdentifier) {
try {
if (selectedMessage.value && selectedMessage.value.identifier === identifier && selectedMessage.value.properties.isRead === false) {
await flagMessages([selectedMessage.value.identifier], { seen: true }, { notify: false })
}
} catch (error) {
console.error('[Mail][UI] Failed to auto-mark message as read:', error)
} finally {
clearMessageReadTimer()
}
}
function openCreateFolderDialog(service: ServiceObject, parentFolder: CollectionObject | null = null) {
createFolderDialogService.value = service
createFolderDialogParent.value = parentFolder
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
createFolderDialogVisible.value = true
}
function closeCreateFolderDialog() {
createFolderDialogVisible.value = false
createFolderDialogService.value = null
createFolderDialogParent.value = null
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
}
async function confirmCreateFolder(label: string) {
const service = createFolderDialogService.value
if (!service) {
return null
}
createFolderDialogLoading.value = true
createFolderDialogError.value = ''
try {
const folder = await mailOperationsStore.createFolder(service, label, createFolderDialogParent.value)
notify(`Folder "${folder.properties.label || label.trim()}" created`, 'success')
closeCreateFolderDialog()
return folder
} catch (error) {
createFolderDialogError.value = error instanceof Error ? error.message : 'Failed to create folder. Please try again.'
throw error
} finally {
createFolderDialogLoading.value = false
}
}
async function openRenameFolderDialog(target: CollectionObject) {
const service = await mailOperationsStore.retrieveService(target.service)
renameFolderDialogService.value = service
renameFolderDialogFolder.value = target
renameFolderDialogError.value = ''
renameFolderDialogLoading.value = false
renameFolderDialogVisible.value = true
}
function closeRenameFolderDialog() {
renameFolderDialogVisible.value = false
renameFolderDialogService.value = null
renameFolderDialogFolder.value = null
renameFolderDialogError.value = ''
renameFolderDialogLoading.value = false
}
async function confirmRenameFolder(label: string) {
const folder = renameFolderDialogFolder.value
if (!folder) {
return null
}
renameFolderDialogLoading.value = true
renameFolderDialogError.value = ''
try {
const updatedFolder = await mailOperationsStore.renameFolder(folder, label)
if (_sameCollection(selectedFolder.value, folder)) {
selectedFolder.value = updatedFolder
}
notify(
`Folder "${folder.properties.label || String(folder.identifier)}" renamed to "${updatedFolder.properties.label || label.trim()}"`,
'success',
)
closeRenameFolderDialog()
return updatedFolder
} catch (error) {
renameFolderDialogError.value = error instanceof Error ? error.message : 'Failed to rename folder. Please try again.'
throw error
} finally {
renameFolderDialogLoading.value = false
}
}
async function openMoveFolderDialog(source: CollectionObject) {
const service = await mailOperationsStore.retrieveService(source.service)
moveFolderDialogService.value = service
moveFolderDialogSource.value = source
moveFolderDialogVisible.value = true
}
function closeMoveFolderDialog() {
moveFolderDialogVisible.value = false
moveFolderDialogService.value = null
moveFolderDialogSource.value = null
}
async function confirmMoveFolder(target: CollectionObject) {
const source = moveFolderDialogSource.value
if (!source) {
return null
}
const movedFolder = await mailOperationsStore.moveFolder(source, target)
if (_sameCollection(selectedFolder.value, source)) {
selectedFolder.value = movedFolder
}
notify(
`Folder "${source.properties.label || String(source.identifier)}" moved to "${target.properties.label || String(target.identifier)}"`,
'success',
)
closeMoveFolderDialog()
return movedFolder
}
async function openDeleteFolderDialog(target: CollectionObject) {
const service = await mailOperationsStore.retrieveService(target.service)
deleteFolderDialogService.value = service
deleteFolderDialogFolder.value = target
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
deleteFolderDialogVisible.value = true
}
function closeDeleteFolderDialog() {
deleteFolderDialogVisible.value = false
deleteFolderDialogService.value = null
deleteFolderDialogFolder.value = null
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
}
async function confirmDeleteFolder() {
const folder = deleteFolderDialogFolder.value
if (!folder) {
return null
}
deleteFolderDialogLoading.value = true
deleteFolderDialogError.value = ''
try {
const deleted = await mailOperationsStore.deleteFolder(folder)
if (_sameCollection(selectedFolder.value, folder)) {
selectFolder(null)
}
notify(`Folder "${folder.properties.label || String(folder.identifier)}" deleted`, 'success')
closeDeleteFolderDialog()
return deleted
} catch (error) {
deleteFolderDialogError.value = error instanceof Error ? error.message : 'Failed to delete folder. Please try again.'
throw error
} finally {
deleteFolderDialogLoading.value = false
}
}
function validateFolderNameBase(service: ServiceObject, name: string): string[] { function validateFolderNameBase(service: ServiceObject, name: string): string[] {
const errors: string[] = [] const errors: string[] = []
@@ -195,29 +512,24 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
selectionList.value = Array.from(new Set(nextIds)) selectionList.value = Array.from(new Set(nextIds))
} }
function openCompose(source?: EntityObject, mode: 'reply' | 'forward' = 'reply') { function openComposer(source?: EntityObject | MessageAddressInterface, mode: ComposerMode = ComposerMode.Fresh) {
mailStore.clearSelectedMessage() mailOperationsStore.selectMessage(null)
composeSource.value = source ?? null composerSource.value = source ?? null
composeMode.value = mode composerMode.value = mode
composeVisible.value = true composerVisible.value = true
} }
function closeCompose() { function closeComposer() {
composeMode.value = 'new' composerMode.value = ComposerMode.Fresh
composeSource.value = null composerSource.value = null
composeVisible.value = false composerVisible.value = false
} }
async function afterSent() { function messageSelectionClear() {
closeCompose()
await mailStore.reloadSelectedFolder()
}
function clearSelection() {
setSelectionList([]) setSelectionList([])
} }
function activateSelectionMode(message?: EntityObject) { function messageSelectionModeActivate(message?: EntityObject) {
selectionMode.value = true selectionMode.value = true
if (!message) { if (!message) {
@@ -231,12 +543,12 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
} }
} }
function deactivateSelectionMode() { function messageSelectionModeDeactivate() {
selectionMode.value = false selectionMode.value = false
clearSelection() messageSelectionClear()
} }
function toggleMessageSelection(message: EntityObject) { function messageSelectionToggleOne(message: EntityObject) {
const identifier = message.identifier const identifier = message.identifier
selectionMode.value = true selectionMode.value = true
@@ -249,18 +561,22 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
setSelectionList([...selectionList.value, identifier]) setSelectionList([...selectionList.value, identifier])
} }
function selectAllCurrentMessages() { function messageSelectionToggleAll(value: boolean) {
selectionMode.value = true selectionMode.value = true
setSelectionList(mailStore.currentMessages.map(message => message.identifier)) if (value) {
setSelectionList(mailOperationsStore.currentMessages.map(message => message.identifier))
} else {
setSelectionList([])
}
} }
function reconcileSelection() { function messageSelectionReconcile() {
if (!mailStore.selectedFolder) { if (!selectedFolder.value) {
clearSelection() messageSelectionClear()
return return
} }
const currentMessageIdentifiers = new Set(mailStore.currentMessages.map(message => message.identifier)) const currentMessageIdentifiers = new Set(mailOperationsStore.currentMessages.map(message => message.identifier))
const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier)) const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
if (nextSelectedIds.length !== selectionList.value.length) { if (nextSelectedIds.length !== selectionList.value.length) {
@@ -272,22 +588,6 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
} }
} }
function toggleSidebar() {
sidebarVisible.value = !sidebarVisible.value
}
function closeSidebar() {
sidebarVisible.value = false
}
function openSettings() {
settingsDialogVisible.value = true
}
function closeSettings() {
settingsDialogVisible.value = false
}
async function openMoveMessagesDialog(entities?: EntityObject | EntityObject[]) { async function openMoveMessagesDialog(entities?: EntityObject | EntityObject[]) {
let moveMessagesServiceIdentifier = null as ServiceIdentifier | null let moveMessagesServiceIdentifier = null as ServiceIdentifier | null
@@ -303,10 +603,10 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
} }
} else { } else {
moveMessagesDialogCandidates.value = [...selectionList.value] moveMessagesDialogCandidates.value = [...selectionList.value]
moveMessagesServiceIdentifier = mailStore.selectedFolder?.service as ServiceIdentifier || null moveMessagesServiceIdentifier = selectedFolder.value?.service as ServiceIdentifier || null
} }
moveMessagesDialogService.value = await mailStore.retrieveService(moveMessagesServiceIdentifier); moveMessagesDialogService.value = await mailOperationsStore.retrieveService(moveMessagesServiceIdentifier)
moveMessagesDialogVisible.value = true moveMessagesDialogVisible.value = true
} }
@@ -316,164 +616,120 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
moveMessagesDialogCandidates.value = null moveMessagesDialogCandidates.value = null
} }
async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailStore.moveMessages>[0]) { async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailOperationsStore.moveMessages>[0]) {
await mailStore.moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? []) await moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? [])
deactivateSelectionMode() messageSelectionModeDeactivate()
closeMoveMessagesDialog() closeMoveMessagesDialog()
} }
async function deleteSelectedMessages() { async function deleteSelectedMessages() {
await mailStore.deleteMessages([...selectionList.value]) await deleteMessages([...selectionList.value])
deactivateSelectionMode() messageSelectionModeDeactivate()
} }
function openCreateFolderDialog(service: ServiceObject, parentFolder: CollectionObject | null = null) { async function flagSelectedMessages(flag: string, value: boolean) {
createFolderDialogService.value = service await flagMessages([...selectionList.value], { [flag]: value })
createFolderDialogParent.value = parentFolder messageSelectionModeDeactivate()
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
createFolderDialogVisible.value = true
} }
function closeCreateFolderDialog() { async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
createFolderDialogVisible.value = false
createFolderDialogService.value = null
createFolderDialogParent.value = null
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
}
async function confirmCreateFolder(label: string) {
const service = createFolderDialogService.value
if (!service) {
return null
}
createFolderDialogLoading.value = true
createFolderDialogError.value = ''
try { try {
const folder = await mailStore.createFolder(service, label, createFolderDialogParent.value) const result = await mailOperationsStore.deleteMessages(entityIdentifiers)
closeCreateFolderDialog()
return folder if (!result) {
return null
}
notifyDeleteMessagesResult(result.successes.length, result.failures.length)
return result
} catch (error) { } catch (error) {
createFolderDialogError.value = error instanceof Error ? error.message : 'Failed to create folder. Please try again.' const messageText = error instanceof Error ? error.message : 'Failed to delete messages'
notify(messageText, 'error')
throw error throw error
} finally {
createFolderDialogLoading.value = false
} }
} }
async function openRenameFolderDialog(target: CollectionObject) { async function flagMessages(
const service = await mailStore.retrieveService(target.service) entityIdentifiers: EntityIdentifier[],
renameFolderDialogService.value = service flags: Partial<NonNullable<MessageInterface['flags']>>,
renameFolderDialogFolder.value = target options: { notify?: boolean } = {},
renameFolderDialogError.value = '' ) {
renameFolderDialogLoading.value = false
renameFolderDialogVisible.value = true
}
function closeRenameFolderDialog() {
renameFolderDialogVisible.value = false
renameFolderDialogService.value = null
renameFolderDialogFolder.value = null
renameFolderDialogError.value = ''
renameFolderDialogLoading.value = false
}
async function confirmRenameFolder(label: string) {
const folder = renameFolderDialogFolder.value
if (!folder) {
return null
}
renameFolderDialogLoading.value = true
renameFolderDialogError.value = ''
try { try {
const updatedFolder = await mailStore.renameFolder(folder, label) const result = await mailOperationsStore.flagMessages(entityIdentifiers, flags)
closeRenameFolderDialog()
return updatedFolder if (!result) {
return null
}
if (options.notify ?? true) {
notifyFlagMessagesResult(result.successes.length, result.failures.length)
}
return result
} catch (error) { } catch (error) {
renameFolderDialogError.value = error instanceof Error ? error.message : 'Failed to rename folder. Please try again.' const messageText = error instanceof Error ? error.message : 'Failed to update messages'
if (options.notify ?? true) {
notify(messageText, 'error')
}
throw error throw error
} finally {
renameFolderDialogLoading.value = false
} }
} }
async function openMoveFolderDialog(source: CollectionObject) { async function flagMessage(entity: EntityObject, flag: string, value: boolean) {
const service = await mailStore.retrieveService(source.service) return await flagMessages([entity.identifier], { [flag]: value })
moveFolderDialogService.value = service
moveFolderDialogSource.value = source
moveFolderDialogVisible.value = true
} }
function closeMoveFolderDialog() { async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
moveFolderDialogVisible.value = false
moveFolderDialogService.value = null
moveFolderDialogSource.value = null
}
async function confirmMoveFolder(target: CollectionObject) {
const source = moveFolderDialogSource.value
if (!source) {
return null
}
const movedFolder = await mailStore.moveFolder(source, target)
closeMoveFolderDialog()
return movedFolder
}
async function openDeleteFolderDialog(target: CollectionObject) {
const service = await mailStore.retrieveService(target.service)
deleteFolderDialogService.value = service
deleteFolderDialogFolder.value = target
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
deleteFolderDialogVisible.value = true
}
function closeDeleteFolderDialog() {
deleteFolderDialogVisible.value = false
deleteFolderDialogService.value = null
deleteFolderDialogFolder.value = null
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
}
async function confirmDeleteFolder() {
const folder = deleteFolderDialogFolder.value
if (!folder) {
return null
}
deleteFolderDialogLoading.value = true
deleteFolderDialogError.value = ''
try { try {
const deleted = await mailStore.deleteFolder(folder) const result = await mailOperationsStore.moveMessages(target, entityIdentifiers)
closeDeleteFolderDialog()
return deleted if (!result) {
return null
}
notifyMoveMessagesResult(result.successes.length, result.failures.length)
return result
} catch (error) { } catch (error) {
deleteFolderDialogError.value = error instanceof Error ? error.message : 'Failed to delete folder. Please try again.' const messageText = error instanceof Error ? error.message : 'Failed to move messages'
notify(messageText, 'error')
throw error throw error
} finally {
deleteFolderDialogLoading.value = false
} }
} }
async function downloadMessage(entity: EntityObject, index?: number) {
try {
await mailOperationsStore.downloadMessage(entity, index)
} catch (error) {
const messageText = error instanceof Error
? error.message
: index === undefined
? 'Failed to download message'
: 'Failed to download attachment'
notify(messageText, 'error')
throw error
}
}
function attachmentBlob(entity: EntityObject, part: Parameters<typeof mailOperationsStore.attachmentBlob>[1]) {
return mailOperationsStore.attachmentBlob(entity, part)
}
function attachmentPreviewBlob(
entity: EntityObject,
part: Parameters<typeof mailOperationsStore.attachmentPreviewBlob>[1],
variant: 'inline' | 'fullscreen' = 'inline',
) {
return mailOperationsStore.attachmentPreviewBlob(entity, part, variant)
}
return { return {
sidebarVisible, sidebarVisible,
settingsDialogVisible, settingsDialogVisible,
composeMode, selectedFolder,
composeSource, selectedMessage,
composeVisible, composerMode,
composerSource,
composerVisible,
selectionMode, selectionMode,
selectionList, selectionList,
moveMessagesDialogVisible, moveMessagesDialogVisible,
@@ -500,18 +756,27 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
deleteFolderDialogFolder, deleteFolderDialogFolder,
deleteFolderDialogLoading, deleteFolderDialogLoading,
deleteFolderDialogError, deleteFolderDialogError,
toggleSidebar, initialize,
closeSidebar, sidebarToggle,
openSettings, sidebarHide,
closeSettings, settingsOpen,
openCompose, settingsClose,
closeCompose, notify,
afterSent, selectFolder,
activateSelectionMode, openComposer,
deactivateSelectionMode, closeComposer,
toggleMessageSelection, deleteMessages,
selectAllCurrentMessages, flagMessages,
clearSelection, flagMessage,
moveMessages,
downloadMessage,
attachmentBlob,
attachmentPreviewBlob,
messageSelectionModeActivate,
messageSelectionModeDeactivate,
messageSelectionToggleOne,
messageSelectionToggleAll,
messageSelectionClear,
validateCreateFolderName, validateCreateFolderName,
validateRenameFolderName, validateRenameFolderName,
openMoveMessagesDialog, openMoveMessagesDialog,
@@ -530,5 +795,6 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
openDeleteFolderDialog, openDeleteFolderDialog,
closeDeleteFolderDialog, closeDeleteFolderDialog,
confirmDeleteFolder, confirmDeleteFolder,
flagSelectedMessages,
} }
}) })
+6 -1
View File
@@ -3,7 +3,8 @@
.mail-container { .mail-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100%;
min-height: 0;
isolation: isolate; isolation: isolate;
} }
@@ -14,6 +15,7 @@
.mail-content { .mail-content {
display: flex; display: flex;
flex: 1; flex: 1;
min-height: 0;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
@@ -25,6 +27,7 @@
.mail-main { .mail-main {
flex: 1; flex: 1;
min-height: 0;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
min-width: 0; min-width: 0;
@@ -32,6 +35,8 @@
.mail-wrapper { .mail-wrapper {
flex: 1; flex: 1;
min-height: 0;
min-width: 0;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
+52
View File
@@ -0,0 +1,52 @@
import type { ServiceObject } from "@MailManager/models/service"
import type { EntityIdentifier } from "@MailManager/types/common"
import type { MessageAddressInterface } from "@MailManager/types/message"
export enum ComposerMode {
Fresh = 'fresh',
Reply = 'reply',
ReplyAll = 'replyAll',
Forward = 'forward',
}
/** Body editing format: rich (Tiptap/HTML) or plain text. */
export type ComposerFormat = 'rich' | 'plain'
export interface ComposerDraftAttachment {
identifier: string
composition: string
origin: 'device' | 'message' | 'documents'
name: string
type: string
size: number
source: EntityIdentifier | null
}
export interface ComposerDraftMessage {
to: MessageAddressInterface[]
cc: MessageAddressInterface[]
bcc: MessageAddressInterface[]
subject: string
body: {
html: string
text: string
}
}
export interface ComposerDraft {
action: ComposerMode
identifier: string
revision: number
sender: ComposerSenderIdentity
source: EntityIdentifier | null
stageStatus: 'local' | 'staged'
stagedAt: Date | null
message: ComposerDraftMessage
attachments: Record<string, ComposerDraftAttachment>
}
export interface ComposerSenderIdentity {
service: ServiceObject
address: string
label: string | null
}
+136
View File
@@ -0,0 +1,136 @@
import type { EntityIdentifier } from '@MailManager/types/common'
import type { MessageAddressInterface } from '@MailManager/types/message'
export interface CompositionSenderInterface {
provider: string
service: string | number | null
address: string
name: string | null
}
export interface CompositionMessageInterface {
to: MessageAddressInterface[]
cc: MessageAddressInterface[]
bcc: MessageAddressInterface[]
subject: string
body: {
text: string
html: string
}
}
export interface CompositionAttachmentInterface {
identifier: string
composition: string
origin: 'device' | 'message' | 'documents'
name: string
type: string
size: number
source: EntityIdentifier | null
data?: string
}
export interface CompositionStageRequest {
identifier: string
action: 'fresh' | 'reply' | 'forward'
sender: CompositionSenderInterface
source?: EntityIdentifier | null
message: CompositionMessageInterface
}
export interface CompositionStageResponse {
identifier: string
revision: number
disposition: 'staged'
attachments: Record<string, CompositionAttachmentInterface>
}
export interface CompositionPatchRequest {
identifier: string
revision: number
sender: CompositionSenderInterface
message: CompositionMessageInterface
}
export interface CompositionPatchResponse {
identifier: string
revision: number
message: CompositionMessageInterface
}
export interface CompositionSaveRequest extends CompositionPatchRequest {}
export interface CompositionSaveResponse {
identifier: string
disposition: 'saved' | 'error'
error?: {
type: string
message: string
}
}
export interface CompositionDiscardRequest {
identifier: string
}
export interface CompositionDiscardResponse {
identifier: string
disposition: 'discarded' | 'error'
error?: {
type: string
message: string
}
}
export interface CompositionSendRequest {
identifier: string
revision: number
sender: CompositionSenderInterface
message: CompositionMessageInterface
attachments: Record<string, CompositionAttachmentInterface>
}
export interface CompositionSendResponse {
identifier: string
disposition: 'sent' | 'error'
error?: {
type: string
message: string
}
}
export interface CompositionAttachmentAddRequest {
composition: string
attachments: Record<string, CompositionAttachmentInterface>
}
export interface CompositionAttachmentAddResponse {
disposition: 'added' | 'partial' | 'error'
error?: {
type: string
message: string
}
composition: string
revision?: number
attachments: Record<string, CompositionAttachmentInterface>
failed?: {
identifier: string
message: string
}[]
}
export interface CompositionAttachmentRemoveRequest {
composition: string
identifier: string
}
export interface CompositionAttachmentRemoveResponse {
disposition: 'removed' | 'error'
error?: {
type: string
message: string
}
composition: string
identifier: string
revision?: number
}
+9
View File
@@ -0,0 +1,9 @@
import type { MessagePartInterface } from "@MailManager/types/message"
export interface MediaViewerItem {
id: string
title: string
mime: string
size?: number | null
meta: { index: number; attachment: MessagePartInterface }
}
+21
View File
@@ -0,0 +1,21 @@
export function formatFileSize(bytes: number | null | undefined): string {
if (bytes == null || !Number.isFinite(bytes)) {
return ''
}
if (bytes < 1024) {
return `${Math.max(0, Math.round(bytes))} B`
}
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes / 1024
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
const precision = value >= 10 || unitIndex === 0 ? 1 : 2
return `${value.toFixed(precision)} ${units[unitIndex]}`
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
describe('Basic Tests', () => {
it('should perform basic assertion', () => {
expect(true).toBe(true)
})
it('should test array operations', () => {
const array = ['foo', 'bar', 'baz']
expect(array).toHaveLength(3)
expect(array).toContain('bar')
expect(array[0]).toBe('foo')
})
it('should test string operations', () => {
const string = 'Hello, World!'
expect(string).toContain('World')
expect(string.length).toBe(13)
})
it('should test object operations', () => {
const obj = { foo: 'bar', count: 42 }
expect(obj).toHaveProperty('foo')
expect(obj.foo).toBe('bar')
expect(obj.count).toBeGreaterThan(40)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { fileURLToPath } from 'node:url'
import { defineConfig, configDefaults } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, '../../src'),
'@KTXC': path.resolve(__dirname, '../../../../core/src'),
},
},
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('../../', import.meta.url)),
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**',
],
},
},
})
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace KTXT\Mail\Tests\Integration;
use PHPUnit\Framework\TestCase;
class BaseTest extends TestCase
{
public function testBasicAssertion(): void
{
$this->assertTrue(true);
}
public function testArrayOperations(): void
{
$array = ['foo' => 'bar'];
$this->assertArrayHasKey('foo', $array);
$this->assertEquals('bar', $array['foo']);
}
public function testStringOperations(): void
{
$string = 'Hello, World!';
$this->assertStringContainsString('World', $string);
$this->assertEquals(13, strlen($string));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace KTXT\Mail\Tests\Unit;
use PHPUnit\Framework\TestCase;
class BaseTest extends TestCase
{
public function testBasicAssertion(): void
{
$this->assertTrue(true);
}
public function testArrayOperations(): void
{
$array = ['foo' => 'bar'];
$this->assertArrayHasKey('foo', $array);
$this->assertEquals('bar', $array['foo']);
}
public function testStringOperations(): void
{
$string = 'Hello, World!';
$this->assertStringContainsString('World', $string);
$this->assertEquals(13, strlen($string));
}
}
+462
View File
@@ -0,0 +1,462 @@
<?php
declare(strict_types=1);
namespace KTXT\Mail\Tests\Unit;
use KTXC\Module\ModuleManager;
use KTXC\Module\ModuleObject;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Documents\Entity\EntityBaseInterface;
use KTXF\Documents\Entity\EntityPropertiesBaseInterface;
use KTXM\Mail\CompositionManager;
use KTXM\Mail\CompositionSavedEvent;
use KTXM\Mail\Stores\CompositionStore;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
// KTXM\MailManager\Manager and KTXM\DocumentsManager\Manager live in sibling
// modules. This module's isolated test bootstrap only autoloads its own PSR-4
// map plus the server's KTXC/KTXF namespaces (see tests/php/bootstrap.php), so
// sibling-module classes are not otherwise resolvable here. Require them
// directly so PHPUnit can reflect on the real classes to build mocks; both
// files only depend on already-autoloadable KTXC/KTXF types. Skipped
// gracefully in setUp() if this module is ever checked out standalone.
$mailManagerFile = dirname(__DIR__, 4) . '/mail_manager/lib/Manager.php';
$documentsManagerFile = dirname(__DIR__, 4) . '/documents_manager/lib/Manager.php';
if (is_file($mailManagerFile)) {
require_once $mailManagerFile;
}
if (is_file($documentsManagerFile)) {
require_once $documentsManagerFile;
}
final class CompositionManagerTest extends TestCase {
private const TENANT_ID = 'tenant-1';
private const USER_ID = 'user-1';
private string $rootDir;
private CompositionStore $compositionStore;
protected function setUp(): void {
if (!class_exists(\KTXM\MailManager\Manager::class) || !class_exists(\KTXM\DocumentsManager\Manager::class)) {
$this->markTestSkipped('mail_manager and documents_manager modules are not available alongside this checkout');
}
$this->rootDir = sys_get_temp_dir() . '/ktrix-mail-composition-test-' . uniqid();
mkdir($this->rootDir, 0755, true);
$this->compositionStore = new CompositionStore($this->rootDir);
}
protected function tearDown(): void {
$this->deleteDir($this->rootDir);
}
public function testStageInitializesDirtyRemoteState(): void {
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$manager = $this->makeManager($documentsManager);
$response = $manager->stage(
self::TENANT_ID,
self::USER_ID,
'draft-new',
'new',
['address' => 'sender@example.com'],
['subject' => 'Draft'],
);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, 'draft-new');
$this->assertArrayNotHasKey('remote', $response);
$this->assertSame([
'status' => 'dirty',
'entity' => null,
'error' => null,
], $snapshot['remote']);
}
public function testStageQueuesDraftSynchronizationAfterLocalSave(): void {
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())
->method('dispatch')
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
$event->tenantIdentifier() === self::TENANT_ID
&& $event->userIdentifier() === self::USER_ID
&& $event->compositionIdentifier() === 'draft-event'
&& $event->revision() === 1));
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$manager = $this->makeManager($documentsManager, events: $events);
$result = $manager->stage(
self::TENANT_ID,
self::USER_ID,
'draft-event',
'fresh',
['address' => 'sender@example.com'],
['subject' => 'Draft'],
);
$this->assertSame('staged', $result['disposition']);
}
public function testStageSurfacesEventQueueingFailureAfterLocalSave(): void {
$events = $this->createStub(EventDispatcherInterface::class);
$events->method('dispatch')->willThrowException(new \RuntimeException('No active event scope'));
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$manager = $this->makeManager($documentsManager, events: $events);
try {
$manager->stage(
self::TENANT_ID,
self::USER_ID,
'draft-event-failure',
'fresh',
['address' => 'sender@example.com'],
['subject' => 'Draft'],
);
$this->fail('Event dispatch failure should be surfaced');
} catch (\RuntimeException $exception) {
$this->assertSame('No active event scope', $exception->getMessage());
}
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, 'draft-event-failure');
$this->assertSame('dirty', $snapshot['remote']['status']);
}
public function testPatchMarksExistingRemoteStateDirty(): void {
$compositionId = $this->stageEmptyComposition();
$setSynced = static function (?array $snapshot): array {
$snapshot['revision'] = 1;
$snapshot['remote'] = [
'status' => 'synced',
'entity' => 'provider:service:drafts:123',
'error' => 'old error',
];
return $snapshot;
};
$this->compositionStore->compositionSave(
self::TENANT_ID,
self::USER_ID,
$compositionId,
$setSynced,
);
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())
->method('dispatch')
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
$event->compositionIdentifier() === $compositionId
&& $event->revision() === 2));
$manager = $this->makeManager($documentsManager, events: $events);
$result = $manager->patch(self::TENANT_ID, self::USER_ID, $compositionId, [
'revision' => 2,
'sender' => ['address' => 'sender@example.com'],
'message' => ['subject' => 'Changed'],
]);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertSame('patched', $result['disposition']);
$this->assertSame('dirty', $snapshot['remote']['status']);
$this->assertSame('provider:service:drafts:123', $snapshot['remote']['entity']);
$this->assertNull($snapshot['remote']['error']);
}
public function testAttachmentAddFromDocumentsHappyPath(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())
->method('dispatch')
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
$event->compositionIdentifier() === $compositionId));
$manager = $this->makeManager($documentsManager, events: $events);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame('added', $result['disposition']);
$this->assertCount(1, $result['attachments']);
$this->assertSame([], $result['failed']);
$this->assertSame(1, $result['revision']);
$attachment = $result['attachments']['attach-1'];
$this->assertSame('documents', $attachment['origin']);
$this->assertSame('default:personal:root:doc1', $attachment['source']);
$this->assertSame('hello.txt', $attachment['name']);
$this->assertSame('text/plain', $attachment['type']);
$this->assertSame(strlen('hello world'), $attachment['size']);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertSame(1, $snapshot['revision']);
$this->assertSame('dirty', $snapshot['remote']['status']);
$this->assertNull($snapshot['remote']['entity']);
$this->assertNull($snapshot['remote']['error']);
}
public function testAttachmentAddIncrementsRevisionOnSubsequentAdd(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
$this->makeEntity('default:personal:root:doc2', 'world.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturnCallback(
fn(): mixed => $this->makeStream('hello world'),
);
$manager = $this->makeManager($documentsManager);
$manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-2',
'origin' => 'documents',
'source' => 'default:personal:root:doc2',
]);
$this->assertSame(2, $result['revision']);
}
public function testAttachmentAddDoesNotChangeRevisionWhenNothingIsAdded(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
// documents_manager unavailable means the sourced attachment fails
// entirely, so no attachment is actually added to the composition.
$manager = $this->makeManager($documentsManager, documentsModuleEnabled: false);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame('error', $result['disposition']);
$this->assertArrayNotHasKey('revision', $result);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertArrayNotHasKey('revision', $snapshot);
}
public function testAttachmentAddFromDocumentsPartialFailure(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
// only doc1 is returned even though doc1 and doc2 are both requested
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
$manager = $this->makeManager($documentsManager);
$result = $manager->attachmentAdd(
self::TENANT_ID,
self::USER_ID,
$compositionId,
['identifier' => 'attach-1', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'],
['identifier' => 'attach-2', 'origin' => 'documents', 'source' => 'default:personal:root:doc2'],
);
$this->assertSame('partial', $result['disposition']);
$this->assertCount(1, $result['attachments']);
$this->assertCount(1, $result['failed']);
$this->assertSame('default:personal:root:doc2', $result['failed'][0]['identifier']);
}
public function testAttachmentAddMixedUploadAndDocuments(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
$manager = $this->makeManager($documentsManager);
$result = $manager->attachmentAdd(
self::TENANT_ID,
self::USER_ID,
$compositionId,
['identifier' => 'attach-upload', 'origin' => 'device', 'name' => 'upload.txt', 'type' => 'text/plain', 'data' => base64_encode('uploaded bytes')],
['identifier' => 'attach-doc', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'],
);
$this->assertSame('added', $result['disposition']);
$this->assertCount(2, $result['attachments']);
$this->assertSame('device', $result['attachments']['attach-upload']['origin']);
$this->assertSame('documents', $result['attachments']['attach-doc']['origin']);
}
public function testAttachmentAddCompositionNotFound(): void {
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$manager = $this->makeManager($documentsManager);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, 'unknown-composition', [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame('error', $result['disposition']);
$this->assertSame('composition_not_found', $result['error']['type']);
}
public function testAttachmentAddDocumentsModuleUnavailable(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
// documents_manager is not installed/enabled: attachmentAdd must still
// work for the composition itself, just report the sourced entry as failed
// rather than throwing (e.g. from a failed DI resolution).
$manager = $this->makeManager($documentsManager, documentsModuleEnabled: false);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame('error', $result['disposition']);
$this->assertCount(0, $result['attachments']);
$this->assertCount(1, $result['failed']);
$this->assertSame('default:personal:root:doc1', $result['failed'][0]['identifier']);
$this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']);
}
public function testAttachmentRemoveIncrementsRevision(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
$manager = $this->makeManager($documentsManager);
$added = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame(1, $added['revision']);
$result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'attach-1');
$this->assertSame('removed', $result['disposition']);
$this->assertSame(2, $result['revision']);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertSame(2, $snapshot['revision']);
$this->assertArrayNotHasKey('attach-1', $snapshot['attachments']);
$this->assertSame('dirty', $snapshot['remote']['status']);
}
public function testAttachmentRemoveNotFoundDoesNotChangeRevision(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$manager = $this->makeManager($documentsManager);
$result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'missing-attachment');
$this->assertSame('error', $result['disposition']);
$this->assertSame('attachment_not_found', $result['error']['type']);
$this->assertArrayNotHasKey('revision', $result);
}
private function makeManager(
Stub $documentsManager,
bool $documentsModuleEnabled = true,
?EventDispatcherInterface $events = null,
): CompositionManager {
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$moduleManager = $this->createStub(ModuleManager::class);
if ($documentsModuleEnabled) {
$module = $this->createStub(ModuleObject::class);
$module->method('enabled')->willReturn(true);
$moduleManager->method('fetch')->willReturn($module);
} else {
$moduleManager->method('fetch')->willReturn(null);
}
$container = $this->createStub(ContainerInterface::class);
$container->method('has')->willReturn($documentsModuleEnabled);
$container->method('get')->willReturn($documentsManager);
return new CompositionManager(
container: $container,
moduleManager: $moduleManager,
compositionStore: $this->compositionStore,
mailManager: $mailManager,
events: $events ?? $this->createStub(EventDispatcherInterface::class),
);
}
private function stageEmptyComposition(): string {
$compositionId = 'draft-' . uniqid();
$saveComposition = static fn(): array => [
'identifier' => $compositionId,
'attachments' => [],
];
$this->compositionStore->compositionSave(
self::TENANT_ID,
self::USER_ID,
$compositionId,
$saveComposition,
);
return $compositionId;
}
private function makeEntity(string $urn, string $label, string $mime): EntityBaseInterface&Stub {
$properties = $this->createStub(EntityPropertiesBaseInterface::class);
$properties->method('getLabel')->willReturn($label);
$properties->method('getMime')->willReturn($mime);
$properties->method('size')->willReturn(0);
$entity = $this->createStub(EntityBaseInterface::class);
$entity->method('urn')->willReturn($urn);
$entity->method('getProperties')->willReturn($properties);
return $entity;
}
/** @return resource */
private function makeStream(string $contents) {
$resource = fopen('php://memory', 'r+');
fwrite($resource, $contents);
rewind($resource);
return $resource;
}
private function deleteDir(string $path): void {
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$child = $path . '/' . $entry;
if (is_dir($child)) {
$this->deleteDir($child);
continue;
}
unlink($child);
}
rmdir($path);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace KTXT\Mail\Tests\Unit;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXM\Mail\CompositionSavedEvent;
use KTXM\Mail\CompositionSyncListener;
use KTXM\Mail\Module;
use PHPUnit\Framework\TestCase;
final class CompositionSavedEventTest extends TestCase {
public function testCarriesOnlySynchronizationCoordinates(): void {
$event = new CompositionSavedEvent('tenant-1', 'user-1', 'draft-1', 7);
$this->assertSame(CompositionSavedEvent::class, $event->label());
$this->assertSame('tenant-1', $event->tenantIdentifier());
$this->assertSame('user-1', $event->userIdentifier());
$this->assertSame('draft-1', $event->compositionIdentifier());
$this->assertSame(7, $event->revision());
$this->assertSame([
'userId' => 'user-1',
'compositionId' => 'draft-1',
'revision' => 7,
], $event->context());
}
public function testModuleRegistersDeferredCompositionSyncListener(): void {
$events = $this->createMock(EventListenerRegistrarInterface::class);
$events->expects($this->once())
->method('listen')
->with(
'mail',
CompositionSavedEvent::class,
CompositionSyncListener::class,
'onCompositionSaved',
DeliveryMode::Deferred,
);
(new Module($events))->boot();
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace KTXT\Mail\Tests\Unit;
use JsonException;
use KTXM\Mail\Stores\CompositionStore;
use PHPUnit\Framework\TestCase;
final class CompositionStoreTest extends TestCase {
private const TENANT_ID = 'tenant-1';
private const USER_ID = 'user-1';
private const COMPOSITION_ID = 'draft-1';
private string $rootDir;
private CompositionStore $store;
protected function setUp(): void {
$this->rootDir = sys_get_temp_dir() . '/ktrix-mail-store-test-' . uniqid('', true);
mkdir($this->rootDir, 0755, true);
$this->store = new CompositionStore($this->rootDir);
}
protected function tearDown(): void {
$this->deleteDir($this->rootDir);
}
public function testFailedReplacementPreservesPreviousSnapshot(): void {
$initial = ['identifier' => self::COMPOSITION_ID, 'revision' => 1];
$this->saveSnapshot($initial);
$resource = fopen('php://memory', 'r');
try {
$saveInvalidSnapshot = static fn(): array => [
'identifier' => self::COMPOSITION_ID,
'invalid' => $resource,
];
$this->store->compositionSave(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
$saveInvalidSnapshot,
);
$this->fail('Saving a value that cannot be encoded as JSON should fail');
} catch (JsonException) {
$this->assertSame(
$initial,
$this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID),
);
} finally {
fclose($resource);
}
}
public function testUpdateCanLeaveSnapshotUnchanged(): void {
$initial = ['identifier' => self::COMPOSITION_ID, 'revision' => 1];
$this->saveSnapshot($initial);
$leaveUnchanged = static fn(?array $snapshot): ?array => null;
$updated = $this->store->compositionSave(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
$leaveUnchanged,
);
$this->assertNull($updated);
$this->assertSame(
$initial,
$this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID),
);
}
public function testConcurrentUpdatesDoNotLoseChanges(): void {
if (!function_exists('pcntl_fork')) {
$this->markTestSkipped('The pcntl extension is required for the concurrency test');
}
$this->saveSnapshot([
'identifier' => self::COMPOSITION_ID,
'updates' => 0,
]);
$childPid = pcntl_fork();
if ($childPid === -1) {
$this->fail('Unable to fork the concurrency test process');
}
if ($childPid === 0) {
$this->incrementAfterDelay();
exit(0);
}
$this->incrementAfterDelay();
pcntl_waitpid($childPid, $status);
$this->assertTrue(pcntl_wifexited($status));
$this->assertSame(0, pcntl_wexitstatus($status));
$this->assertSame(
2,
$this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID)['updates'],
);
}
private function incrementAfterDelay(): void {
$incrementSnapshot = static function (?array $snapshot): array {
usleep(200_000);
$snapshot['updates']++;
return $snapshot;
};
$this->store->compositionSave(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
$incrementSnapshot,
);
}
private function saveSnapshot(array $snapshot): void {
$saveSnapshot = static fn(): array => $snapshot;
$this->store->compositionSave(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
$saveSnapshot,
);
}
private function deleteDir(string $path): void {
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$child = $path . '/' . $entry;
if (is_dir($child)) {
$this->deleteDir($child);
continue;
}
unlink($child);
}
rmdir($path);
}
}
@@ -0,0 +1,474 @@
<?php
declare(strict_types=1);
namespace KTXT\Mail\Tests\Unit;
use KTXC\Module\ModuleManager;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Mail\Collection\CollectionBaseInterface;
use KTXF\Mail\Entity\EntityBaseInterface;
use KTXF\Mail\Entity\EntityMutableInterface;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Mail\Service\ServiceEntityMutableInterface;
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXM\Mail\CompositionManager;
use KTXM\Mail\Stores\CompositionStore;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
$mailManagerFile = dirname(__DIR__, 4) . '/mail_manager/lib/Manager.php';
if (is_file($mailManagerFile)) {
require_once $mailManagerFile;
}
final class CompositionSynchronizationTest extends TestCase {
private const TENANT_ID = 'tenant-1';
private const USER_ID = 'user-1';
private const COMPOSITION_ID = 'draft-1';
private string $rootDir;
private CompositionStore $compositionStore;
protected function setUp(): void {
if (!class_exists(\KTXM\MailManager\Manager::class)) {
$this->markTestSkipped('mail_manager module is not available alongside this checkout');
}
$this->rootDir = sys_get_temp_dir() . '/ktrix-mail-sync-test-' . uniqid('', true);
mkdir($this->rootDir, 0755, true);
$this->compositionStore = new CompositionStore($this->rootDir);
}
protected function tearDown(): void {
$this->deleteDir($this->rootDir);
}
public function testCreatesDraftInConfiguredCollection(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->mutableService(['draftTarget' => 'drafts'], $properties);
$created = $this->entity('provider', 'service', 'drafts', 'created-1');
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$mailManager->expects($this->once())
->method('entityCreate')
->with(
self::TENANT_ID,
self::USER_ID,
$this->callback(static fn(CollectionIdentifier $target): bool => (string)$target === 'provider:service:drafts'),
$properties,
)
->willReturn($created);
$compositionManager = $this->compositionManager($mailManager);
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$replayed = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$this->assertSame('saved', $result['disposition']);
$this->assertSame('saved', $replayed['disposition']);
$this->assertSame('synced', $snapshot['remote']['status']);
$this->assertSame('provider:service:drafts:created-1', $snapshot['remote']['entity']);
$this->assertNull($snapshot['remote']['error']);
}
public function testModifiesExistingDraftAndStoresAuthoritativeIdentifier(): void {
$this->stageComposition('provider:service:drafts:old-1');
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->mutableService(['draftTarget' => 'drafts'], $properties);
$modified = $this->entity('provider', 'service', 'drafts', 'replacement-2');
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$mailManager->expects($this->once())
->method('entityModify')
->with(
self::TENANT_ID,
self::USER_ID,
$this->callback(static fn(EntityIdentifier $target): bool => (string)$target === 'provider:service:drafts:old-1'),
$properties,
)
->willReturn($modified);
$compositionManager = $this->compositionManager($mailManager);
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$this->assertSame('saved', $result['disposition']);
$this->assertSame('provider:service:drafts:replacement-2', $snapshot['remote']['entity']);
}
public function testResolvesDraftCollectionByRoleWhenNoTargetIsConfigured(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->mutableService([], $properties);
$filter = $this->createMock(IFilter::class);
$filter->expects($this->once())->method('condition')->with('role', 'drafts');
$service->method('collectionListFilter')->willReturn($filter);
$collection = $this->createStub(CollectionBaseInterface::class);
$collection->method('identifier')->willReturn('role-drafts');
$service->method('collectionList')->willReturn([$collection]);
$created = $this->entity('provider', 'service', 'role-drafts', 'created-1');
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$mailManager->expects($this->once())
->method('entityCreate')
->with(
self::TENANT_ID,
self::USER_ID,
$this->callback(static fn(CollectionIdentifier $target): bool => (string)$target === 'provider:service:role-drafts'),
$properties,
)
->willReturn($created);
$compositionManager = $this->compositionManager($mailManager);
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$this->assertSame('saved', $result['disposition']);
}
public function testRecordsFailureWhileKeepingLocalDraftStaged(): void {
$this->stageComposition();
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn(null);
$compositionManager = $this->compositionManager($mailManager);
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$this->assertSame('staged', $result['disposition']);
$this->assertArrayHasKey('error', $result);
$this->assertSame('failed', $snapshot['remote']['status']);
$this->assertSame($result['error'], $snapshot['remote']['error']);
$this->assertNull($snapshot['remote']['entity']);
}
public function testInlineSaveSynchronizesAndPurgesLocalComposition(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->mutableService(['draftTarget' => 'drafts'], $properties);
$created = $this->entity('provider', 'service', 'drafts', 'created-inline');
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$mailManager->expects($this->once())
->method('entityCreate')
->willReturn($created);
$result = $this->compositionManager($mailManager)->save(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
[
'revision' => 2,
'sender' => ['address' => 'sender@example.com'],
'message' => ['subject' => 'Saved inline'],
],
);
$this->assertSame('saved', $result['disposition']);
$this->assertNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
public function testInlineSaveFailureKeepsLocalComposition(): void {
$this->stageComposition();
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn(null);
$result = $this->compositionManager($mailManager)->save(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
[
'revision' => 2,
'sender' => ['address' => 'sender@example.com'],
'message' => ['subject' => 'Keep this draft'],
],
);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$this->assertSame('error', $result['disposition']);
$this->assertSame('composition_save_failed', $result['error']['type']);
$this->assertSame('Keep this draft', $snapshot['message']['subject']);
$this->assertSame('failed', $snapshot['remote']['status']);
}
public function testDiscardDeletesRemoteDraftBeforePurgingLocalComposition(): void {
$remote = 'provider:service:drafts:remote-1';
$this->stageComposition($remote);
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->expects($this->once())
->method('entityDelete')
->with(
self::TENANT_ID,
self::USER_ID,
$this->callback(static fn(EntityIdentifier $target): bool => (string)$target === $remote),
)
->willReturn([$remote => ['disposition' => 'deleted']]);
$result = $this->compositionManager($mailManager)->discard(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
);
$this->assertSame('discarded', $result['disposition']);
$this->assertNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
public function testRemoteDiscardFailureKeepsLocalComposition(): void {
$remote = 'provider:service:drafts:remote-1';
$this->stageComposition($remote);
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->method('entityDelete')
->willReturn([$remote => ['disposition' => 'error', 'error' => 'Provider unavailable']]);
$result = $this->compositionManager($mailManager)->discard(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
);
$this->assertSame('error', $result['disposition']);
$this->assertSame('Provider unavailable', $result['error']['message']);
$this->assertNotNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
public function testSendPurgesLocalCompositionOnSuccess(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->submitService($properties);
$service->method('entitySubmit')->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Sent draft'],
[],
);
$this->assertSame('sent', $result['disposition']);
$this->assertNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
public function testSendReusesSyncedRemoteDraftAsSubmissionSource(): void {
$this->stageComposition('provider:service:drafts:remote-1');
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->createMockForIntersectionOfInterfaces([
ServiceBaseInterface::class,
ServiceEntitySubmitInterface::class,
]);
$entity = $this->createStub(EntityMutableInterface::class);
$entity->method('getProperties')->willReturn($properties);
$service->method('provider')->willReturn('provider');
$service->method('identifier')->willReturn('service');
$service->method('getEnabled')->willReturn(true);
$service->method('entityFresh')->willReturn($entity);
$service->expects($this->once())
->method('entitySubmit')
->with(
$this->anything(),
$this->callback(static fn(?EntityIdentifier $source): bool => $source !== null
&& (string)$source === 'provider:service:drafts:remote-1'),
$properties,
)
->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Sent draft'],
[],
);
$this->assertSame('sent', $result['disposition']);
}
public function testSendIgnoresRemoteDraftBelongingToAnotherService(): void {
$this->stageComposition('provider:other-service:drafts:remote-1');
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->createMockForIntersectionOfInterfaces([
ServiceBaseInterface::class,
ServiceEntitySubmitInterface::class,
]);
$entity = $this->createStub(EntityMutableInterface::class);
$entity->method('getProperties')->willReturn($properties);
$service->method('provider')->willReturn('provider');
$service->method('identifier')->willReturn('service');
$service->method('getEnabled')->willReturn(true);
$service->method('entityFresh')->willReturn($entity);
$service->expects($this->once())
->method('entitySubmit')
->with($this->anything(), $this->isNull(), $properties)
->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Sent draft'],
[],
);
$this->assertSame('sent', $result['disposition']);
}
public function testSendFailureKeepsLocalComposition(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->submitService($properties);
$service->method('entitySubmit')->willReturn(new EntitySubmitResult(
EntitySubmitResult::DISPOSITION_ERROR,
errorMessage: 'Transport unavailable',
));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Keep this draft'],
[],
);
$this->assertSame('error', $result['disposition']);
$this->assertSame('Transport unavailable', $result['error']['message']);
$this->assertNotNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
private function stageComposition(?string $remoteEntity = null): void {
$saveComposition = static fn(): array => [
'identifier' => self::COMPOSITION_ID,
'sender' => ['address' => 'sender@example.com'],
'message' => ['subject' => 'Draft'],
'attachments' => [],
'remote' => [
'status' => 'dirty',
'entity' => $remoteEntity,
'error' => null,
],
];
$this->compositionStore->compositionSave(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
$saveComposition,
);
}
private function mutableService(array $auxiliary, MessagePropertiesMutableInterface $properties): ServiceBaseInterface&ServiceEntityMutableInterface&Stub {
$service = $this->createStubForIntersectionOfInterfaces([
ServiceBaseInterface::class,
ServiceEntityMutableInterface::class,
]);
$entity = $this->createStub(EntityMutableInterface::class);
$entity->method('getProperties')->willReturn($properties);
$service->method('provider')->willReturn('provider');
$service->method('identifier')->willReturn('service');
$service->method('getEnabled')->willReturn(true);
$service->method('getAuxiliary')->willReturn($auxiliary);
$service->method('entityFresh')->willReturn($entity);
return $service;
}
private function submitService(MessagePropertiesMutableInterface $properties): ServiceBaseInterface&ServiceEntitySubmitInterface&Stub {
$service = $this->createStubForIntersectionOfInterfaces([
ServiceBaseInterface::class,
ServiceEntitySubmitInterface::class,
]);
$entity = $this->createStub(EntityMutableInterface::class);
$entity->method('getProperties')->willReturn($properties);
$service->method('provider')->willReturn('provider');
$service->method('identifier')->willReturn('service');
$service->method('getEnabled')->willReturn(true);
$service->method('entityFresh')->willReturn($entity);
return $service;
}
private function compositionManager(\KTXM\MailManager\Manager $mailManager): CompositionManager {
return new CompositionManager(
container: $this->createStub(ContainerInterface::class),
moduleManager: $this->createStub(ModuleManager::class),
compositionStore: $this->compositionStore,
mailManager: $mailManager,
events: $this->createStub(EventDispatcherInterface::class),
);
}
private function entity(string $provider, string $service, string $collection, string $identifier): EntityBaseInterface&Stub {
$entity = $this->createStub(EntityBaseInterface::class);
$entity->method('provider')->willReturn($provider);
$entity->method('service')->willReturn($service);
$entity->method('collection')->willReturn($collection);
$entity->method('identifier')->willReturn($identifier);
return $entity;
}
private function deleteDir(string $path): void {
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$child = $path . '/' . $entry;
if (is_dir($child)) {
$this->deleteDir($child);
} else {
unlink($child);
}
}
rmdir($path);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
require dirname(__DIR__, 2).'/vendor/autoload.php';
// When this module is checked out inside a full server (server/modules/<handle>,
// as it is in CI and in this monorepo checkout), also load the server's own
// core/shared autoloader so tests can reference framework (KTXC/KTXF) types.
// Standalone module checkouts without a server alongside them skip this.
define('SERVER_ROOT', dirname(__DIR__, 4));
$serverAutoload = SERVER_ROOT . '/vendor/autoload.php';
if (is_file($serverAutoload)) {
require $serverAutoload;
}
if (isset($_SERVER['APP_DEBUG']) && $_SERVER['APP_DEBUG']) {
umask(0000);
}
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="bootstrap.php"
cacheDirectory="../../.phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>
<testsuites>
<testsuite name="Unit Tests">
<directory>Unit</directory>
</testsuite>
<testsuite name="Integration Tests">
<directory>Integration</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>../../lib</directory>
</include>
</source>
<extensions>
</extensions>
</phpunit>
+6 -2
View File
@@ -3,9 +3,12 @@
"include": [ "include": [
"src/**/*", "src/**/*",
"src/**/*.vue", "src/**/*.vue",
"src/utile/**/*.ts",
"../../core/src/**/*.ts", "../../core/src/**/*.ts",
"../mail_manager/src/**/*.ts", "../mail_manager/src/**/*.ts",
"../mail_manager/src/**/*.vue" "../mail_manager/src/**/*.vue",
"../people_manager/src/**/*.ts",
"../people_manager/src/**/*.vue"
], ],
"exclude": ["src/**/__tests__/*"], "exclude": ["src/**/__tests__/*"],
"compilerOptions": { "compilerOptions": {
@@ -14,7 +17,8 @@
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@KTXC": ["../../core/src/shared/index.ts"], "@KTXC": ["../../core/src/shared/index.ts"],
"@KTXC/*": ["../../core/src/*"], "@KTXC/*": ["../../core/src/*"],
"@MailManager/*": ["../mail_manager/src/*"] "@MailManager/*": ["../mail_manager/src/*"],
"@PeopleManager/*": ["../people_manager/src/*"]
} }
} }
} }
+5
View File
@@ -31,6 +31,7 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
'@KTXC': path.resolve(__dirname, '../../core/src'), '@KTXC': path.resolve(__dirname, '../../core/src'),
'@MailManager': path.resolve(__dirname, '../mail_manager/src'), '@MailManager': path.resolve(__dirname, '../mail_manager/src'),
'@PeopleManager': path.resolve(__dirname, '../people_manager/src'),
}, },
}, },
build: { build: {
@@ -49,6 +50,7 @@ export default defineConfig({
'pinia', 'pinia',
'@KTXC', '@KTXC',
/^@MailManager\//, /^@MailManager\//,
/^@PeopleManager\//,
], ],
output: { output: {
paths: (id) => { paths: (id) => {
@@ -56,6 +58,9 @@ export default defineConfig({
if (id.startsWith('@MailManager/')) { if (id.startsWith('@MailManager/')) {
return '/modules/mail_manager/static/module.mjs' return '/modules/mail_manager/static/module.mjs'
} }
if (id.startsWith('@PeopleManager/')) {
return '/modules/people_manager/static/module.mjs'
}
return id return id
}, },
assetFileNames: (assetInfo) => { assetFileNames: (assetInfo) => {