Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10527c0bb0 | |||
| 75d034f478 | |||
| 44fb30b74d | |||
| 671bad6f22 | |||
| 05e6171b31 | |||
| 415cd96450 | |||
| c9f720d46e | |||
| ac0d8de3fb | |||
| 3d0016bf3e | |||
| 121014cd20 | |||
| 3178b2e234 | |||
| e9ce62fc3f | |||
| f285c7b51e | |||
| 9826913c91 | |||
| 0242f00d40 | |||
| 946b20ce5b | |||
| b914e9b2df | |||
| f02d141be0 | |||
| f87503d139 | |||
| b43753665b | |||
| 2aa8e7b74c | |||
| 53d2d41cf1 | |||
| ab705e3612 | |||
| c63b516c2e | |||
| dff46de730 | |||
| f7210ff8b0 | |||
| f96e2b029d | |||
| acb8c14fb2 | |||
| 8b02392603 | |||
| 93a8157166 | |||
| 7d9bd8491c | |||
| 392881d56e | |||
| 63f38919f0 | |||
| db0d2bbc46 | |||
| 6112dff5d9 | |||
| e90d71e424 | |||
| d5d91ed1f5 | |||
| 7fb6f364ab | |||
| dd7bff6caa | |||
| 36f69fd72d | |||
| 4a3fd8abe5 | |||
| 0973291462 | |||
| 382091e3d1 | |||
| 88741802c2 | |||
| 5a523c61d4 | |||
| 7e1a7679c8 | |||
| b419af09eb | |||
| 949bbfa538 | |||
| 5257c39227 | |||
| 75ff60a04d | |||
| 12ee277d29 | |||
| 7f65539241 | |||
| 27816efb0c | |||
| 34dc636d13 | |||
| 0af38cf50c | |||
| 27cba6ecff | |||
| bbdb417997 | |||
| d8c4bcd29a | |||
| 6ff6304dd5 | |||
| 84d71bc1b7 | |||
| f06f936434 | |||
| f1cff5441a | |||
| 822e791768 | |||
| 9bf30507e1 | |||
| 63b0ad823f | |||
| 7736d26f49 | |||
| 307a2e3ef0 | |||
| f379e40de4 | |||
| 19464beba1 | |||
| 6e7592d39d | |||
| 37d723cbdb | |||
| 461cd5dd92 | |||
| 805863f084 | |||
| 7a3d90d0cd | |||
| d24e6110b9 | |||
| ec776bbb78 | |||
| 4f9a6cc5bd | |||
| 09da7b4770 | |||
| 41c6c82546 | |||
| 967dadf864 | |||
| c1c334fe22 | |||
| 5a355c1536 | |||
| 3a3cf77da1 | |||
| 800d6bce83 | |||
| 6745361a5c | |||
| 9a98042fa0 | |||
| 42c24642ce | |||
| b66ebbd078 | |||
| 4b9a3b7945 | |||
| cdff4d0d3f | |||
| bafb294e1a | |||
| f1d0511cbb | |||
| 897a03578e | |||
| 4367fcfe9a | |||
| b7fcc0a368 | |||
| 0db23271e5 | |||
| 3f4aeb99c7 | |||
| 7e544d16f4 | |||
| 8ba40a971e | |||
| c3c41f7ba0 | |||
| 5a58c3c7ac | |||
| 7853a21288 | |||
| f1823a246c | |||
| 820c2c812f | |||
| 6e76bec190 | |||
| c304ab6b6e | |||
| 12e0c7b428 | |||
| 7c366fdd96 | |||
| 55e18bf35e | |||
| 1d62412407 | |||
| d76166addd | |||
| 99766ddb79 | |||
| ad76488705 | |||
| 8c9b746a1c | |||
| 55c87096b9 | |||
| c69763e57d | |||
| 232f588225 | |||
| b1cdaea6af | |||
| fe9f4d9656 | |||
| d069fb36a7 | |||
| 5fef85aa85 | |||
| 46632d2454 | |||
| f007d5a514 | |||
| a5f33e5f7b | |||
| 603c0caf17 | |||
| 49a067e4ff | |||
| 85a7f23889 | |||
| 304a24e838 | |||
| d7e78ab088 | |||
| 9ac2b38653 | |||
| fd4e495c84 | |||
| 969c31b1fd | |||
| 0b26dcf0a2 | |||
| a6f3d56791 | |||
| ff64f30503 | |||
| d7dec71047 | |||
| ef645969f0 | |||
| 297b5082ec | |||
| 80a7eeafb7 | |||
| f1f4e135bb | |||
| acd28954ff | |||
| 477562a455 | |||
| 47b3fbc9da | |||
| 7f63c3383f | |||
| ab0a46f5e0 | |||
| ea2947b6ce | |||
| db848a8f15 | |||
| 96002b6187 | |||
| bfae65c594 | |||
| 1d62813697 | |||
| 31a9ab419c | |||
| 509fbc2480 | |||
| c4173a040d | |||
| b24b751e4a | |||
| 36321a7584 | |||
| ec673984f7 | |||
| 11de31397d | |||
| 4ff377c6ac | |||
| 69f3c430cc |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v6.2.0
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Set up PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.5'
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
|
- name: Install 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
|
||||||
|
env:
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
RENOVATE_PLATFORM: gitea
|
||||||
|
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
|
||||||
|
run: |
|
||||||
|
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
|
||||||
+2
-4
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3236
-1362
File diff suppressed because it is too large
Load Diff
+23
-15
@@ -11,25 +11,33 @@
|
|||||||
"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": "^2.1.13",
|
"@tiptap/vue-3": "^3.0.0",
|
||||||
"@tiptap/starter-kit": "^2.1.13",
|
"@tiptap/starter-kit": "^3.0.0",
|
||||||
"@tiptap/extension-link": "^2.1.13",
|
"@tiptap/extension-link": "^3.0.0",
|
||||||
"@tiptap/extension-placeholder": "^2.1.13",
|
"@tiptap/extension-placeholder": "^3.0.0",
|
||||||
"@tiptap/extension-text-align": "^2.1.13",
|
"@tiptap/extension-text-align": "^3.0.0",
|
||||||
"@tiptap/extension-underline": "^2.1.13",
|
"@tiptap/extension-underline": "^3.0.0",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^5.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^4.0.0",
|
||||||
"vuetify": "^3.5.0"
|
"vuetify": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
"@vue/tsconfig": "^0.5.0",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "~6.0.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^8.0.0",
|
||||||
"vue-tsc": "^1.8.27"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -1,3 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"enabledManagers": ["npm", "composer", "github-actions"],
|
||||||
|
"timezone": "UTC",
|
||||||
|
"schedule": ["* 0-3 * * *"],
|
||||||
|
"dependencyDashboard": true,
|
||||||
|
"prConcurrentLimit": 5,
|
||||||
|
"prHourlyLimit": 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
|
||||||
import { CollectionPropertiesObject } from '@MailManager/models/collection'
|
|
||||||
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'
|
||||||
|
|
||||||
@@ -9,28 +7,27 @@ import type { ServiceObject } from '@MailManager/models'
|
|||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
service: ServiceObject
|
service: ServiceObject
|
||||||
parentFolder?: CollectionObject | null
|
parentFolderLabel?: string
|
||||||
allFolders?: CollectionObject[]
|
validateName?: (name: string) => string[]
|
||||||
|
loading?: boolean
|
||||||
|
errorMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
parentFolder: null,
|
parentFolderLabel: 'Root',
|
||||||
allFolders: () => []
|
validateName: () => [],
|
||||||
|
loading: false,
|
||||||
|
errorMessage: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
'created': [folder: CollectionObject]
|
confirm: [folderName: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Store
|
|
||||||
const collectionsStore = useCollectionsStore()
|
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const folderName = ref('')
|
const folderName = ref('')
|
||||||
const loading = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const validationErrors = ref<string[]>([])
|
const validationErrors = ref<string[]>([])
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
@@ -39,67 +36,13 @@ const dialogValue = computed({
|
|||||||
set: (value: boolean) => emit('update:modelValue', value)
|
set: (value: boolean) => emit('update:modelValue', value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const parentFolderLabel = computed(() => {
|
|
||||||
if (!props.parentFolder) return 'Root'
|
|
||||||
return props.parentFolder.properties.label
|
|
||||||
})
|
|
||||||
|
|
||||||
const isValid = computed(() => {
|
const isValid = computed(() => {
|
||||||
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
|
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validation functions
|
|
||||||
const validateFolderName = (name: string): string[] => {
|
|
||||||
const errors: string[] = []
|
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
|
||||||
errors.push('Folder name is required')
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name.length > 255) {
|
|
||||||
errors.push('Folder name too long (max 255 characters)')
|
|
||||||
}
|
|
||||||
|
|
||||||
// No special characters that might cause issues
|
|
||||||
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
|
|
||||||
errors.push('Folder name contains invalid characters')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider-specific rules
|
|
||||||
if (props.service.provider === 'imap' && /[\/\\]/.test(name)) {
|
|
||||||
errors.push('IMAP folder names cannot contain / or \\')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leading/trailing spaces
|
|
||||||
if (name !== name.trim()) {
|
|
||||||
errors.push('Folder name cannot have leading or trailing spaces')
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkDuplicateName = (name: string): boolean => {
|
|
||||||
const parentId = props.parentFolder?.identifier ?? null
|
|
||||||
|
|
||||||
return props.allFolders.some(f =>
|
|
||||||
f.properties.label === name &&
|
|
||||||
String(f.collection) === String(parentId) &&
|
|
||||||
String(f.service) === String(props.service.identifier)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch folder name for validation
|
// Watch folder name for validation
|
||||||
watch(folderName, (newName) => {
|
watch(folderName, (newName) => {
|
||||||
errorMessage.value = ''
|
validationErrors.value = props.validateName(newName)
|
||||||
validationErrors.value = validateFolderName(newName)
|
|
||||||
|
|
||||||
// Check for duplicates only if no other validation errors
|
|
||||||
if (validationErrors.value.length === 0 && newName.trim().length > 0) {
|
|
||||||
if (checkDuplicateName(newName)) {
|
|
||||||
validationErrors.value.push('A folder with this name already exists in this location')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reset form when dialog opens/closes
|
// Reset form when dialog opens/closes
|
||||||
@@ -111,59 +54,25 @@ watch(dialogValue, (isOpen) => {
|
|||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
folderName.value = ''
|
folderName.value = ''
|
||||||
errorMessage.value = ''
|
|
||||||
validationErrors.value = []
|
validationErrors.value = []
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
// Final validation
|
const errors = props.validateName(folderName.value)
|
||||||
const errors = validateFolderName(folderName.value)
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
validationErrors.value = errors
|
validationErrors.value = errors
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkDuplicateName(folderName.value)) {
|
emit('confirm', folderName.value.trim())
|
||||||
validationErrors.value = ['A folder with this name already exists in this location']
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create properties object
|
|
||||||
const properties = new CollectionPropertiesObject()
|
|
||||||
properties.label = folderName.value.trim()
|
|
||||||
properties.rank = 0
|
|
||||||
properties.subscribed = true
|
|
||||||
|
|
||||||
// Create the collection
|
|
||||||
const newFolder = await collectionsStore.create(
|
|
||||||
props.service.provider,
|
|
||||||
props.service.identifier as string | number,
|
|
||||||
props.parentFolder?.identifier ?? null,
|
|
||||||
properties
|
|
||||||
)
|
|
||||||
|
|
||||||
// Success!
|
|
||||||
emit('created', newFolder)
|
|
||||||
dialogValue.value = false
|
|
||||||
resetForm()
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[CreateFolderDialog] Failed to create folder:', error)
|
|
||||||
errorMessage.value = error.message || 'Failed to create folder. Please try again.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
dialogValue.value = false
|
dialogValue.value = false
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -182,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>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
|
import type { CollectionObject } from '@MailManager/models/collection'
|
||||||
|
import type { ServiceObject } from '@MailManager/models'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean
|
||||||
|
service: ServiceObject
|
||||||
|
folder: CollectionObject
|
||||||
|
loading?: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false,
|
||||||
|
errorMessage: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
confirm: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const collectionsStore = useCollectionsStore()
|
||||||
|
|
||||||
|
const dialogValue = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const folderLabel = computed(() => props.folder.properties.label || String(props.folder.identifier))
|
||||||
|
|
||||||
|
const hasChildren = computed(() => {
|
||||||
|
return collectionsStore.hasChildrenInCollection(props.folder.provider, props.folder.service, props.folder.identifier)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(dialogValue, isOpen => {
|
||||||
|
if (isOpen) {
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
emit('confirm')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
dialogValue.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="dialogValue"
|
||||||
|
max-width="520"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h5">
|
||||||
|
Delete Folder
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="text-caption text-medium-emphasis">Account</div>
|
||||||
|
<div class="text-body-2">
|
||||||
|
{{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="text-caption text-medium-emphasis">Folder</div>
|
||||||
|
<div class="text-body-2">
|
||||||
|
{{ folderLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-body-2 mb-0">
|
||||||
|
Confirm deletion of this folder.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="hasChildren"
|
||||||
|
class="text-body-2 mt-3 mb-0"
|
||||||
|
>
|
||||||
|
This folder contains subfolders. The delete request may be rejected if the provider does not allow deleting non-empty folders.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
variant="elevated"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleDelete"
|
||||||
|
>
|
||||||
|
Delete Folder
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-caption {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.0333em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22,7 +22,9 @@ const collectionsStore = useCollectionsStore()
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [folder: CollectionObject]
|
select: [folder: CollectionObject]
|
||||||
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
|
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
|
||||||
editFolder: [service: ServiceObject, folder: CollectionObject]
|
editFolder: [folder: CollectionObject]
|
||||||
|
moveFolder: [folder: CollectionObject]
|
||||||
|
deleteFolder: [folder: CollectionObject]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Page-based navigation state per service account
|
// Page-based navigation state per service account
|
||||||
@@ -42,6 +44,10 @@ const getCurrentPageLevel = (service: ServiceObject): (string | number | null)[]
|
|||||||
|
|
||||||
// Get folders for current page level
|
// Get folders for current page level
|
||||||
const getCurrentPageFolders = (service: ServiceObject): CollectionObject[] => {
|
const getCurrentPageFolders = (service: ServiceObject): CollectionObject[] => {
|
||||||
|
if (service.identifier === null) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const level = getCurrentPageLevel(service)
|
const level = getCurrentPageLevel(service)
|
||||||
const currentParent = level[level.length - 1]
|
const currentParent = level[level.length - 1]
|
||||||
return collectionsStore.collectionsInCollection(service.provider, service.identifier, currentParent)
|
return collectionsStore.collectionsInCollection(service.provider, service.identifier, currentParent)
|
||||||
@@ -53,9 +59,17 @@ const hasChildren = (folder: CollectionObject): boolean => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
||||||
|
if (service.identifier === null) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
return collectionsStore.collectionsForService(service.provider, service.identifier)
|
return collectionsStore.collectionsForService(service.provider, service.identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canDeleteFolder = (folder: CollectionObject): boolean => {
|
||||||
|
return !folder.properties.role
|
||||||
|
}
|
||||||
|
|
||||||
// Get icon for folder based on role
|
// Get icon for folder based on role
|
||||||
const getFolderIcon = (folder: CollectionObject): string => {
|
const getFolderIcon = (folder: CollectionObject): string => {
|
||||||
switch (folder.properties.role) {
|
switch (folder.properties.role) {
|
||||||
@@ -127,6 +141,10 @@ const navigateBack = (service: ServiceObject) => {
|
|||||||
|
|
||||||
// Get breadcrumb label for current page
|
// Get breadcrumb label for current page
|
||||||
const getCurrentBreadcrumb = (service: ServiceObject): string => {
|
const getCurrentBreadcrumb = (service: ServiceObject): string => {
|
||||||
|
if (service.identifier === null) {
|
||||||
|
return 'Folders'
|
||||||
|
}
|
||||||
|
|
||||||
const level = getCurrentPageLevel(service)
|
const level = getCurrentPageLevel(service)
|
||||||
const currentParent = level[level.length - 1]
|
const currentParent = level[level.length - 1]
|
||||||
if (currentParent === null) return 'All Folders'
|
if (currentParent === null) return 'All Folders'
|
||||||
@@ -143,6 +161,10 @@ const getCurrentBreadcrumb = (service: ServiceObject): string => {
|
|||||||
|
|
||||||
// Get current parent folder for dialog context
|
// Get current parent folder for dialog context
|
||||||
const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null => {
|
const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null => {
|
||||||
|
if (service.identifier === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const level = getCurrentPageLevel(service)
|
const level = getCurrentPageLevel(service)
|
||||||
const currentParent = level[level.length - 1]
|
const currentParent = level[level.length - 1]
|
||||||
if (currentParent === null) return null
|
if (currentParent === null) return null
|
||||||
@@ -165,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" />
|
||||||
@@ -261,7 +283,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-pencil"
|
prepend-icon="mdi-pencil"
|
||||||
@click="emit('editFolder', group.service, folder)"
|
@click="emit('editFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -271,6 +293,20 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
>
|
>
|
||||||
<v-list-item-title>New Subfolder</v-list-item-title>
|
<v-list-item-title>New Subfolder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-folder-move"
|
||||||
|
@click="emit('moveFolder', folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Move Folder</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
v-if="canDeleteFolder(folder)"
|
||||||
|
prepend-icon="mdi-delete"
|
||||||
|
base-color="error"
|
||||||
|
@click="emit('deleteFolder', folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</template>
|
</template>
|
||||||
@@ -312,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" />
|
||||||
@@ -410,7 +446,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-pencil"
|
prepend-icon="mdi-pencil"
|
||||||
@click="emit('editFolder', group.service, folder)"
|
@click="emit('editFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -420,6 +456,20 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
>
|
>
|
||||||
<v-list-item-title>New Subfolder</v-list-item-title>
|
<v-list-item-title>New Subfolder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-folder-move"
|
||||||
|
@click="emit('moveFolder', folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Move Folder</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
v-if="canDeleteFolder(folder)"
|
||||||
|
prepend-icon="mdi-delete"
|
||||||
|
base-color="error"
|
||||||
|
@click="emit('deleteFolder', folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
||||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
|
||||||
import { useMailStore } from '@/stores/mailStore'
|
|
||||||
import type { ServiceObject, CollectionObject } from '@MailManager/models'
|
import type { ServiceObject, CollectionObject } from '@MailManager/models'
|
||||||
import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue'
|
import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue'
|
||||||
|
|
||||||
@@ -12,6 +11,8 @@ interface Props {
|
|||||||
title?: string
|
title?: string
|
||||||
confirmText?: string
|
confirmText?: string
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
|
service?: ServiceObject | null
|
||||||
|
disabledFolderKeys?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -19,17 +20,18 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
title: 'Move To',
|
title: 'Move To',
|
||||||
confirmText: 'Move',
|
confirmText: 'Move',
|
||||||
emptyText: 'No folders are available.',
|
emptyText: 'No folders are available.',
|
||||||
|
service: null,
|
||||||
|
disabledFolderKeys: () => [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
select: [folder: CollectionObject]
|
select: [target: CollectionObject]
|
||||||
cancel: []
|
cancel: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const mailOperationsStore = useMailOperationsStore()
|
||||||
const collectionsStore = useCollectionsStore()
|
const collectionsStore = useCollectionsStore()
|
||||||
const servicesStore = useServicesStore()
|
|
||||||
const mailStore = useMailStore()
|
|
||||||
|
|
||||||
const selectedFolderKey = ref<string | null>(null)
|
const selectedFolderKey = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -38,10 +40,6 @@ const dialogValue = computed({
|
|||||||
set: (value: boolean) => emit('update:modelValue', value),
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
})
|
})
|
||||||
|
|
||||||
const folderKeyFor = (folder: CollectionObject): string => {
|
|
||||||
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceGroup {
|
interface ServiceGroup {
|
||||||
service: ServiceObject
|
service: ServiceObject
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -50,17 +48,7 @@ interface ServiceGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serviceGroups = computed<ServiceGroup[]>(() => {
|
const serviceGroups = computed<ServiceGroup[]>(() => {
|
||||||
const moveCandidate = mailStore.moveMessageCandidate
|
const service = props.service
|
||||||
|
|
||||||
if (!moveCandidate) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const service = servicesStore.services.find(entry =>
|
|
||||||
entry.provider === moveCandidate.provider &&
|
|
||||||
String(entry.identifier) === String(moveCandidate.service),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!service) {
|
if (!service) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -71,12 +59,40 @@ 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),
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedFolder = computed(() => {
|
||||||
|
if (!selectedFolderKey.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = serviceGroups.value[0]
|
||||||
|
if (!group) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return getServiceFolders(group.service).find(folder => folder.identifier === selectedFolderKey.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const canConfirm = computed(() => {
|
||||||
|
return selectedFolder.value !== null && !props.loading
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFolderKey.value = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
|
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
|
||||||
if (service.identifier === null) {
|
if (service.identifier === null) {
|
||||||
return []
|
return []
|
||||||
@@ -93,36 +109,8 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
|||||||
return collectionsStore.collectionsForService(service.provider, service.identifier)
|
return collectionsStore.collectionsForService(service.provider, service.identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedFolder = computed(() => {
|
|
||||||
if (!selectedFolderKey.value) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const group = serviceGroups.value[0]
|
|
||||||
if (!group) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return getServiceFolders(group.service).find(folder => folderKeyFor(folder) === selectedFolderKey.value) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const canConfirm = computed(() => {
|
|
||||||
return selectedFolder.value !== null && !props.loading
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [props.modelValue, mailStore.moveMessageCandidate],
|
|
||||||
([isOpen]) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedFolderKey.value = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSelect = (folder: CollectionObject) => {
|
const handleSelect = (folder: CollectionObject) => {
|
||||||
selectedFolderKey.value = folderKeyFor(folder)
|
selectedFolderKey.value = folder.identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
@@ -163,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" />
|
||||||
@@ -172,10 +160,11 @@ const handleConfirm = () => {
|
|||||||
|
|
||||||
<FolderSelectionTreeNode
|
<FolderSelectionTreeNode
|
||||||
v-for="folder in getRootFolders(group.service)"
|
v-for="folder in getRootFolders(group.service)"
|
||||||
:key="folderKeyFor(folder)"
|
:key="folder.identifier"
|
||||||
:folder="folder"
|
:folder="folder"
|
||||||
:service="group.service"
|
:service="group.service"
|
||||||
:selected-folder-key="selectedFolderKey"
|
:selected-folder-key="selectedFolderKey"
|
||||||
|
:disabled-folder-keys="disabledFolderKeys"
|
||||||
@select="handleSelect"
|
@select="handleSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { computed, ref } from 'vue'
|
|||||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
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'
|
||||||
|
import type { CollectionIdentifier } from '@MailManager/types/common'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
folder: CollectionObject
|
folder: CollectionObject
|
||||||
service: ServiceObject
|
service: ServiceObject
|
||||||
selectedFolderKey: string | null
|
selectedFolderKey: string | null
|
||||||
|
disabledFolderKeys?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -19,9 +21,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
|
|
||||||
const folderKeyFor = (folder: CollectionObject): string => {
|
const folderKeyFor = (folder: CollectionObject): string => String(folder.identifier)
|
||||||
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderLabelFor = (folder: CollectionObject): string => {
|
const folderLabelFor = (folder: CollectionObject): string => {
|
||||||
return folder.properties.label || String(folder.identifier)
|
return folder.properties.label || String(folder.identifier)
|
||||||
@@ -66,6 +66,7 @@ const folderColorFor = (folder: CollectionObject): string | undefined => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const key = computed(() => folderKeyFor(props.folder))
|
const key = computed(() => folderKeyFor(props.folder))
|
||||||
|
const isDisabled = computed(() => (props.disabledFolderKeys ?? []).includes(key.value))
|
||||||
|
|
||||||
const childFolders = computed(() => {
|
const childFolders = computed(() => {
|
||||||
const serviceIdentifier = props.service.identifier
|
const serviceIdentifier = props.service.identifier
|
||||||
@@ -74,7 +75,11 @@ const childFolders = computed(() => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return collectionsStore.collectionsInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
|
return collectionsStore.collectionsInCollection(
|
||||||
|
props.service.provider,
|
||||||
|
serviceIdentifier,
|
||||||
|
props.folder.identifier as CollectionIdentifier,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasChildren = computed(() => {
|
const hasChildren = computed(() => {
|
||||||
@@ -84,11 +89,15 @@ const hasChildren = computed(() => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
|
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier as CollectionIdentifier)
|
||||||
})
|
})
|
||||||
const isSelected = computed(() => props.selectedFolderKey === key.value)
|
const isSelected = computed(() => props.selectedFolderKey === key.value)
|
||||||
|
|
||||||
const onSelect = () => {
|
const onSelect = () => {
|
||||||
|
if (isDisabled.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
emit('select', props.folder)
|
emit('select', props.folder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +120,7 @@ const onGroupDoubleClick = () => {
|
|||||||
class="folder-node"
|
class="folder-node"
|
||||||
:title="folderLabelFor(folder)"
|
:title="folderLabelFor(folder)"
|
||||||
:active="isSelected"
|
:active="isSelected"
|
||||||
|
:disabled="isDisabled"
|
||||||
@click="onSelect"
|
@click="onSelect"
|
||||||
@dblclick.stop="onGroupDoubleClick"
|
@dblclick.stop="onGroupDoubleClick"
|
||||||
>
|
>
|
||||||
@@ -152,6 +162,7 @@ const onGroupDoubleClick = () => {
|
|||||||
class="folder-node"
|
class="folder-node"
|
||||||
:title="folderLabelFor(folder)"
|
:title="folderLabelFor(folder)"
|
||||||
:active="isSelected"
|
:active="isSelected"
|
||||||
|
:disabled="isDisabled"
|
||||||
@click="onSelect"
|
@click="onSelect"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
|
||||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
|
||||||
import { useMailStore } from '@/stores/mailStore'
|
|
||||||
import { useUser } from '@KTXC'
|
|
||||||
import FolderTreeView from './FolderTreeView.vue'
|
|
||||||
import FolderPageView from './FolderPageView.vue'
|
|
||||||
import CreateFolderDialog from './CreateFolderDialog.vue'
|
|
||||||
import RenameFolderDialog from './RenameFolderDialog.vue'
|
|
||||||
import type { CollectionObject } from '@MailManager/models/collection'
|
|
||||||
import type { ServiceObject } from '@MailManager/models'
|
|
||||||
|
|
||||||
type FolderViewMode = 'tree' | 'page'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
selectedFolder?: CollectionObject | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Emits
|
|
||||||
const emit = defineEmits<{
|
|
||||||
select: [folder: CollectionObject]
|
|
||||||
folderCreated: [folder: CollectionObject]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Stores
|
|
||||||
const collectionsStore = useCollectionsStore()
|
|
||||||
const servicesStore = useServicesStore()
|
|
||||||
const mailStore = useMailStore()
|
|
||||||
|
|
||||||
// User settings
|
|
||||||
const { settings } = useUser()
|
|
||||||
|
|
||||||
// Folder view mode from user settings
|
|
||||||
const folderViewMode = computed(() => {
|
|
||||||
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create folder dialog state
|
|
||||||
const createDialogVisible = ref(false)
|
|
||||||
const createDialogService = ref<ServiceObject | null>(null)
|
|
||||||
const createDialogParent = ref<CollectionObject | null>(null)
|
|
||||||
const renameDialogVisible = ref(false)
|
|
||||||
const renameDialogService = ref<ServiceObject | null>(null)
|
|
||||||
const renameDialogFolder = ref<CollectionObject | null>(null)
|
|
||||||
|
|
||||||
// Handle create folder event from child components
|
|
||||||
const handleCreateFolder = (service: ServiceObject, parentFolder: CollectionObject | null = null) => {
|
|
||||||
createDialogService.value = service
|
|
||||||
createDialogParent.value = parentFolder
|
|
||||||
createDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle folder created
|
|
||||||
const handleFolderCreated = (newFolder: CollectionObject) => {
|
|
||||||
emit('folderCreated', newFolder)
|
|
||||||
emit('select', newFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditFolder = (service: ServiceObject, folder: CollectionObject) => {
|
|
||||||
renameDialogService.value = service
|
|
||||||
renameDialogFolder.value = folder
|
|
||||||
renameDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFolderRenamed = (updatedFolder: CollectionObject) => {
|
|
||||||
emit('select', updatedFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Computed: all folders for validation
|
|
||||||
const allFolders = computed(() =>
|
|
||||||
servicesStore.services.flatMap(service =>
|
|
||||||
collectionsStore.collectionsForService(service.provider, service.identifier),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
interface ServiceGroup {
|
|
||||||
service: ServiceObject
|
|
||||||
loading: boolean
|
|
||||||
loaded: boolean
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
const serviceGroups = computed(() => {
|
|
||||||
const groups: ServiceGroup[] = []
|
|
||||||
|
|
||||||
servicesStore.services.forEach(service => {
|
|
||||||
groups.push({
|
|
||||||
service,
|
|
||||||
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
|
|
||||||
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
|
|
||||||
error: mailStore.getServiceFolderError(service.provider, service.identifier),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return groups
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-list density="compact" nav>
|
|
||||||
<!-- Tree View -->
|
|
||||||
<FolderTreeView
|
|
||||||
v-if="folderViewMode === 'tree'"
|
|
||||||
:selected-folder="selectedFolder"
|
|
||||||
:service-groups="serviceGroups"
|
|
||||||
@select="emit('select', $event)"
|
|
||||||
@create-folder="handleCreateFolder"
|
|
||||||
@edit-folder="handleEditFolder"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Page-based View -->
|
|
||||||
<FolderPageView
|
|
||||||
v-else
|
|
||||||
:selected-folder="selectedFolder"
|
|
||||||
:service-groups="serviceGroups"
|
|
||||||
@select="emit('select', $event)"
|
|
||||||
@create-folder="handleCreateFolder"
|
|
||||||
@edit-folder="handleEditFolder"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<v-list-item v-if="servicesStore.services.length === 0">
|
|
||||||
<v-list-item-title class="text-center text-medium-emphasis">
|
|
||||||
No mail accounts configured
|
|
||||||
</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
|
|
||||||
<!-- Create Folder Dialog -->
|
|
||||||
<CreateFolderDialog
|
|
||||||
v-if="createDialogService"
|
|
||||||
v-model="createDialogVisible"
|
|
||||||
:service="createDialogService"
|
|
||||||
:parent-folder="createDialogParent"
|
|
||||||
:all-folders="allFolders"
|
|
||||||
@created="handleFolderCreated"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RenameFolderDialog
|
|
||||||
v-if="renameDialogService && renameDialogFolder"
|
|
||||||
v-model="renameDialogVisible"
|
|
||||||
:service="renameDialogService"
|
|
||||||
:folder="renameDialogFolder"
|
|
||||||
:all-folders="allFolders"
|
|
||||||
@updated="handleFolderRenamed"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.v-list-item--active {
|
|
||||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -17,17 +17,21 @@ const expanded = ref(false)
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [folder: CollectionObject]
|
select: [folder: CollectionObject]
|
||||||
createSubfolder: [service: ServiceObject, parentFolder: CollectionObject]
|
createSubfolder: [service: ServiceObject, parentFolder: CollectionObject]
|
||||||
editFolder: [service: ServiceObject, folder: CollectionObject]
|
editFolder: [folder: CollectionObject]
|
||||||
|
moveFolder: [folder: CollectionObject]
|
||||||
|
deleteFolder: [folder: CollectionObject]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const childFolders = computed(() => {
|
const childFolders = computed(() => {
|
||||||
return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier, props.folder.identifier)
|
return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasChildren = computed(() => {
|
const hasChildren = computed(() => {
|
||||||
return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier, props.folder.identifier)
|
return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const canDeleteFolder = computed(() => !props.folder.properties.role)
|
||||||
|
|
||||||
const handleExpandableFolderClick = () => {
|
const handleExpandableFolderClick = () => {
|
||||||
expanded.value = !expanded.value
|
expanded.value = !expanded.value
|
||||||
emit('select', props.folder)
|
emit('select', props.folder)
|
||||||
@@ -110,7 +114,6 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
class="mr-2"
|
class="mr-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Action menu -->
|
|
||||||
<v-menu>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props: menuProps }">
|
<template v-slot:activator="{ props: menuProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -128,7 +131,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-pencil"
|
prepend-icon="mdi-pencil"
|
||||||
@click="emit('editFolder', service, folder)"
|
@click="emit('editFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -138,6 +141,20 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
>
|
>
|
||||||
<v-list-item-title>New Subfolder</v-list-item-title>
|
<v-list-item-title>New Subfolder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-folder-move"
|
||||||
|
@click="emit('moveFolder', folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Move Folder</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
v-if="canDeleteFolder"
|
||||||
|
prepend-icon="mdi-delete"
|
||||||
|
base-color="error"
|
||||||
|
@click="emit('deleteFolder', folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</template>
|
</template>
|
||||||
@@ -153,7 +170,9 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
:selected-folder="selectedFolder"
|
:selected-folder="selectedFolder"
|
||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
@create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)"
|
@create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)"
|
||||||
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
|
@edit-folder="(folder) => emit('editFolder', folder)"
|
||||||
|
@move-folder="(folder) => emit('moveFolder', folder)"
|
||||||
|
@delete-folder="(folder) => emit('deleteFolder', folder)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</v-list-group>
|
</v-list-group>
|
||||||
@@ -181,7 +200,6 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
class="mr-2"
|
class="mr-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Action menu -->
|
|
||||||
<v-menu>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props: menuProps }">
|
<template v-slot:activator="{ props: menuProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -199,7 +217,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-pencil"
|
prepend-icon="mdi-pencil"
|
||||||
@click="emit('editFolder', service, folder)"
|
@click="emit('editFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -209,6 +227,20 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
>
|
>
|
||||||
<v-list-item-title>New Subfolder</v-list-item-title>
|
<v-list-item-title>New Subfolder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-folder-move"
|
||||||
|
@click="emit('moveFolder', folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Move Folder</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
v-if="canDeleteFolder"
|
||||||
|
prepend-icon="mdi-delete"
|
||||||
|
base-color="error"
|
||||||
|
@click="emit('deleteFolder', folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ interface Props {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
defineProps<Props>()
|
||||||
const collectionsStore = useCollectionsStore()
|
const collectionsStore = useCollectionsStore()
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [folder: CollectionObject]
|
select: [folder: CollectionObject]
|
||||||
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
|
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
|
||||||
editFolder: [service: ServiceObject, folder: CollectionObject]
|
editFolder: [folder: CollectionObject]
|
||||||
|
moveFolder: [folder: CollectionObject]
|
||||||
|
deleteFolder: [folder: CollectionObject]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
|
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
|
||||||
@@ -44,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" />
|
||||||
@@ -73,7 +75,9 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
|||||||
:selected-folder="selectedFolder"
|
:selected-folder="selectedFolder"
|
||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
|
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
|
||||||
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
|
@edit-folder="(folder) => emit('editFolder', folder)"
|
||||||
|
@move-folder="(folder) => emit('moveFolder', folder)"
|
||||||
|
@delete-folder="(folder) => emit('deleteFolder', folder)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item">
|
<v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item">
|
||||||
@@ -112,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" />
|
||||||
@@ -155,6 +159,8 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
|||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
|
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
|
||||||
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
|
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
|
||||||
|
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
|
||||||
|
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item">
|
<v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item">
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||||
|
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
||||||
|
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
|
||||||
|
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||||
|
import FolderTreeView from './FolderTreeView.vue'
|
||||||
|
import FolderPageView from './FolderPageView.vue'
|
||||||
|
import type { CollectionObject } from '@MailManager/models/collection'
|
||||||
|
import type { ServiceObject } from '@MailManager/models'
|
||||||
|
|
||||||
|
interface ServiceGroup {
|
||||||
|
service: ServiceObject
|
||||||
|
loading: boolean
|
||||||
|
loaded: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedFolder?: CollectionObject | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [folder: CollectionObject]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Stores
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
const mailOperationsStore = useMailOperationsStore()
|
||||||
|
const mailUiStore = useMailUiStore()
|
||||||
|
const mailSettingsStore = useMailSettingsStore()
|
||||||
|
const { folderViewMode } = storeToRefs(mailSettingsStore)
|
||||||
|
|
||||||
|
const serviceGroups = computed(() => {
|
||||||
|
const groups: ServiceGroup[] = []
|
||||||
|
|
||||||
|
servicesStore.servicesEnabled.forEach(service => {
|
||||||
|
if (service.identifier === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
service,
|
||||||
|
loading: mailOperationsStore.isServiceFolderLoading(service.provider, service.identifier),
|
||||||
|
loaded: mailOperationsStore.hasServiceFoldersLoaded(service.provider, service.identifier),
|
||||||
|
error: mailOperationsStore.getServiceFolderError(service.provider, service.identifier),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleFolderCreate = (service: ServiceObject, parentFolder: CollectionObject | null = null) => {
|
||||||
|
mailUiStore.openCreateFolderDialog(service, parentFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderEdit = (folder: CollectionObject) => {
|
||||||
|
mailUiStore.openRenameFolderDialog(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderDelete = (folder: CollectionObject) => {
|
||||||
|
mailUiStore.openDeleteFolderDialog(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderMove = (folder: CollectionObject) => {
|
||||||
|
mailUiStore.openMoveFolderDialog(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<!-- Tree View -->
|
||||||
|
<FolderTreeView
|
||||||
|
v-if="folderViewMode === 'tree'"
|
||||||
|
:selected-folder="selectedFolder"
|
||||||
|
:service-groups="serviceGroups"
|
||||||
|
@select="emit('select', $event)"
|
||||||
|
@create-folder="handleFolderCreate"
|
||||||
|
@edit-folder="handleFolderEdit"
|
||||||
|
@move-folder="handleFolderMove"
|
||||||
|
@delete-folder="handleFolderDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Page-based View -->
|
||||||
|
<FolderPageView
|
||||||
|
v-else
|
||||||
|
:selected-folder="selectedFolder"
|
||||||
|
:service-groups="serviceGroups"
|
||||||
|
@select="emit('select', $event)"
|
||||||
|
@create-folder="handleFolderCreate"
|
||||||
|
@edit-folder="handleFolderEdit"
|
||||||
|
@move-folder="handleFolderMove"
|
||||||
|
@delete-folder="handleFolderDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<v-list-item v-if="servicesStore.servicesEnabled.length === 0">
|
||||||
|
<v-list-item-title class="text-center text-medium-emphasis">
|
||||||
|
No mail accounts configured
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-list-item--active {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+151
-379
@@ -1,22 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useEditor } from '@tiptap/vue-3'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import Underline from '@tiptap/extension-underline'
|
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 { entityService } from '@MailManager/services'
|
|
||||||
import type { EntityInterface } from '@MailManager/types/entity'
|
|
||||||
import type { MessageInterface } from '@MailManager/types/message'
|
|
||||||
import type { CollectionInterface } from '@MailManager/types/collection'
|
|
||||||
import { MessageObject } from '@MailManager/models/message'
|
|
||||||
import { EntityObject } from '@MailManager/models/entity'
|
import { EntityObject } from '@MailManager/models/entity'
|
||||||
|
import type { CollectionObject, MessageAddressObject } from '@MailManager/models'
|
||||||
|
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
||||||
|
import { useMailCompositionStore } from '@/stores/mailCompositionStore'
|
||||||
|
import { ComposerMode } from '@/types/composer'
|
||||||
|
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
|
||||||
|
import ComposerSender from '@/components/composer/ComposerSender.vue'
|
||||||
|
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
|
||||||
|
import ComposerAttachments from '@/components/composer/ComposerAttachments.vue'
|
||||||
|
import ComposerEditor from '@/components/composer/ComposerEditor.vue'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
replyTo?: EntityInterface<MessageInterface> | null
|
mode: ComposerMode
|
||||||
folder?: CollectionInterface | null
|
source?: EntityObject | MessageAddressObject | null
|
||||||
|
folder?: CollectionObject | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -24,23 +30,55 @@ const props = defineProps<Props>()
|
|||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
sent: []
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const mailOperationsStore = useMailOperationsStore()
|
||||||
|
const compositionStore = useMailCompositionStore()
|
||||||
|
const {
|
||||||
|
composerSending: sending,
|
||||||
|
} = storeToRefs(mailOperationsStore)
|
||||||
|
|
||||||
|
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 sending = ref(false)
|
const applyingDraftToEditor = ref(false)
|
||||||
const saving = ref(false)
|
|
||||||
const lastSaved = ref<Date | null>(null)
|
|
||||||
const draftId = ref<string | null>(null)
|
|
||||||
|
|
||||||
// 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({
|
||||||
@@ -65,162 +103,75 @@ const editor = useEditor({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize from reply-to message
|
watch(
|
||||||
if (props.replyTo) {
|
[() => props.mode, () => props.source, () => editor.value],
|
||||||
const replyMessage = new MessageObject(props.replyTo.properties)
|
([, , currentEditor]) => {
|
||||||
|
if (!currentEditor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const fromEmail = replyMessage.from?.address
|
compositionStore.openDraft(props.mode, props.source)
|
||||||
to.value = fromEmail ? [fromEmail] : []
|
showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0
|
||||||
|
showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0
|
||||||
|
|
||||||
const originalSubject = replyMessage.subject || ''
|
applyingDraftToEditor.value = true
|
||||||
subject.value = originalSubject.startsWith('Re:')
|
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
|
||||||
? originalSubject
|
applyingDraftToEditor.value = false
|
||||||
: `Re: ${originalSubject}`
|
},
|
||||||
|
{ immediate: true },
|
||||||
// Add quoted reply - prefer HTML content, fallback to text
|
)
|
||||||
const originalBody = replyMessage.getHtmlContent() || replyMessage.getTextContent() || ''
|
|
||||||
const senderName = replyMessage.from?.label || replyMessage.from?.address || 'Unknown'
|
|
||||||
const quotedReply = `<p><br></p><p>On ${new Date(replyMessage.date || '').toLocaleString()}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`
|
|
||||||
editor.value?.commands.setContent(quotedReply)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const canSend = computed(() => {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
saving.value = true
|
const handleUpdate = () => {
|
||||||
|
if (applyingDraftToEditor.value) {
|
||||||
try {
|
return
|
||||||
const draftData = {
|
|
||||||
to: to.value,
|
|
||||||
cc: cc.value,
|
|
||||||
bcc: bcc.value,
|
|
||||||
subject: subject.value,
|
|
||||||
body: {
|
|
||||||
html: editor.value?.getHTML() || '',
|
|
||||||
text: editor.value?.getText() || '',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find drafts folder for this service
|
compositionStore.updateBody({
|
||||||
// For now, we'll use the current folder's service
|
html: currentEditor.getHTML(),
|
||||||
// In a real implementation, you'd find the actual Drafts folder
|
text: currentEditor.getText(),
|
||||||
const response = await entityService.create({
|
|
||||||
provider: props.folder.provider,
|
|
||||||
service: props.folder.service,
|
|
||||||
collection: props.folder.identifier, // Should be drafts folder ID
|
|
||||||
properties: draftData,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response) {
|
|
||||||
draftId.value = String(response.identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSaved.value = new Date()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MessageComposer] Failed to save draft:', error)
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch for changes and trigger auto-save
|
|
||||||
const scheduleAutoSave = () => {
|
|
||||||
if (autoSaveTimer) {
|
|
||||||
clearTimeout(autoSaveTimer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
autoSaveTimer = setTimeout(() => {
|
currentEditor.on('update', handleUpdate)
|
||||||
saveDraft()
|
|
||||||
}, 30000) // 30 seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([to, cc, bcc, subject], () => {
|
return () => {
|
||||||
scheduleAutoSave()
|
currentEditor.off('update', handleUpdate)
|
||||||
}, { deep: true })
|
}
|
||||||
|
})
|
||||||
// Watch editor content changes
|
|
||||||
if (editor.value) {
|
|
||||||
editor.value.on('update', () => {
|
|
||||||
scheduleAutoSave()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (autoSaveTimer) {
|
void compositionStore.flushSave()
|
||||||
clearTimeout(autoSaveTimer)
|
|
||||||
}
|
|
||||||
editor.value?.destroy()
|
editor.value?.destroy()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleClose = () => {
|
const handleClose = async () => {
|
||||||
|
await compositionStore.closeDraft()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!canSend.value || sending.value) return
|
await compositionStore.sendDraft()
|
||||||
|
}
|
||||||
|
|
||||||
sending.value = true
|
const handleAttach = async (files: File[]) => {
|
||||||
|
await compositionStore.addAttachments(files)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const handleDetach = async (identifier: string) => {
|
||||||
await entityService.transmit({
|
await compositionStore.removeAttachment(identifier)
|
||||||
message: {
|
|
||||||
to: to.value,
|
|
||||||
cc: cc.value.length > 0 ? cc.value : undefined,
|
|
||||||
bcc: bcc.value.length > 0 ? bcc.value : undefined,
|
|
||||||
subject: subject.value,
|
|
||||||
body: {
|
|
||||||
html: editor.value?.getHTML() || '',
|
|
||||||
text: editor.value?.getText() || '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Delete draft if it was saved
|
|
||||||
if (draftId.value && props.folder) {
|
|
||||||
try {
|
|
||||||
await entityService.delete({
|
|
||||||
provider: props.folder.provider,
|
|
||||||
service: props.folder.service,
|
|
||||||
collection: props.folder.identifier,
|
|
||||||
identifier: draftId.value,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MessageComposer] Failed to delete draft:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('sent')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MessageComposer] Failed to send message:', error)
|
|
||||||
alert('Failed to send message. Please try again.')
|
|
||||||
} finally {
|
|
||||||
sending.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleCc = () => {
|
const toggleCc = () => {
|
||||||
@@ -244,7 +195,14 @@ const setLink = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const removeLink = () => editor.value?.chain().focus().unsetLink().run()
|
const removeLink = () => editor.value?.chain().focus().unsetLink().run()
|
||||||
|
const toggleLink = () => {
|
||||||
|
if (isActive('link')) {
|
||||||
|
removeLink()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLink()
|
||||||
|
}
|
||||||
const isActive = (name: string, attrs?: any) => {
|
const isActive = (name: string, attrs?: any) => {
|
||||||
return editor.value?.isActive(name, attrs) || false
|
return editor.value?.isActive(name, attrs) || false
|
||||||
}
|
}
|
||||||
@@ -252,188 +210,62 @@ const isActive = (name: string, attrs?: any) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="message-composer">
|
<div class="message-composer">
|
||||||
<!-- Toolbar -->
|
<ComposerToolbar
|
||||||
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
|
:mode="mode"
|
||||||
<v-btn
|
:status="stageStatus"
|
||||||
variant="text"
|
:can-send="canSend"
|
||||||
@click="handleClose"
|
:sending="sending"
|
||||||
icon="mdi-close"
|
@close="handleClose"
|
||||||
>
|
@send="handleSend"
|
||||||
<v-icon>mdi-close</v-icon>
|
/>
|
||||||
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-toolbar-title>
|
|
||||||
{{ replyTo ? 'Reply' : 'New Message' }}
|
|
||||||
</v-toolbar-title>
|
|
||||||
|
|
||||||
<v-spacer />
|
|
||||||
|
|
||||||
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
|
|
||||||
{{ saveStatus }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
color="primary"
|
|
||||||
:disabled="!canSend"
|
|
||||||
:loading="sending"
|
|
||||||
@click="handleSend"
|
|
||||||
prepend-icon="mdi-send"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</v-btn>
|
|
||||||
</v-toolbar>
|
|
||||||
|
|
||||||
<!-- Composer content -->
|
|
||||||
<div class="composer-content">
|
<div class="composer-content">
|
||||||
<!-- Recipients -->
|
<ComposerSender
|
||||||
<div class="composer-fields pa-4">
|
v-model="sender"
|
||||||
<v-combobox
|
:options="compositionStore.senderIdentities"
|
||||||
v-model="to"
|
/>
|
||||||
label="To"
|
|
||||||
chips
|
|
||||||
multiple
|
|
||||||
closable-chips
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
class="mb-2"
|
|
||||||
>
|
|
||||||
<template v-slot:append-inner>
|
|
||||||
<v-btn
|
|
||||||
size="x-small"
|
|
||||||
variant="text"
|
|
||||||
@click="toggleCc"
|
|
||||||
class="mr-1"
|
|
||||||
>
|
|
||||||
Cc
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
size="x-small"
|
|
||||||
variant="text"
|
|
||||||
@click="toggleBcc"
|
|
||||||
>
|
|
||||||
Bcc
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
</v-combobox>
|
|
||||||
|
|
||||||
<v-combobox
|
<ComposerRecipients
|
||||||
v-if="showCc"
|
:to="to"
|
||||||
v-model="cc"
|
:cc="cc"
|
||||||
label="Cc"
|
:bcc="bcc"
|
||||||
chips
|
:subject="subject"
|
||||||
multiple
|
:show-cc="showCc"
|
||||||
closable-chips
|
:show-bcc="showBcc"
|
||||||
variant="outlined"
|
@update:to="to = $event"
|
||||||
density="compact"
|
@update:cc="cc = $event"
|
||||||
class="mb-2"
|
@update:bcc="bcc = $event"
|
||||||
/>
|
@update:subject="subject = $event"
|
||||||
|
@toggle:cc="toggleCc"
|
||||||
<v-combobox
|
@toggle:bcc="toggleBcc"
|
||||||
v-if="showBcc"
|
/>
|
||||||
v-model="bcc"
|
|
||||||
label="Bcc"
|
|
||||||
chips
|
|
||||||
multiple
|
|
||||||
closable-chips
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
class="mb-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="subject"
|
|
||||||
label="Subject"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
||||||
<!-- Editor toolbar -->
|
<ComposerAttachments
|
||||||
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
|
v-if="Object.keys(attachments).length > 0"
|
||||||
<v-btn
|
:attachments="attachments"
|
||||||
icon
|
@remove="handleDetach"
|
||||||
size="small"
|
/>
|
||||||
:class="{ 'v-btn--active': isActive('bold') }"
|
|
||||||
@click="toggleBold"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-format-bold</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
<v-divider v-if="Object.keys(attachments).length > 0" />
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:class="{ 'v-btn--active': isActive('italic') }"
|
|
||||||
@click="toggleItalic"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-format-italic</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
<ComposerEditor
|
||||||
icon
|
:editor="editor"
|
||||||
size="small"
|
:is-bold-active="isActive('bold')"
|
||||||
:class="{ 'v-btn--active': isActive('underline') }"
|
:is-italic-active="isActive('italic')"
|
||||||
@click="toggleUnderline"
|
:is-underline-active="isActive('underline')"
|
||||||
>
|
:is-bullet-list-active="isActive('bulletList')"
|
||||||
<v-icon>mdi-format-underline</v-icon>
|
:is-ordered-list-active="isActive('orderedList')"
|
||||||
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
|
:is-link-active="isActive('link')"
|
||||||
</v-btn>
|
@bold="toggleBold"
|
||||||
|
@italic="toggleItalic"
|
||||||
<v-divider vertical class="mx-2" />
|
@underline="toggleUnderline"
|
||||||
|
@bullet-list="toggleBulletList"
|
||||||
<v-btn
|
@ordered-list="toggleOrderedList"
|
||||||
icon
|
@link="toggleLink"
|
||||||
size="small"
|
@attach="handleAttach"
|
||||||
:class="{ 'v-btn--active': isActive('bulletList') }"
|
/>
|
||||||
@click="toggleBulletList"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-format-list-bulleted</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:class="{ 'v-btn--active': isActive('orderedList') }"
|
|
||||||
@click="toggleOrderedList"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-format-list-numbered</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-divider vertical class="mx-2" />
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:class="{ 'v-btn--active': isActive('link') }"
|
|
||||||
@click="isActive('link') ? removeLink() : setLink()"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-link</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-spacer />
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-paperclip</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
</v-toolbar>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<!-- Editor -->
|
|
||||||
<div class="editor-container">
|
|
||||||
<editor-content :editor="editor" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -458,64 +290,4 @@ const isActive = (name: string, attrs?: any) => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-fields {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-toolbar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
background-color: rgb(var(--v-theme-background));
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-btn--active {
|
|
||||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
|
||||||
color: rgb(var(--v-theme-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.tiptap-editor) {
|
|
||||||
outline: none;
|
|
||||||
min-height: 300px;
|
|
||||||
|
|
||||||
p.is-editor-empty:first-child::before {
|
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
|
||||||
content: attr(data-placeholder);
|
|
||||||
float: left;
|
|
||||||
height: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
line-height: 1.3;
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul, ol {
|
|
||||||
padding-left: 1.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: 3px solid rgb(var(--v-border-color));
|
|
||||||
padding-left: 1em;
|
|
||||||
margin-left: 0;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: rgb(var(--v-theme-primary));
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+490
-97
@@ -1,52 +1,100 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
import type { EntityInterface } from '@MailManager/types/entity'
|
import { storeToRefs } from 'pinia'
|
||||||
import type { MessageInterface } from '@MailManager/types/message'
|
import type { EntityIdentifier } from '@MailManager/types/common'
|
||||||
|
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 {
|
||||||
messages: EntityInterface<MessageInterface>[]
|
messages: EntityObject[]
|
||||||
selectedMessage?: EntityInterface<MessageInterface> | null
|
selectedMessage?: EntityObject | null
|
||||||
|
selectionList?: EntityIdentifier[]
|
||||||
|
selectionMode?: boolean
|
||||||
selectedCollection?: CollectionObject | null
|
selectedCollection?: CollectionObject | null
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
loading: false
|
loading: false,
|
||||||
|
selectionList: () => [],
|
||||||
|
selectionMode: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [message: EntityInterface<MessageInterface>]
|
open: [message: EntityObject]
|
||||||
|
reply: [message: EntityObject]
|
||||||
|
forward: [message: EntityObject]
|
||||||
|
move: [message: EntityObject]
|
||||||
|
delete: [message: EntityObject]
|
||||||
|
flag: [message: EntityObject, flag: string, value: boolean]
|
||||||
|
selectionMode: [message: EntityObject]
|
||||||
|
selectionToggleOne: [message: EntityObject]
|
||||||
|
selectionToggleAll: [value: boolean]
|
||||||
|
selectionClear: []
|
||||||
|
selectionMove: []
|
||||||
|
selectionDelete: []
|
||||||
|
selectionFlag: [flag: string, value: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Check if message is selected
|
const mailSettingsStore = useMailSettingsStore()
|
||||||
const isSelected = (message: EntityInterface<MessageInterface>): boolean => {
|
const { messageListDensity } = storeToRefs(mailSettingsStore)
|
||||||
|
const densityConfig = computed(() => messageListDensityConfig[messageListDensity.value])
|
||||||
|
|
||||||
|
const longPressTimer = ref<number | null>(null)
|
||||||
|
const longPressActivated = ref(false)
|
||||||
|
const suppressNextClick = ref(false)
|
||||||
|
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 selectionCount = computed(() => props.selectionList.length ?? 0)
|
||||||
|
|
||||||
|
// Sorted messages (newest first)
|
||||||
|
const sortedMessages = computed(() => {
|
||||||
|
return [...props.messages].sort((a, b) => {
|
||||||
|
const dateA = timeStamp(a) ?? 0
|
||||||
|
const dateB = timeStamp(b) ?? 0
|
||||||
|
return dateB - dateA
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOpened = (message: EntityObject): boolean => {
|
||||||
if (!props.selectedMessage) return false
|
if (!props.selectedMessage) return false
|
||||||
return (
|
return (message.identifier === props.selectedMessage.identifier)
|
||||||
message.provider === props.selectedMessage.provider &&
|
|
||||||
message.service === props.selectedMessage.service &&
|
|
||||||
message.collection === props.selectedMessage.collection &&
|
|
||||||
message.identifier === props.selectedMessage.identifier
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if message is unread
|
const isSelected = (message: EntityObject): boolean => {
|
||||||
const isUnread = (message: EntityInterface<MessageInterface>): boolean => {
|
return selectedIdSet.value.has(message.identifier)
|
||||||
return !message.properties.flags?.read
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if message is flagged
|
const timeStamp = (message: EntityObject): number | null => {
|
||||||
const isFlagged = (message: EntityInterface<MessageInterface>): boolean => {
|
const timestamp = message.properties.received
|
||||||
return message.properties.flags?.flagged || false
|
|| 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)
|
||||||
|
if (Number.isNaN(messageDate.getTime())) return ''
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
const yesterday = new Date(today)
|
const yesterday = new Date(today)
|
||||||
@@ -74,6 +122,7 @@ const formatDate = (date: Date | string | null | undefined): string => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Other years - show full date
|
// Other years - show full date
|
||||||
return messageDate.toLocaleDateString('en-US', {
|
return messageDate.toLocaleDateString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -82,44 +131,149 @@ const formatDate = (date: Date | string | null | undefined): string => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate text
|
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
|
||||||
const truncate = (text: string | null | undefined, length: number = 100): string => {
|
return event.target instanceof Element && event.target.closest('.message-selection-checkbox, .message-item-menu-trigger, .message-item-menu-content') !== null
|
||||||
if (!text) return ''
|
|
||||||
return text.length > length ? text.substring(0, length) + '...' : text
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle message click
|
const handleSelectionToggleOne = (message: EntityObject) => {
|
||||||
const handleMessageClick = (message: EntityInterface<MessageInterface>) => {
|
emit('selectionToggleOne', message)
|
||||||
emit('select', message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorted messages (newest first)
|
const handleSelectionToggleAll = (value: boolean | null) => {
|
||||||
const sortedMessages = computed(() => {
|
emit('selectionToggleAll', value === true)
|
||||||
return [...props.messages].sort((a, b) => {
|
}
|
||||||
const dateA = a.properties.date ? new Date(a.properties.date).getTime() : 0
|
|
||||||
const dateB = b.properties.date ? new Date(b.properties.date).getTime() : 0
|
|
||||||
return dateB - dateA
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Read/Unread counts from collection properties
|
const handleMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
|
||||||
const unreadCount = computed(() => {
|
if (isSelectionControlClick(event)) {
|
||||||
return props.selectedCollection?.properties.unread ?? 0
|
return
|
||||||
})
|
}
|
||||||
|
|
||||||
const readCount = computed(() => {
|
if (suppressNextClick.value) {
|
||||||
const total = props.selectedCollection?.properties.total ?? 0
|
suppressNextClick.value = false
|
||||||
const unread = props.selectedCollection?.properties.unread ?? 0
|
return
|
||||||
return total - unread
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const totalCount = computed(() => {
|
if (event.shiftKey && !props.selectionMode) {
|
||||||
return props.selectedCollection?.properties.total ?? 0
|
event.preventDefault()
|
||||||
})
|
event.stopPropagation()
|
||||||
|
emit('selectionMode', message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// True only when the collection explicitly provides total/unread counts
|
if (longPressActivated.value) {
|
||||||
const hasCountData = computed(() => {
|
longPressActivated.value = false
|
||||||
return props.selectedCollection?.properties.total != null
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.selectionMode) {
|
||||||
|
emit('selectionToggleOne', message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('open', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
|
||||||
|
if (isSelectionControlClick(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.shiftKey || props.selectionMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
suppressNextClick.value = true
|
||||||
|
emit('selectionMode', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openContextMenu = (event: MouseEvent, message: EntityObject) => {
|
||||||
|
if (isSelectionControlClick(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
clearLongPressTimer()
|
||||||
|
longPressActivated.value = false
|
||||||
|
|
||||||
|
longPressTimer.value = window.setTimeout(() => {
|
||||||
|
if (!props.selectionMode) {
|
||||||
|
emit('selectionMode', message)
|
||||||
|
} else {
|
||||||
|
emit('selectionToggleOne', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
longPressActivated.value = true
|
||||||
|
clearLongPressTimer()
|
||||||
|
}, LONG_PRESS_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
clearLongPressTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchMove = () => {
|
||||||
|
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(() => {
|
||||||
|
clearLongPressTimer()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -127,16 +281,82 @@ const hasCountData = computed(() => {
|
|||||||
<div class="message-list">
|
<div class="message-list">
|
||||||
<!-- 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">
|
||||||
<h2 class="text-h6">{{ selectedCollection.properties.label || 'Folder' }}</h2>
|
<div class="message-list-heading">
|
||||||
<div class="folder-counts text-caption text-medium-emphasis">
|
<h2 class="text-h6">{{ selectedCollection?.properties.label || 'Folder' }}</h2>
|
||||||
<span v-if="hasCountData">
|
<div class="folder-counts text-caption text-medium-emphasis">
|
||||||
<span class="unread-count">{{ unreadCount }}</span>
|
<span v-if="selectedCollection?.properties.total != null">
|
||||||
<span class="mx-1">/</span>
|
<span class="unread-count">{{ selectedCollection?.properties.unread ?? 0 }}</span>
|
||||||
<span>{{ totalCount }}</span>
|
<span class="mx-1">/</span>
|
||||||
</span>
|
<span>{{ selectedCollection?.properties.total ?? 0 }}</span>
|
||||||
<span v-else-if="messages.length > 0">
|
</span>
|
||||||
{{ messages.length }} loaded
|
<span v-else-if="messages.length > 0">
|
||||||
</span>
|
{{ messages.length }} loaded
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectionMode && messages.length > 0" class="selection-summary">
|
||||||
|
<div class="selection-controls">
|
||||||
|
<v-checkbox-btn
|
||||||
|
:model-value="selectionCount !== 0"
|
||||||
|
:indeterminate="selectionCount > 0 && selectionCount !== messages.length"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
@update:model-value="handleSelectionToggleAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selection-actions">
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
icon="mdi-folder-move-outline"
|
||||||
|
variant="text"
|
||||||
|
:disabled="selectionCount === 0"
|
||||||
|
@click="emit('selectionMove')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-folder-move-outline</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Move Selected</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
icon="mdi-delete-outline"
|
||||||
|
variant="text"
|
||||||
|
:disabled="selectionCount === 0"
|
||||||
|
@click="emit('selectionDelete')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-delete-outline</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Delete Selected</v-tooltip>
|
||||||
|
</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
|
||||||
|
size="small"
|
||||||
|
icon="mdi-close"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('selectionClear')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Clear Selected</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,68 +383,124 @@ const hasCountData = computed(() => {
|
|||||||
<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 }">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
:key="`${message.provider}-${message.service}-${message.collection}-${message.identifier}`"
|
:key="message.identifier"
|
||||||
class="message-item"
|
class="message-item"
|
||||||
:class="{
|
:class="{
|
||||||
|
'opened': isOpened(message),
|
||||||
'selected': isSelected(message),
|
'selected': isSelected(message),
|
||||||
'unread': isUnread(message)
|
'selection-mode': selectionMode,
|
||||||
|
'unread': !message.properties.isRead
|
||||||
}"
|
}"
|
||||||
@click="handleMessageClick(message)"
|
@mousedown="handleMouseDown($event, message)"
|
||||||
lines="three"
|
@click="handleMouseClick($event, message)"
|
||||||
|
@contextmenu="openContextMenu($event, message)"
|
||||||
|
@touchstart.passive="handleTouchStart(message)"
|
||||||
|
@touchend="handleTouchEnd"
|
||||||
|
@touchcancel="handleTouchEnd"
|
||||||
|
@touchmove="handleTouchMove"
|
||||||
|
:lines="densityConfig.lines"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-avatar size="40" color="primary">
|
<div class="message-item-prepend">
|
||||||
<span class="text-white text-body-1">
|
<v-checkbox-btn
|
||||||
{{ (message.properties.from?.label || message.properties.from?.address || 'U')[0].toUpperCase() }}
|
v-if="selectionMode || isSelected(message)"
|
||||||
</span>
|
:model-value="isSelected(message)"
|
||||||
</v-avatar>
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
@click.stop
|
||||||
|
@update:model-value="handleSelectionToggleOne(message)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-avatar size="40" color="primary">
|
||||||
|
<span class="text-white text-body-1">
|
||||||
|
{{ (message.properties.from?.label || message.properties.from?.address || 'U')[0].toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</v-avatar>
|
||||||
|
</div>
|
||||||
</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(message.properties.date) }}
|
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">
|
||||||
{{ truncate(message.properties.snippet, 80) }}
|
{{ '' }}
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
|
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<div class="d-flex flex-column align-center">
|
<div class="message-item-append">
|
||||||
<v-icon
|
<div class="d-flex flex-column align-center">
|
||||||
v-if="isFlagged(message)"
|
<v-icon
|
||||||
size="small"
|
v-if="message.properties.isFlagged"
|
||||||
color="warning"
|
size="small"
|
||||||
class="mb-1"
|
color="warning"
|
||||||
>
|
class="mb-1"
|
||||||
mdi-star
|
>
|
||||||
</v-icon>
|
mdi-star
|
||||||
<v-icon
|
</v-icon>
|
||||||
v-if="message.properties.attachments && message.properties.attachments.length > 0"
|
<v-icon
|
||||||
size="small"
|
v-if="message.properties.hasAttachments"
|
||||||
color="grey"
|
size="small"
|
||||||
>
|
color="grey"
|
||||||
mdi-paperclip
|
>
|
||||||
</v-icon>
|
mdi-paperclip
|
||||||
|
</v-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessageListItemMenu
|
||||||
|
:message="message"
|
||||||
|
@reply="handleContextMenuReply"
|
||||||
|
@forward="handleContextMenuForward"
|
||||||
|
@move="handleContextMenuMove"
|
||||||
|
@delete="handleContextMenuDelete"
|
||||||
|
@flag="handleContextMenuFlag"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
</template>
|
</template>
|
||||||
</v-virtual-scroll>
|
</v-virtual-scroll>
|
||||||
|
|
||||||
|
<MessageListItemMenu
|
||||||
|
v-if="contextMenuMessage"
|
||||||
|
v-model="contextMenuVisible"
|
||||||
|
:message="getContextMenuMessage()"
|
||||||
|
:target="contextMenuTarget"
|
||||||
|
:show-activator="false"
|
||||||
|
@reply="handleContextMenuReply"
|
||||||
|
@forward="handleContextMenuForward"
|
||||||
|
@move="handleContextMenuMove"
|
||||||
|
@delete="handleContextMenuDelete"
|
||||||
|
@flag="handleContextMenuFlag"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -241,14 +517,42 @@ const hasCountData = computed(() => {
|
|||||||
background-color: rgb(var(--v-theme-surface));
|
background-color: rgb(var(--v-theme-surface));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
|
.message-list-heading {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selection-summary {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.folder-counts {
|
.folder-counts {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -270,19 +574,108 @@ const hasCountData = computed(() => {
|
|||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-item.selection-mode {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item-prepend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item.selected {
|
.message-item.opened {
|
||||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
border-left-color: rgb(var(--v-theme-primary));
|
border-left-color: rgb(var(--v-theme-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-item.selected:not(.opened) {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.message-item.unread {
|
.message-item.unread {
|
||||||
:deep(.v-list-item-title),
|
:deep(.v-list-item-title),
|
||||||
: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) {
|
||||||
|
.selection-summary {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,56 +1,50 @@
|
|||||||
<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 type { EntityInterface } from '@MailManager/types/entity'
|
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||||
import type { MessageInterface } from '@MailManager/types/message'
|
import type { EntityObject, MessageObject } from '@MailManager/models'
|
||||||
import { MessageObject } from '@MailManager/models/message'
|
|
||||||
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'
|
||||||
import ReaderBody from './reader/ReaderBody.vue'
|
import ReaderBody from './reader/ReaderBody.vue'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
const props = defineProps<{
|
||||||
message?: EntityInterface<MessageInterface> | null
|
entity: EntityObject | null
|
||||||
}
|
}>()
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
// User settings
|
|
||||||
const { getSetting } = useUser()
|
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
reply: [message: EntityInterface<MessageInterface>]
|
reply: [entity: EntityObject]
|
||||||
forward: [message: EntityInterface<MessageInterface>]
|
forward: [entity: EntityObject]
|
||||||
move: [message: EntityInterface<MessageInterface>]
|
move: [entity: EntityObject]
|
||||||
delete: [message: EntityInterface<MessageInterface>]
|
delete: [entity: EntityObject]
|
||||||
flag: [message: EntityInterface<MessageInterface>]
|
flag: [entity: EntityObject]
|
||||||
compose: []
|
compose: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// User settings
|
||||||
|
const { getSetting } = useUser()
|
||||||
|
const mailUiStore = useMailUiStore()
|
||||||
|
|
||||||
|
// Security overrides
|
||||||
|
const allowImages = ref(false)
|
||||||
|
const overrideSecurityLevel = ref<SecurityLevel | null>(null)
|
||||||
|
|
||||||
|
// Viewer state
|
||||||
|
const showViewer = ref(false)
|
||||||
|
const viewerIndex = ref(0)
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const hasMessage = computed(() => !!props.message)
|
const message = computed<MessageObject | null>(() => {
|
||||||
|
return props.entity?.properties ?? null
|
||||||
const messageInstance = computed(() => {
|
|
||||||
if (!props.message) return null
|
|
||||||
return new MessageObject(props.message.properties)
|
|
||||||
})
|
|
||||||
|
|
||||||
const messageBody = computed(() => {
|
|
||||||
if (!messageInstance.value) return ''
|
|
||||||
|
|
||||||
const htmlContent = messageInstance.value.getHtmlContent()
|
|
||||||
if (htmlContent) return htmlContent
|
|
||||||
|
|
||||||
const textContent = messageInstance.value.getTextContent()
|
|
||||||
return textContent || ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const isHtml = computed(() => {
|
const isHtml = computed(() => {
|
||||||
if (!messageInstance.value) return false
|
return message.value?.hasHtmlContent() ?? false
|
||||||
return messageInstance.value.hasHtmlContent()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Security preferences from user settings
|
// Security preferences from user settings
|
||||||
@@ -62,21 +56,40 @@ const allowImagesDefault = computed(() => {
|
|||||||
return getSetting('mail.security.allowImagesDefault') as boolean || false
|
return getSetting('mail.security.allowImagesDefault') as boolean || false
|
||||||
})
|
})
|
||||||
|
|
||||||
// Per-message overrides
|
|
||||||
const allowImages = ref(false)
|
|
||||||
const overrideSecurityLevel = ref<SecurityLevel | null>(null)
|
|
||||||
|
|
||||||
// Computed effective security level (use override if set, otherwise default)
|
// Computed effective security level (use override if set, otherwise default)
|
||||||
const effectiveSecurityLevel = computed(() => {
|
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.message, () => {
|
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
|
||||||
@@ -89,52 +102,75 @@ const setSecurityLevel = (level: SecurityLevel) => {
|
|||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleReply = () => {
|
const handleReply = () => {
|
||||||
if (props.message) {
|
if (props.entity) {
|
||||||
emit('reply', props.message)
|
emit('reply', props.entity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleForward = () => {
|
const handleForward = () => {
|
||||||
if (props.message) {
|
if (props.entity) {
|
||||||
emit('forward', props.message)
|
emit('forward', props.entity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (props.message) {
|
if (props.entity) {
|
||||||
emit('delete', props.message)
|
emit('delete', props.entity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMove = () => {
|
const handleMove = () => {
|
||||||
if (props.message) {
|
if (props.entity) {
|
||||||
emit('move', props.message)
|
emit('move', props.entity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDownload = async (partIndex?: number) => {
|
||||||
|
if (!props.entity) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await mailUiStore.downloadMessage(props.entity, partIndex)
|
||||||
|
}
|
||||||
|
|
||||||
const handleFlag = () => {
|
const handleFlag = () => {
|
||||||
if (props.message) {
|
if (props.entity) {
|
||||||
emit('flag', props.message)
|
emit('flag', props.entity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
<div class="message-reader">
|
<div class="message-reader">
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<ReaderEmpty
|
<ReaderEmpty
|
||||||
v-if="!hasMessage"
|
v-if="!message"
|
||||||
@compose="handleCompose"
|
@compose="handleCompose"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Message viewer -->
|
<!-- Message viewer -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ReaderToolbar
|
<ReaderToolbar
|
||||||
:message="message!"
|
|
||||||
:is-html="isHtml"
|
:is-html="isHtml"
|
||||||
:allow-images="allowImages"
|
:allow-images="allowImages"
|
||||||
:security-level="effectiveSecurityLevel"
|
:security-level="effectiveSecurityLevel"
|
||||||
@@ -144,23 +180,40 @@ 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-instance="messageInstance!" />
|
<ReaderHeader
|
||||||
|
:entity="props.entity"
|
||||||
|
@download="handleDownload"
|
||||||
|
@preview="handlePreview"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
||||||
<ReaderBody
|
<ReaderBody
|
||||||
:message-body="messageBody"
|
:message="message!"
|
||||||
:is-html="isHtml"
|
|
||||||
:security-level="effectiveSecurityLevel"
|
:security-level="effectiveSecurityLevel"
|
||||||
: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,7 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
|
||||||
import { CollectionPropertiesObject } from '@MailManager/models/collection'
|
|
||||||
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'
|
||||||
|
|
||||||
@@ -9,23 +7,25 @@ interface Props {
|
|||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
service: ServiceObject
|
service: ServiceObject
|
||||||
folder: CollectionObject
|
folder: CollectionObject
|
||||||
allFolders?: CollectionObject[]
|
parentFolderLabel?: string
|
||||||
|
validateName?: (name: string) => string[]
|
||||||
|
loading?: boolean
|
||||||
|
errorMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
allFolders: () => []
|
parentFolderLabel: 'Root',
|
||||||
|
validateName: () => [],
|
||||||
|
loading: false,
|
||||||
|
errorMessage: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
updated: [folder: CollectionObject]
|
confirm: [folderName: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const collectionsStore = useCollectionsStore()
|
|
||||||
|
|
||||||
const folderName = ref('')
|
const folderName = ref('')
|
||||||
const loading = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const validationErrors = ref<string[]>([])
|
const validationErrors = ref<string[]>([])
|
||||||
|
|
||||||
const dialogValue = computed({
|
const dialogValue = computed({
|
||||||
@@ -37,74 +37,13 @@ const isValid = computed(() => {
|
|||||||
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
|
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const parentFolderLabel = computed(() => {
|
|
||||||
const parentId = props.folder.collection
|
|
||||||
if (parentId === null || parentId === undefined) return 'Root'
|
|
||||||
|
|
||||||
const parent = props.allFolders.find(
|
|
||||||
f =>
|
|
||||||
String(f.identifier) === String(parentId) &&
|
|
||||||
f.provider === props.folder.provider &&
|
|
||||||
String(f.service) === String(props.folder.service)
|
|
||||||
)
|
|
||||||
|
|
||||||
return parent?.properties.label || 'Root'
|
|
||||||
})
|
|
||||||
|
|
||||||
const validateFolderName = (name: string): string[] => {
|
|
||||||
const errors: string[] = []
|
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
|
||||||
errors.push('Folder name is required')
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name.length > 255) {
|
|
||||||
errors.push('Folder name too long (max 255 characters)')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
|
|
||||||
errors.push('Folder name contains invalid characters')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.service.provider === 'imap' && /[\/\\]/.test(name)) {
|
|
||||||
errors.push('IMAP folder names cannot contain / or \\')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name !== name.trim()) {
|
|
||||||
errors.push('Folder name cannot have leading or trailing spaces')
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkDuplicateName = (name: string): boolean => {
|
|
||||||
const parentId = props.folder.collection ?? null
|
|
||||||
return props.allFolders.some(f => {
|
|
||||||
if (String(f.identifier) === String(props.folder.identifier)) return false
|
|
||||||
return (
|
|
||||||
f.properties.label === name &&
|
|
||||||
String(f.collection) === String(parentId) &&
|
|
||||||
f.provider === props.folder.provider &&
|
|
||||||
String(f.service) === String(props.folder.service)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(folderName, (newName) => {
|
watch(folderName, (newName) => {
|
||||||
errorMessage.value = ''
|
validationErrors.value = props.validateName(newName)
|
||||||
validationErrors.value = validateFolderName(newName)
|
|
||||||
|
|
||||||
if (validationErrors.value.length === 0 && newName.trim().length > 0 && checkDuplicateName(newName)) {
|
|
||||||
validationErrors.value.push('A folder with this name already exists in this location')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
folderName.value = props.folder.properties.label || ''
|
folderName.value = props.folder.properties.label || ''
|
||||||
errorMessage.value = ''
|
|
||||||
validationErrors.value = []
|
validationErrors.value = []
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(dialogValue, (isOpen) => {
|
watch(dialogValue, (isOpen) => {
|
||||||
@@ -114,54 +53,27 @@ watch(dialogValue, (isOpen) => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
const handleRename = async () => {
|
const handleRename = async () => {
|
||||||
const errors = validateFolderName(folderName.value)
|
const errors = props.validateName(folderName.value)
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
validationErrors.value = errors
|
validationErrors.value = errors
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkDuplicateName(folderName.value)) {
|
|
||||||
validationErrors.value = ['A folder with this name already exists in this location']
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const newName = folderName.value.trim()
|
const newName = folderName.value.trim()
|
||||||
if (newName === props.folder.properties.label) {
|
if (newName === props.folder.properties.label) {
|
||||||
dialogValue.value = false
|
dialogValue.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
emit('confirm', newName)
|
||||||
errorMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const properties = new CollectionPropertiesObject()
|
|
||||||
properties.label = newName
|
|
||||||
properties.rank = props.folder.properties.rank ?? 0
|
|
||||||
properties.subscribed = props.folder.properties.subscribed ?? true
|
|
||||||
|
|
||||||
const updatedFolder = await collectionsStore.update(
|
|
||||||
props.folder.provider,
|
|
||||||
props.folder.service,
|
|
||||||
props.folder.identifier,
|
|
||||||
properties
|
|
||||||
)
|
|
||||||
|
|
||||||
emit('updated', updatedFolder)
|
|
||||||
dialogValue.value = false
|
|
||||||
resetForm()
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[RenameFolderDialog] Failed to rename folder:', error)
|
|
||||||
errorMessage.value = error.message || 'Failed to rename folder. Please try again.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
dialogValue.value = false
|
dialogValue.value = false
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -179,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import { EditorContent, type Editor } from '@tiptap/vue-3'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
editor: {
|
||||||
|
type: Object as PropType<Editor | null>,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isBoldActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isItalicActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isUnderlineActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isBulletListActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isOrderedListActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isLinkActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
bold: []
|
||||||
|
italic: []
|
||||||
|
underline: []
|
||||||
|
bulletList: []
|
||||||
|
orderedList: []
|
||||||
|
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>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isBoldActive }"
|
||||||
|
@click="$emit('bold')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-bold</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isItalicActive }"
|
||||||
|
@click="$emit('italic')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-italic</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isUnderlineActive }"
|
||||||
|
@click="$emit('underline')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-underline</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-divider vertical class="mx-2" />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isBulletListActive }"
|
||||||
|
@click="$emit('bulletList')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-list-bulleted</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isOrderedListActive }"
|
||||||
|
@click="$emit('orderedList')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-list-numbered</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-divider vertical class="mx-2" />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isLinkActive }"
|
||||||
|
@click="$emit('link')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-link</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<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-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<div class="editor-container">
|
||||||
|
<EditorContent :editor="editor ?? undefined" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.editor-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: rgb(var(--v-theme-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--active {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.tiptap-editor) {
|
||||||
|
outline: none;
|
||||||
|
min-height: 300px;
|
||||||
|
|
||||||
|
p.is-editor-empty:first-child::before {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 3px solid rgb(var(--v-border-color));
|
||||||
|
padding-left: 1em;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRecipientLookupStore } from '@/stores/mailRecipientsStore'
|
||||||
|
import type { MessageAddressInterface } from '@MailManager/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
to: MessageAddressInterface[]
|
||||||
|
cc: MessageAddressInterface[]
|
||||||
|
bcc: MessageAddressInterface[]
|
||||||
|
subject: string
|
||||||
|
showCc: boolean
|
||||||
|
showBcc: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:to': [value: MessageAddressInterface[]]
|
||||||
|
'update:cc': [value: MessageAddressInterface[]]
|
||||||
|
'update:bcc': [value: MessageAddressInterface[]]
|
||||||
|
'update:subject': [value: string]
|
||||||
|
'toggle:cc': []
|
||||||
|
'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>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="composer-fields pa-4">
|
||||||
|
<v-combobox
|
||||||
|
:model-value="to"
|
||||||
|
label="To"
|
||||||
|
chips
|
||||||
|
multiple
|
||||||
|
closable-chips
|
||||||
|
return-object
|
||||||
|
no-filter
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
class="mb-2"
|
||||||
|
: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>
|
||||||
|
<v-btn
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
class="mr-1"
|
||||||
|
@click="$emit('toggle:cc')"
|
||||||
|
>
|
||||||
|
Cc
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('toggle:bcc')"
|
||||||
|
>
|
||||||
|
Bcc
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-combobox>
|
||||||
|
|
||||||
|
<v-combobox
|
||||||
|
v-if="showCc"
|
||||||
|
:model-value="cc"
|
||||||
|
label="Cc"
|
||||||
|
chips
|
||||||
|
multiple
|
||||||
|
closable-chips
|
||||||
|
return-object
|
||||||
|
no-filter
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
class="mb-2"
|
||||||
|
: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-if="showBcc"
|
||||||
|
:model-value="bcc"
|
||||||
|
label="Bcc"
|
||||||
|
chips
|
||||||
|
multiple
|
||||||
|
closable-chips
|
||||||
|
return-object
|
||||||
|
no-filter
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
class="mb-2"
|
||||||
|
: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
|
||||||
|
:model-value="subject"
|
||||||
|
label="Subject"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
@update:model-value="$emit('update:subject', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.composer-fields {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ComposerMode } from '@/types/composer'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: ComposerMode
|
||||||
|
status: string
|
||||||
|
canSend: boolean
|
||||||
|
sending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
send: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
icon="mdi-close"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-toolbar-title>
|
||||||
|
{{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
|
||||||
|
</v-toolbar-title>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<span v-if="status" class="text-caption text-medium-emphasis mr-4">
|
||||||
|
{{ status }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:disabled="!canSend"
|
||||||
|
:loading="sending"
|
||||||
|
prepend-icon="mdi-send"
|
||||||
|
@click="$emit('send')"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.composer-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,21 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import type { MessageObject } from '@MailManager/models'
|
||||||
import { EmailSanitizer, SecurityLevel } from '@/utile/emailSanitizer'
|
import { EmailSanitizer, SecurityLevel } from '@/utile/emailSanitizer'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messageBody: string
|
message: MessageObject
|
||||||
isHtml: boolean
|
|
||||||
securityLevel: SecurityLevel
|
securityLevel: SecurityLevel
|
||||||
allowImages: boolean
|
allowImages: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
// Sanitize HTML content for security
|
// Computed
|
||||||
const sanitizedMessageBody = computed(() => {
|
const displayFrame = ref<HTMLIFrameElement | null>(null)
|
||||||
if (!props.messageBody || !props.isHtml) return props.messageBody
|
|
||||||
|
|
||||||
return EmailSanitizer.sanitize(props.messageBody, {
|
const isHtml = computed(() => {
|
||||||
|
return props.message.hasHtmlContent()
|
||||||
|
})
|
||||||
|
|
||||||
|
const messageBody = computed(() => {
|
||||||
|
if (!props.message.hasContent()) return ''
|
||||||
|
|
||||||
|
if (props.message.hasHtmlContent()) {
|
||||||
|
return props.message.getHtmlContent() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.message.getTextContent() ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const sanitizedMessageBody = computed(() => {
|
||||||
|
if (!messageBody.value || !isHtml.value) return messageBody.value
|
||||||
|
|
||||||
|
return EmailSanitizer.sanitize(messageBody.value, {
|
||||||
securityLevel: props.securityLevel,
|
securityLevel: props.securityLevel,
|
||||||
allowImages: props.allowImages,
|
allowImages: props.allowImages,
|
||||||
allowExternalLinks: true,
|
allowExternalLinks: true,
|
||||||
@@ -23,25 +39,22 @@ const sanitizedMessageBody = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Iframe reference for sandboxed HTML rendering
|
// Watchers
|
||||||
const emailFrame = ref<HTMLIFrameElement | null>(null)
|
watch(messageBody, () => {
|
||||||
|
|
||||||
// Resize iframe to fit content
|
|
||||||
const resizeIframe = () => {
|
|
||||||
if (emailFrame.value?.contentWindow?.document?.body) {
|
|
||||||
const height = emailFrame.value.contentWindow.document.body.scrollHeight
|
|
||||||
emailFrame.value.style.height = `${height + 20}px`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch for changes to trigger resize
|
|
||||||
watch(() => props.messageBody, () => {
|
|
||||||
setTimeout(resizeIframe, 100)
|
setTimeout(resizeIframe, 100)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.allowImages, () => {
|
watch(() => props.allowImages, () => {
|
||||||
setTimeout(resizeIframe, 100)
|
setTimeout(resizeIframe, 100)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
const resizeIframe = () => {
|
||||||
|
if (displayFrame.value?.contentWindow?.document?.body) {
|
||||||
|
const height = displayFrame.value.contentWindow.document.body.scrollHeight
|
||||||
|
displayFrame.value.style.height = `${height + 20}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -49,7 +62,7 @@ watch(() => props.allowImages, () => {
|
|||||||
<!-- HTML body (sandboxed iframe) -->
|
<!-- HTML body (sandboxed iframe) -->
|
||||||
<iframe
|
<iframe
|
||||||
v-if="isHtml"
|
v-if="isHtml"
|
||||||
ref="emailFrame"
|
ref="displayFrame"
|
||||||
sandbox="allow-same-origin"
|
sandbox="allow-same-origin"
|
||||||
class="html-content-frame"
|
class="html-content-frame"
|
||||||
:srcdoc="sanitizedMessageBody"
|
:srcdoc="sanitizedMessageBody"
|
||||||
|
|||||||
@@ -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 {
|
||||||
messageInstance: 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,68 +79,92 @@ 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>
|
||||||
<div class="message-header pa-6">
|
<div class="message-header pa-6">
|
||||||
<div class="text-h5 mb-4">{{ messageInstance?.subject || '(No subject)' }}</div>
|
<div class="text-h5 mb-4">{{ message?.subject || '(No subject)' }}</div>
|
||||||
|
|
||||||
<div class="d-flex align-center mb-3">
|
<div class="d-flex align-center mb-3">
|
||||||
<v-avatar size="48" color="primary" class="mr-3">
|
<v-avatar size="48" color="primary" class="mr-3">
|
||||||
<span class="text-white text-h6">
|
<span class="text-white text-h6">
|
||||||
{{ (messageInstance?.from?.label || messageInstance?.from?.address || 'U')[0].toUpperCase() }}
|
{{ (message?.from?.label || message?.from?.address || 'U')[0].toUpperCase() }}
|
||||||
</span>
|
</span>
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
|
|
||||||
<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">
|
||||||
{{ messageInstance?.from?.label || messageInstance?.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(messageInstance?.date) }}
|
{{ formatDate(message?.received || message?.sent) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recipients -->
|
<!-- Recipients -->
|
||||||
<div v-if="messageInstance?.to && messageInstance?.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>
|
||||||
{{ messageInstance?.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="messageInstance?.cc && messageInstance?.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>
|
||||||
{{ messageInstance?.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 -->
|
||||||
<div v-if="messageInstance?.attachments && messageInstance?.attachments.length > 0" class="mt-4">
|
<div v-if="message?.attachments && message?.attachments.length > 0" class="mt-4">
|
||||||
<div class="text-body-2 text-medium-emphasis mb-2">
|
<div class="text-body-2 text-medium-emphasis mb-2">
|
||||||
Attachments ({{ messageInstance?.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 messageInstance?.attachments"
|
v-for="(attachment, index) in message?.attachments"
|
||||||
:key="index"
|
:key="attachment.partId || attachment.blobId || attachment.cid || index"
|
||||||
prepend-icon="mdi-paperclip"
|
class="attachment-item"
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
class="attachment-chip"
|
|
||||||
>
|
>
|
||||||
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
|
<v-chip
|
||||||
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1">
|
:prepend-icon="isPreviewable(attachment) ? 'mdi-eye-outline' : 'mdi-paperclip'"
|
||||||
({{ formatFileSize(attachment.size) }})
|
size="small"
|
||||||
</span>
|
variant="outlined"
|
||||||
</v-chip>
|
class="attachment-chip"
|
||||||
|
:title="isPreviewable(attachment) ? 'Preview' : 'Download'"
|
||||||
|
@click="handleAttachmentClick(attachment, index)"
|
||||||
|
>
|
||||||
|
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
|
||||||
|
<span v-if="attachment.size != null" class="text-caption text-medium-emphasis ml-1">
|
||||||
|
({{ formatFileSize(attachment.size) }})
|
||||||
|
</span>
|
||||||
|
</v-chip>
|
||||||
|
|
||||||
|
<!-- Hover preview (provided by the media_viewer module) -->
|
||||||
|
<component
|
||||||
|
:is="previewPopover"
|
||||||
|
v-if="previewPopover && isPreviewable(attachment)"
|
||||||
|
:item="attachmentItems[index]"
|
||||||
|
:source="handleViewerRetrieve"
|
||||||
|
@expand="emit('preview', index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,8 +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>
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { EntityInterface } from '@MailManager/types/entity'
|
|
||||||
import type { MessageInterface } from '@MailManager/types/message'
|
|
||||||
import { SecurityLevel } from '@/utile/emailSanitizer'
|
import { SecurityLevel } from '@/utile/emailSanitizer'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: EntityInterface<MessageInterface>
|
|
||||||
isHtml: boolean
|
isHtml: boolean
|
||||||
allowImages: boolean
|
allowImages: boolean
|
||||||
securityLevel: SecurityLevel
|
securityLevel: SecurityLevel
|
||||||
@@ -20,6 +17,7 @@ const emit = defineEmits<{
|
|||||||
move: []
|
move: []
|
||||||
delete: []
|
delete: []
|
||||||
flag: []
|
flag: []
|
||||||
|
download: []
|
||||||
toggleImages: []
|
toggleImages: []
|
||||||
setSecurityLevel: [level: SecurityLevel]
|
setSecurityLevel: [level: SecurityLevel]
|
||||||
}>()
|
}>()
|
||||||
@@ -147,6 +145,24 @@ const currentSecurityLevel = computed(() => {
|
|||||||
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-folder-move-outline"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('move')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-folder-move-outline</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Move To Folder</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-delete-outline"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('delete')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-delete-outline</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Delete</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
<v-menu>
|
<v-menu>
|
||||||
<template #activator="{ props: menuProps }">
|
<template #activator="{ props: menuProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -161,15 +177,11 @@ const currentSecurityLevel = computed(() => {
|
|||||||
|
|
||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-folder-move-outline"
|
prepend-icon="mdi-download"
|
||||||
title="Move To Folder"
|
@click="emit('download')"
|
||||||
@click="emit('move')"
|
>
|
||||||
/>
|
<v-list-item-title>Download</v-list-item-title>
|
||||||
<v-list-item
|
</v-list-item>
|
||||||
prepend-icon="mdi-delete-outline"
|
|
||||||
title="Delete"
|
|
||||||
@click="emit('delete')"
|
|
||||||
/>
|
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||||
import { AddAccountDialog, EditAccountDialog } from '@MailManager/main'
|
import { AddAccountDialog, EditAccountDialog } from '@MailManager/main'
|
||||||
|
import type { ServiceObject } from '@MailManager/models'
|
||||||
|
|
||||||
const servicesStore = useServicesStore()
|
const servicesStore = useServicesStore()
|
||||||
|
|
||||||
// Dialog state
|
// Dialog state
|
||||||
const showAddDialog = ref(false)
|
const showAddDialog = ref(false)
|
||||||
const showEditDialog = ref(false)
|
const showEditDialog = ref(false)
|
||||||
const editServiceProvider = ref<string>('')
|
const editService = ref<ServiceObject>()
|
||||||
const editServiceIdentifier = ref<string | number>('')
|
|
||||||
|
|
||||||
// Load services on mount
|
// Load services on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -22,11 +22,8 @@ const handleAddAccount = () => {
|
|||||||
showAddDialog.value = true
|
showAddDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfigureAccount = (serviceKey: string) => {
|
const handleConfigureAccount = (service: ServiceObject) => {
|
||||||
// Service key is in format "provider:identifier"
|
editService.value = service
|
||||||
const [provider, identifier] = serviceKey.split(':')
|
|
||||||
editServiceProvider.value = provider
|
|
||||||
editServiceIdentifier.value = identifier
|
|
||||||
showEditDialog.value = true
|
showEditDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,14 +48,14 @@ 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
|
||||||
icon="mdi-cog"
|
icon="mdi-cog"
|
||||||
variant="text"
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
@click="handleConfigureAccount(`${service.provider}:${service.identifier}`)"
|
@click="handleConfigureAccount(service)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -87,10 +84,10 @@ const handleAccountSaved = async () => {
|
|||||||
|
|
||||||
<!-- Edit Account Dialog -->
|
<!-- Edit Account Dialog -->
|
||||||
<EditAccountDialog
|
<EditAccountDialog
|
||||||
v-if="editServiceProvider && editServiceIdentifier"
|
v-if="editService"
|
||||||
v-model="showEditDialog"
|
v-model="showEditDialog"
|
||||||
:service-provider="editServiceProvider"
|
:service-provider="editService.provider"
|
||||||
:service-identifier="editServiceIdentifier"
|
:service-identifier="editService.identifier"
|
||||||
@saved="handleAccountSaved"
|
@saved="handleAccountSaved"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,317 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { useDisplay } from 'vuetify'
|
|
||||||
import { useModuleStore } from '@KTXC'
|
|
||||||
import { useMailStore } from '@/stores/mailStore'
|
|
||||||
import type { CollectionObject, EntityObject } from '@MailManager/models'
|
|
||||||
import FolderTree from '@/components/FolderTree.vue'
|
|
||||||
import MessageList from '@/components/MessageList.vue'
|
|
||||||
import MessageReader from '@/components/MessageReader.vue'
|
|
||||||
import MessageComposer from '@/components/MessageComposer.vue'
|
|
||||||
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
|
|
||||||
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
|
|
||||||
|
|
||||||
// Vuetify display for responsive behavior
|
|
||||||
const display = useDisplay()
|
|
||||||
const isMobile = computed(() => display.mdAndDown.value)
|
|
||||||
|
|
||||||
// Check if mail manager is available
|
|
||||||
const moduleStore = useModuleStore()
|
|
||||||
const isMailManagerAvailable = computed(() => {
|
|
||||||
return moduleStore.has('mail_manager') || moduleStore.has('MailManager')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mail module store
|
|
||||||
const mailStore = useMailStore()
|
|
||||||
|
|
||||||
// storeToRefs preserves reactivity for state and computed properties
|
|
||||||
const {
|
|
||||||
sidebarVisible,
|
|
||||||
settingsDialogVisible,
|
|
||||||
loading,
|
|
||||||
selectedFolder,
|
|
||||||
selectedMessage,
|
|
||||||
composeMode,
|
|
||||||
composeReplyTo,
|
|
||||||
currentMessages,
|
|
||||||
moveDialogVisible,
|
|
||||||
} = storeToRefs(mailStore)
|
|
||||||
|
|
||||||
// Complex store/composable objects accessed directly (not simple refs)
|
|
||||||
const { mailSync, entitiesStore } = mailStore
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!isMailManagerAvailable.value) return
|
|
||||||
await mailStore.initialize()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handlers — thin wrappers that delegate to the store
|
|
||||||
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder)
|
|
||||||
|
|
||||||
const handleMessageSelect = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value)
|
|
||||||
|
|
||||||
const handleCompose = (replyTo?: EntityObject) => mailStore.openCompose(replyTo)
|
|
||||||
|
|
||||||
const handleComposeClose = () => mailStore.closeCompose()
|
|
||||||
|
|
||||||
const handleComposeSent = () => mailStore.afterSent()
|
|
||||||
|
|
||||||
const handleReply = (message: EntityObject) => mailStore.openCompose(message)
|
|
||||||
|
|
||||||
const handleDelete = (message: EntityObject) => mailStore.deleteMessage(message)
|
|
||||||
|
|
||||||
const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message)
|
|
||||||
|
|
||||||
const handleMoveConfirm = async (folder: CollectionObject) => {
|
|
||||||
await mailStore.moveMessage(folder)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMoveCancel = () => mailStore.closeMoveDialog()
|
|
||||||
|
|
||||||
const toggleSidebar = () => mailStore.toggleSidebar()
|
|
||||||
|
|
||||||
const handleSettingsOpen = () => mailStore.openSettings()
|
|
||||||
|
|
||||||
const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Folder "${folder.properties.label}" created`, 'success')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Manager Unavailable -->
|
|
||||||
<div v-if="!isMailManagerAvailable" class="mail-unavailable">
|
|
||||||
<v-alert
|
|
||||||
type="warning"
|
|
||||||
variant="outlined"
|
|
||||||
class="mail-unavailable-alert"
|
|
||||||
>
|
|
||||||
<v-icon size="64" color="warning" class="mb-6">mdi-email-off-outline</v-icon>
|
|
||||||
<h2 class="text-h5 font-weight-bold mb-4">Mail Manager Not Available</h2>
|
|
||||||
<p>
|
|
||||||
The Mail Manager module is not installed or enabled.
|
|
||||||
This module requires the <strong>mail_manager</strong> module to function properly.
|
|
||||||
</p>
|
|
||||||
<p class="mb-0 mt-2">
|
|
||||||
Please contact your system administrator to install and enable the
|
|
||||||
<code>mail_manager</code> module.
|
|
||||||
</p>
|
|
||||||
</v-alert>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Normal UI -->
|
|
||||||
<div v-else class="mail-container">
|
|
||||||
<!-- Top toolbar -->
|
|
||||||
<v-app-bar class="mail-toolbar" elevation="0" density="compact">
|
|
||||||
<v-app-bar-nav-icon
|
|
||||||
v-if="isMobile"
|
|
||||||
@click="toggleSidebar"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-app-bar-title>Mail</v-app-bar-title>
|
|
||||||
|
|
||||||
<v-spacer />
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-pencil"
|
|
||||||
@click="handleCompose()"
|
|
||||||
color="primary"
|
|
||||||
variant="text"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-pencil</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Compose</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-refresh"
|
|
||||||
@click="mailSync.sync()"
|
|
||||||
:loading="mailSync.isRunning && entitiesStore.transceiving"
|
|
||||||
variant="text"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-refresh</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">
|
|
||||||
Refresh {{ mailSync.lastSync ? `(Last: ${new Date(mailSync.lastSync).toLocaleTimeString()})` : '' }}
|
|
||||||
</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-icon
|
|
||||||
v-if="mailSync.isRunning"
|
|
||||||
color="success"
|
|
||||||
size="small"
|
|
||||||
class="ml-2"
|
|
||||||
>
|
|
||||||
mdi-sync
|
|
||||||
</v-icon>
|
|
||||||
</v-app-bar>
|
|
||||||
|
|
||||||
<!-- Main content area -->
|
|
||||||
<div class="mail-content">
|
|
||||||
<!-- Folder tree sidebar -->
|
|
||||||
<v-navigation-drawer
|
|
||||||
v-model="sidebarVisible"
|
|
||||||
:permanent="!isMobile"
|
|
||||||
:temporary="isMobile"
|
|
||||||
width="280"
|
|
||||||
class="mail-sidebar"
|
|
||||||
>
|
|
||||||
<FolderTree
|
|
||||||
:selected-folder="selectedFolder"
|
|
||||||
@select="handleFolderSelect"
|
|
||||||
@folder-created="handleFolderCreated"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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 -->
|
|
||||||
<div class="mail-main">
|
|
||||||
<div class="mail-wrapper">
|
|
||||||
<!-- Message list panel -->
|
|
||||||
<div class="mail-list-panel">
|
|
||||||
<MessageList
|
|
||||||
:messages="currentMessages"
|
|
||||||
:selected-message="selectedMessage"
|
|
||||||
:selected-collection="selectedFolder"
|
|
||||||
:loading="loading"
|
|
||||||
@select="handleMessageSelect"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reader/Composer panel -->
|
|
||||||
<div class="mail-reader-panel">
|
|
||||||
<MessageComposer
|
|
||||||
v-if="composeMode"
|
|
||||||
:reply-to="composeReplyTo"
|
|
||||||
:folder="selectedFolder"
|
|
||||||
@close="handleComposeClose"
|
|
||||||
@sent="handleComposeSent"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MessageReader
|
|
||||||
v-else
|
|
||||||
:message="selectedMessage"
|
|
||||||
@reply="handleReply"
|
|
||||||
@move="handleMove"
|
|
||||||
@delete="handleDelete"
|
|
||||||
@compose="handleCompose()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings Dialog -->
|
|
||||||
<SettingsDialog v-model="settingsDialogVisible" />
|
|
||||||
|
|
||||||
<FolderSelectionDialog
|
|
||||||
v-model="moveDialogVisible"
|
|
||||||
:loading="loading"
|
|
||||||
title="Move To"
|
|
||||||
confirm-text="Move"
|
|
||||||
empty-text="No other folders are available in this account."
|
|
||||||
@select="handleMoveConfirm"
|
|
||||||
@cancel="handleMoveCancel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.mail-unavailable {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
padding: 48px;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-unavailable-alert {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-toolbar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-content {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-sidebar {
|
|
||||||
border-right: 1px solid rgb(var(--v-border-color));
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-list-panel {
|
|
||||||
width: 320px;
|
|
||||||
min-width: 280px;
|
|
||||||
max-width: 450px;
|
|
||||||
border-right: 1px solid rgb(var(--v-border-color));
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-reader-panel {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive adjustments */
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.mail-wrapper {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-list-panel {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
|
||||||
max-height: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-reader-panel {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, unref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useModuleStore } from '@KTXC'
|
||||||
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
|
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
||||||
|
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
|
||||||
|
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||||
|
import type { CollectionObject, EntityObject } from '@MailManager/models'
|
||||||
|
import { ComposerMode } from '@/types/composer'
|
||||||
|
import MessageList from '@/components/MessageList.vue'
|
||||||
|
import MessageReader from '@/components/MessageReader.vue'
|
||||||
|
import MessageComposer from '@/components/MessageComposer.vue'
|
||||||
|
import CreateFolderDialog from '@/components/CreateFolderDialog.vue'
|
||||||
|
import DeleteFolderDialog from '@/components/DeleteFolderDialog.vue'
|
||||||
|
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
|
||||||
|
import RenameFolderDialog from '@/components/RenameFolderDialog.vue'
|
||||||
|
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
|
||||||
|
import Sidebar from '@/components/Sidebar.vue'
|
||||||
|
|
||||||
|
// Vuetify display for responsive behavior
|
||||||
|
const display = useDisplay()
|
||||||
|
const isMobile = computed(() => display.mdAndDown.value)
|
||||||
|
|
||||||
|
// Check if mail manager is available
|
||||||
|
const moduleStore = useModuleStore()
|
||||||
|
const isManagerAvailable = computed(() => {
|
||||||
|
return moduleStore.has('mail_manager') || moduleStore.has('MailManager')
|
||||||
|
})
|
||||||
|
const collectionsStore = useCollectionsStore()
|
||||||
|
|
||||||
|
// Mail module store
|
||||||
|
const mailOperationsStore = useMailOperationsStore()
|
||||||
|
const mailUiStore = useMailUiStore()
|
||||||
|
const mailSettingsStore = useMailSettingsStore()
|
||||||
|
const { messageListWidth } = storeToRefs(mailSettingsStore)
|
||||||
|
|
||||||
|
// storeToRefs preserves reactivity for state and computed properties
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
selectedMessage,
|
||||||
|
currentMessages,
|
||||||
|
} = storeToRefs(mailOperationsStore)
|
||||||
|
|
||||||
|
const {
|
||||||
|
settingsDialogVisible,
|
||||||
|
selectedFolder,
|
||||||
|
composerMode,
|
||||||
|
composerSource,
|
||||||
|
composerVisible,
|
||||||
|
selectionList,
|
||||||
|
selectionMode,
|
||||||
|
moveMessagesDialogVisible,
|
||||||
|
moveMessagesDialogService,
|
||||||
|
createFolderDialogVisible,
|
||||||
|
createFolderDialogService,
|
||||||
|
createFolderDialogLoading,
|
||||||
|
createFolderDialogError,
|
||||||
|
renameFolderDialogVisible,
|
||||||
|
renameFolderDialogService,
|
||||||
|
renameFolderDialogFolder,
|
||||||
|
renameFolderDialogLoading,
|
||||||
|
renameFolderDialogError,
|
||||||
|
moveFolderDialogVisible,
|
||||||
|
moveFolderDialogService,
|
||||||
|
deleteFolderDialogVisible,
|
||||||
|
deleteFolderDialogService,
|
||||||
|
deleteFolderDialogFolder,
|
||||||
|
deleteFolderDialogLoading,
|
||||||
|
deleteFolderDialogError,
|
||||||
|
} = storeToRefs(mailUiStore)
|
||||||
|
|
||||||
|
// Complex store/composable objects accessed directly (not simple refs)
|
||||||
|
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 lastSync = unref(unref(mailSync.lastSync))
|
||||||
|
|
||||||
|
if (!(lastSync instanceof Date)) return ''
|
||||||
|
return `(Last: ${lastSync.toLocaleTimeString()})`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!isManagerAvailable.value) return
|
||||||
|
await mailUiStore.initialize()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handlers — thin wrappers that delegate to the store
|
||||||
|
const {
|
||||||
|
validateCreateFolderName,
|
||||||
|
validateRenameFolderName,
|
||||||
|
} = mailUiStore
|
||||||
|
|
||||||
|
const sidebarToggle = () => mailUiStore.sidebarToggle()
|
||||||
|
|
||||||
|
const handleFolderSelect = (folder: CollectionObject) => mailUiStore.selectFolder(folder)
|
||||||
|
|
||||||
|
const handleFolderCreateConfirm = async (folderName: string) => {
|
||||||
|
try {
|
||||||
|
const mutatedFolder = await mailUiStore.confirmCreateFolder(folderName)
|
||||||
|
|
||||||
|
if (mutatedFolder) {
|
||||||
|
handleFolderSelect(mutatedFolder)
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('[Mail] Failed to create folder:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderEditConfirm = async (folderName: string) => {
|
||||||
|
try {
|
||||||
|
const mutatedFolder = await mailUiStore.confirmRenameFolder(folderName)
|
||||||
|
|
||||||
|
if (mutatedFolder) {
|
||||||
|
handleFolderSelect(mutatedFolder)
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
|
||||||
|
try {
|
||||||
|
await mailUiStore.confirmMoveFolder(targetFolder)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('[Mail] Failed to move folder:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog()
|
||||||
|
|
||||||
|
|
||||||
|
const handleMessageOpen = (message: EntityObject) => {
|
||||||
|
mailOperationsStore.selectMessage(message)
|
||||||
|
|
||||||
|
if (isMobile.value) {
|
||||||
|
mailUiStore.sidebarHide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessageComposeFresh = () => mailUiStore.openComposer()
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Manager Unavailable -->
|
||||||
|
<div v-if="!isManagerAvailable" class="mail-unavailable">
|
||||||
|
<v-alert
|
||||||
|
type="warning"
|
||||||
|
variant="outlined"
|
||||||
|
class="mail-unavailable-alert"
|
||||||
|
>
|
||||||
|
<v-icon size="64" color="warning" class="mb-6">mdi-email-off-outline</v-icon>
|
||||||
|
<h2 class="text-h5 font-weight-bold mb-4">Mail Manager Not Available</h2>
|
||||||
|
<p>
|
||||||
|
The Mail Manager module is not installed or enabled.
|
||||||
|
This module requires the <strong>mail_manager</strong> module to function properly.
|
||||||
|
</p>
|
||||||
|
<p class="mb-0 mt-2">
|
||||||
|
Please contact your system administrator to install and enable the
|
||||||
|
<code>mail_manager</code> module.
|
||||||
|
</p>
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal UI -->
|
||||||
|
<div v-else class="mail-container">
|
||||||
|
<!-- Top toolbar -->
|
||||||
|
<v-toolbar class="mail-toolbar" elevation="0" density="compact">
|
||||||
|
<template #prepend>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-menu"
|
||||||
|
variant="text"
|
||||||
|
@click="sidebarToggle"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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 />
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
|
icon="mdi-pencil"
|
||||||
|
@click="handleMessageComposeFresh()"
|
||||||
|
color="primary"
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-pencil</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Compose</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-refresh"
|
||||||
|
@click="mailSync.sync()"
|
||||||
|
:loading="mailSync.isRunning && entitiesStore.transceiving"
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-refresh</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">
|
||||||
|
Refresh {{ lastSyncLabel }}
|
||||||
|
</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-icon
|
||||||
|
v-if="mailSync.isRunning"
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
class="ml-2"
|
||||||
|
>
|
||||||
|
mdi-sync
|
||||||
|
</v-icon>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<!-- Main content area -->
|
||||||
|
<div class="mail-content">
|
||||||
|
<!-- Folder tree sidebar -->
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<!-- Main area with message list and reader -->
|
||||||
|
<div class="mail-main">
|
||||||
|
<div class="mail-wrapper">
|
||||||
|
<!-- Message list panel -->
|
||||||
|
<div
|
||||||
|
class="mail-list-panel"
|
||||||
|
:style="{ '--mail-list-width': messageListWidth + '%' }"
|
||||||
|
>
|
||||||
|
<MessageList
|
||||||
|
:messages="filteredMessages"
|
||||||
|
:selected-collection="selectedFolder"
|
||||||
|
:selected-message="selectedMessage"
|
||||||
|
:selection-list="selectionList"
|
||||||
|
:selection-mode="selectionMode"
|
||||||
|
:loading="loading"
|
||||||
|
@open="handleMessageOpen"
|
||||||
|
@reply="handleMessageComposeReply"
|
||||||
|
@forward="handleMessageComposeForward"
|
||||||
|
@move="handleMessageMove"
|
||||||
|
@delete="handleMessageDelete"
|
||||||
|
@flag="handleMessageFlag"
|
||||||
|
@selection-mode="handleMessageSelectionMode"
|
||||||
|
@selection-toggle-one="handleMessageSelectionToggleOne"
|
||||||
|
@selection-toggle-all="handleMessageSelectionToggleAll"
|
||||||
|
@selection-clear="handleMessageSelectionClear"
|
||||||
|
@selection-flag="handleMessageSelectionFlag"
|
||||||
|
@selection-move="handleMessageSelectionMove"
|
||||||
|
@selection-delete="handleMessageSelectionDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reader/Composer panel -->
|
||||||
|
<div class="mail-reader-panel">
|
||||||
|
<MessageComposer
|
||||||
|
v-if="composerVisible"
|
||||||
|
:mode="composerMode"
|
||||||
|
:source="composerSource"
|
||||||
|
:folder="selectedFolder"
|
||||||
|
@close="handleMessageComposeClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MessageReader
|
||||||
|
v-else
|
||||||
|
:entity="selectedMessage"
|
||||||
|
@compose="handleMessageComposeFresh"
|
||||||
|
@reply="handleMessageComposeReply"
|
||||||
|
@forward="handleMessageComposeForward"
|
||||||
|
@move="handleMessageMove"
|
||||||
|
@delete="handleMessageDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Dialog -->
|
||||||
|
<SettingsDialog v-model="settingsDialogVisible" />
|
||||||
|
|
||||||
|
<FolderSelectionDialog
|
||||||
|
v-if="moveMessagesDialogService"
|
||||||
|
v-model="moveMessagesDialogVisible"
|
||||||
|
:service="moveMessagesDialogService"
|
||||||
|
:loading="loading"
|
||||||
|
title="Move Messages To"
|
||||||
|
confirm-text="Move"
|
||||||
|
empty-text="No other folders are available in this account."
|
||||||
|
@select="handleMessageMoveConfirm"
|
||||||
|
@cancel="handleMessageMoveCancel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FolderSelectionDialog
|
||||||
|
v-if="moveFolderDialogService"
|
||||||
|
v-model="moveFolderDialogVisible"
|
||||||
|
:service="moveFolderDialogService"
|
||||||
|
:loading="collectionsStore.transceiving"
|
||||||
|
title="Move Folder To"
|
||||||
|
confirm-text="Move"
|
||||||
|
empty-text="No other folders are available in this account."
|
||||||
|
:disabled-folder-keys="mailUiStore.moveFolderDialogInvalidFolderKeys"
|
||||||
|
@select="handleFolderMoveConfirm"
|
||||||
|
@cancel="handleFolderMoveCancel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateFolderDialog
|
||||||
|
v-if="createFolderDialogService"
|
||||||
|
v-model="createFolderDialogVisible"
|
||||||
|
:service="createFolderDialogService"
|
||||||
|
:parent-folder-label="mailUiStore.createFolderDialogParentLabel"
|
||||||
|
:validate-name="validateCreateFolderName"
|
||||||
|
:loading="createFolderDialogLoading"
|
||||||
|
:error-message="createFolderDialogError"
|
||||||
|
@confirm="handleFolderCreateConfirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RenameFolderDialog
|
||||||
|
v-if="renameFolderDialogService && renameFolderDialogFolder"
|
||||||
|
v-model="renameFolderDialogVisible"
|
||||||
|
:service="renameFolderDialogService"
|
||||||
|
:folder="renameFolderDialogFolder"
|
||||||
|
:parent-folder-label="mailUiStore.renameFolderDialogParentLabel"
|
||||||
|
:validate-name="validateRenameFolderName"
|
||||||
|
:loading="renameFolderDialogLoading"
|
||||||
|
:error-message="renameFolderDialogError"
|
||||||
|
@confirm="handleFolderEditConfirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteFolderDialog
|
||||||
|
v-if="deleteFolderDialogService && deleteFolderDialogFolder"
|
||||||
|
v-model="deleteFolderDialogVisible"
|
||||||
|
:service="deleteFolderDialogService"
|
||||||
|
:folder="deleteFolderDialogFolder"
|
||||||
|
:loading="deleteFolderDialogLoading"
|
||||||
|
:error-message="deleteFolderDialogError"
|
||||||
|
@confirm="handleFolderDeleteConfirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.mail-unavailable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-unavailable-alert {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-toolbar {
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-main {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-panel {
|
||||||
|
flex: 0 0 var(--mail-list-width, 35%);
|
||||||
|
width: var(--mail-list-width, 35%);
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 70%;
|
||||||
|
border-right: 1px solid rgb(var(--v-border-color));
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-reader-panel {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.mail-wrapper {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-panel {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||||
|
max-height: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-reader-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+3
-2
@@ -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'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
import { ref, computed, shallowRef } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
|
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
|
||||||
|
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||||
|
import { useMailSync } from '@MailManager/composables/useMailSync'
|
||||||
|
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
||||||
|
import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity'
|
||||||
|
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
|
||||||
|
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
|
||||||
|
import { CollectionPropertiesObject } from '@MailManager/models/collection'
|
||||||
|
|
||||||
|
interface ComposerMessageInput {
|
||||||
|
to: string[]
|
||||||
|
cc: string[]
|
||||||
|
bcc: string[]
|
||||||
|
subject: string
|
||||||
|
body: {
|
||||||
|
html: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
const collectionsStore = useCollectionsStore()
|
||||||
|
const entitiesStore = useEntitiesStore()
|
||||||
|
|
||||||
|
// Background mail sync
|
||||||
|
const mailSyncController = useMailSync({
|
||||||
|
interval: 30000,
|
||||||
|
autoStart: false,
|
||||||
|
fetchDetails: true,
|
||||||
|
})
|
||||||
|
const mailSync = {
|
||||||
|
isRunning: mailSyncController.isRunning,
|
||||||
|
lastSync: mailSyncController.lastSync,
|
||||||
|
error: mailSyncController.error,
|
||||||
|
sync: mailSyncController.sync,
|
||||||
|
start: mailSyncController.start,
|
||||||
|
stop: mailSyncController.stop,
|
||||||
|
restart: mailSyncController.restart,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── General State ─────────────────-───────────────────────────────────────
|
||||||
|
const loading = ref(false)
|
||||||
|
const serviceFolderLoadingState = ref<Record<string, boolean>>({})
|
||||||
|
const serviceFolderLoadedState = ref<Record<string, boolean>>({})
|
||||||
|
const serviceFolderErrorState = ref<Record<string, string | null>>({})
|
||||||
|
|
||||||
|
const selectedFolder = shallowRef<CollectionObject | null>(null)
|
||||||
|
const selectedMessage = shallowRef<EntityObject | null>(null)
|
||||||
|
const composerSaving = ref(false)
|
||||||
|
const composerSending = ref(false)
|
||||||
|
const composerLastSaved = ref<Date | null>(null)
|
||||||
|
const composerDraftIdentifier = ref<EntityIdentifier | null>(null)
|
||||||
|
|
||||||
|
// ── Computed ──────────────────────────────────────────────────────────────
|
||||||
|
const currentMessages = computed(() => {
|
||||||
|
if (!selectedFolder.value) return []
|
||||||
|
|
||||||
|
const folder = selectedFolder.value
|
||||||
|
|
||||||
|
return entitiesStore.entities.filter(e =>
|
||||||
|
e.provider === folder.provider &&
|
||||||
|
String(e.service) === String(folder.service) &&
|
||||||
|
String(e.collection) === String(folder.identifier),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Initialization ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await servicesStore.list()
|
||||||
|
|
||||||
|
const services = [...servicesStore.servicesEnabled]
|
||||||
|
await Promise.all(services.map(service => loadFoldersForService(service)))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mail][Operations] Failed to initialize:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFoldersForService(service: ServiceObject) {
|
||||||
|
|
||||||
|
if (service.identifier === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_setServiceFolderLoading(service.provider, service.identifier, true)
|
||||||
|
_setServiceFolderError(service.provider, service.identifier, null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// retrieve folders for service
|
||||||
|
await collectionsStore.collectionsForService(service.provider, service.identifier, true)
|
||||||
|
|
||||||
|
_setServiceFolderLoaded(service.provider, service.identifier, true)
|
||||||
|
|
||||||
|
_updateSyncSources()
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load folders'
|
||||||
|
_setServiceFolderError(service.provider, service.identifier, message)
|
||||||
|
console.error(`[Mail][Operations] Failed to load folders for ${service.provider}:${String(service.identifier)}:`, error)
|
||||||
|
_updateSyncSources()
|
||||||
|
return {}
|
||||||
|
} finally {
|
||||||
|
_setServiceFolderLoading(service.provider, service.identifier, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier {
|
||||||
|
if (item instanceof ServiceObject) {
|
||||||
|
return `${item.provider}:${String(item.identifier)}` as ServiceIdentifier
|
||||||
|
}
|
||||||
|
return `${item.provider}:${String(item.service)}` as ServiceIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
|
||||||
|
serviceFolderLoadingState.value = {
|
||||||
|
...serviceFolderLoadingState.value,
|
||||||
|
[_serviceIdentifier({ provider, service })]: loadingState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setServiceFolderLoaded(provider: string, service: string | number, loaded: boolean) {
|
||||||
|
serviceFolderLoadedState.value = {
|
||||||
|
...serviceFolderLoadedState.value,
|
||||||
|
[_serviceIdentifier({ provider, service })]: loaded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setServiceFolderError(provider: string, service: string | number, error: string | null) {
|
||||||
|
serviceFolderErrorState.value = {
|
||||||
|
...serviceFolderErrorState.value,
|
||||||
|
[_serviceIdentifier({ provider, service })]: error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateSyncSources() {
|
||||||
|
mailSyncController.clearSources()
|
||||||
|
|
||||||
|
// Track the currently selected folder
|
||||||
|
if (selectedFolder.value) {
|
||||||
|
mailSyncController.addSource(selectedFolder.value.identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always track inboxes for each account (for new-mail notifications)
|
||||||
|
servicesStore.servicesEnabled.forEach(service => {
|
||||||
|
if (service.identifier === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter(
|
||||||
|
c =>
|
||||||
|
String(c.service) === String(service.identifier) &&
|
||||||
|
(c.properties.role === 'inbox' ||
|
||||||
|
String(c.identifier).toLowerCase() === 'inbox'),
|
||||||
|
)
|
||||||
|
|
||||||
|
inboxes.forEach(inbox => mailSyncController.addSource(inbox.identifier))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (mailSyncController.sources.value.length > 0 && !mailSyncController.isRunning.value) {
|
||||||
|
mailSyncController.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isServiceFolderLoading(provider: string, service: string | number) {
|
||||||
|
return serviceFolderLoadingState.value[_serviceIdentifier({ provider, service })] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasServiceFoldersLoaded(provider: string, service: string | number) {
|
||||||
|
return serviceFolderLoadedState.value[_serviceIdentifier({ provider, service })] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServiceFolderError(provider: string, service: string | number) {
|
||||||
|
return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findDraftFolder(folder: CollectionObject): CollectionObject {
|
||||||
|
return collectionsStore.collectionsForService(folder.provider, folder.service).find(
|
||||||
|
candidate =>
|
||||||
|
candidate.provider === folder.provider &&
|
||||||
|
String(candidate.service) === String(folder.service) &&
|
||||||
|
(candidate.properties.role === 'drafts' ||
|
||||||
|
String(candidate.identifier).toLowerCase() === 'drafts' ||
|
||||||
|
candidate.properties.label.toLowerCase() === 'drafts'),
|
||||||
|
) ?? folder
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toMessageAddresses(addresses: string[]): MessageAddressInterface[] | undefined {
|
||||||
|
const normalized = addresses
|
||||||
|
.map(address => address.trim())
|
||||||
|
.filter(address => address.length > 0)
|
||||||
|
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.map(address => ({ address }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toDraftBody(body: ComposerMessageInput['body']): MessagePartInterface | null {
|
||||||
|
const parts: MessagePartInterface[] = []
|
||||||
|
const text = body.text.trim()
|
||||||
|
const html = body.html.trim()
|
||||||
|
|
||||||
|
if (text.length > 0) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text/plain',
|
||||||
|
content: text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html.length > 0) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text/html',
|
||||||
|
content: html,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'multipart/alternative',
|
||||||
|
subParts: parts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toDraftProperties(message: ComposerMessageInput): MessageInterface {
|
||||||
|
return {
|
||||||
|
'@type': 'mail:message',
|
||||||
|
to: _toMessageAddresses(message.to),
|
||||||
|
cc: _toMessageAddresses(message.cc),
|
||||||
|
bcc: _toMessageAddresses(message.bcc),
|
||||||
|
subject: message.subject.trim() || null,
|
||||||
|
body: _toDraftBody(message.body),
|
||||||
|
flags: {
|
||||||
|
draft: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetComposerState() {
|
||||||
|
composerSaving.value = false
|
||||||
|
composerSending.value = false
|
||||||
|
composerLastSaved.value = null
|
||||||
|
composerDraftIdentifier.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function retrieveService(identifier: ServiceIdentifier, force: boolean = false): Promise<ServiceObject | null> {
|
||||||
|
let service = servicesStore.serviceByIdentifier(identifier)
|
||||||
|
if (service && !force) {
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
service = await servicesStore.serviceByIdentifier(identifier, true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Mail][Operations] Failed to retrieve service ${identifier}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
const message = `Service ${identifier} not found`
|
||||||
|
console.error(`[Mail][Operations] ${message}`)
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectFolder(folder: CollectionObject | null) {
|
||||||
|
selectedFolder.value = folder
|
||||||
|
selectedMessage.value = null
|
||||||
|
|
||||||
|
if (folder) {
|
||||||
|
try {
|
||||||
|
await entitiesStore.list([folder.identifier])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mail][Operations] Failed to load messages:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateSyncSources()
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMessage(entity: EntityObject | null) {
|
||||||
|
selectedMessage.value = entity
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) {
|
||||||
|
composerSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetFolder = _findDraftFolder(folder)
|
||||||
|
const properties = _toDraftProperties(message)
|
||||||
|
const draft = composerDraftIdentifier.value
|
||||||
|
? await entitiesStore.update(composerDraftIdentifier.value, properties)
|
||||||
|
: await entitiesStore.create(targetFolder.identifier, properties)
|
||||||
|
|
||||||
|
composerDraftIdentifier.value = draft.identifier
|
||||||
|
composerLastSaved.value = new Date()
|
||||||
|
|
||||||
|
return draft
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mail][Operations] Failed to save draft:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
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) {
|
||||||
|
composerSending.value = true
|
||||||
|
|
||||||
|
const transmitRequest: EntityTransmitRequest = {
|
||||||
|
message: {
|
||||||
|
to: message.to.map(address => address.trim()).filter(address => address.length > 0),
|
||||||
|
cc: message.cc.map(address => address.trim()).filter(address => address.length > 0),
|
||||||
|
bcc: message.bcc.map(address => address.trim()).filter(address => address.length > 0),
|
||||||
|
subject: message.subject,
|
||||||
|
body: {
|
||||||
|
html: message.body.html,
|
||||||
|
text: message.body.text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transmitRequest.message.cc?.length === 0) {
|
||||||
|
delete transmitRequest.message.cc
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transmitRequest.message.bcc?.length === 0) {
|
||||||
|
delete transmitRequest.message.bcc
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await entitiesStore.transmit(transmitRequest)
|
||||||
|
|
||||||
|
if (composerDraftIdentifier.value) {
|
||||||
|
try {
|
||||||
|
await entitiesStore.delete([composerDraftIdentifier.value])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mail][Operations] Failed to delete draft after send:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetComposerState()
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mail][Operations] Failed to send message:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
composerSending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFolder(service: ServiceObject, label: string, parentFolder: CollectionObject | null = null): Promise<CollectionObject> {
|
||||||
|
if (service.identifier === null) {
|
||||||
|
throw new Error('Cannot create folder for a service without an identifier')
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties = new CollectionPropertiesObject()
|
||||||
|
properties.label = label.trim()
|
||||||
|
properties.rank = 0
|
||||||
|
properties.subscribed = true
|
||||||
|
|
||||||
|
const newFolder = await collectionsStore.create(
|
||||||
|
service.provider,
|
||||||
|
service.identifier,
|
||||||
|
properties,
|
||||||
|
parentFolder?.identifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
return newFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameFolder(folder: CollectionObject, label: string): Promise<CollectionObject> {
|
||||||
|
const properties = new CollectionPropertiesObject()
|
||||||
|
properties.label = label.trim()
|
||||||
|
properties.rank = folder.properties.rank ?? 0
|
||||||
|
properties.subscribed = folder.properties.subscribed ?? true
|
||||||
|
|
||||||
|
const updatedFolder = await collectionsStore.update(folder.identifier, properties)
|
||||||
|
|
||||||
|
if (_sameCollection(selectedFolder.value, folder)) {
|
||||||
|
selectedFolder.value = updatedFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveFolder(source: CollectionObject, target: CollectionObject): Promise<CollectionObject> {
|
||||||
|
const movedFolder = await collectionsStore.move(target.identifier, source.identifier)
|
||||||
|
|
||||||
|
if (_sameCollection(selectedFolder.value, source)) {
|
||||||
|
selectedFolder.value = movedFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
return movedFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFolder(folder: CollectionObject): Promise<CollectionObject | boolean> {
|
||||||
|
const deletedFolder = await collectionsStore.delete(folder.identifier)
|
||||||
|
|
||||||
|
if (_sameCollection(selectedFolder.value, folder)) {
|
||||||
|
await selectFolder(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]) {
|
||||||
|
const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
|
||||||
|
(accumulator, identifier) => {
|
||||||
|
const entity = entitiesStore.entity(identifier)
|
||||||
|
|
||||||
|
if (!entity) {
|
||||||
|
return accumulator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow moving messages within the same service and disallow moving into the same folder
|
||||||
|
const canMove = entity.provider === target.provider &&
|
||||||
|
String(entity.service) === String(target.service) &&
|
||||||
|
String(entity.collection) !== String(target.identifier)
|
||||||
|
|
||||||
|
if (!canMove) {
|
||||||
|
return accumulator
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulator.movableIdentifiers.push(identifier)
|
||||||
|
|
||||||
|
if (!accumulator.sourceCollections.some(
|
||||||
|
collection => String(collection) === String(entity.collection),
|
||||||
|
)) {
|
||||||
|
accumulator.sourceCollections.push(entity.collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator
|
||||||
|
},
|
||||||
|
{
|
||||||
|
movableIdentifiers: [] as EntityIdentifier[],
|
||||||
|
sourceCollections: [] as CollectionIdentifier[],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (movableIdentifiers.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
|
||||||
|
|
||||||
|
// update source collections to reflect moved messages
|
||||||
|
sourceCollections.push(target.identifier)
|
||||||
|
await collectionsStore.fetch(sourceCollections)
|
||||||
|
|
||||||
|
return { successes, failures }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mail][Operations] Failed to move messages:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadMessage(entity: EntityObject, index?: number) {
|
||||||
|
const target = entity.identifier
|
||||||
|
let part = null
|
||||||
|
|
||||||
|
if (index !== undefined) {
|
||||||
|
part = entity.properties.attachments?.[Number(index)] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await entitiesStore.download(target, part)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
index === undefined
|
||||||
|
? '[Mail][Operations] Failed to download message:'
|
||||||
|
: '[Mail][Operations] Failed to download attachment:',
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachmentBlob(entity: EntityObject, part: MessagePartInterface): Promise<Blob> {
|
||||||
|
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 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Sub-stores (forwarded for template convenience)
|
||||||
|
collectionsStore,
|
||||||
|
entitiesStore,
|
||||||
|
servicesStore,
|
||||||
|
mailSync,
|
||||||
|
|
||||||
|
// State
|
||||||
|
loading,
|
||||||
|
selectedMessage,
|
||||||
|
composerSaving,
|
||||||
|
composerSending,
|
||||||
|
composerLastSaved,
|
||||||
|
composerDraftIdentifier,
|
||||||
|
serviceFolderLoadingState,
|
||||||
|
serviceFolderLoadedState,
|
||||||
|
serviceFolderErrorState,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
currentMessages,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
retrieveService,
|
||||||
|
selectFolder,
|
||||||
|
selectMessage,
|
||||||
|
createFolder,
|
||||||
|
saveComposerDraft,
|
||||||
|
sendComposerMessage,
|
||||||
|
resetComposerState,
|
||||||
|
flagMessages,
|
||||||
|
deleteMessages,
|
||||||
|
deleteFolder,
|
||||||
|
moveMessages,
|
||||||
|
downloadMessage,
|
||||||
|
attachmentBlob,
|
||||||
|
moveFolder,
|
||||||
|
renameFolder,
|
||||||
|
isServiceFolderLoading,
|
||||||
|
hasServiceFoldersLoaded,
|
||||||
|
getServiceFolderError,
|
||||||
|
findFoldersByRole,
|
||||||
|
loadFoldersForService,
|
||||||
|
initialize,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,409 +0,0 @@
|
|||||||
import { ref, computed, shallowRef } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
|
||||||
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
|
|
||||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
|
||||||
import { useMailSync } from '@MailManager/composables/useMailSync'
|
|
||||||
import { useSnackbar } from '@KTXC'
|
|
||||||
import type { CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
|
||||||
import type { MessageInterface } from '@MailManager/types/message'
|
|
||||||
import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models'
|
|
||||||
|
|
||||||
export const useMailStore = defineStore('mailStore', () => {
|
|
||||||
const collectionsStore = useCollectionsStore()
|
|
||||||
const entitiesStore = useEntitiesStore()
|
|
||||||
const servicesStore = useServicesStore()
|
|
||||||
const { showSnackbar } = useSnackbar()
|
|
||||||
|
|
||||||
// Background mail sync
|
|
||||||
const mailSyncController = useMailSync({
|
|
||||||
interval: 30000,
|
|
||||||
autoStart: false,
|
|
||||||
fetchDetails: true,
|
|
||||||
})
|
|
||||||
const mailSync = {
|
|
||||||
isRunning: mailSyncController.isRunning,
|
|
||||||
lastSync: mailSyncController.lastSync,
|
|
||||||
error: mailSyncController.error,
|
|
||||||
sync: mailSyncController.sync,
|
|
||||||
start: mailSyncController.start,
|
|
||||||
stop: mailSyncController.stop,
|
|
||||||
restart: mailSyncController.restart,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── General State ─────────────────-───────────────────────────────────────
|
|
||||||
const sidebarVisible = ref(true)
|
|
||||||
const settingsDialogVisible = ref(false)
|
|
||||||
const loading = ref(false)
|
|
||||||
const serviceFolderLoadingState = ref<Record<string, boolean>>({})
|
|
||||||
const serviceFolderLoadedState = ref<Record<string, boolean>>({})
|
|
||||||
const serviceFolderErrorState = ref<Record<string, string | null>>({})
|
|
||||||
|
|
||||||
// ── Selection State ───────────────────────────────────────────────────────
|
|
||||||
const selectedFolder = shallowRef<CollectionObject | null>(null)
|
|
||||||
const selectedMessage = shallowRef<EntityObject | null>(null)
|
|
||||||
|
|
||||||
// ── Compose State ─────────────────────────────────────────────────────────
|
|
||||||
const composeMode = ref(false)
|
|
||||||
const composeReplyTo = shallowRef<EntityObject | null>(null)
|
|
||||||
|
|
||||||
// ── Move State ────────────────────────────────────────────────────────────
|
|
||||||
const moveDialogVisible = ref(false)
|
|
||||||
const moveMessageCandidate = shallowRef<EntityObject | null>(null)
|
|
||||||
|
|
||||||
// ── Computed ──────────────────────────────────────────────────────────────
|
|
||||||
const currentMessages = computed(() => {
|
|
||||||
if (!selectedFolder.value) return []
|
|
||||||
|
|
||||||
const folder = selectedFolder.value
|
|
||||||
|
|
||||||
return entitiesStore.entities.filter(e =>
|
|
||||||
e.provider === folder.provider &&
|
|
||||||
String(e.service) === String(folder.service) &&
|
|
||||||
String(e.collection) === String(folder.identifier),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Initialization ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function initialize() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await servicesStore.list()
|
|
||||||
|
|
||||||
const services = [...servicesStore.services]
|
|
||||||
services.forEach(service => {
|
|
||||||
void loadFoldersForService(service,{ selectInbox: true })
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Mail] Failed to initialize:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFoldersForService(
|
|
||||||
service: ServiceObject,
|
|
||||||
options: { selectInbox?: boolean } = {},
|
|
||||||
) {
|
|
||||||
|
|
||||||
if (service.identifier === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_setServiceFolderLoading(service.provider, service.identifier, true)
|
|
||||||
_setServiceFolderError(service.provider, service.identifier, null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// retrieve folders for service
|
|
||||||
const collections = await collectionsStore.list({
|
|
||||||
[service.provider]: {
|
|
||||||
[String(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()
|
|
||||||
return collections
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to load folders'
|
|
||||||
_setServiceFolderError(service.provider, service.identifier, message)
|
|
||||||
console.error(
|
|
||||||
`[Mail] Failed to load folders for ${service.provider}:${String(service.identifier)}:`,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
_updateSyncSources()
|
|
||||||
return {}
|
|
||||||
} finally {
|
|
||||||
_setServiceFolderLoading(service.provider, service.identifier, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sync Helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function _serviceKey(provider: string, service: string | number) {
|
|
||||||
return `${provider}:${String(service)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
|
|
||||||
serviceFolderLoadingState.value = {
|
|
||||||
...serviceFolderLoadingState.value,
|
|
||||||
[_serviceKey(provider, service)]: loadingState,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setServiceFolderLoaded(provider: string, service: string | number, loaded: boolean) {
|
|
||||||
serviceFolderLoadedState.value = {
|
|
||||||
...serviceFolderLoadedState.value,
|
|
||||||
[_serviceKey(provider, service)]: loaded,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setServiceFolderError(provider: string, service: string | number, error: string | null) {
|
|
||||||
serviceFolderErrorState.value = {
|
|
||||||
...serviceFolderErrorState.value,
|
|
||||||
[_serviceKey(provider, service)]: error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _updateSyncSources() {
|
|
||||||
mailSyncController.clearSources()
|
|
||||||
|
|
||||||
// Track the currently selected folder
|
|
||||||
if (selectedFolder.value) {
|
|
||||||
mailSyncController.addSource({
|
|
||||||
provider: selectedFolder.value.provider,
|
|
||||||
service: selectedFolder.value.service,
|
|
||||||
collections: [selectedFolder.value.identifier],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always track inboxes for each account (for new-mail notifications)
|
|
||||||
servicesStore.services.forEach(service => {
|
|
||||||
const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter(
|
|
||||||
c =>
|
|
||||||
String(c.service) === String(service.identifier) &&
|
|
||||||
(c.properties.role === 'inbox' ||
|
|
||||||
String(c.identifier).toLowerCase() === 'inbox'),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (inboxes.length > 0) {
|
|
||||||
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) {
|
|
||||||
mailSyncController.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isServiceFolderLoading(provider: string, service: string | number) {
|
|
||||||
return serviceFolderLoadingState.value[_serviceKey(provider, service)] === true
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasServiceFoldersLoaded(provider: string, service: string | number) {
|
|
||||||
return serviceFolderLoadedState.value[_serviceKey(provider, service)] === true
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServiceFolderError(provider: string, service: string | number) {
|
|
||||||
return serviceFolderErrorState.value[_serviceKey(provider, service)] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function _entityIdentifier(entity: EntityObject): EntityIdentifier {
|
|
||||||
return `${entity.provider}:${String(entity.service)}:${String(entity.collection)}:${String(entity.identifier)}` as EntityIdentifier
|
|
||||||
}
|
|
||||||
|
|
||||||
function _collectionIdentifier(collection: CollectionObject): CollectionIdentifier {
|
|
||||||
return `${collection.provider}:${String(collection.service)}:${String(collection.identifier)}` as CollectionIdentifier
|
|
||||||
}
|
|
||||||
|
|
||||||
function _isSameMessage(left: EntityObject | null, right: EntityObject): boolean {
|
|
||||||
if (!left) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
left.provider === right.provider &&
|
|
||||||
String(left.service) === String(right.service) &&
|
|
||||||
String(left.collection) === String(right.collection) &&
|
|
||||||
String(left.identifier) === String(right.identifier)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function selectFolder(folder: CollectionObject) {
|
|
||||||
selectedFolder.value = folder
|
|
||||||
selectedMessage.value = null
|
|
||||||
composeMode.value = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
await entitiesStore.list({
|
|
||||||
[folder.provider]: {
|
|
||||||
[folder.service]: {
|
|
||||||
[folder.identifier]: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Mail] Failed to load messages:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateSyncSources()
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectMessage(message: EntityObject, closeSidebar = false) {
|
|
||||||
selectedMessage.value = message
|
|
||||||
composeMode.value = false
|
|
||||||
|
|
||||||
if (closeSidebar) {
|
|
||||||
sidebarVisible.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCompose(replyTo?: EntityObject) {
|
|
||||||
composeMode.value = true
|
|
||||||
composeReplyTo.value = replyTo ?? null
|
|
||||||
selectedMessage.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMoveDialog(message: EntityObject) {
|
|
||||||
moveMessageCandidate.value = message
|
|
||||||
moveDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMoveDialog() {
|
|
||||||
moveDialogVisible.value = false
|
|
||||||
moveMessageCandidate.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCompose() {
|
|
||||||
composeMode.value = false
|
|
||||||
composeReplyTo.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function afterSent() {
|
|
||||||
composeMode.value = false
|
|
||||||
composeReplyTo.value = null
|
|
||||||
|
|
||||||
// Reload the current folder so the sent message appears in Sent
|
|
||||||
if (selectedFolder.value) {
|
|
||||||
await selectFolder(selectedFolder.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteMessage(message: EntityObject) {
|
|
||||||
// TODO: implement delete via entity / collection store
|
|
||||||
console.log('[Mail] Delete message:', message.identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function moveMessage(targetFolder: CollectionObject) {
|
|
||||||
const message = moveMessageCandidate.value
|
|
||||||
|
|
||||||
if (!message) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSameCollection =
|
|
||||||
targetFolder.provider === message.provider &&
|
|
||||||
String(targetFolder.service) === String(message.service) &&
|
|
||||||
String(targetFolder.identifier) === String(message.collection)
|
|
||||||
|
|
||||||
if (isSameCollection) {
|
|
||||||
closeMoveDialog()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sourceIdentifier = _entityIdentifier(message)
|
|
||||||
const response = await entitiesStore.move(_collectionIdentifier(targetFolder), [sourceIdentifier])
|
|
||||||
const result = response[sourceIdentifier]
|
|
||||||
|
|
||||||
if (!result || !result.success) {
|
|
||||||
throw new Error(result && 'error' in result ? result.error : 'Failed to move message')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isSameMessage(selectedMessage.value, message)) {
|
|
||||||
selectedMessage.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const service = servicesStore.services.find(entry =>
|
|
||||||
entry.provider === message.provider &&
|
|
||||||
String(entry.identifier) === String(message.service),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (service) {
|
|
||||||
void loadFoldersForService(service)
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(`Message moved to "${targetFolder.properties.label || targetFolder.identifier}"`, 'success')
|
|
||||||
closeMoveDialog()
|
|
||||||
} catch (error) {
|
|
||||||
const messageText = error instanceof Error ? error.message : 'Failed to move message'
|
|
||||||
console.error('[Mail] Failed to move message:', error)
|
|
||||||
notify(messageText, 'error')
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSidebar() {
|
|
||||||
sidebarVisible.value = !sidebarVisible.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSettings() {
|
|
||||||
settingsDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
|
|
||||||
showSnackbar({ message, color })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Exports ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Sub-stores (forwarded for template convenience)
|
|
||||||
collectionsStore,
|
|
||||||
entitiesStore,
|
|
||||||
servicesStore,
|
|
||||||
mailSync,
|
|
||||||
|
|
||||||
// State
|
|
||||||
sidebarVisible,
|
|
||||||
settingsDialogVisible,
|
|
||||||
loading,
|
|
||||||
selectedFolder,
|
|
||||||
selectedMessage,
|
|
||||||
composeMode,
|
|
||||||
composeReplyTo,
|
|
||||||
moveDialogVisible,
|
|
||||||
moveMessageCandidate,
|
|
||||||
serviceFolderLoadingState,
|
|
||||||
serviceFolderLoadedState,
|
|
||||||
serviceFolderErrorState,
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
currentMessages,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
selectFolder,
|
|
||||||
selectMessage,
|
|
||||||
openCompose,
|
|
||||||
openMoveDialog,
|
|
||||||
closeMoveDialog,
|
|
||||||
closeCompose,
|
|
||||||
afterSent,
|
|
||||||
deleteMessage,
|
|
||||||
moveMessage,
|
|
||||||
toggleSidebar,
|
|
||||||
openSettings,
|
|
||||||
notify,
|
|
||||||
isServiceFolderLoading,
|
|
||||||
hasServiceFoldersLoaded,
|
|
||||||
getServiceFolderError,
|
|
||||||
loadFoldersForService,
|
|
||||||
initialize,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,791 @@
|
|||||||
|
import { computed, ref, shallowRef, watch } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
|
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
||||||
|
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
|
||||||
|
import { ComposerMode } from '@/types/composer'
|
||||||
|
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
||||||
|
import { EntityObject, type ServiceObject } from '@MailManager/models'
|
||||||
|
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', () => {
|
||||||
|
const collectionsStore = useCollectionsStore()
|
||||||
|
const mailOperationsStore = useMailOperationsStore()
|
||||||
|
const mailSettingsStore = useMailSettingsStore()
|
||||||
|
const { showSnackbar } = useSnackbar()
|
||||||
|
|
||||||
|
const sidebarVisible = ref(true)
|
||||||
|
const settingsDialogVisible = ref(false)
|
||||||
|
const selectedFolder = shallowRef<CollectionObject | null>(null)
|
||||||
|
const selectedMessage = shallowRef<EntityObject | null>(null)
|
||||||
|
const composerMode = ref<ComposerMode>(ComposerMode.Fresh)
|
||||||
|
const composerSource = shallowRef<EntityObject | MessageAddressInterface | null>(null)
|
||||||
|
const composerVisible = ref(false)
|
||||||
|
const selectionMode = ref(false)
|
||||||
|
const selectionList = ref<EntityIdentifier[]>([])
|
||||||
|
const moveMessagesDialogVisible = ref(false)
|
||||||
|
const moveMessagesDialogService = shallowRef<ServiceObject | null>(null)
|
||||||
|
const moveMessagesDialogCandidates = ref<EntityIdentifier[] | null>(null)
|
||||||
|
const createFolderDialogVisible = ref(false)
|
||||||
|
const createFolderDialogService = shallowRef<ServiceObject | null>(null)
|
||||||
|
const createFolderDialogParent = shallowRef<CollectionObject | null>(null)
|
||||||
|
const createFolderDialogLoading = ref(false)
|
||||||
|
const createFolderDialogError = ref('')
|
||||||
|
const renameFolderDialogVisible = ref(false)
|
||||||
|
const renameFolderDialogService = shallowRef<ServiceObject | null>(null)
|
||||||
|
const renameFolderDialogFolder = shallowRef<CollectionObject | null>(null)
|
||||||
|
const renameFolderDialogLoading = ref(false)
|
||||||
|
const renameFolderDialogError = ref('')
|
||||||
|
const moveFolderDialogVisible = ref(false)
|
||||||
|
const moveFolderDialogService = shallowRef<ServiceObject | null>(null)
|
||||||
|
const moveFolderDialogSource = shallowRef<CollectionObject | null>(null)
|
||||||
|
const deleteFolderDialogVisible = ref(false)
|
||||||
|
const deleteFolderDialogService = shallowRef<ServiceObject | null>(null)
|
||||||
|
const deleteFolderDialogFolder = shallowRef<CollectionObject | null>(null)
|
||||||
|
const deleteFolderDialogLoading = ref(false)
|
||||||
|
const deleteFolderDialogError = ref('')
|
||||||
|
const messageReadIdentifier = ref<EntityIdentifier | null>(null)
|
||||||
|
const messageReadTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const createFolderDialogParentLabel = computed(() => {
|
||||||
|
return createFolderDialogParent.value?.properties.label || 'Root'
|
||||||
|
})
|
||||||
|
|
||||||
|
const renameFolderDialogParentLabel = computed(() => {
|
||||||
|
const folder = renameFolderDialogFolder.value
|
||||||
|
|
||||||
|
if (!folder || folder.collection === null || folder.collection === undefined) {
|
||||||
|
return 'Root'
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = collectionsStore.collectionsInCollection(folder.provider, folder.service, null)
|
||||||
|
.flatMap(rootFolder => [rootFolder, ...collectionsStore.collectionsForService(folder.provider, folder.service)])
|
||||||
|
.find(candidate => String(candidate.identifier) === String(folder.collection))
|
||||||
|
|
||||||
|
return parent?.properties.label || 'Root'
|
||||||
|
})
|
||||||
|
|
||||||
|
const moveFolderDialogInvalidFolderKeys = computed(() => {
|
||||||
|
const sourceFolder = moveFolderDialogSource.value
|
||||||
|
|
||||||
|
if (!sourceFolder) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidKeys = new Set<string>()
|
||||||
|
const queue = [sourceFolder]
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentFolder = queue.shift()
|
||||||
|
|
||||||
|
if (!currentFolder) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidKeys.add(String(currentFolder.identifier))
|
||||||
|
|
||||||
|
collectionsStore
|
||||||
|
.collectionsInCollection(currentFolder.provider, currentFolder.service, currentFolder.identifier)
|
||||||
|
.forEach(childFolder => {
|
||||||
|
queue.push(childFolder)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(invalidKeys)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mailOperationsStore.selectedMessage,
|
||||||
|
message => {
|
||||||
|
if (message) {
|
||||||
|
closeComposer()
|
||||||
|
}
|
||||||
|
|
||||||
|
selectMessage(message)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mailOperationsStore.currentMessages,
|
||||||
|
() => {
|
||||||
|
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[] {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
errors.push('Folder name is required')
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > 255) {
|
||||||
|
errors.push('Folder name too long (max 255 characters)')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
|
||||||
|
errors.push('Folder name contains invalid characters')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service.provider === 'imap' && /[\/\\]/.test(name)) {
|
||||||
|
errors.push('IMAP folder names cannot contain / or \\')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name !== name.trim()) {
|
||||||
|
errors.push('Folder name cannot have leading or trailing spaces')
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCreateFolderName(name: string): string[] {
|
||||||
|
const service = createFolderDialogService.value
|
||||||
|
|
||||||
|
if (!service || service.identifier === null) {
|
||||||
|
return ['Folder service is unavailable']
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validateFolderNameBase(service, name)
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentIdentifier = createFolderDialogParent.value?.identifier ?? null
|
||||||
|
const duplicate = collectionsStore
|
||||||
|
.collectionsInCollection(service.provider, service.identifier, parentIdentifier)
|
||||||
|
.some(folder => folder.properties.label === name)
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
errors.push('A folder with this name already exists in this location')
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRenameFolderName(name: string): string[] {
|
||||||
|
const service = renameFolderDialogService.value
|
||||||
|
const folder = renameFolderDialogFolder.value
|
||||||
|
|
||||||
|
if (!service || !folder || service.identifier === null) {
|
||||||
|
return ['Folder service is unavailable']
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validateFolderNameBase(service, name)
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentIdentifier = folder.collection ?? null
|
||||||
|
const duplicate = collectionsStore
|
||||||
|
.collectionsInCollection(service.provider, service.identifier, parentIdentifier)
|
||||||
|
.some(candidate =>
|
||||||
|
String(candidate.identifier) !== String(folder.identifier) &&
|
||||||
|
candidate.properties.label === name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
errors.push('A folder with this name already exists in this location')
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectionList(nextIds: EntityIdentifier[]) {
|
||||||
|
selectionList.value = Array.from(new Set(nextIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openComposer(source?: EntityObject | MessageAddressInterface, mode: ComposerMode = ComposerMode.Fresh) {
|
||||||
|
mailOperationsStore.selectMessage(null)
|
||||||
|
composerSource.value = source ?? null
|
||||||
|
composerMode.value = mode
|
||||||
|
composerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeComposer() {
|
||||||
|
composerMode.value = ComposerMode.Fresh
|
||||||
|
composerSource.value = null
|
||||||
|
composerVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageSelectionClear() {
|
||||||
|
setSelectionList([])
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageSelectionModeActivate(message?: EntityObject) {
|
||||||
|
selectionMode.value = true
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = message.identifier
|
||||||
|
|
||||||
|
if (!selectionList.value.includes(identifier)) {
|
||||||
|
setSelectionList([...selectionList.value, identifier])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageSelectionModeDeactivate() {
|
||||||
|
selectionMode.value = false
|
||||||
|
messageSelectionClear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageSelectionToggleOne(message: EntityObject) {
|
||||||
|
const identifier = message.identifier
|
||||||
|
|
||||||
|
selectionMode.value = true
|
||||||
|
|
||||||
|
if (selectionList.value.includes(identifier)) {
|
||||||
|
setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectionList([...selectionList.value, identifier])
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageSelectionToggleAll(value: boolean) {
|
||||||
|
selectionMode.value = true
|
||||||
|
if (value) {
|
||||||
|
setSelectionList(mailOperationsStore.currentMessages.map(message => message.identifier))
|
||||||
|
} else {
|
||||||
|
setSelectionList([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageSelectionReconcile() {
|
||||||
|
if (!selectedFolder.value) {
|
||||||
|
messageSelectionClear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMessageIdentifiers = new Set(mailOperationsStore.currentMessages.map(message => message.identifier))
|
||||||
|
const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
|
||||||
|
|
||||||
|
if (nextSelectedIds.length !== selectionList.value.length) {
|
||||||
|
setSelectionList(nextSelectedIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextSelectedIds.length === 0) {
|
||||||
|
selectionMode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMoveMessagesDialog(entities?: EntityObject | EntityObject[]) {
|
||||||
|
|
||||||
|
let moveMessagesServiceIdentifier = null as ServiceIdentifier | null
|
||||||
|
moveMessagesDialogCandidates.value = []
|
||||||
|
|
||||||
|
if (entities) {
|
||||||
|
if (Array.isArray(entities)) {
|
||||||
|
moveMessagesDialogCandidates.value = entities.map(entity => entity.identifier)
|
||||||
|
moveMessagesServiceIdentifier = entities[0]?.service as ServiceIdentifier || null
|
||||||
|
} else {
|
||||||
|
moveMessagesDialogCandidates.value = [entities.identifier]
|
||||||
|
moveMessagesServiceIdentifier = entities.service as ServiceIdentifier || null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
moveMessagesDialogCandidates.value = [...selectionList.value]
|
||||||
|
moveMessagesServiceIdentifier = selectedFolder.value?.service as ServiceIdentifier || null
|
||||||
|
}
|
||||||
|
|
||||||
|
moveMessagesDialogService.value = await mailOperationsStore.retrieveService(moveMessagesServiceIdentifier)
|
||||||
|
moveMessagesDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMoveMessagesDialog() {
|
||||||
|
moveMessagesDialogVisible.value = false
|
||||||
|
moveMessagesDialogService.value = null
|
||||||
|
moveMessagesDialogCandidates.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailOperationsStore.moveMessages>[0]) {
|
||||||
|
await moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? [])
|
||||||
|
messageSelectionModeDeactivate()
|
||||||
|
closeMoveMessagesDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedMessages() {
|
||||||
|
await deleteMessages([...selectionList.value])
|
||||||
|
messageSelectionModeDeactivate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flagSelectedMessages(flag: string, value: boolean) {
|
||||||
|
await flagMessages([...selectionList.value], { [flag]: value })
|
||||||
|
messageSelectionModeDeactivate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
|
||||||
|
try {
|
||||||
|
const result = await mailOperationsStore.deleteMessages(entityIdentifiers)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyDeleteMessagesResult(result.successes.length, result.failures.length)
|
||||||
|
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 {
|
||||||
|
const result = await mailOperationsStore.flagMessages(entityIdentifiers, flags)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.notify ?? true) {
|
||||||
|
notifyFlagMessagesResult(result.successes.length, result.failures.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const messageText = error instanceof Error ? error.message : 'Failed to update messages'
|
||||||
|
if (options.notify ?? true) {
|
||||||
|
notify(messageText, 'error')
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flagMessage(entity: EntityObject, flag: string, value: boolean) {
|
||||||
|
return await flagMessages([entity.identifier], { [flag]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
|
||||||
|
try {
|
||||||
|
const result = await mailOperationsStore.moveMessages(target, entityIdentifiers)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
sidebarVisible,
|
||||||
|
settingsDialogVisible,
|
||||||
|
selectedFolder,
|
||||||
|
selectedMessage,
|
||||||
|
composerMode,
|
||||||
|
composerSource,
|
||||||
|
composerVisible,
|
||||||
|
selectionMode,
|
||||||
|
selectionList,
|
||||||
|
moveMessagesDialogVisible,
|
||||||
|
moveMessagesDialogService,
|
||||||
|
moveMessagesDialogCandidates,
|
||||||
|
createFolderDialogParentLabel,
|
||||||
|
createFolderDialogVisible,
|
||||||
|
createFolderDialogService,
|
||||||
|
createFolderDialogParent,
|
||||||
|
createFolderDialogLoading,
|
||||||
|
createFolderDialogError,
|
||||||
|
renameFolderDialogVisible,
|
||||||
|
renameFolderDialogService,
|
||||||
|
renameFolderDialogFolder,
|
||||||
|
renameFolderDialogParentLabel,
|
||||||
|
renameFolderDialogLoading,
|
||||||
|
renameFolderDialogError,
|
||||||
|
moveFolderDialogVisible,
|
||||||
|
moveFolderDialogService,
|
||||||
|
moveFolderDialogSource,
|
||||||
|
moveFolderDialogInvalidFolderKeys,
|
||||||
|
deleteFolderDialogVisible,
|
||||||
|
deleteFolderDialogService,
|
||||||
|
deleteFolderDialogFolder,
|
||||||
|
deleteFolderDialogLoading,
|
||||||
|
deleteFolderDialogError,
|
||||||
|
initialize,
|
||||||
|
sidebarToggle,
|
||||||
|
sidebarHide,
|
||||||
|
settingsOpen,
|
||||||
|
settingsClose,
|
||||||
|
notify,
|
||||||
|
selectFolder,
|
||||||
|
openComposer,
|
||||||
|
closeComposer,
|
||||||
|
deleteMessages,
|
||||||
|
flagMessages,
|
||||||
|
flagMessage,
|
||||||
|
moveMessages,
|
||||||
|
downloadMessage,
|
||||||
|
attachmentBlob,
|
||||||
|
messageSelectionModeActivate,
|
||||||
|
messageSelectionModeDeactivate,
|
||||||
|
messageSelectionToggleOne,
|
||||||
|
messageSelectionToggleAll,
|
||||||
|
messageSelectionClear,
|
||||||
|
validateCreateFolderName,
|
||||||
|
validateRenameFolderName,
|
||||||
|
openMoveMessagesDialog,
|
||||||
|
closeMoveMessagesDialog,
|
||||||
|
confirmMoveMessages,
|
||||||
|
deleteSelectedMessages,
|
||||||
|
openCreateFolderDialog,
|
||||||
|
closeCreateFolderDialog,
|
||||||
|
confirmCreateFolder,
|
||||||
|
openRenameFolderDialog,
|
||||||
|
closeRenameFolderDialog,
|
||||||
|
confirmRenameFolder,
|
||||||
|
openMoveFolderDialog,
|
||||||
|
closeMoveFolderDialog,
|
||||||
|
confirmMoveFolder,
|
||||||
|
openDeleteFolderDialog,
|
||||||
|
closeDeleteFolderDialog,
|
||||||
|
confirmDeleteFolder,
|
||||||
|
flagSelectedMessages,
|
||||||
|
}
|
||||||
|
})
|
||||||
+6
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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]}`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
+12
-2
@@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
"include": ["src/**/*", "src/**/*.vue", "../../core/src/**/*.ts"],
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"src/utile/**/*.ts",
|
||||||
|
"../../core/src/**/*.ts",
|
||||||
|
"../mail_manager/src/**/*.ts",
|
||||||
|
"../mail_manager/src/**/*.vue",
|
||||||
|
"../people_manager/src/**/*.ts",
|
||||||
|
"../people_manager/src/**/*.vue"
|
||||||
|
],
|
||||||
"exclude": ["src/**/__tests__/*"],
|
"exclude": ["src/**/__tests__/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
@@ -8,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/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user