Compare commits
66 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 | |||
| 307a2e3ef0 |
@@ -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
|
||||
@@ -25,11 +25,17 @@ jobs:
|
||||
tools: composer:v2
|
||||
|
||||
- name: Install Renovate
|
||||
run: npm install -g renovate
|
||||
run: |
|
||||
npm install --global --no-audit --fund=false \
|
||||
--prefix "${{ runner.temp }}/renovate-npm" \
|
||||
--cache "${{ runner.temp }}/renovate-npm-cache" \
|
||||
renovate
|
||||
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
|
||||
|
||||
- name: Run Renovate
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
|
||||
run: renovate ${{ gitea.repository }}
|
||||
run: |
|
||||
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
|
||||
+2
-4
@@ -12,12 +12,10 @@ node_modules/
|
||||
/static/
|
||||
|
||||
# Backend development
|
||||
/vendor/
|
||||
/lib/vendor/
|
||||
coverage/
|
||||
phpunit.xml.cache
|
||||
.phpunit.result.cache
|
||||
.php-cs-fixer.cache
|
||||
.phpstan.cache
|
||||
*.cache
|
||||
.phpactor/
|
||||
|
||||
# Editors
|
||||
|
||||
+13
-1
@@ -9,6 +9,18 @@
|
||||
}
|
||||
},
|
||||
"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
+2338
-634
File diff suppressed because it is too large
Load Diff
+12
-4
@@ -11,7 +11,11 @@
|
||||
"dev": "vite build --mode development --config vite.config.ts",
|
||||
"watch": "vite build --mode development --watch --config vite.config.ts",
|
||||
"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": {
|
||||
"@tiptap/vue-3": "^3.0.0",
|
||||
@@ -22,14 +26,18 @@
|
||||
"@tiptap/extension-underline": "^3.0.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^5.0.0",
|
||||
"pinia": "^3.0.0",
|
||||
"pinia": "^4.0.0",
|
||||
"vuetify": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"typescript": "^6.0.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vue-tsc": "^3.0.0"
|
||||
"vue-tsc": "^3.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ const handleCancel = () => {
|
||||
<div class="mb-4">
|
||||
<div class="text-caption text-medium-emphasis">Account</div>
|
||||
<div class="text-body-2">
|
||||
{{ service.label || service.primaryAddress || 'Mail Account' }}
|
||||
{{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const handleCancel = () => {
|
||||
<div class="mb-4">
|
||||
<div class="text-caption text-medium-emphasis">Account</div>
|
||||
<div class="text-body-2">
|
||||
{{ service.label || service.primaryAddress || 'Mail Account' }}
|
||||
{{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
||||
v-bind="activatorProps"
|
||||
class="account-header-item"
|
||||
:title="group.service.label || 'Mail Account'"
|
||||
:subtitle="group.service.primaryAddress || undefined"
|
||||
:subtitle="group.service.primaryAddress?.address || undefined"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon icon="mdi-email-outline" />
|
||||
@@ -348,7 +348,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
||||
<v-list-item
|
||||
class="account-header-item"
|
||||
:title="group.service.label || 'Mail Account'"
|
||||
:subtitle="group.service.primaryAddress || undefined"
|
||||
:subtitle="group.service.primaryAddress?.address || undefined"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon icon="mdi-email-outline" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||
import type { ServiceObject, CollectionObject } from '@MailManager/models'
|
||||
import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue'
|
||||
@@ -30,7 +30,7 @@ const emit = defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const mailOperationsStore = useMailOperationsStore()
|
||||
const collectionsStore = useCollectionsStore()
|
||||
|
||||
const selectedFolderKey = ref<string | null>(null)
|
||||
@@ -59,9 +59,9 @@ const serviceGroups = computed<ServiceGroup[]>(() => {
|
||||
|
||||
return [{
|
||||
service,
|
||||
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
|
||||
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
|
||||
error: mailStore.getServiceFolderError(service.provider, service.identifier),
|
||||
loading: mailOperationsStore.isServiceFolderLoading(service.provider, service.identifier),
|
||||
loaded: mailOperationsStore.hasServiceFoldersLoaded(service.provider, service.identifier),
|
||||
error: mailOperationsStore.getServiceFolderError(service.provider, service.identifier),
|
||||
}]
|
||||
})
|
||||
|
||||
@@ -151,7 +151,7 @@ const handleConfirm = () => {
|
||||
<v-list-item
|
||||
class="account-header-item account-header-static"
|
||||
:title="group.service.label || 'Mail Account'"
|
||||
:subtitle="group.service.primaryAddress || undefined"
|
||||
:subtitle="group.service.primaryAddress?.address || undefined"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-email-outline" />
|
||||
|
||||
@@ -46,7 +46,7 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
||||
v-bind="activatorProps"
|
||||
class="account-header-item"
|
||||
:title="group.service.label || 'Mail Account'"
|
||||
:subtitle="group.service.primaryAddress || undefined"
|
||||
:subtitle="group.service.primaryAddress?.address || undefined"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon icon="mdi-email-outline" />
|
||||
@@ -116,7 +116,7 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
||||
<v-list-item
|
||||
class="account-header-item account-header-static"
|
||||
:title="group.service.label || 'Mail Account'"
|
||||
:subtitle="group.service.primaryAddress || undefined"
|
||||
:subtitle="group.service.primaryAddress?.address || undefined"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon icon="mdi-email-outline" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
||||
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
|
||||
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||
import FolderTreeView from './FolderTreeView.vue'
|
||||
@@ -28,7 +28,7 @@ const emit = defineEmits<{
|
||||
|
||||
// Stores
|
||||
const servicesStore = useServicesStore()
|
||||
const mailStore = useMailStore()
|
||||
const mailOperationsStore = useMailOperationsStore()
|
||||
const mailUiStore = useMailUiStore()
|
||||
const mailSettingsStore = useMailSettingsStore()
|
||||
const { folderViewMode } = storeToRefs(mailSettingsStore)
|
||||
@@ -43,9 +43,9 @@ const serviceGroups = computed(() => {
|
||||
|
||||
groups.push({
|
||||
service,
|
||||
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
|
||||
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
|
||||
error: mailStore.getServiceFolderError(service.provider, service.identifier),
|
||||
loading: mailOperationsStore.isServiceFolderLoading(service.provider, service.identifier),
|
||||
loaded: mailOperationsStore.hasServiceFoldersLoaded(service.provider, service.identifier),
|
||||
error: mailOperationsStore.getServiceFolderError(service.provider, service.identifier),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+100
-149
@@ -8,18 +8,20 @@ import Underline from '@tiptap/extension-underline'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { EntityObject } from '@MailManager/models/entity'
|
||||
import type { CollectionObject } from '@MailManager/models'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import type { MessageAddressInterface } from '@MailManager/types/message'
|
||||
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
|
||||
interface Props {
|
||||
mode: ComposerMode
|
||||
source?: EntityObject | MessageAddressInterface | null
|
||||
source?: EntityObject | MessageAddressObject | null
|
||||
folder?: CollectionObject | null
|
||||
}
|
||||
|
||||
@@ -30,23 +32,53 @@ const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const mailOperationsStore = useMailOperationsStore()
|
||||
const compositionStore = useMailCompositionStore()
|
||||
const {
|
||||
composerSending: sending,
|
||||
composerSaving: saving,
|
||||
composerLastSaved: lastSaved,
|
||||
} = storeToRefs(mailStore)
|
||||
} = storeToRefs(mailOperationsStore)
|
||||
|
||||
const {
|
||||
activeDraft,
|
||||
saving,
|
||||
stageStatus,
|
||||
} = storeToRefs(compositionStore)
|
||||
|
||||
// State
|
||||
const to = ref<string[]>([])
|
||||
const cc = ref<string[]>([])
|
||||
const bcc = ref<string[]>([])
|
||||
const subject = ref('')
|
||||
const showCc = ref(false)
|
||||
const showBcc = ref(false)
|
||||
const applyingDraftToEditor = ref(false)
|
||||
|
||||
// Auto-save timer
|
||||
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const sender = computed({
|
||||
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
|
||||
const editor = useEditor({
|
||||
@@ -71,61 +103,6 @@ const editor = useEditor({
|
||||
},
|
||||
})
|
||||
|
||||
function resetComposerFields() {
|
||||
to.value = []
|
||||
cc.value = []
|
||||
bcc.value = []
|
||||
subject.value = ''
|
||||
showCc.value = false
|
||||
showBcc.value = false
|
||||
editor.value?.commands.setContent('')
|
||||
}
|
||||
|
||||
function initializeComposerFromProps() {
|
||||
mailStore.resetComposerState()
|
||||
resetComposerFields()
|
||||
|
||||
if (props.mode === ComposerMode.Fresh) {
|
||||
if (props.source && 'address' in props.source) {
|
||||
// If source is an email address, pre-fill the "To" field
|
||||
to.value = [props.source.address]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.source instanceof EntityObject == false) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceMessage = props.source.properties
|
||||
const originalSubject = sourceMessage.subject || ''
|
||||
const originalBody = sourceMessage.getHtmlContent() || sourceMessage.getTextContent() || ''
|
||||
const senderName = sourceMessage.from?.label || sourceMessage.from?.address || 'Unknown'
|
||||
const sentAt = sourceMessage.sent || props.source.created || ''
|
||||
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
|
||||
|
||||
if (props.mode === ComposerMode.Reply) {
|
||||
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address
|
||||
to.value = fromEmail ? [fromEmail] : []
|
||||
subject.value = /^Re:/i.test(originalSubject)
|
||||
? originalSubject
|
||||
: `Re: ${originalSubject}`
|
||||
editor.value?.commands.setContent(
|
||||
`<p><br></p><p>---------- Original message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.mode === ComposerMode.Forward) {
|
||||
subject.value = /^Fwd:/i.test(originalSubject)
|
||||
? originalSubject
|
||||
: `Fwd: ${originalSubject}`
|
||||
editor.value?.commands.setContent(
|
||||
`<p><br></p><p>---------- Forwarded message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.mode, () => props.source, () => editor.value],
|
||||
([, , currentEditor]) => {
|
||||
@@ -133,7 +110,13 @@ watch(
|
||||
return
|
||||
}
|
||||
|
||||
initializeComposerFromProps()
|
||||
compositionStore.openDraft(props.mode, props.source)
|
||||
showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0
|
||||
showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0
|
||||
|
||||
applyingDraftToEditor.value = true
|
||||
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
|
||||
applyingDraftToEditor.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -143,97 +126,52 @@ const canSend = computed(() => {
|
||||
return to.value.length > 0 && subject.value.trim().length > 0
|
||||
})
|
||||
|
||||
const saveStatus = computed(() => {
|
||||
if (saving.value) return 'Saving...'
|
||||
if (lastSaved.value) {
|
||||
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()) {
|
||||
// Watch editor content changes
|
||||
watch(editor, currentEditor => {
|
||||
if (!currentEditor) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await mailStore.saveComposerDraft(props.folder, {
|
||||
to: to.value,
|
||||
cc: cc.value,
|
||||
bcc: bcc.value,
|
||||
subject: subject.value,
|
||||
body: {
|
||||
html: editor.value?.getHTML() || '',
|
||||
text: editor.value?.getText() || '',
|
||||
},
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (applyingDraftToEditor.value) {
|
||||
return
|
||||
}
|
||||
|
||||
compositionStore.updateBody({
|
||||
html: currentEditor.getHTML(),
|
||||
text: currentEditor.getText(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Mail][Composer] Failed to save draft:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes and trigger auto-save
|
||||
const scheduleAutoSave = () => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
currentEditor.on('update', handleUpdate)
|
||||
|
||||
return () => {
|
||||
currentEditor.off('update', handleUpdate)
|
||||
}
|
||||
|
||||
autoSaveTimer = setTimeout(() => {
|
||||
saveDraft()
|
||||
}, 30000) // 30 seconds
|
||||
}
|
||||
|
||||
watch([to, cc, bcc, subject], () => {
|
||||
scheduleAutoSave()
|
||||
}, { deep: true })
|
||||
|
||||
// Watch editor content changes
|
||||
if (editor.value) {
|
||||
editor.value.on('update', () => {
|
||||
scheduleAutoSave()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
onBeforeUnmount(() => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
}
|
||||
mailStore.resetComposerState()
|
||||
void compositionStore.flushSave()
|
||||
editor.value?.destroy()
|
||||
})
|
||||
|
||||
// Handlers
|
||||
const handleClose = () => {
|
||||
mailStore.resetComposerState()
|
||||
const handleClose = async () => {
|
||||
await compositionStore.closeDraft()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!canSend.value || sending.value) return
|
||||
await compositionStore.sendDraft()
|
||||
}
|
||||
|
||||
try {
|
||||
await mailStore.sendComposerMessage({
|
||||
to: to.value,
|
||||
cc: cc.value,
|
||||
bcc: bcc.value,
|
||||
subject: subject.value,
|
||||
body: {
|
||||
html: editor.value?.getHTML() || '',
|
||||
text: editor.value?.getText() || '',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Mail][Composer] Failed to send message:', error)
|
||||
}
|
||||
const handleAttach = async (files: File[]) => {
|
||||
await compositionStore.addAttachments(files)
|
||||
}
|
||||
|
||||
const handleDetach = async (identifier: string) => {
|
||||
await compositionStore.removeAttachment(identifier)
|
||||
}
|
||||
|
||||
const toggleCc = () => {
|
||||
@@ -257,11 +195,6 @@ const setLink = () => {
|
||||
}
|
||||
}
|
||||
const removeLink = () => editor.value?.chain().focus().unsetLink().run()
|
||||
|
||||
const isActive = (name: string, attrs?: any) => {
|
||||
return editor.value?.isActive(name, attrs) || false
|
||||
}
|
||||
|
||||
const toggleLink = () => {
|
||||
if (isActive('link')) {
|
||||
removeLink()
|
||||
@@ -270,13 +203,16 @@ const toggleLink = () => {
|
||||
|
||||
setLink()
|
||||
}
|
||||
const isActive = (name: string, attrs?: any) => {
|
||||
return editor.value?.isActive(name, attrs) || false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-composer">
|
||||
<ComposerToolbar
|
||||
:mode="mode"
|
||||
:save-status="saveStatus"
|
||||
:status="stageStatus"
|
||||
:can-send="canSend"
|
||||
:sending="sending"
|
||||
@close="handleClose"
|
||||
@@ -284,6 +220,11 @@ const toggleLink = () => {
|
||||
/>
|
||||
|
||||
<div class="composer-content">
|
||||
<ComposerSender
|
||||
v-model="sender"
|
||||
:options="compositionStore.senderIdentities"
|
||||
/>
|
||||
|
||||
<ComposerRecipients
|
||||
:to="to"
|
||||
:cc="cc"
|
||||
@@ -301,6 +242,14 @@ const toggleLink = () => {
|
||||
|
||||
<v-divider />
|
||||
|
||||
<ComposerAttachments
|
||||
v-if="Object.keys(attachments).length > 0"
|
||||
:attachments="attachments"
|
||||
@remove="handleDetach"
|
||||
/>
|
||||
|
||||
<v-divider v-if="Object.keys(attachments).length > 0" />
|
||||
|
||||
<ComposerEditor
|
||||
:editor="editor"
|
||||
:is-bold-active="isActive('bold')"
|
||||
@@ -315,6 +264,7 @@ const toggleLink = () => {
|
||||
@bullet-list="toggleBulletList"
|
||||
@ordered-list="toggleOrderedList"
|
||||
@link="toggleLink"
|
||||
@attach="handleAttach"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -339,4 +289,5 @@ const toggleLink = () => {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { EntityIdentifier } from '@MailManager/types/common'
|
||||
import type { EntityObject } from '@MailManager/models'
|
||||
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'
|
||||
|
||||
@@ -39,6 +41,10 @@ const emit = defineEmits<{
|
||||
selectionFlag: [flag: string, value: boolean]
|
||||
}>()
|
||||
|
||||
const mailSettingsStore = useMailSettingsStore()
|
||||
const { messageListDensity } = storeToRefs(mailSettingsStore)
|
||||
const densityConfig = computed(() => messageListDensityConfig[messageListDensity.value])
|
||||
|
||||
const longPressTimer = ref<number | null>(null)
|
||||
const longPressActivated = ref(false)
|
||||
const suppressNextClick = ref(false)
|
||||
@@ -326,7 +332,7 @@ onBeforeUnmount(() => {
|
||||
icon="mdi-email-open-outline"
|
||||
variant="text"
|
||||
:disabled="selectionCount === 0"
|
||||
@click="handleFlag('read', true)"
|
||||
@click="handleFlag('seen', true)"
|
||||
>
|
||||
<v-icon>mdi-email-open-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Mark as Read</v-tooltip>
|
||||
@@ -336,7 +342,7 @@ onBeforeUnmount(() => {
|
||||
icon="mdi-email-outline"
|
||||
variant="text"
|
||||
:disabled="selectionCount === 0"
|
||||
@click="handleFlag('read', false)"
|
||||
@click="handleFlag('seen', false)"
|
||||
>
|
||||
<v-icon>mdi-email-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Mark as Unread</v-tooltip>
|
||||
@@ -377,7 +383,7 @@ onBeforeUnmount(() => {
|
||||
<v-virtual-scroll
|
||||
v-else
|
||||
:items="sortedMessages"
|
||||
:item-height="80"
|
||||
:item-height="densityConfig.itemHeight"
|
||||
class="message-virtual-scroll"
|
||||
>
|
||||
<template v-slot:default="{ item: message }">
|
||||
@@ -397,7 +403,7 @@ onBeforeUnmount(() => {
|
||||
@touchend="handleTouchEnd"
|
||||
@touchcancel="handleTouchEnd"
|
||||
@touchmove="handleTouchMove"
|
||||
lines="three"
|
||||
:lines="densityConfig.lines"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="message-item-prepend">
|
||||
@@ -419,7 +425,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="d-flex align-center">
|
||||
<span class="flex-grow-1 text-truncate">
|
||||
<span class="message-sender text-truncate">
|
||||
<RecipientDetails
|
||||
:address="message.properties.from"
|
||||
@clicked="handleRecipientClick(message)"
|
||||
@@ -429,16 +435,22 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</RecipientDetails>
|
||||
</span>
|
||||
<span class="text-caption text-medium-emphasis ml-2">
|
||||
<span
|
||||
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>
|
||||
</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)' }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<v-list-item-subtitle class="text-caption text-truncate">
|
||||
<v-list-item-subtitle v-if="densityConfig.showThirdLine" class="text-caption text-truncate">
|
||||
{{ '' }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
@@ -632,6 +644,21 @@ onBeforeUnmount(() => {
|
||||
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%;
|
||||
|
||||
@@ -98,7 +98,7 @@ const handleDelete = () => {
|
||||
<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('read', !isRead)"
|
||||
@click="handleFlag('seen', !isRead)"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ isRead ? 'Mark as unread' : 'Mark as read' }}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useUser } from '@KTXC'
|
||||
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||
import type { EntityObject, MessageObject } from '@MailManager/models'
|
||||
import { SecurityLevel } from '@/utile/emailSanitizer'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer'
|
||||
import type { MediaViewerItem } from '@/types/mediaViewer'
|
||||
import ReaderEmpty from './reader/ReaderEmpty.vue'
|
||||
import ReaderToolbar from './reader/ReaderToolbar.vue'
|
||||
import ReaderHeader from './reader/ReaderHeader.vue'
|
||||
@@ -26,12 +28,16 @@ const emit = defineEmits<{
|
||||
|
||||
// User settings
|
||||
const { getSetting } = useUser()
|
||||
const mailStore = useMailStore()
|
||||
const mailUiStore = useMailUiStore()
|
||||
|
||||
// Per-message overrides
|
||||
// Security overrides
|
||||
const allowImages = ref(false)
|
||||
const overrideSecurityLevel = ref<SecurityLevel | null>(null)
|
||||
|
||||
// Viewer state
|
||||
const showViewer = ref(false)
|
||||
const viewerIndex = ref(0)
|
||||
|
||||
// Computed
|
||||
const message = computed<MessageObject | null>(() => {
|
||||
return props.entity?.properties ?? null
|
||||
@@ -55,12 +61,35 @@ const effectiveSecurityLevel = computed(() => {
|
||||
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
|
||||
watch(() => props.entity, () => {
|
||||
allowImages.value = allowImagesDefault.value
|
||||
overrideSecurityLevel.value = null
|
||||
})
|
||||
|
||||
// Close the viewer when switching messages.
|
||||
watch(() => props.entity, () => {
|
||||
showViewer.value = false
|
||||
})
|
||||
|
||||
// Toggle images for current message only
|
||||
const toggleImages = () => {
|
||||
allowImages.value = !allowImages.value
|
||||
@@ -101,7 +130,7 @@ const handleDownload = async (partIndex?: number) => {
|
||||
return
|
||||
}
|
||||
|
||||
await mailStore.downloadMessage(props.entity, partIndex)
|
||||
await mailUiStore.downloadMessage(props.entity, partIndex)
|
||||
}
|
||||
|
||||
const handleFlag = () => {
|
||||
@@ -113,6 +142,22 @@ const handleFlag = () => {
|
||||
const handleCompose = () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -135,7 +180,7 @@ const handleCompose = () => {
|
||||
@move="handleMove"
|
||||
@delete="handleDelete"
|
||||
@flag="handleFlag"
|
||||
@download="handleDownload()"
|
||||
@download="handleDownload"
|
||||
@toggle-images="toggleImages"
|
||||
@set-security-level="setSecurityLevel"
|
||||
/>
|
||||
@@ -144,7 +189,8 @@ const handleCompose = () => {
|
||||
<div class="message-content">
|
||||
<ReaderHeader
|
||||
:entity="props.entity"
|
||||
@download-attachment="handleDownload"
|
||||
@download="handleDownload"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
|
||||
<v-divider />
|
||||
@@ -155,6 +201,19 @@ const handleCompose = () => {
|
||||
:allow-images="allowImages"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -91,7 +91,7 @@ const handleCancel = () => {
|
||||
<div class="mb-4">
|
||||
<div class="text-caption text-medium-emphasis">Account</div>
|
||||
<div class="text-body-2">
|
||||
{{ service.label || service.primaryAddress || 'Mail Account' }}
|
||||
{{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
|
||||
</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>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useSnackbar } from '@KTXC'
|
||||
import type { MessageAddressInterface } from '@MailManager/types/message'
|
||||
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||
import { ComposerMode } from '@/types/composer'
|
||||
@@ -17,7 +16,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const mailUiStore = useMailUiStore()
|
||||
const { showSnackbar } = useSnackbar()
|
||||
|
||||
const recipientLabel = computed(() => props.address?.label?.trim() || '')
|
||||
const recipientAddress = computed(() => props.address?.address?.trim() || '')
|
||||
@@ -32,7 +30,7 @@ const formattedAddress = computed(() => {
|
||||
const hasEmail = computed(() => recipientAddress.value.length > 0)
|
||||
|
||||
const showCopyResult = (message: string, color: 'success' | 'error') => {
|
||||
showSnackbar({ message, color })
|
||||
mailUiStore.notify(message, color)
|
||||
}
|
||||
|
||||
const copy = async (value: string, label: string) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { EditorContent, type Editor } from '@tiptap/vue-3'
|
||||
|
||||
@@ -33,14 +34,32 @@ defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
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>
|
||||
@@ -111,7 +130,15 @@ defineEmits<{
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-btn icon size="small">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
class="sr-only"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
|
||||
<v-btn icon size="small" @click="openFilePicker">
|
||||
<v-icon>mdi-paperclip</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
||||
</v-btn>
|
||||
@@ -120,7 +147,7 @@ defineEmits<{
|
||||
<v-divider />
|
||||
|
||||
<div class="editor-container">
|
||||
<EditorContent :editor="editor" />
|
||||
<EditorContent :editor="editor ?? undefined" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -136,6 +163,10 @@ defineEmits<{
|
||||
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));
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useRecipientLookupStore } from '@/stores/mailRecipientsStore'
|
||||
import type { MessageAddressInterface } from '@MailManager/types/message'
|
||||
|
||||
interface Props {
|
||||
to: string[]
|
||||
cc: string[]
|
||||
bcc: string[]
|
||||
to: MessageAddressInterface[]
|
||||
cc: MessageAddressInterface[]
|
||||
bcc: MessageAddressInterface[]
|
||||
subject: string
|
||||
showCc: boolean
|
||||
showBcc: boolean
|
||||
@@ -10,14 +13,34 @@ interface Props {
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'update:to': [value: string[]]
|
||||
'update:cc': [value: string[]]
|
||||
'update:bcc': [value: string[]]
|
||||
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>
|
||||
@@ -28,11 +51,23 @@ defineEmits<{
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
return-object
|
||||
no-filter
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
@update:model-value="$emit('update:to', $event)"
|
||||
:items="recipientLookup.suggestions"
|
||||
:loading="recipientLookup.searching"
|
||||
:item-title="itemTitle"
|
||||
@update:search="recipientLookup.search($event)"
|
||||
@update:model-value="emit('update:to', normalize($event))"
|
||||
>
|
||||
<template #chip="{ item, props: chipProps }">
|
||||
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
|
||||
</template>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
|
||||
</template>
|
||||
<template #append-inner>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
@@ -59,11 +94,24 @@ defineEmits<{
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
return-object
|
||||
no-filter
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
@update:model-value="$emit('update:cc', $event)"
|
||||
/>
|
||||
:items="recipientLookup.suggestions"
|
||||
:loading="recipientLookup.searching"
|
||||
:item-title="itemTitle"
|
||||
@update:search="recipientLookup.search($event)"
|
||||
@update:model-value="emit('update:cc', normalize($event))"
|
||||
>
|
||||
<template #chip="{ item, props: chipProps }">
|
||||
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
|
||||
</template>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
|
||||
</template>
|
||||
</v-combobox>
|
||||
|
||||
<v-combobox
|
||||
v-if="showBcc"
|
||||
@@ -72,11 +120,24 @@ defineEmits<{
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
return-object
|
||||
no-filter
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
@update:model-value="$emit('update:bcc', $event)"
|
||||
/>
|
||||
:items="recipientLookup.suggestions"
|
||||
:loading="recipientLookup.searching"
|
||||
:item-title="itemTitle"
|
||||
@update:search="recipientLookup.search($event)"
|
||||
@update:model-value="emit('update:bcc', normalize($event))"
|
||||
>
|
||||
<template #chip="{ item, props: chipProps }">
|
||||
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
|
||||
</template>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
|
||||
</template>
|
||||
</v-combobox>
|
||||
|
||||
<v-text-field
|
||||
:model-value="subject"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComposerSenderIdentity } from '@/types/composer';
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: ComposerSenderIdentity | null
|
||||
options: ComposerSenderIdentity[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ComposerSenderIdentity | null]
|
||||
}>()
|
||||
|
||||
type SenderListItem = {
|
||||
title: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const items = computed<SenderListItem[]>(() => {
|
||||
return props.options.map(option => ({
|
||||
title: formatSenderLabel(option.label, option.address),
|
||||
value: option.address,
|
||||
}))
|
||||
})
|
||||
|
||||
const selectedValue = computed(() => props.modelValue?.address ?? null)
|
||||
|
||||
const singleOptionLabel = computed(() => {
|
||||
const onlyOption = props.options[0]
|
||||
return onlyOption ? formatSenderLabel(onlyOption.label, onlyOption.address) : ''
|
||||
})
|
||||
|
||||
const errorMessages = computed(() => {
|
||||
if (props.options.length === 0) {
|
||||
return ['No send-capable account is available.']
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
function handleUpdate(value: string | null) {
|
||||
const match = value ? props.options.find(o => o.address === value) ?? null : null
|
||||
emit('update:modelValue', match)
|
||||
}
|
||||
|
||||
function formatSenderLabel(label: string | null | undefined, address: string): string {
|
||||
return label ? `${label} <${address}>` : address
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer-sender px-4 pt-4 pb-0">
|
||||
<v-text-field
|
||||
v-if="options.length === 1"
|
||||
:model-value="singleOptionLabel"
|
||||
label="From"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
readonly
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-else
|
||||
:model-value="selectedValue"
|
||||
:items="items"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="From"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:error="errorMessages.length > 0"
|
||||
:error-messages="errorMessages"
|
||||
:disabled="options.length === 0"
|
||||
class="mb-2"
|
||||
@update:model-value="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.composer-sender {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ import { ComposerMode } from '@/types/composer'
|
||||
|
||||
interface Props {
|
||||
mode: ComposerMode
|
||||
saveStatus: string
|
||||
status: string
|
||||
canSend: boolean
|
||||
sending: boolean
|
||||
}
|
||||
@@ -33,8 +33,8 @@ defineEmits<{
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
|
||||
{{ saveStatus }}
|
||||
<span v-if="status" class="text-caption text-medium-emphasis mr-4">
|
||||
{{ status }}
|
||||
</span>
|
||||
|
||||
<v-btn
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
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 {
|
||||
entity: EntityObject | null
|
||||
@@ -10,9 +22,13 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
downloadAttachment: [index: number]
|
||||
download: [index: number]
|
||||
preview: [index: number]
|
||||
}>()
|
||||
|
||||
const mailUiStore = useMailUiStore()
|
||||
const previewPopover = computed(() => mediaViewerComponent('popover'))
|
||||
|
||||
const message = computed(() => {
|
||||
return props.entity?.properties ?? null
|
||||
})
|
||||
@@ -21,6 +37,33 @@ 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
|
||||
const formatDate = (date: Date | string | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
@@ -36,19 +79,6 @@ const formatDate = (date: Date | string | null | undefined): string => {
|
||||
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'
|
||||
}
|
||||
|
||||
const download = async (index: number): Promise<void> => {
|
||||
emit('downloadAttachment', index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -109,21 +139,31 @@ const download = async (index: number): Promise<void> => {
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(attachment, index) in message?.attachments"
|
||||
:key="randomKey"
|
||||
:key="attachment.partId || attachment.blobId || attachment.cid || index"
|
||||
class="attachment-item"
|
||||
>
|
||||
<v-chip
|
||||
prepend-icon="mdi-paperclip"
|
||||
:prepend-icon="isPreviewable(attachment) ? 'mdi-eye-outline' : 'mdi-paperclip'"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="attachment-chip"
|
||||
@click="download(index)"
|
||||
:title="isPreviewable(attachment) ? 'Preview' : 'Download'"
|
||||
@click="handleAttachmentClick(attachment, index)"
|
||||
>
|
||||
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
|
||||
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1">
|
||||
({{ formatFileSize(attachment.size ?? undefined) }})
|
||||
<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>
|
||||
|
||||
@@ -48,7 +48,7 @@ const handleAccountSaved = async () => {
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<v-btn
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { folderViewModeOptions, useMailSettingsStore } from '@/stores/mailSettingsStore'
|
||||
import {
|
||||
folderViewModeOptions,
|
||||
messageListDensityOptions,
|
||||
messageListWidthOptions,
|
||||
useMailSettingsStore,
|
||||
} from '@/stores/mailSettingsStore'
|
||||
|
||||
const mailSettingsStore = useMailSettingsStore()
|
||||
const { folderViewMode } = storeToRefs(mailSettingsStore)
|
||||
const { folderViewMode, messageListDensity, messageListWidth } = storeToRefs(mailSettingsStore)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,6 +31,38 @@ const { folderViewMode } = storeToRefs(mailSettingsStore)
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item>
|
||||
<v-list-item-title>Message list density</v-list-item-title>
|
||||
<v-list-item-subtitle>Control row height and how much detail is shown</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-select
|
||||
v-model="messageListDensity"
|
||||
:items="messageListDensityOptions"
|
||||
item-value="value"
|
||||
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-title="title"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
style="width: 150px"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, unref } from 'vue'
|
||||
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 { useMailStore } from '@/stores/mailStore'
|
||||
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'
|
||||
@@ -16,7 +17,7 @@ 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 FolderView from '@/components/FolderView.vue'
|
||||
import Sidebar from '@/components/Sidebar.vue'
|
||||
|
||||
// Vuetify display for responsive behavior
|
||||
const display = useDisplay()
|
||||
@@ -30,18 +31,19 @@ const isManagerAvailable = computed(() => {
|
||||
const collectionsStore = useCollectionsStore()
|
||||
|
||||
// Mail module store
|
||||
const mailStore = useMailStore()
|
||||
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(mailStore)
|
||||
} = storeToRefs(mailOperationsStore)
|
||||
|
||||
const {
|
||||
sidebarVisible,
|
||||
settingsDialogVisible,
|
||||
selectedFolder,
|
||||
composerMode,
|
||||
@@ -70,7 +72,22 @@ const {
|
||||
} = storeToRefs(mailUiStore)
|
||||
|
||||
// Complex store/composable objects accessed directly (not simple refs)
|
||||
const { mailSync, entitiesStore } = mailStore
|
||||
const { mailSync, entitiesStore } = mailOperationsStore
|
||||
|
||||
// Message search (filters the current message list by subject/sender)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const filteredMessages = computed(() => {
|
||||
const query = (searchQuery.value ?? '').trim().toLowerCase()
|
||||
if (!query) return currentMessages.value
|
||||
|
||||
return currentMessages.value.filter((message: EntityObject) => {
|
||||
const subject = (message.properties.subject || '').toLowerCase()
|
||||
const fromLabel = (message.properties.from?.label || '').toLowerCase()
|
||||
const fromAddress = (message.properties.from?.address || '').toLowerCase()
|
||||
return subject.includes(query) || fromLabel.includes(query) || fromAddress.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const lastSyncLabel = computed(() => {
|
||||
const lastSync = unref(unref(mailSync.lastSync))
|
||||
@@ -93,8 +110,6 @@ const {
|
||||
|
||||
const sidebarToggle = () => mailUiStore.sidebarToggle()
|
||||
|
||||
const handleSettingsOpen = () => mailUiStore.settingsOpen()
|
||||
|
||||
const handleFolderSelect = (folder: CollectionObject) => mailUiStore.selectFolder(folder)
|
||||
|
||||
const handleFolderCreateConfirm = async (folderName: string) => {
|
||||
@@ -105,7 +120,7 @@ const handleFolderCreateConfirm = async (folderName: string) => {
|
||||
handleFolderSelect(mutatedFolder)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('[MailPage] Failed to create folder:', error)
|
||||
console.error('[Mail] Failed to create folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +132,7 @@ const handleFolderEditConfirm = async (folderName: string) => {
|
||||
handleFolderSelect(mutatedFolder)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('[MailPage] Failed to rename folder:', error)
|
||||
console.error('[Mail] Failed to rename folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +140,7 @@ const handleFolderDeleteConfirm = async () => {
|
||||
try {
|
||||
await mailUiStore.confirmDeleteFolder()
|
||||
} catch (error: unknown) {
|
||||
console.error('[MailPage] Failed to delete folder:', error)
|
||||
console.error('[Mail] Failed to delete folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +148,7 @@ const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
|
||||
try {
|
||||
await mailUiStore.confirmMoveFolder(targetFolder)
|
||||
} catch (error: unknown) {
|
||||
console.error('[MailPage] Failed to move folder:', error)
|
||||
console.error('[Mail] Failed to move folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +156,7 @@ const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog()
|
||||
|
||||
|
||||
const handleMessageOpen = (message: EntityObject) => {
|
||||
mailStore.selectMessage(message)
|
||||
mailOperationsStore.selectMessage(message)
|
||||
|
||||
if (isMobile.value) {
|
||||
mailUiStore.sidebarHide()
|
||||
@@ -156,12 +171,12 @@ const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openC
|
||||
|
||||
const handleMessageComposeClose = () => mailUiStore.closeComposer()
|
||||
|
||||
const handleMessageFlag = (message: EntityObject, flag: string, value: boolean) => {
|
||||
mailStore.flagMessages([message.identifier], { [flag]: value })
|
||||
const handleMessageFlag = async (message: EntityObject, flag: string, value: boolean) => {
|
||||
await mailUiStore.flagMessage(message, flag, value)
|
||||
}
|
||||
|
||||
const handleMessageDelete = (message: EntityObject) => {
|
||||
mailStore.deleteMessages([message.identifier])
|
||||
const handleMessageDelete = async (message: EntityObject) => {
|
||||
await mailUiStore.deleteMessages([message.identifier])
|
||||
}
|
||||
|
||||
const handleMessageMove = (message: EntityObject) => mailUiStore.openMoveMessagesDialog(message)
|
||||
@@ -212,16 +227,35 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
<!-- 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="sidebarToggle"
|
||||
/>
|
||||
|
||||
<v-app-bar-title>Mail</v-app-bar-title>
|
||||
|
||||
<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()"
|
||||
@@ -252,45 +286,23 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
>
|
||||
mdi-sync
|
||||
</v-icon>
|
||||
</v-app-bar>
|
||||
</v-toolbar>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
|
||||
<FolderView
|
||||
:selected-folder="selectedFolder"
|
||||
@select="handleFolderSelect"
|
||||
/>
|
||||
|
||||
<template #append>
|
||||
<div class="pa-2">
|
||||
<v-btn
|
||||
block
|
||||
variant="text"
|
||||
prepend-icon="mdi-cog"
|
||||
@click="handleSettingsOpen"
|
||||
>
|
||||
Settings
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
<Sidebar />
|
||||
|
||||
<!-- Main area with message list and reader -->
|
||||
<div class="mail-main">
|
||||
<div class="mail-wrapper">
|
||||
<!-- Message list panel -->
|
||||
<div class="mail-list-panel">
|
||||
<div
|
||||
class="mail-list-panel"
|
||||
:style="{ '--mail-list-width': messageListWidth + '%' }"
|
||||
>
|
||||
<MessageList
|
||||
:messages="currentMessages"
|
||||
:messages="filteredMessages"
|
||||
:selected-collection="selectedFolder"
|
||||
:selected-message="selectedMessage"
|
||||
:selection-list="selectionList"
|
||||
@@ -405,7 +417,8 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
@@ -419,28 +432,38 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
.mail-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
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-sidebar {
|
||||
border-right: 1px solid rgb(var(--v-border-color));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mail-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
@@ -448,18 +471,22 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
|
||||
.mail-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mail-list-panel {
|
||||
width: 320px;
|
||||
flex: 0 0 var(--mail-list-width, 35%);
|
||||
width: var(--mail-list-width, 35%);
|
||||
min-width: 280px;
|
||||
max-width: 450px;
|
||||
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 {
|
||||
@@ -477,6 +504,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
}
|
||||
|
||||
.mail-list-panel {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-right: none;
|
||||
+3
-2
@@ -2,10 +2,11 @@ const routes = [
|
||||
{
|
||||
name: 'mail',
|
||||
path: '/',
|
||||
component: () => import('@/pages/MailPage.vue'),
|
||||
component: () => import('@/pages/Main.vue'),
|
||||
meta: {
|
||||
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)
|
||||
}
|
||||
@@ -4,9 +4,8 @@ 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 { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
||||
import type { EntityTransmitRequest } from '@MailManager/types/entity'
|
||||
import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity'
|
||||
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
|
||||
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
|
||||
import { CollectionPropertiesObject } from '@MailManager/models/collection'
|
||||
@@ -22,11 +21,10 @@ interface ComposerMessageInput {
|
||||
}
|
||||
}
|
||||
|
||||
export const useMailStore = defineStore('mailStore', () => {
|
||||
export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
||||
const servicesStore = useServicesStore()
|
||||
const collectionsStore = useCollectionsStore()
|
||||
const entitiesStore = useEntitiesStore()
|
||||
const { showSnackbar } = useSnackbar()
|
||||
|
||||
// Background mail sync
|
||||
const mailSyncController = useMailSync({
|
||||
@@ -158,11 +156,7 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
|
||||
// Track the currently selected folder
|
||||
if (selectedFolder.value) {
|
||||
//mailSyncController.addSource({
|
||||
// provider: selectedFolder.value.provider,
|
||||
// service: selectedFolder.value.service,
|
||||
// collections: [selectedFolder.value.identifier],
|
||||
//})
|
||||
mailSyncController.addSource(selectedFolder.value.identifier)
|
||||
}
|
||||
|
||||
// Always track inboxes for each account (for new-mail notifications)
|
||||
@@ -177,13 +171,7 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
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),
|
||||
//})
|
||||
}
|
||||
inboxes.forEach(inbox => mailSyncController.addSource(inbox.identifier))
|
||||
})
|
||||
|
||||
if (mailSyncController.sources.value.length > 0 && !mailSyncController.isRunning.value) {
|
||||
@@ -398,13 +386,10 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
notify('Message sent', 'success')
|
||||
resetComposerState()
|
||||
return response
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : 'Failed to send message'
|
||||
console.error('[Mail][Operations] Failed to send message:', error)
|
||||
notify(messageText, 'error')
|
||||
throw error
|
||||
} finally {
|
||||
composerSending.value = false
|
||||
@@ -428,11 +413,6 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
parentFolder?.identifier,
|
||||
)
|
||||
|
||||
notify(
|
||||
`Folder "${newFolder.properties.label || properties.label}" created`,
|
||||
'success',
|
||||
)
|
||||
|
||||
return newFolder
|
||||
}
|
||||
|
||||
@@ -448,11 +428,6 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
selectedFolder.value = updatedFolder
|
||||
}
|
||||
|
||||
notify(
|
||||
`Folder "${folder.properties.label || String(folder.identifier)}" renamed to "${updatedFolder.properties.label || properties.label}"`,
|
||||
'success',
|
||||
)
|
||||
|
||||
return updatedFolder
|
||||
}
|
||||
|
||||
@@ -463,11 +438,6 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
selectedFolder.value = movedFolder
|
||||
}
|
||||
|
||||
notify(
|
||||
`Folder "${source.properties.label || String(source.identifier)}" moved to "${target.properties.label || String(target.identifier)}"`,
|
||||
'success',
|
||||
)
|
||||
|
||||
return movedFolder
|
||||
}
|
||||
|
||||
@@ -478,81 +448,38 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
await selectFolder(null)
|
||||
}
|
||||
|
||||
notify(
|
||||
`Folder "${folder.properties.label || String(folder.identifier)}" deleted`,
|
||||
'success',
|
||||
)
|
||||
|
||||
return deletedFolder
|
||||
}
|
||||
|
||||
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
|
||||
if (entityIdentifiers.length === 0) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const { successes, failures } = await entitiesStore.delete(entityIdentifiers)
|
||||
|
||||
if (failures.length === 0) {
|
||||
notify(
|
||||
successes.length === 1 ? 'Message deleted' : `${successes.length} messages deleted`,
|
||||
'success',
|
||||
)
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
notify(
|
||||
successes.length === 0
|
||||
? `Delete failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
|
||||
: `Deleted ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
|
||||
successes.length === 0 ? 'error' : 'warning',
|
||||
)
|
||||
}
|
||||
return await entitiesStore.delete(entityIdentifiers)
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : 'Failed to delete messages'
|
||||
console.error('[Mail][Operations] Failed to delete messages:', error)
|
||||
notify(messageText, 'error')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function flagMessages(entityIdentifiers: EntityIdentifier[], flags: Partial<MessageInterface['flags']>, options: { notify?: boolean } = {}) {
|
||||
async function flagMessages(entityIdentifiers: EntityIdentifier[], flags: Partial<NonNullable<MessageInterface['flags']>>) {
|
||||
if (entityIdentifiers.length === 0) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
const shouldNotify = options.notify ?? true
|
||||
|
||||
try {
|
||||
const patch = entitiesStore.fresh().properties
|
||||
patch.flags = flags
|
||||
|
||||
const { successes, failures } = await entitiesStore.patch(patch, entityIdentifiers)
|
||||
|
||||
if (shouldNotify && successes.length > 0) {
|
||||
notify(
|
||||
successes.length === 1 ? 'Message updated' : `${successes.length} messages updated`,
|
||||
'success',
|
||||
)
|
||||
}
|
||||
|
||||
if (shouldNotify && failures.length > 0) {
|
||||
notify(
|
||||
successes.length === 0
|
||||
? `Update failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
|
||||
: `Updated ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
|
||||
successes.length === 0 ? 'error' : 'warning',
|
||||
)
|
||||
}
|
||||
return await entitiesStore.patch(patch, entityIdentifiers)
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : 'Failed to update messages'
|
||||
console.error('[Mail][Operations] Failed to update messages:', error)
|
||||
notify(messageText, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -592,7 +519,7 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
)
|
||||
|
||||
if (movableIdentifiers.length === 0) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
@@ -600,29 +527,13 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
try {
|
||||
const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
|
||||
|
||||
if (failures.length === 0) {
|
||||
notify(
|
||||
successes.length === 1 ? 'Message moved' : `${successes.length} messages moved`,
|
||||
'success',
|
||||
)
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
notify(
|
||||
successes.length === 0
|
||||
? `Move failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
|
||||
: `Moved ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
|
||||
successes.length === 0 ? 'error' : 'warning',
|
||||
)
|
||||
}
|
||||
|
||||
// update source collections to reflect moved messages
|
||||
sourceCollections.push(target.identifier)
|
||||
await collectionsStore.fetch(sourceCollections)
|
||||
|
||||
return { successes, failures }
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
|
||||
console.error('[Mail][Operations] Failed to move messages:', error)
|
||||
notify(messageText, 'error')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -640,24 +551,28 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
try {
|
||||
await entitiesStore.download(target, part)
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error
|
||||
? error.message
|
||||
: index === undefined
|
||||
? 'Failed to download message'
|
||||
: 'Failed to download attachment'
|
||||
console.error(
|
||||
index === undefined
|
||||
? '[Mail][Operations] Failed to download message:'
|
||||
: '[Mail][Operations] Failed to download attachment:',
|
||||
error,
|
||||
)
|
||||
notify(messageText, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
|
||||
showSnackbar({ message, color })
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
@@ -696,9 +611,9 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
deleteFolder,
|
||||
moveMessages,
|
||||
downloadMessage,
|
||||
attachmentBlob,
|
||||
moveFolder,
|
||||
renameFolder,
|
||||
notify,
|
||||
isServiceFolderLoading,
|
||||
hasServiceFoldersLoaded,
|
||||
getServiceFolderError,
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -5,13 +5,46 @@ 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' },
|
||||
@@ -42,6 +75,14 @@ function normalizeFolderViewMode(value: unknown, fallback: FolderViewMode): Fold
|
||||
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()
|
||||
|
||||
@@ -66,8 +107,26 @@ export const useMailSettingsStore = defineStore('mailSettingsStore', () => {
|
||||
),
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
+185
-35
@@ -1,18 +1,21 @@
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
||||
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
|
||||
import { ComposerMode } from '@/types/composer'
|
||||
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
||||
import { 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 mailStore = useMailStore()
|
||||
const mailOperationsStore = useMailOperationsStore()
|
||||
const mailSettingsStore = useMailSettingsStore()
|
||||
const { showSnackbar } = useSnackbar()
|
||||
|
||||
const sidebarVisible = ref(true)
|
||||
const settingsDialogVisible = ref(false)
|
||||
@@ -95,7 +98,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => mailStore.selectedMessage,
|
||||
() => mailOperationsStore.selectedMessage,
|
||||
message => {
|
||||
if (message) {
|
||||
closeComposer()
|
||||
@@ -106,12 +109,24 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
)
|
||||
|
||||
watch(
|
||||
() => mailStore.currentMessages,
|
||||
() => 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
|
||||
}
|
||||
@@ -128,6 +143,52 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
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
|
||||
@@ -138,25 +199,13 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
String(left.identifier) === String(right.identifier)
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await mailStore.initialize()
|
||||
|
||||
if (!selectedFolder.value) {
|
||||
const inbox = mailStore.findFoldersByRole('inbox')[0] ?? null
|
||||
|
||||
if (inbox) {
|
||||
await selectFolder(inbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFolder(folder: CollectionObject | null) {
|
||||
closeComposer()
|
||||
messageSelectionModeDeactivate()
|
||||
clearMessageReadTimer()
|
||||
selectedMessage.value = null
|
||||
selectedFolder.value = folder
|
||||
await mailStore.selectFolder(folder)
|
||||
await mailOperationsStore.selectFolder(folder)
|
||||
}
|
||||
|
||||
async function selectMessage(message: EntityObject | null) {
|
||||
@@ -200,7 +249,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
async function completeMessageRead(identifier: EntityIdentifier) {
|
||||
try {
|
||||
if (selectedMessage.value && selectedMessage.value.identifier === identifier && selectedMessage.value.properties.isRead === false) {
|
||||
await mailStore.flagMessages([selectedMessage.value.identifier], { read: true }, { notify: false })
|
||||
await flagMessages([selectedMessage.value.identifier], { seen: true }, { notify: false })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Mail][UI] Failed to auto-mark message as read:', error)
|
||||
@@ -236,7 +285,8 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
createFolderDialogError.value = ''
|
||||
|
||||
try {
|
||||
const folder = await mailStore.createFolder(service, label, createFolderDialogParent.value)
|
||||
const folder = await mailOperationsStore.createFolder(service, label, createFolderDialogParent.value)
|
||||
notify(`Folder "${folder.properties.label || label.trim()}" created`, 'success')
|
||||
closeCreateFolderDialog()
|
||||
return folder
|
||||
} catch (error) {
|
||||
@@ -248,7 +298,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
}
|
||||
|
||||
async function openRenameFolderDialog(target: CollectionObject) {
|
||||
const service = await mailStore.retrieveService(target.service)
|
||||
const service = await mailOperationsStore.retrieveService(target.service)
|
||||
renameFolderDialogService.value = service
|
||||
renameFolderDialogFolder.value = target
|
||||
renameFolderDialogError.value = ''
|
||||
@@ -275,12 +325,17 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
renameFolderDialogError.value = ''
|
||||
|
||||
try {
|
||||
const updatedFolder = await mailStore.renameFolder(folder, label)
|
||||
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) {
|
||||
@@ -292,7 +347,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
}
|
||||
|
||||
async function openMoveFolderDialog(source: CollectionObject) {
|
||||
const service = await mailStore.retrieveService(source.service)
|
||||
const service = await mailOperationsStore.retrieveService(source.service)
|
||||
moveFolderDialogService.value = service
|
||||
moveFolderDialogSource.value = source
|
||||
moveFolderDialogVisible.value = true
|
||||
@@ -311,18 +366,23 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const movedFolder = await mailStore.moveFolder(source, target)
|
||||
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 mailStore.retrieveService(target.service)
|
||||
const service = await mailOperationsStore.retrieveService(target.service)
|
||||
deleteFolderDialogService.value = service
|
||||
deleteFolderDialogFolder.value = target
|
||||
deleteFolderDialogError.value = ''
|
||||
@@ -349,13 +409,14 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
deleteFolderDialogError.value = ''
|
||||
|
||||
try {
|
||||
const deleted = await mailStore.deleteFolder(folder)
|
||||
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) {
|
||||
@@ -452,7 +513,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
}
|
||||
|
||||
function openComposer(source?: EntityObject | MessageAddressInterface, mode: ComposerMode = ComposerMode.Fresh) {
|
||||
mailStore.selectMessage(null)
|
||||
mailOperationsStore.selectMessage(null)
|
||||
composerSource.value = source ?? null
|
||||
composerMode.value = mode
|
||||
composerVisible.value = true
|
||||
@@ -503,7 +564,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
function messageSelectionToggleAll(value: boolean) {
|
||||
selectionMode.value = true
|
||||
if (value) {
|
||||
setSelectionList(mailStore.currentMessages.map(message => message.identifier))
|
||||
setSelectionList(mailOperationsStore.currentMessages.map(message => message.identifier))
|
||||
} else {
|
||||
setSelectionList([])
|
||||
}
|
||||
@@ -515,7 +576,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
return
|
||||
}
|
||||
|
||||
const currentMessageIdentifiers = new Set(mailStore.currentMessages.map(message => message.identifier))
|
||||
const currentMessageIdentifiers = new Set(mailOperationsStore.currentMessages.map(message => message.identifier))
|
||||
const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
|
||||
|
||||
if (nextSelectedIds.length !== selectionList.value.length) {
|
||||
@@ -545,7 +606,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
moveMessagesServiceIdentifier = selectedFolder.value?.service as ServiceIdentifier || null
|
||||
}
|
||||
|
||||
moveMessagesDialogService.value = await mailStore.retrieveService(moveMessagesServiceIdentifier)
|
||||
moveMessagesDialogService.value = await mailOperationsStore.retrieveService(moveMessagesServiceIdentifier)
|
||||
moveMessagesDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -555,22 +616,104 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
moveMessagesDialogCandidates.value = null
|
||||
}
|
||||
|
||||
async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailStore.moveMessages>[0]) {
|
||||
await mailStore.moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? [])
|
||||
async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailOperationsStore.moveMessages>[0]) {
|
||||
await moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? [])
|
||||
messageSelectionModeDeactivate()
|
||||
closeMoveMessagesDialog()
|
||||
}
|
||||
|
||||
async function deleteSelectedMessages() {
|
||||
await mailStore.deleteMessages([...selectionList.value])
|
||||
await deleteMessages([...selectionList.value])
|
||||
messageSelectionModeDeactivate()
|
||||
}
|
||||
|
||||
async function flagSelectedMessages(flag: string, value: boolean) {
|
||||
await mailStore.flagMessages([...selectionList.value], { [flag]: value })
|
||||
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,
|
||||
@@ -605,14 +748,21 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
deleteFolderDialogFolder,
|
||||
deleteFolderDialogLoading,
|
||||
deleteFolderDialogError,
|
||||
initialize,
|
||||
sidebarToggle,
|
||||
sidebarHide,
|
||||
settingsOpen,
|
||||
settingsClose,
|
||||
initialize,
|
||||
notify,
|
||||
selectFolder,
|
||||
openComposer,
|
||||
closeComposer,
|
||||
deleteMessages,
|
||||
flagMessages,
|
||||
flagMessage,
|
||||
moveMessages,
|
||||
downloadMessage,
|
||||
attachmentBlob,
|
||||
messageSelectionModeActivate,
|
||||
messageSelectionModeDeactivate,
|
||||
messageSelectionToggleOne,
|
||||
|
||||
+6
-1
@@ -3,7 +3,8 @@
|
||||
.mail-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@@ -14,6 +15,7 @@
|
||||
.mail-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
@@ -25,6 +27,7 @@
|
||||
|
||||
.mail-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
@@ -32,6 +35,8 @@
|
||||
|
||||
.mail-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
+46
-3
@@ -1,5 +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,
|
||||
Reply,
|
||||
Forward,
|
||||
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>
|
||||
+6
-2
@@ -3,9 +3,12 @@
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"src/utile/**/*.ts",
|
||||
"../../core/src/**/*.ts",
|
||||
"../mail_manager/src/**/*.ts",
|
||||
"../mail_manager/src/**/*.vue"
|
||||
"../mail_manager/src/**/*.vue",
|
||||
"../people_manager/src/**/*.ts",
|
||||
"../people_manager/src/**/*.vue"
|
||||
],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
@@ -14,7 +17,8 @@
|
||||
"@/*": ["./src/*"],
|
||||
"@KTXC": ["../../core/src/shared/index.ts"],
|
||||
"@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'),
|
||||
'@KTXC': path.resolve(__dirname, '../../core/src'),
|
||||
'@MailManager': path.resolve(__dirname, '../mail_manager/src'),
|
||||
'@PeopleManager': path.resolve(__dirname, '../people_manager/src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
@@ -49,6 +50,7 @@ export default defineConfig({
|
||||
'pinia',
|
||||
'@KTXC',
|
||||
/^@MailManager\//,
|
||||
/^@PeopleManager\//,
|
||||
],
|
||||
output: {
|
||||
paths: (id) => {
|
||||
@@ -56,6 +58,9 @@ export default defineConfig({
|
||||
if (id.startsWith('@MailManager/')) {
|
||||
return '/modules/mail_manager/static/module.mjs'
|
||||
}
|
||||
if (id.startsWith('@PeopleManager/')) {
|
||||
return '/modules/people_manager/static/module.mjs'
|
||||
}
|
||||
return id
|
||||
},
|
||||
assetFileNames: (assetInfo) => {
|
||||
|
||||
Reference in New Issue
Block a user