110 Commits

Author SHA1 Message Date
Sebastian 10527c0bb0 fix(deps): update dependency pinia to v4
Build Test / test (pull_request) Successful in 1m12s
JS Unit Tests / test (pull_request) Successful in 1m14s
PHP Unit Tests / test (pull_request) Successful in 1m28s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m39s
2026-07-27 03:05:01 +00: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
60 changed files with 8539 additions and 1568 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 }}
+2 -4
View File
@@ -12,12 +12,10 @@ 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
+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
+1702
View File
File diff suppressed because it is too large Load Diff
+396
View File
@@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail;
use InvalidArgumentException;
use KTXF\Mail\Object\Address;
use KTXF\Mail\Object\AddressInterface;
use KTXF\Mail\Object\MessagePart;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Resource\Identifier\EntityIdentifierInterface;
use KTXF\Resource\Identifier\ResourceIdentifier;
use KTXF\Utile\UUID;
use KTXM\Mail\Stores\CompositionStore;
use KTXM\MailManager\Manager as MailManager;
class CompositionManager {
public function __construct(
private readonly CompositionStore $compositionStore,
private readonly MailManager $mailManager,
) {}
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' => [],
];
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $snapshot);
if ($action === 'forward') {
$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'] = 'source';
$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;
}
// update the snapshot with the staged attachments
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $snapshot);
}
return $snapshot;
}
public function patch(string $tenantId, string $userId, string $identifier, array $data): array {
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return $result;
}
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 $result;
}
// Apply the patch data to the composed message
$composed['revision'] = $data['revision'];
$composed['sender'] = $data['sender'] ?? [];
$composed['message'] = $data['message'] ?? [];
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
$result = [
'identifier' => $identifier,
'disposition' => 'patched',
];
return $result;
}
public function discard(string $tenantId, string $userId, string $identifier): array {
return [
'identifier' => $identifier,
'disposition' => $this->compositionStore->compositionDiscard($tenantId, $userId, $identifier),
];
}
public function send(string $tenantId, string $userId, string $identifier, array $sender, array $message, array $attachments): array {
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return $result;
}
// store the message and sender information before sending
$composed['sender'] = $sender;
$composed['message'] = $message;
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
// 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",
],
];
}
$source = 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',
],
];
}
return [
'identifier' => $identifier,
'disposition' => 'sent'
];
}
private function buildMessageProperties(
ServiceEntitySubmitInterface $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;
}
/**
* @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;
}
public function attachmentAdd(string $tenantId, string $userId, string $composition, array ...$attachments): array {
$result = [
'composition' => $composition,
'attachments' => [],
];
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return $result;
}
foreach ($attachments as $attachment) {
if (!isset($attachment['identifier']) || !is_string($attachment['identifier']) || $attachment['identifier'] === '') {
$identifier = UUID::v4();
} else {
$identifier = $attachment['identifier'];
}
$meta = $this->compositionStore->attachmentStageFromBase64(
tenantId: $tenantId,
userId: $userId,
compositionId: $composition,
attachmentId: $identifier,
name: $attachment['name'] ?? 'unknown.bin',
type: $attachment['type'] ?? 'application/octet-stream',
data: $attachment['data'] ?? '',
);
$meta['origin'] = 'upload';
$composed['attachments'][$meta['identifier']] = $meta;
}
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
$result['disposition'] = 'added';
$result['attachments'] = $composed['attachments'];
return $result;
}
public function attachmentRemove(string $tenantId, string $userId, string $composition, string $identifier): array {
$result = [
'composition' => $composition,
'identifier' => $identifier,
];
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return $result;
}
if (!isset($composed['attachments'][$identifier])) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'attachment_not_found',
'message' => 'Attachment not found',
];
return $result;
}
unset($composed['attachments'][$identifier]);
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
$result['disposition'] = 'removed';
return $result;
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail\Controllers;
use InvalidArgumentException;
use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\Response;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
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 SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
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->tenantIdentity->identifier();
$userId = $this->userIdentity->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),
'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', 'forward'], true)) {
throw new InvalidArgumentException('Invalid parameter: action must be one of fresh, reply, 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'] === 'forward') && (!isset($data['source']) || !is_string($data['source']) || $data['source'] === '')) {
throw new InvalidArgumentException('Invalid parameter: source must be a non-empty string for reply 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 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']);
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail\Stores;
use DI\Attribute\Inject;
use KTXF\Resource\BinaryResource;
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 {
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
if (!is_dir($draftDir)) {
return null;
}
$messagePath = $draftDir . '/' . self::COMPOSITION_FILENAME;
if (!file_exists($messagePath)) {
return null;
}
$decoded = json_decode((string)file_get_contents($messagePath), true);
return is_array($decoded) ? $decoded : null;
}
public function compositionSave(string $tenantId, string $userId, string $draftId, array $snapshot): array {
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
if (!is_dir($draftDir)) {
mkdir($draftDir, 0755, true);
}
file_put_contents($draftDir . '/' . self::COMPOSITION_FILENAME, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return $snapshot;
}
public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool {
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
if (!is_dir($draftDir)) {
return false;
}
$this->deleteDir($draftDir);
return true;
}
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 attachmentStageFromBase64(
string $tenantId,
string $userId,
string $compositionId,
string $attachmentId,
string $name,
string $type,
string $data,
): array {
$decoded = base64_decode($data, true);
if ($decoded === false) {
throw new \InvalidArgumentException('Attachment payload is not valid base64');
}
$attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId);
if (!is_dir($attachmentDir)) {
mkdir($attachmentDir, 0755, true);
}
file_put_contents($attachmentDir . '/' . $attachmentId . '.blob', $decoded);
$metadata = [
'identifier' => $attachmentId,
'composition' => $compositionId,
'name' => $name,
'type' => $type,
'size' => strlen($decoded),
];
file_put_contents($attachmentDir . '/' . $attachmentId . '.meta', json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return $metadata;
}
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 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);
}
}
+2340 -671
View File
File diff suppressed because it is too large Load Diff
+12 -4
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",
@@ -22,14 +26,18 @@
"@tiptap/extension-underline": "^3.0.0", "@tiptap/extension-underline": "^3.0.0",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^5.0.0", "vue-router": "^5.0.0",
"pinia": "^3.0.0", "pinia": "^4.0.0",
"vuetify": "^4.0.0" "vuetify": "^4.0.0"
}, },
"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),
}) })
}) })
+101 -142
View File
@@ -8,16 +8,20 @@ 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 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 +30,55 @@ 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 applyingDraftToEditor = ref(false)
// 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 ?? {})
// Initialize Tiptap editor // Initialize Tiptap editor
const editor = useEditor({ const editor = useEditor({
@@ -70,53 +103,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 +110,13 @@ 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
applyingDraftToEditor.value = true
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
applyingDraftToEditor.value = false
}, },
{ immediate: true }, { immediate: true },
) )
@@ -134,98 +126,52 @@ 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,
body: {
html: editor.value?.getHTML() || '',
text: editor.value?.getText() || '',
},
})
} catch (error) {
console.error('[Mail][Composer] Failed to save draft:', error)
}
} }
// Watch for changes and trigger auto-save compositionStore.updateBody({
const scheduleAutoSave = () => { html: currentEditor.getHTML(),
if (autoSaveTimer) { text: currentEditor.getText(),
clearTimeout(autoSaveTimer)
}
autoSaveTimer = setTimeout(() => {
saveDraft()
}, 30000) // 30 seconds
}
watch([to, cc, bcc, subject], () => {
scheduleAutoSave()
}, { deep: true })
// Watch editor content changes
if (editor.value) {
editor.value.on('update', () => {
scheduleAutoSave()
}) })
} }
currentEditor.on('update', handleUpdate)
return () => {
currentEditor.off('update', handleUpdate)
}
})
// 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 = async () => {
mailStore.resetComposerState() await compositionStore.closeDraft()
emit('close') emit('close')
} }
const handleSend = async () => { const handleSend = async () => {
if (!canSend.value || sending.value) return await compositionStore.sendDraft()
try {
await mailStore.sendComposerMessage({
to: to.value,
cc: cc.value,
bcc: bcc.value,
subject: subject.value,
body: {
html: editor.value?.getHTML() || '',
text: editor.value?.getText() || '',
},
})
emit('sent')
} catch (error) {
console.error('[Mail][Composer] Failed to send message:', error)
} }
const handleAttach = async (files: File[]) => {
await compositionStore.addAttachments(files)
}
const handleDetach = async (identifier: string) => {
await compositionStore.removeAttachment(identifier)
} }
const toggleCc = () => { const toggleCc = () => {
@@ -249,11 +195,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,13 +203,16 @@ const toggleLink = () => {
setLink() setLink()
} }
const isActive = (name: string, attrs?: any) => {
return editor.value?.isActive(name, attrs) || false
}
</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"
@close="handleClose" @close="handleClose"
@@ -276,6 +220,11 @@ const toggleLink = () => {
/> />
<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,6 +242,14 @@ 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"
:is-bold-active="isActive('bold')" :is-bold-active="isActive('bold')"
@@ -307,6 +264,7 @@ const toggleLink = () => {
@bullet-list="toggleBulletList" @bullet-list="toggleBulletList"
@ordered-list="toggleOrderedList" @ordered-list="toggleOrderedList"
@link="toggleLink" @link="toggleLink"
@attach="handleAttach"
/> />
</div> </div>
</div> </div>
@@ -331,4 +289,5 @@ const toggleLink = () => {
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
</style> </style>
+264 -105
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 {
@@ -23,72 +27,43 @@ const props = withDefaults(defineProps<Props>(), {
// 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 +73,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 +131,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 +156,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 +166,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 +185,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 +231,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 +249,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 +282,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 +298,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 +311,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 +321,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,7 +371,7 @@ 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">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">
@@ -329,7 +383,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 +396,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 +413,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,23 +425,37 @@ 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="message-item-append">
<div class="d-flex flex-column align-center"> <div class="d-flex flex-column align-center">
<v-icon <v-icon
v-if="message.properties.isFlagged" v-if="message.properties.isFlagged"
@@ -404,11 +473,34 @@ const handleSelectAllToggle = (value: boolean | null) => {
mdi-paperclip mdi-paperclip
</v-icon> </v-icon>
</div> </div>
<MessageListItemMenu
:message="message"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
</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 +585,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 +628,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>
+75 -2
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 { 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'
@@ -25,11 +28,16 @@ const emit = defineEmits<{
// User settings // User settings
const { getSetting } = useUser() const { getSetting } = useUser()
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 +61,35 @@ const effectiveSecurityLevel = computed(() => {
return overrideSecurityLevel.value ?? securityLevel.value return overrideSecurityLevel.value ?? securityLevel.value
}) })
// Preview state
const previewDialog = computed(() => mediaViewerComponent('dialog'))
const previewItems = computed<MediaViewerItem[]>(() => {
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
@@ -94,6 +125,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 +142,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.attachmentBlob(props.entity, item.meta.attachment)
}
const handleViewerDownload = (item: MediaViewerItem) => {
if (props.entity) mailUiStore.downloadMessage(props.entity, item.meta.index)
}
</script> </script>
<template> <template>
@@ -125,13 +180,18 @@ const handleCompose = () => {
@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 +201,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>
+34 -3
View File
@@ -1,4 +1,5 @@
<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'
@@ -33,14 +34,32 @@ defineProps({
}, },
}) })
defineEmits<{ const emit = defineEmits<{
bold: [] bold: []
italic: [] italic: []
underline: [] underline: []
bulletList: [] bulletList: []
orderedList: [] orderedList: []
link: [] link: []
attach: [files: File[]]
}>() }>()
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('attach', files)
}
if (input) {
input.value = ''
}
}
</script> </script>
<template> <template>
@@ -111,7 +130,15 @@ defineEmits<{
<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>
@@ -120,7 +147,7 @@ defineEmits<{
<v-divider /> <v-divider />
<div class="editor-container"> <div class="editor-container">
<EditorContent :editor="editor" /> <EditorContent :editor="editor ?? undefined" />
</div> </div>
</template> </template>
@@ -136,6 +163,10 @@ 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));
+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,87 @@
<script setup lang="ts">
import type { ComposerSenderIdentity } from '@/types/composer';
import { computed } 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 singleOptionLabel = computed(() => {
const onlyOption = props.options[0]
return onlyOption ? formatSenderLabel(onlyOption.label, onlyOption.address) : ''
})
const errorMessages = computed(() => {
if (props.options.length === 0) {
return ['No send-capable account is available.']
}
return []
})
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 class="composer-sender px-4 pt-4 pb-0">
<v-text-field
v-if="options.length === 1"
:model-value="singleOptionLabel"
label="From"
variant="outlined"
density="compact"
readonly
class="mb-2"
/>
<v-select
v-else
: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>
+7 -5
View File
@@ -1,7 +1,9 @@
<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
} }
@@ -26,13 +28,13 @@ 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.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 <v-btn
+124 -19
View File
@@ -1,12 +1,69 @@
<script setup lang="ts"> <script setup lang="ts">
import { MessageObject } from '@MailManager/models/message' import { computed } from 'vue'
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 previewPopover = computed(() => mediaViewerComponent('popover'))
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 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.attachmentBlob(props.entity, item.meta.attachment)
}
// 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 +79,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 +94,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 +109,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 +137,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"
>
<v-chip
:prepend-icon="isPreviewable(attachment) ? 'mdi-eye-outline' : 'mdi-paperclip'"
size="small" size="small"
variant="outlined" variant="outlined"
class="attachment-chip" class="attachment-chip"
:title="isPreviewable(attachment) ? 'Preview' : 'Download'"
@click="handleAttachmentClick(attachment, index)"
> >
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span> <span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1"> <span v-if="attachment.size != null" class="text-caption text-medium-emphasis ml-1">
({{ formatFileSize(attachment.size) }}) ({{ formatFileSize(attachment.size) }})
</span> </span>
</v-chip> </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 +179,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 +195,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>
+23
View File
@@ -17,6 +17,7 @@ const emit = defineEmits<{
move: [] move: []
delete: [] delete: []
flag: [] flag: []
download: []
toggleImages: [] toggleImages: []
setSecurityLevel: [level: SecurityLevel] setSecurityLevel: [level: SecurityLevel]
}>() }>()
@@ -161,6 +162,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>
+1 -1
View File
@@ -48,7 +48,7 @@ const handleAccountSaved = async () => {
</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>
+169 -137
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 } 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,30 @@ 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) } = 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 +64,6 @@ const {
renameFolderDialogError, renameFolderDialogError,
moveFolderDialogVisible, moveFolderDialogVisible,
moveFolderDialogService, moveFolderDialogService,
moveFolderDialogSource,
deleteFolderDialogVisible, deleteFolderDialogVisible,
deleteFolderDialogService, deleteFolderDialogService,
deleteFolderDialogFolder, deleteFolderDialogFolder,
@@ -71,7 +72,22 @@ 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 (filters the current message list by subject/sender)
const searchQuery = ref('')
const filteredMessages = computed(() => {
const query = (searchQuery.value ?? '').trim().toLowerCase()
if (!query) return currentMessages.value
return currentMessages.value.filter((message: EntityObject) => {
const subject = (message.properties.subject || '').toLowerCase()
const fromLabel = (message.properties.from?.label || '').toLowerCase()
const fromAddress = (message.properties.from?.address || '').toLowerCase()
return subject.includes(query) || fromLabel.includes(query) || fromAddress.includes(query)
})
})
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,58 @@ 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 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 +227,38 @@ 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"
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 +286,62 @@ 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"
@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" @forward="handleMessageComposeForward"
@move="handleMove" @move="handleMessageMove"
@delete="handleDelete" @delete="handleMessageDelete"
/> />
</div> </div>
</div> </div>
@@ -336,19 +352,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 +396,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 +417,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 +432,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 +471,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 +504,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'
} }
}, },
] ]
+71
View File
@@ -0,0 +1,71 @@
import { createFetchWrapper } from '@KTXC'
import type { ApiRequest, ApiResponse } from '@MailManager/types/common'
import type {
CompositionAttachmentAddRequest,
CompositionAttachmentAddResponse,
CompositionAttachmentRemoveRequest,
CompositionAttachmentRemoveResponse,
CompositionDiscardRequest,
CompositionDiscardResponse,
CompositionPatchRequest,
CompositionPatchResponse,
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 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
+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
+563
View File
@@ -0,0 +1,563 @@
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,
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 { 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
}
activeDraft.value = buildDraft(mode, sender, source)
activeDraft.value.action = mode
stageDraft()
}
function closeDraft() {
return discardDraft()
}
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
}
const draftId = activeDraft.value.identifier
try {
await compositionService.discard({ identifier: draftId })
} catch (error) {
console.error('[Mail][Composer] Failed to discard staged draft:', error)
} finally {
activeDraft.value = null
}
}
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 addAttachments(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: 'upload',
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,
}
})
}
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]
}
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,
closeDraft,
sendDraft,
updateSender,
updateRecipients,
updateSubject,
updateBody,
addAttachments,
removeAttachment,
flushSave,
}
})
function buildDraft(
mode: ComposerMode,
sender: ComposerSenderIdentity,
source?: EntityObject | MessageAddressObject | null,
): ComposerDraft {
const composition = createIdentifier()
const freshMessage = buildMessage(mode, source)
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,
): 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) {
const replyToAddr = sourceMessage.replyTo?.[0] ?? sourceMessage.from ?? null
const replyToAddress = replyToAddr?.address || ''
return {
to: replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : [],
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 || !(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: 'source',
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,8 @@ 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 } from '@MailManager/types/common' import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } 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 +21,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({
@@ -78,20 +76,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 +95,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 +156,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 +171,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 +279,38 @@ 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) {
selectedFolder.value = folder selectedFolder.value = folder
selectedMessage.value = null selectedMessage.value = null
if (folder) {
try { try {
await entitiesStore.list([folder.identifier]) await entitiesStore.list([folder.identifier])
} catch (error) { } catch (error) {
console.error('[Mail] Failed to load messages:', error) console.error('[Mail][Operations] Failed to load messages:', error)
}
} }
_updateSyncSources() _updateSyncSources()
} }
function clearSelectedFolder() { function selectMessage(entity: EntityObject | null) {
selectedFolder.value = null
selectedMessage.value = null
_updateSyncSources()
}
function selectMessage(entity: EntityObject) {
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 +326,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 +382,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 +413,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 +428,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 +438,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 +445,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 +519,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 +527,52 @@ 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
} }
// ── Exports ─────────────────────────────────────────────────────────────── // ── Exports ───────────────────────────────────────────────────────────────
@@ -632,7 +586,6 @@ export const useMailStore = defineStore('mailStore', () => {
// State // State
loading, loading,
selectedFolder,
selectedMessage, selectedMessage,
composerSaving, composerSaving,
composerSending, composerSending,
@@ -648,23 +601,23 @@ export const useMailStore = defineStore('mailStore', () => {
// Actions // Actions
retrieveService, retrieveService,
selectFolder, selectFolder,
clearSelectedFolder,
selectMessage, selectMessage,
clearSelectedMessage,
createFolder, createFolder,
reloadSelectedFolder,
saveComposerDraft, saveComposerDraft,
sendComposerMessage, sendComposerMessage,
resetComposerState, resetComposerState,
flagMessages,
deleteMessages, deleteMessages,
deleteFolder, deleteFolder,
moveMessages, moveMessages,
downloadMessage,
attachmentBlob,
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,
}
})
+461 -204
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,112 @@ 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
} 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) { if (!result) {
const service = await mailStore.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 return null
} }
renameFolderDialogLoading.value = true notifyDeleteMessagesResult(result.successes.length, result.failures.length)
renameFolderDialogError.value = '' return result
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to delete messages'
notify(messageText, 'error')
throw error
}
}
async function flagMessages(
entityIdentifiers: EntityIdentifier[],
flags: Partial<NonNullable<MessageInterface['flags']>>,
options: { notify?: boolean } = {},
) {
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) {
} catch (error) { return null
deleteFolderDialogError.value = error instanceof Error ? error.message : 'Failed to delete folder. Please try again.'
throw error
} finally {
deleteFolderDialogLoading.value = false
} }
notifyMoveMessagesResult(result.successes.length, result.failures.length)
return result
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
notify(messageText, 'error')
throw error
}
}
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)
} }
return { return {
sidebarVisible, sidebarVisible,
settingsDialogVisible, settingsDialogVisible,
composeMode, selectedFolder,
composeSource, selectedMessage,
composeVisible, composerMode,
composerSource,
composerVisible,
selectionMode, selectionMode,
selectionList, selectionList,
moveMessagesDialogVisible, moveMessagesDialogVisible,
@@ -500,18 +748,26 @@ 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,
messageSelectionModeActivate,
messageSelectionModeDeactivate,
messageSelectionToggleOne,
messageSelectionToggleAll,
messageSelectionClear,
validateCreateFolderName, validateCreateFolderName,
validateRenameFolderName, validateRenameFolderName,
openMoveMessagesDialog, openMoveMessagesDialog,
@@ -530,5 +786,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;
} }
+48
View File
@@ -0,0 +1,48 @@
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',
Forward = 'forward',
}
export interface ComposerDraftAttachment {
identifier: string
composition: string
origin: 'source' | 'upload'
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
}
+119
View File
@@ -0,0 +1,119 @@
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: 'source' | 'upload'
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 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' | 'error'
error?: {
type: string
message: string
}
composition: string
attachments: Record<string, CompositionAttachmentInterface>
}
export interface CompositionAttachmentRemoveRequest {
composition: string
identifier: string
}
export interface CompositionAttachmentRemoveResponse {
disposition: 'removed' | 'error'
error?: {
type: string
message: string
}
composition: string
identifier: string
}
+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));
}
}
+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) => {