1 Commits

Author SHA1 Message Date
Sebastian 092ff5c2ff chore(deps): update dependency vue-tsc to v3 2026-05-07 02:49:11 +00:00
65 changed files with 3401 additions and 11279 deletions
-58
View File
@@ -1,58 +0,0 @@
name: Build Test
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Install Mail Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/mail_manager
ref: main
path: server/modules/mail_manager
github-server-url: https://git.ktrix.dev
- name: Install People Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/people_manager
ref: main
path: server/modules/people_manager
github-server-url: https://git.ktrix.dev
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/mail
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/mail
- name: Build
run: npm run build
working-directory: server/modules/mail
-58
View File
@@ -1,58 +0,0 @@
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
@@ -1,59 +0,0 @@
name: PHP Integration Tests
on:
pull_request:
workflow_dispatch:
jobs:
test:
name: Integration Tests
runs-on: ubuntu-latest
services:
mongo:
image: mongo:7
options: >-
--health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\""
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install server
uses: ./action-server-install
with:
install-php: 'true'
php-version: '8.5'
server-path: './server'
database-uri: 'mongodb://mongo:27017/?tls=false'
database-name: 'ktrix_ci'
app-environment: 'test'
- name: Checkout module under test
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha || github.sha }}
path: server/modules/mail
github-server-url: https://git.ktrix.dev
- name: Install module dependencies
run: composer install --prefer-dist --no-progress
working-directory: server/modules/mail
- name: Install and enable module
working-directory: server
run: |
php bin/console module:install mail
php bin/console module:enable mail
- name: Run integration tests
working-directory: server/modules/mail
run: composer test:integration
-42
View File
@@ -1,42 +0,0 @@
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
+2 -8
View File
@@ -25,17 +25,11 @@ jobs:
tools: composer:v2
- name: Install Renovate
run: |
npm install --global --no-audit --fund=false \
--prefix "${{ runner.temp }}/renovate-npm" \
--cache "${{ runner.temp }}/renovate-npm-cache" \
renovate
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
run: npm install -g renovate
- name: Run Renovate
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
run: |
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
run: renovate ${{ gitea.repository }}
+4 -2
View File
@@ -12,10 +12,12 @@ node_modules/
/static/
# Backend development
/vendor/
/lib/vendor/
coverage/
*.cache
phpunit.xml.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
.phpactor/
# Editors
+1 -13
View File
@@ -9,18 +9,6 @@
}
},
"require": {
"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"
"php": ">=8.2"
}
}
Generated
-1702
View File
File diff suppressed because it is too large Load Diff
-396
View File
@@ -1,396 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail;
use InvalidArgumentException;
use KTXF\Mail\Object\Address;
use KTXF\Mail\Object\AddressInterface;
use KTXF\Mail\Object\MessagePart;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Resource\Identifier\EntityIdentifierInterface;
use KTXF\Resource\Identifier\ResourceIdentifier;
use KTXF\Utile\UUID;
use KTXM\Mail\Stores\CompositionStore;
use KTXM\MailManager\Manager as MailManager;
class CompositionManager {
public function __construct(
private readonly CompositionStore $compositionStore,
private readonly MailManager $mailManager,
) {}
public function stage(string $tenantId, string $userId, string $identifier, string $action, array $sender, array $message, ?string $source = null): array {
if ($identifier === '') {
throw new InvalidArgumentException('Draft identifier is required');
}
$snapshot = [
'identifier' => $identifier,
'action' => $action,
'source' => $source,
'revision' => 1,
'disposition' => 'staged',
'sender' => $sender,
'message' => $message,
'attachments' => [],
];
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $snapshot);
if ($action === 'forward') {
$sourceIndentifier = ResourceIdentifier::fromString($source);
if ($sourceIndentifier === null) {
throw new InvalidArgumentException('Invalid source identifier');
}
$entities = $this->mailManager->entityFetchBulk($tenantId, $userId, $sourceIndentifier);
if (count($entities) === 0) {
throw new InvalidArgumentException('Source message not found');
}
// retrieve the attachments from the source message
$attachments = reset($entities)->getProperties()->getAttachments();
// For each attachment, we download the content and stage it in the composition store
foreach ($attachments as $attachment) {
$contentId = $attachment->getContentId();
$isInline = $attachment->getDisposition() === 'inline';
// target part
$targetPart = [
'partId' => $attachment->getId(),
'blobId' => $attachment->getBlobId(),
'cid' => $contentId,
'cId' => $contentId,
];
// retrieve the attachment content as a stream
$data = $this->mailManager->entityDownload($tenantId, $userId, $sourceIndentifier, $targetPart);
// stage the attachment in the composition store
$meta = $this->compositionStore->attachmentStageFromStream(
tenantId: $tenantId,
userId: $userId,
compositionId: $identifier,
attachmentId: UUID::v4(),
data: $data,
);
// supplement the attachment metadata with source information
$meta['origin'] = 'source';
$meta['source'] = $source;
$meta['partId'] = $attachment->getId();
$meta['blobId'] = $attachment->getBlobId();
$meta['cid'] = $contentId;
$meta['contentId'] = $contentId;
$meta['disposition'] = $attachment->getDisposition();
$meta['inline'] = $isInline;
$meta['name'] = $attachment->getName() ?? $meta['name'];
$meta['type'] = $attachment->getType() ?? $meta['type'];
$meta['size'] = $attachment->getSize() ?? $meta['size'];
$snapshot['attachments'][$meta['identifier']] = $meta;
}
// update the snapshot with the staged attachments
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $snapshot);
}
return $snapshot;
}
public function patch(string $tenantId, string $userId, string $identifier, array $data): array {
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return $result;
}
if (!isset($data['revision']) || !is_int($data['revision']) || $data['revision'] < $composed['revision']) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'revision_mismatch',
'message' => 'Revision mismatch',
'currentRevision' => $composed['revision'],
];
return $result;
}
// Apply the patch data to the composed message
$composed['revision'] = $data['revision'];
$composed['sender'] = $data['sender'] ?? [];
$composed['message'] = $data['message'] ?? [];
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
$result = [
'identifier' => $identifier,
'disposition' => 'patched',
];
return $result;
}
public function discard(string $tenantId, string $userId, string $identifier): array {
return [
'identifier' => $identifier,
'disposition' => $this->compositionStore->compositionDiscard($tenantId, $userId, $identifier),
];
}
public function send(string $tenantId, string $userId, string $identifier, array $sender, array $message, array $attachments): array {
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return $result;
}
// store the message and sender information before sending
$composed['sender'] = $sender;
$composed['message'] = $message;
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
// validate the sender address before attempting to send the message
if ($sender['address'] === '' || !filter_var($sender['address'], FILTER_VALIDATE_EMAIL)) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_invalid_sender',
'message' => 'Invalid sender address',
];
return $result;
}
$senderAddress = $sender['address'];
$senderObject = Address::fromArray($sender);
// resolve submit-capable service for sender
$service = $this->mailManager->serviceFindByAddress($tenantId, $userId, $senderAddress);
if ($service === null || $service->getEnabled() === false) {
return [
'disposition' => 'error',
'error' => [
'type' => 'service_not_found',
'message' => "Service not found for sender '$senderAddress' or service is disabled",
],
];
}
if ($service instanceof ServiceEntitySubmitInterface === false) {
return [
'disposition' => 'error',
'error' => [
'type' => 'service_not_supported',
'message' => "Service '{$service->identifier()}' does not support entity submission",
],
];
}
$source = null;
$properties = $this->buildMessageProperties(
service: $service,
sender: $senderObject,
message: $message,
attachments: $attachments,
composed: $composed,
tenantId: $tenantId,
userId: $userId,
compositionId: $identifier,
);
$sendResult = $service->entitySubmit($senderObject, $source, $properties);
if ($sendResult->disposition === EntitySubmitResult::DISPOSITION_ERROR) {
return [
'identifier' => $identifier,
'disposition' => 'error',
'error' => [
'type' => 'service_submission_error',
'message' => $sendResult->errorMessage ?? 'An unknown error occurred during submission',
],
];
}
return [
'identifier' => $identifier,
'disposition' => 'sent'
];
}
private function buildMessageProperties(
ServiceEntitySubmitInterface $service,
AddressInterface $sender,
array $message,
array $attachments,
array $composed,
string $tenantId,
string $userId,
string $compositionId,
): MessagePropertiesMutableInterface {
$properties = $service->entityFresh()->getProperties();
$bodyTextPlain = isset($message['body']['text']) ? (string)$message['body']['text'] : '';
$bodyTextHtml = isset($message['body']['html']) ? (string)$message['body']['html'] : '';
$properties->setFrom($sender);
$to = $this->mapAddresses($message['to'] ?? []);
if ($to !== []) {
$properties->setTo(...$to);
}
$cc = $this->mapAddresses($message['cc'] ?? []);
if ($cc !== []) {
$properties->setCc(...$cc);
}
$bcc = $this->mapAddresses($message['bcc'] ?? []);
if ($bcc !== []) {
$properties->setBcc(...$bcc);
}
$replyTo = $this->mapAddresses($message['replyTo'] ?? []);
if ($replyTo !== []) {
$properties->setReplyTo(...$replyTo);
}
$properties->setSubject((string)($message['subject'] ?? ''));
$properties->setBodyTextPlain($bodyTextPlain);
$properties->setBodyTextHtml($bodyTextHtml);
if (isset($message['flags']) && is_array($message['flags'])) {
$properties->setFlags($message['flags']);
}
$attachmentObjects = [];
foreach ($attachments as $attachment) {
if (!isset($attachment['identifier']) || !is_string($attachment['identifier']) || $attachment['identifier'] === '') {
continue;
}
$composedAttachment = $composed['attachments'][$attachment['identifier']] ?? null;
if (!is_array($composedAttachment)) {
continue;
}
$attachmentObjects[] = MessagePart::fromArray([
'partId' => $attachment['identifier'],
'blobId' => $attachment['blobId'] ?? $composedAttachment['blobId'] ?? null,
'size' => $composedAttachment['size'] ?? null,
'name' => $composedAttachment['name'] ?? 'unknown.bin',
'type' => $composedAttachment['type'] ?? 'application/octet-stream',
'disposition' => (($attachment['inline'] ?? $composedAttachment['inline'] ?? false) === true) ? 'inline' : 'attachment',
'content' => $this->compositionStore->attachmentFetchData($tenantId, $userId, $compositionId, $attachment['identifier']),
'cid' => $attachment['contentId'] ?? $attachment['cid'] ?? $composedAttachment['contentId'] ?? $composedAttachment['cid'] ?? null,
]);
}
if ($attachmentObjects !== []) {
$properties->setAttachments(...$attachmentObjects);
}
return $properties;
}
/**
* @param array<int,array|string> $entries
* @return array<int,Address>
*/
private function mapAddresses(array $entries): array {
$addresses = [];
foreach ($entries as $entry) {
if (is_array($entry)) {
$address = Address::fromArray($entry);
if ($address->getAddress() !== '') {
$addresses[] = $address;
}
continue;
}
if (is_string($entry) && $entry !== '') {
$address = Address::fromString($entry);
if ($address->getAddress() !== '') {
$addresses[] = $address;
}
}
}
return $addresses;
}
public function attachmentAdd(string $tenantId, string $userId, string $composition, array ...$attachments): array {
$result = [
'composition' => $composition,
'attachments' => [],
];
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return $result;
}
foreach ($attachments as $attachment) {
if (!isset($attachment['identifier']) || !is_string($attachment['identifier']) || $attachment['identifier'] === '') {
$identifier = UUID::v4();
} else {
$identifier = $attachment['identifier'];
}
$meta = $this->compositionStore->attachmentStageFromBase64(
tenantId: $tenantId,
userId: $userId,
compositionId: $composition,
attachmentId: $identifier,
name: $attachment['name'] ?? 'unknown.bin',
type: $attachment['type'] ?? 'application/octet-stream',
data: $attachment['data'] ?? '',
);
$meta['origin'] = 'upload';
$composed['attachments'][$meta['identifier']] = $meta;
}
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
$result['disposition'] = 'added';
$result['attachments'] = $composed['attachments'];
return $result;
}
public function attachmentRemove(string $tenantId, string $userId, string $composition, string $identifier): array {
$result = [
'composition' => $composition,
'identifier' => $identifier,
];
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return $result;
}
if (!isset($composed['attachments'][$identifier])) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'attachment_not_found',
'message' => 'Attachment not found',
];
return $result;
}
unset($composed['attachments'][$identifier]);
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
$result['disposition'] = 'removed';
return $result;
}
}
-157
View File
@@ -1,157 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail\Controllers;
use InvalidArgumentException;
use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\Response;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
use KTXM\Mail\CompositionManager;
use Psr\Log\LoggerInterface;
use Throwable;
final class CompositionController extends ControllerAbstract {
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly CompositionManager $compositionManager,
private readonly LoggerInterface $logger,
) {}
#[AuthenticatedRoute('/compose/v1', name: 'mail.compose.v1', methods: ['POST'])]
public function index(
int $version,
string $transaction,
string|null $operation = null,
array|null $data = null,
): Response {
$tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->identifier();
try {
if ($operation === null) {
throw new InvalidArgumentException('Operation must be provided');
}
$result = match ($operation) {
'stage' => $this->stage($tenantId, $userId, $data),
'patch' => $this->patch($tenantId, $userId, $data),
'discard' => $this->discard($tenantId, $userId, $data),
'send' => $this->send($tenantId, $userId, $data),
'attachment.add' => $this->attachmentAdd($tenantId, $userId, $data),
'attachment.remove' => $this->attachmentRemove($tenantId, $userId, $data),
default => throw new InvalidArgumentException('Invalid operation: ' . $operation),
};
return new JsonResponse([
'version' => $version,
'transaction' => $transaction,
'operation' => $operation,
'status' => 'success',
'data' => $result,
], JsonResponse::HTTP_OK);
} catch (Throwable $throwable) {
$this->logger->error('Mail draft request failed', ['exception' => $throwable]);
return new JsonResponse([
'version' => $version,
'transaction' => $transaction,
'operation' => $operation,
'status' => 'error',
'data' => [
'code' => $throwable->getCode(),
'message' => $throwable->getMessage(),
],
], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
}
}
private function stage(string $tenantId, string $userId, array $data): array {
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
if (!isset($data['action']) || !is_string($data['action'])) {
throw new InvalidArgumentException('Invalid parameter: action must be a string');
}
if (!in_array($data['action'], ['fresh', 'reply', 'forward'], true)) {
throw new InvalidArgumentException('Invalid parameter: action must be one of fresh, reply, forward');
}
if (!isset($data['sender']) || !is_array($data['sender'])) {
throw new InvalidArgumentException('Invalid parameter: sender must be an array');
}
if (!isset($data['message']) || !is_array($data['message'])) {
throw new InvalidArgumentException('Invalid parameter: message must be an array');
}
if (($data['action'] === 'reply' || $data['action'] === 'forward') && (!isset($data['source']) || !is_string($data['source']) || $data['source'] === '')) {
throw new InvalidArgumentException('Invalid parameter: source must be a non-empty string for reply or forward action');
}
return $this->compositionManager->stage($tenantId, $userId, $data['identifier'], $data['action'], $data['sender'], $data['message'], $data['source'] ?? null);
}
private function patch(string $tenantId, string $userId, array $data): array {
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
if (!isset($data['sender']) || !is_array($data['sender'])) {
throw new InvalidArgumentException('Invalid parameter: sender must be an array');
}
if (!isset($data['message']) || !is_array($data['message'])) {
throw new InvalidArgumentException('Invalid parameter: message must be an array');
}
return $this->compositionManager->patch($tenantId, $userId, $data['identifier'], $data);
}
private function discard(string $tenantId, string $userId, array $data): array {
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
return $this->compositionManager->discard($tenantId, $userId, $data['identifier']);
}
private function send(string $tenantId, string $userId, array $data): array {
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
if (!isset($data['sender']) || !is_array($data['sender'])) {
throw new InvalidArgumentException('Invalid parameter: sender must be an array');
}
if (!isset($data['message']) || !is_array($data['message'])) {
throw new InvalidArgumentException('Invalid parameter: message must be an array');
}
if (!isset($data['attachments']) || !is_array($data['attachments'])) {
throw new InvalidArgumentException('Invalid parameter: attachments must be an array');
}
return $this->compositionManager->send($tenantId, $userId, $data['identifier'], $data['sender'], $data['message'], $data['attachments']);
}
private function attachmentAdd(string $tenantId, string $userId, array $data): array {
if (!isset($data['composition']) || !is_string($data['composition']) || $data['composition'] === '') {
throw new InvalidArgumentException('Invalid parameter: composition must be a non-empty string');
}
if (!isset($data['attachments']) || !is_array($data['attachments'])) {
throw new InvalidArgumentException('Invalid parameter: attachments must be an array');
}
return $this->compositionManager->attachmentAdd($tenantId, $userId, $data['composition'], ...$data['attachments']);
}
private function attachmentRemove(string $tenantId, string $userId, array $data): array {
if (!isset($data['composition']) || !is_string($data['composition']) || $data['composition'] === '') {
throw new InvalidArgumentException('Invalid parameter: composition must be a non-empty string');
}
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
return $this->compositionManager->attachmentRemove($tenantId, $userId, $data['composition'], $data['identifier']);
}
}
-176
View File
@@ -1,176 +0,0 @@
<?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);
}
}
+1237 -3261
View File
File diff suppressed because it is too large Load Diff
+14 -22
View File
@@ -11,33 +11,25 @@
"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",
"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"
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@tiptap/vue-3": "^3.0.0",
"@tiptap/starter-kit": "^3.0.0",
"@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-placeholder": "^3.0.0",
"@tiptap/extension-text-align": "^3.0.0",
"@tiptap/extension-underline": "^3.0.0",
"@tiptap/vue-3": "^2.1.13",
"@tiptap/starter-kit": "^2.1.13",
"@tiptap/extension-link": "^2.1.13",
"@tiptap/extension-placeholder": "^2.1.13",
"@tiptap/extension-text-align": "^2.1.13",
"@tiptap/extension-underline": "^2.1.13",
"vue": "^3.4.0",
"vue-router": "^5.0.0",
"pinia": "^3.0.0",
"vuetify": "^4.0.0"
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"vuetify": "^3.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"@vue/tsconfig": "^0.9.0",
"typescript": "~6.0.0",
"vite": "^8.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"
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^3.0.0"
}
}
+106 -15
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { CollectionPropertiesObject } from '@MailManager/models/collection'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
@@ -7,27 +9,28 @@ import type { ServiceObject } from '@MailManager/models'
interface Props {
modelValue: boolean
service: ServiceObject
parentFolderLabel?: string
validateName?: (name: string) => string[]
loading?: boolean
errorMessage?: string
parentFolder?: CollectionObject | null
allFolders?: CollectionObject[]
}
const props = withDefaults(defineProps<Props>(), {
parentFolderLabel: 'Root',
validateName: () => [],
loading: false,
errorMessage: '',
parentFolder: null,
allFolders: () => []
})
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: [folderName: string]
'created': [folder: CollectionObject]
}>()
// Store
const collectionsStore = useCollectionsStore()
// Form state
const folderName = ref('')
const loading = ref(false)
const errorMessage = ref('')
const validationErrors = ref<string[]>([])
// Computed
@@ -36,13 +39,67 @@ const dialogValue = computed({
set: (value: boolean) => emit('update:modelValue', value)
})
const parentFolderLabel = computed(() => {
if (!props.parentFolder) return 'Root'
return props.parentFolder.properties.label
})
const isValid = computed(() => {
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
})
// Validation functions
const validateFolderName = (name: string): string[] => {
const errors: string[] = []
if (!name || name.trim().length === 0) {
errors.push('Folder name is required')
return errors
}
if (name.length > 255) {
errors.push('Folder name too long (max 255 characters)')
}
// No special characters that might cause issues
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
errors.push('Folder name contains invalid characters')
}
// Provider-specific rules
if (props.service.provider === 'imap' && /[\/\\]/.test(name)) {
errors.push('IMAP folder names cannot contain / or \\')
}
// Leading/trailing spaces
if (name !== name.trim()) {
errors.push('Folder name cannot have leading or trailing spaces')
}
return errors
}
const checkDuplicateName = (name: string): boolean => {
const parentId = props.parentFolder?.identifier ?? null
return props.allFolders.some(f =>
f.properties.label === name &&
String(f.collection) === String(parentId) &&
String(f.service) === String(props.service.identifier)
)
}
// Watch folder name for validation
watch(folderName, (newName) => {
validationErrors.value = props.validateName(newName)
errorMessage.value = ''
validationErrors.value = validateFolderName(newName)
// Check for duplicates only if no other validation errors
if (validationErrors.value.length === 0 && newName.trim().length > 0) {
if (checkDuplicateName(newName)) {
validationErrors.value.push('A folder with this name already exists in this location')
}
}
})
// Reset form when dialog opens/closes
@@ -54,25 +111,59 @@ watch(dialogValue, (isOpen) => {
const resetForm = () => {
folderName.value = ''
errorMessage.value = ''
validationErrors.value = []
loading.value = false
}
const handleCreate = async () => {
const errors = props.validateName(folderName.value)
// Final validation
const errors = validateFolderName(folderName.value)
if (errors.length > 0) {
validationErrors.value = errors
return
}
emit('confirm', folderName.value.trim())
if (checkDuplicateName(folderName.value)) {
validationErrors.value = ['A folder with this name already exists in this location']
return
}
loading.value = true
errorMessage.value = ''
try {
// Create properties object
const properties = new CollectionPropertiesObject()
properties.label = folderName.value.trim()
properties.rank = 0
properties.subscribed = true
// Create the collection
const newFolder = await collectionsStore.create(
props.service.provider,
props.service.identifier as string | number,
props.parentFolder?.identifier ?? null,
properties
)
// Success!
emit('created', newFolder)
dialogValue.value = false
resetForm()
} catch (error: any) {
console.error('[CreateFolderDialog] Failed to create folder:', error)
errorMessage.value = error.message || 'Failed to create folder. Please try again.'
} finally {
loading.value = false
}
}
const handleCancel = () => {
dialogValue.value = false
resetForm()
}
</script>
<template>
@@ -91,7 +182,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?.format() || 'Mail Account' }}
{{ service.label || service.primaryAddress || 'Mail Account' }}
</div>
</div>
+28 -10
View File
@@ -8,22 +8,20 @@ interface Props {
modelValue: boolean
service: ServiceObject
folder: CollectionObject
loading?: boolean
errorMessage?: string
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
errorMessage: '',
})
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: []
deleted: [folder: CollectionObject]
}>()
const collectionsStore = useCollectionsStore()
const loading = ref(false)
const errorMessage = ref('')
const dialogValue = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
@@ -35,17 +33,37 @@ const hasChildren = computed(() => {
return collectionsStore.hasChildrenInCollection(props.folder.provider, props.folder.service, props.folder.identifier)
})
const resetState = () => {
loading.value = false
errorMessage.value = ''
}
watch(dialogValue, isOpen => {
if (isOpen) {
resetState()
}
})
const handleDelete = () => {
emit('confirm')
const handleDelete = async () => {
loading.value = true
errorMessage.value = ''
try {
await collectionsStore.delete(props.folder.provider, props.folder.service, props.folder.identifier)
emit('deleted', props.folder)
dialogValue.value = false
resetState()
} catch (error: any) {
console.error('[DeleteFolderDialog] Failed to delete folder:', error)
errorMessage.value = error.message || 'Failed to delete folder. Please try again.'
} finally {
loading.value = false
}
}
const handleCancel = () => {
dialogValue.value = false
resetState()
}
</script>
@@ -64,7 +82,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?.format() || 'Mail Account' }}
{{ service.label || service.primaryAddress || 'Mail Account' }}
</div>
</div>
+11 -11
View File
@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
const emit = defineEmits<{
select: [folder: CollectionObject]
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
editFolder: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject]
}>()
// Page-based navigation state per service account
@@ -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?.address || undefined"
:subtitle="group.service.primaryAddress || undefined"
>
<template v-slot:prepend>
<v-icon icon="mdi-email-outline" />
@@ -283,7 +283,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
<v-list density="compact">
<v-list-item
prepend-icon="mdi-pencil"
@click="emit('editFolder', folder)"
@click="emit('editFolder', group.service, folder)"
>
<v-list-item-title>Edit Folder Name</v-list-item-title>
</v-list-item>
@@ -295,7 +295,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
</v-list-item>
<v-list-item
prepend-icon="mdi-folder-move"
@click="emit('moveFolder', folder)"
@click="emit('moveFolder', group.service, folder)"
>
<v-list-item-title>Move Folder</v-list-item-title>
</v-list-item>
@@ -303,7 +303,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
v-if="canDeleteFolder(folder)"
prepend-icon="mdi-delete"
base-color="error"
@click="emit('deleteFolder', folder)"
@click="emit('deleteFolder', group.service, folder)"
>
<v-list-item-title>Delete Folder</v-list-item-title>
</v-list-item>
@@ -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?.address || undefined"
:subtitle="group.service.primaryAddress || undefined"
>
<template v-slot:prepend>
<v-icon icon="mdi-email-outline" />
@@ -446,7 +446,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
<v-list density="compact">
<v-list-item
prepend-icon="mdi-pencil"
@click="emit('editFolder', folder)"
@click="emit('editFolder', group.service, folder)"
>
<v-list-item-title>Edit Folder Name</v-list-item-title>
</v-list-item>
@@ -458,7 +458,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
</v-list-item>
<v-list-item
prepend-icon="mdi-folder-move"
@click="emit('moveFolder', folder)"
@click="emit('moveFolder', group.service, folder)"
>
<v-list-item-title>Move Folder</v-list-item-title>
</v-list-item>
@@ -466,7 +466,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
v-if="canDeleteFolder(folder)"
prepend-icon="mdi-delete"
base-color="error"
@click="emit('deleteFolder', folder)"
@click="emit('deleteFolder', group.service, folder)"
>
<v-list-item-title>Delete Folder</v-list-item-title>
</v-list-item>
+42 -37
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore'
import type { ServiceObject, CollectionObject } from '@MailManager/models'
import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue'
@@ -30,8 +30,8 @@ const emit = defineEmits<{
cancel: []
}>()
const mailOperationsStore = useMailOperationsStore()
const collectionsStore = useCollectionsStore()
const mailStore = useMailStore()
const selectedFolderKey = ref<string | null>(null)
@@ -40,6 +40,10 @@ const dialogValue = computed({
set: (value: boolean) => emit('update:modelValue', value),
})
const folderKeyFor = (folder: CollectionObject): string => {
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
}
interface ServiceGroup {
service: ServiceObject
loading: boolean
@@ -48,7 +52,8 @@ interface ServiceGroup {
}
const serviceGroups = computed<ServiceGroup[]>(() => {
const service = props.service
const service = props.service ?? mailStore.moveDialogService
if (!service) {
return []
}
@@ -59,40 +64,12 @@ const serviceGroups = computed<ServiceGroup[]>(() => {
return [{
service,
loading: mailOperationsStore.isServiceFolderLoading(service.provider, service.identifier),
loaded: mailOperationsStore.hasServiceFoldersLoaded(service.provider, service.identifier),
error: mailOperationsStore.getServiceFolderError(service.provider, service.identifier),
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
error: mailStore.getServiceFolderError(service.provider, service.identifier),
}]
})
const selectedFolder = computed(() => {
if (!selectedFolderKey.value) {
return null
}
const group = serviceGroups.value[0]
if (!group) {
return null
}
return getServiceFolders(group.service).find(folder => folder.identifier === selectedFolderKey.value) ?? null
})
const canConfirm = computed(() => {
return selectedFolder.value !== null && !props.loading
})
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
return
}
selectedFolderKey.value = null
},
)
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
if (service.identifier === null) {
return []
@@ -109,8 +86,36 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
return collectionsStore.collectionsForService(service.provider, service.identifier)
}
const selectedFolder = computed(() => {
if (!selectedFolderKey.value) {
return null
}
const group = serviceGroups.value[0]
if (!group) {
return null
}
return getServiceFolders(group.service).find(folder => folderKeyFor(folder) === selectedFolderKey.value) ?? null
})
const canConfirm = computed(() => {
return selectedFolder.value !== null && !props.loading
})
watch(
() => [props.modelValue, mailStore.moveDialogService],
([isOpen]) => {
if (!isOpen) {
return
}
selectedFolderKey.value = null
},
)
const handleSelect = (folder: CollectionObject) => {
selectedFolderKey.value = folder.identifier
selectedFolderKey.value = folderKeyFor(folder)
}
const handleCancel = () => {
@@ -151,7 +156,7 @@ const handleConfirm = () => {
<v-list-item
class="account-header-item account-header-static"
:title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress?.address || undefined"
:subtitle="group.service.primaryAddress || undefined"
>
<template #prepend>
<v-icon icon="mdi-email-outline" />
@@ -160,7 +165,7 @@ const handleConfirm = () => {
<FolderSelectionTreeNode
v-for="folder in getRootFolders(group.service)"
:key="folder.identifier"
:key="folderKeyFor(folder)"
:folder="folder"
:service="group.service"
:selected-folder-key="selectedFolderKey"
+5 -8
View File
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
import type { CollectionIdentifier } from '@MailManager/types/common'
interface Props {
folder: CollectionObject
@@ -21,7 +20,9 @@ const emit = defineEmits<{
const expanded = ref(false)
const folderKeyFor = (folder: CollectionObject): string => String(folder.identifier)
const folderKeyFor = (folder: CollectionObject): string => {
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
}
const folderLabelFor = (folder: CollectionObject): string => {
return folder.properties.label || String(folder.identifier)
@@ -75,11 +76,7 @@ const childFolders = computed(() => {
return []
}
return collectionsStore.collectionsInCollection(
props.service.provider,
serviceIdentifier,
props.folder.identifier as CollectionIdentifier,
)
return collectionsStore.collectionsInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
})
const hasChildren = computed(() => {
@@ -89,7 +86,7 @@ const hasChildren = computed(() => {
return false
}
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier as CollectionIdentifier)
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
})
const isSelected = computed(() => props.selectedFolderKey === key.value)
+278
View File
@@ -0,0 +1,278 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailStore } from '@/stores/mailStore'
import { useUser } from '@KTXC'
import FolderTreeView from './FolderTreeView.vue'
import FolderPageView from './FolderPageView.vue'
import CreateFolderDialog from './CreateFolderDialog.vue'
import DeleteFolderDialog from './DeleteFolderDialog.vue'
import FolderSelectionDialog from './FolderSelectionDialog.vue'
import RenameFolderDialog from './RenameFolderDialog.vue'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
type FolderViewMode = 'tree' | 'page'
const props = defineProps<{
selectedFolder?: CollectionObject | null
}>()
// Emits
const emit = defineEmits<{
select: [folder: CollectionObject]
folderCreated: [folder: CollectionObject]
}>()
// Stores
const collectionsStore = useCollectionsStore()
const servicesStore = useServicesStore()
const mailStore = useMailStore()
// User settings
const { settings } = useUser()
// Folder view mode from user settings
const folderViewMode = computed(() => {
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
})
// Create folder dialog state
const createDialogVisible = ref(false)
const createDialogService = ref<ServiceObject | null>(null)
const createDialogParent = ref<CollectionObject | null>(null)
const renameDialogVisible = ref(false)
const renameDialogService = ref<ServiceObject | null>(null)
const renameDialogFolder = ref<CollectionObject | null>(null)
const moveDialogVisible = ref(false)
const moveDialogService = ref<ServiceObject | null>(null)
const moveDialogFolder = ref<CollectionObject | null>(null)
const deleteDialogVisible = ref(false)
const deleteDialogService = ref<ServiceObject | null>(null)
const deleteDialogFolder = ref<CollectionObject | null>(null)
// Handle create folder event from child components
const handleCreateFolder = (service: ServiceObject, parentFolder: CollectionObject | null = null) => {
createDialogService.value = service
createDialogParent.value = parentFolder
createDialogVisible.value = true
}
// Handle folder created
const handleFolderCreated = (newFolder: CollectionObject) => {
emit('folderCreated', newFolder)
emit('select', newFolder)
}
const handleEditFolder = (service: ServiceObject, folder: CollectionObject) => {
renameDialogService.value = service
renameDialogFolder.value = folder
renameDialogVisible.value = true
}
const handleMoveFolder = (service: ServiceObject, folder: CollectionObject) => {
moveDialogService.value = service
moveDialogFolder.value = folder
moveDialogVisible.value = true
}
const handleDeleteFolder = (service: ServiceObject, folder: CollectionObject) => {
deleteDialogService.value = service
deleteDialogFolder.value = folder
deleteDialogVisible.value = true
}
const handleFolderRenamed = (updatedFolder: CollectionObject) => {
emit('select', updatedFolder)
}
const folderKeyFor = (folder: CollectionObject): string => {
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
}
const moveDialogInvalidFolderKeys = computed(() => {
const sourceFolder = moveDialogFolder.value
if (!sourceFolder) {
return []
}
const invalidKeys = new Set<string>()
const queue: CollectionObject[] = [sourceFolder]
while (queue.length > 0) {
const currentFolder = queue.shift()
if (!currentFolder) {
continue
}
invalidKeys.add(folderKeyFor(currentFolder))
collectionsStore
.collectionsInCollection(currentFolder.provider, currentFolder.service, currentFolder.identifier)
.forEach(childFolder => {
queue.push(childFolder)
})
}
return Array.from(invalidKeys)
})
const isSameFolder = (left: CollectionObject | null | undefined, right: CollectionObject | null | undefined) => {
if (!left || !right) {
return false
}
return left.provider === right.provider &&
String(left.service) === String(right.service) &&
String(left.identifier) === String(right.identifier)
}
const handleFolderDeleted = (deletedFolder: CollectionObject) => {
if (isSameFolder(props.selectedFolder, deletedFolder)) {
mailStore.clearSelectedFolder()
}
mailStore.notify(`Folder "${deletedFolder.properties.label || String(deletedFolder.identifier)}" deleted`, 'success')
}
const handleMoveDialogCancel = () => {
moveDialogVisible.value = false
}
const handleFolderMove = async (targetFolder: CollectionObject) => {
const sourceFolder = moveDialogFolder.value
if (!sourceFolder) {
return
}
try {
const movedFolder = await collectionsStore.move(folderKeyFor(targetFolder), folderKeyFor(sourceFolder))
moveDialogVisible.value = false
if (isSameFolder(props.selectedFolder, sourceFolder)) {
emit('select', movedFolder)
}
mailStore.notify(
`Folder "${sourceFolder.properties.label || String(sourceFolder.identifier)}" moved to "${targetFolder.properties.label || String(targetFolder.identifier)}"`,
'success',
)
} catch (error: unknown) {
console.error('[FolderTree] Failed to move folder:', error)
mailStore.notify(error instanceof Error ? error.message : 'Failed to move folder', 'error')
}
}
// Computed: all folders for validation
const allFolders = computed(() =>
servicesStore.servicesEnabled.flatMap(service =>
collectionsStore.collectionsForService(service.provider, service.identifier),
)
)
interface ServiceGroup {
service: ServiceObject
loading: boolean
loaded: boolean
error: string | null
}
const serviceGroups = computed(() => {
const groups: ServiceGroup[] = []
servicesStore.servicesEnabled.forEach(service => {
groups.push({
service,
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
error: mailStore.getServiceFolderError(service.provider, service.identifier),
})
})
return groups
})
</script>
<template>
<v-list density="compact" nav>
<!-- Tree View -->
<FolderTreeView
v-if="folderViewMode === 'tree'"
:selected-folder="selectedFolder"
:service-groups="serviceGroups"
@select="emit('select', $event)"
@create-folder="handleCreateFolder"
@edit-folder="handleEditFolder"
@move-folder="handleMoveFolder"
@delete-folder="handleDeleteFolder"
/>
<!-- Page-based View -->
<FolderPageView
v-else
:selected-folder="selectedFolder"
:service-groups="serviceGroups"
@select="emit('select', $event)"
@create-folder="handleCreateFolder"
@edit-folder="handleEditFolder"
@move-folder="handleMoveFolder"
@delete-folder="handleDeleteFolder"
/>
<!-- Empty state -->
<v-list-item v-if="servicesStore.servicesEnabled.length === 0">
<v-list-item-title class="text-center text-medium-emphasis">
No mail accounts configured
</v-list-item-title>
</v-list-item>
</v-list>
<!-- Create Folder Dialog -->
<CreateFolderDialog
v-if="createDialogService"
v-model="createDialogVisible"
:service="createDialogService"
:parent-folder="createDialogParent"
:all-folders="allFolders"
@created="handleFolderCreated"
/>
<RenameFolderDialog
v-if="renameDialogService && renameDialogFolder"
v-model="renameDialogVisible"
:service="renameDialogService"
:folder="renameDialogFolder"
:all-folders="allFolders"
@updated="handleFolderRenamed"
/>
<FolderSelectionDialog
v-if="moveDialogService && moveDialogFolder"
v-model="moveDialogVisible"
:service="moveDialogService"
:loading="collectionsStore.transceiving"
title="Move Folder"
confirm-text="Move Folder"
empty-text="No valid target folders are available."
:disabled-folder-keys="moveDialogInvalidFolderKeys"
@cancel="handleMoveDialogCancel"
@select="handleFolderMove"
/>
<DeleteFolderDialog
v-if="deleteDialogService && deleteDialogFolder"
v-model="deleteDialogVisible"
:service="deleteDialogService"
:folder="deleteDialogFolder"
@deleted="handleFolderDeleted"
/>
</template>
<style scoped>
.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.12);
}
</style>
+14 -14
View File
@@ -17,17 +17,17 @@ const expanded = ref(false)
const emit = defineEmits<{
select: [folder: CollectionObject]
createSubfolder: [service: ServiceObject, parentFolder: CollectionObject]
editFolder: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject]
}>()
const childFolders = computed(() => {
return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier)
return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier, props.folder.identifier)
})
const hasChildren = computed(() => {
return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier)
return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier, props.folder.identifier)
})
const canDeleteFolder = computed(() => !props.folder.properties.role)
@@ -131,7 +131,7 @@ const isSelected = (folder: CollectionObject): boolean => {
<v-list density="compact">
<v-list-item
prepend-icon="mdi-pencil"
@click="emit('editFolder', folder)"
@click="emit('editFolder', service, folder)"
>
<v-list-item-title>Edit Folder Name</v-list-item-title>
</v-list-item>
@@ -143,7 +143,7 @@ const isSelected = (folder: CollectionObject): boolean => {
</v-list-item>
<v-list-item
prepend-icon="mdi-folder-move"
@click="emit('moveFolder', folder)"
@click="emit('moveFolder', service, folder)"
>
<v-list-item-title>Move Folder</v-list-item-title>
</v-list-item>
@@ -151,7 +151,7 @@ const isSelected = (folder: CollectionObject): boolean => {
v-if="canDeleteFolder"
prepend-icon="mdi-delete"
base-color="error"
@click="emit('deleteFolder', folder)"
@click="emit('deleteFolder', service, folder)"
>
<v-list-item-title>Delete Folder</v-list-item-title>
</v-list-item>
@@ -170,9 +170,9 @@ const isSelected = (folder: CollectionObject): boolean => {
:selected-folder="selectedFolder"
@select="emit('select', $event)"
@create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)"
@edit-folder="(folder) => emit('editFolder', folder)"
@move-folder="(folder) => emit('moveFolder', folder)"
@delete-folder="(folder) => emit('deleteFolder', folder)"
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
/>
</div>
</v-list-group>
@@ -217,7 +217,7 @@ const isSelected = (folder: CollectionObject): boolean => {
<v-list density="compact">
<v-list-item
prepend-icon="mdi-pencil"
@click="emit('editFolder', folder)"
@click="emit('editFolder', service, folder)"
>
<v-list-item-title>Edit Folder Name</v-list-item-title>
</v-list-item>
@@ -229,7 +229,7 @@ const isSelected = (folder: CollectionObject): boolean => {
</v-list-item>
<v-list-item
prepend-icon="mdi-folder-move"
@click="emit('moveFolder', folder)"
@click="emit('moveFolder', service, folder)"
>
<v-list-item-title>Move Folder</v-list-item-title>
</v-list-item>
@@ -237,7 +237,7 @@ const isSelected = (folder: CollectionObject): boolean => {
v-if="canDeleteFolder"
prepend-icon="mdi-delete"
base-color="error"
@click="emit('deleteFolder', folder)"
@click="emit('deleteFolder', service, folder)"
>
<v-list-item-title>Delete Folder</v-list-item-title>
</v-list-item>
+8 -8
View File
@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
const emit = defineEmits<{
select: [folder: CollectionObject]
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
editFolder: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject]
}>()
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
@@ -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?.address || undefined"
:subtitle="group.service.primaryAddress || undefined"
>
<template v-slot:prepend>
<v-icon icon="mdi-email-outline" />
@@ -75,9 +75,9 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
:selected-folder="selectedFolder"
@select="emit('select', $event)"
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
@edit-folder="(folder) => emit('editFolder', folder)"
@move-folder="(folder) => emit('moveFolder', folder)"
@delete-folder="(folder) => emit('deleteFolder', folder)"
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
/>
<v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item">
@@ -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?.address || undefined"
:subtitle="group.service.primaryAddress || undefined"
>
<template v-slot:prepend>
<v-icon icon="mdi-email-outline" />
-113
View File
@@ -1,113 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { useMailUiStore } from '@/stores/mailUiStore'
import FolderTreeView from './FolderTreeView.vue'
import FolderPageView from './FolderPageView.vue'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
interface ServiceGroup {
service: ServiceObject
loading: boolean
loaded: boolean
error: string | null
}
const props = defineProps<{
selectedFolder?: CollectionObject | null
}>()
// Emits
const emit = defineEmits<{
select: [folder: CollectionObject]
}>()
// Stores
const servicesStore = useServicesStore()
const mailOperationsStore = useMailOperationsStore()
const mailUiStore = useMailUiStore()
const mailSettingsStore = useMailSettingsStore()
const { folderViewMode } = storeToRefs(mailSettingsStore)
const serviceGroups = computed(() => {
const groups: ServiceGroup[] = []
servicesStore.servicesEnabled.forEach(service => {
if (service.identifier === null) {
return
}
groups.push({
service,
loading: mailOperationsStore.isServiceFolderLoading(service.provider, service.identifier),
loaded: mailOperationsStore.hasServiceFoldersLoaded(service.provider, service.identifier),
error: mailOperationsStore.getServiceFolderError(service.provider, service.identifier),
})
})
return groups
})
// Handlers
const handleFolderCreate = (service: ServiceObject, parentFolder: CollectionObject | null = null) => {
mailUiStore.openCreateFolderDialog(service, parentFolder)
}
const handleFolderEdit = (folder: CollectionObject) => {
mailUiStore.openRenameFolderDialog(folder)
}
const handleFolderDelete = (folder: CollectionObject) => {
mailUiStore.openDeleteFolderDialog(folder)
}
const handleFolderMove = (folder: CollectionObject) => {
mailUiStore.openMoveFolderDialog(folder)
}
</script>
<template>
<v-list density="compact" nav>
<!-- Tree View -->
<FolderTreeView
v-if="folderViewMode === 'tree'"
:selected-folder="selectedFolder"
:service-groups="serviceGroups"
@select="emit('select', $event)"
@create-folder="handleFolderCreate"
@edit-folder="handleFolderEdit"
@move-folder="handleFolderMove"
@delete-folder="handleFolderDelete"
/>
<!-- Page-based View -->
<FolderPageView
v-else
:selected-folder="selectedFolder"
:service-groups="serviceGroups"
@select="emit('select', $event)"
@create-folder="handleFolderCreate"
@edit-folder="handleFolderEdit"
@move-folder="handleFolderMove"
@delete-folder="handleFolderDelete"
/>
<!-- Empty state -->
<v-list-item v-if="servicesStore.servicesEnabled.length === 0">
<v-list-item-title class="text-center text-medium-emphasis">
No mail accounts configured
</v-list-item-title>
</v-list-item>
</v-list>
</template>
<style scoped>
.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.12);
}
</style>
+383 -155
View File
@@ -1,28 +1,22 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { useEditor } from '@tiptap/vue-3'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Placeholder from '@tiptap/extension-placeholder'
import { entityService } from '@MailManager/services'
import type { EntityInterface } from '@MailManager/types/entity'
import type { MessageInterface } from '@MailManager/types/message'
import type { CollectionInterface } from '@MailManager/types/collection'
import { MessageObject } from '@MailManager/models/message'
import { EntityObject } from '@MailManager/models/entity'
import 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 | MessageAddressObject | null
folder?: CollectionObject | null
replyTo?: EntityInterface<MessageInterface> | null
folder?: CollectionInterface | null
}
const props = defineProps<Props>()
@@ -30,55 +24,23 @@ const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
close: []
sent: []
}>()
const mailOperationsStore = useMailOperationsStore()
const compositionStore = useMailCompositionStore()
const {
composerSending: sending,
} = 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)
const sending = ref(false)
const saving = ref(false)
const lastSaved = ref<Date | null>(null)
const draftId = ref<string | 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 ?? {})
// Auto-save timer
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
// Initialize Tiptap editor
const editor = useEditor({
@@ -103,75 +65,162 @@ const editor = useEditor({
},
})
watch(
[() => props.mode, () => props.source, () => editor.value],
([, , currentEditor]) => {
if (!currentEditor) {
return
// Initialize from reply-to message
if (props.replyTo) {
const replyMessage = new MessageObject(props.replyTo.properties)
const fromEmail = replyMessage.from?.address
to.value = fromEmail ? [fromEmail] : []
const originalSubject = replyMessage.subject || ''
subject.value = originalSubject.startsWith('Re:')
? originalSubject
: `Re: ${originalSubject}`
// Add quoted reply - prefer HTML content, fallback to text
const originalBody = replyMessage.getHtmlContent() || replyMessage.getTextContent() || ''
const senderName = replyMessage.from?.label || replyMessage.from?.address || 'Unknown'
const quotedReply = `<p><br></p><p>On ${new Date(replyMessage.date || '').toLocaleString()}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`
editor.value?.commands.setContent(quotedReply)
}
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 },
)
// Computed
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()) {
return
}
saving.value = true
try {
const draftData = {
to: to.value,
cc: cc.value,
bcc: bcc.value,
subject: subject.value,
body: {
html: editor.value?.getHTML() || '',
text: editor.value?.getText() || '',
},
}
// Find drafts folder for this service
// For now, we'll use the current folder's service
// In a real implementation, you'd find the actual Drafts folder
const response = await entityService.create({
provider: props.folder.provider,
service: props.folder.service,
collection: props.folder.identifier, // Should be drafts folder ID
properties: draftData,
})
if (response) {
draftId.value = String(response.identifier)
}
lastSaved.value = new Date()
} catch (error) {
console.error('[MessageComposer] Failed to save draft:', error)
} finally {
saving.value = false
}
}
// Watch for changes and trigger auto-save
const scheduleAutoSave = () => {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
}
autoSaveTimer = setTimeout(() => {
saveDraft()
}, 30000) // 30 seconds
}
watch([to, cc, bcc, subject], () => {
scheduleAutoSave()
}, { deep: true })
// Watch editor content changes
watch(editor, currentEditor => {
if (!currentEditor) {
return
}
const handleUpdate = () => {
if (applyingDraftToEditor.value) {
return
}
compositionStore.updateBody({
html: currentEditor.getHTML(),
text: currentEditor.getText(),
if (editor.value) {
editor.value.on('update', () => {
scheduleAutoSave()
})
}
currentEditor.on('update', handleUpdate)
return () => {
currentEditor.off('update', handleUpdate)
}
})
// Cleanup
onBeforeUnmount(() => {
void compositionStore.flushSave()
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
}
editor.value?.destroy()
})
// Handlers
const handleClose = async () => {
await compositionStore.closeDraft()
const handleClose = () => {
emit('close')
}
const handleSend = async () => {
await compositionStore.sendDraft()
if (!canSend.value || sending.value) return
sending.value = true
try {
await entityService.transmit({
message: {
to: to.value,
cc: cc.value.length > 0 ? cc.value : undefined,
bcc: bcc.value.length > 0 ? bcc.value : undefined,
subject: subject.value,
body: {
html: editor.value?.getHTML() || '',
text: editor.value?.getText() || '',
},
},
})
// Delete draft if it was saved
if (draftId.value && props.folder) {
try {
await entityService.delete({
provider: props.folder.provider,
service: props.folder.service,
collection: props.folder.identifier,
identifier: draftId.value,
})
} catch (error) {
console.error('[MessageComposer] Failed to delete draft:', error)
}
}
const handleAttach = async (files: File[]) => {
await compositionStore.addAttachments(files)
emit('sent')
} catch (error) {
console.error('[MessageComposer] Failed to send message:', error)
alert('Failed to send message. Please try again.')
} finally {
sending.value = false
}
const handleDetach = async (identifier: string) => {
await compositionStore.removeAttachment(identifier)
}
const toggleCc = () => {
@@ -195,14 +244,7 @@ const setLink = () => {
}
}
const removeLink = () => editor.value?.chain().focus().unsetLink().run()
const toggleLink = () => {
if (isActive('link')) {
removeLink()
return
}
setLink()
}
const isActive = (name: string, attrs?: any) => {
return editor.value?.isActive(name, attrs) || false
}
@@ -210,62 +252,188 @@ const isActive = (name: string, attrs?: any) => {
<template>
<div class="message-composer">
<ComposerToolbar
:mode="mode"
:status="stageStatus"
:can-send="canSend"
:sending="sending"
@close="handleClose"
@send="handleSend"
/>
<!-- Toolbar -->
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
<v-btn
variant="text"
@click="handleClose"
icon="mdi-close"
>
<v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
</v-btn>
<v-toolbar-title>
{{ replyTo ? 'Reply' : 'New Message' }}
</v-toolbar-title>
<v-spacer />
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
{{ saveStatus }}
</span>
<v-btn
color="primary"
:disabled="!canSend"
:loading="sending"
@click="handleSend"
prepend-icon="mdi-send"
>
Send
</v-btn>
</v-toolbar>
<!-- Composer content -->
<div class="composer-content">
<ComposerSender
v-model="sender"
:options="compositionStore.senderIdentities"
<!-- Recipients -->
<div class="composer-fields pa-4">
<v-combobox
v-model="to"
label="To"
chips
multiple
closable-chips
variant="outlined"
density="compact"
class="mb-2"
>
<template v-slot:append-inner>
<v-btn
size="x-small"
variant="text"
@click="toggleCc"
class="mr-1"
>
Cc
</v-btn>
<v-btn
size="x-small"
variant="text"
@click="toggleBcc"
>
Bcc
</v-btn>
</template>
</v-combobox>
<v-combobox
v-if="showCc"
v-model="cc"
label="Cc"
chips
multiple
closable-chips
variant="outlined"
density="compact"
class="mb-2"
/>
<ComposerRecipients
:to="to"
:cc="cc"
:bcc="bcc"
:subject="subject"
:show-cc="showCc"
:show-bcc="showBcc"
@update:to="to = $event"
@update:cc="cc = $event"
@update:bcc="bcc = $event"
@update:subject="subject = $event"
@toggle:cc="toggleCc"
@toggle:bcc="toggleBcc"
<v-combobox
v-if="showBcc"
v-model="bcc"
label="Bcc"
chips
multiple
closable-chips
variant="outlined"
density="compact"
class="mb-2"
/>
<v-text-field
v-model="subject"
label="Subject"
variant="outlined"
density="compact"
/>
</div>
<v-divider />
<ComposerAttachments
v-if="Object.keys(attachments).length > 0"
:attachments="attachments"
@remove="handleDetach"
/>
<!-- Editor toolbar -->
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('bold') }"
@click="toggleBold"
>
<v-icon>mdi-format-bold</v-icon>
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
</v-btn>
<v-divider v-if="Object.keys(attachments).length > 0" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('italic') }"
@click="toggleItalic"
>
<v-icon>mdi-format-italic</v-icon>
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
</v-btn>
<ComposerEditor
:editor="editor"
:is-bold-active="isActive('bold')"
:is-italic-active="isActive('italic')"
:is-underline-active="isActive('underline')"
:is-bullet-list-active="isActive('bulletList')"
:is-ordered-list-active="isActive('orderedList')"
:is-link-active="isActive('link')"
@bold="toggleBold"
@italic="toggleItalic"
@underline="toggleUnderline"
@bullet-list="toggleBulletList"
@ordered-list="toggleOrderedList"
@link="toggleLink"
@attach="handleAttach"
/>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('underline') }"
@click="toggleUnderline"
>
<v-icon>mdi-format-underline</v-icon>
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('bulletList') }"
@click="toggleBulletList"
>
<v-icon>mdi-format-list-bulleted</v-icon>
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('orderedList') }"
@click="toggleOrderedList"
>
<v-icon>mdi-format-list-numbered</v-icon>
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('link') }"
@click="isActive('link') ? removeLink() : setLink()"
>
<v-icon>mdi-link</v-icon>
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
</v-btn>
<v-spacer />
<v-btn
icon
size="small"
>
<v-icon>mdi-paperclip</v-icon>
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
</v-btn>
</v-toolbar>
<v-divider />
<!-- Editor -->
<div class="editor-container">
<editor-content :editor="editor" />
</div>
</div>
</div>
</template>
@@ -290,4 +458,64 @@ const isActive = (name: string, attrs?: any) => {
overflow: hidden;
}
.composer-fields {
flex-shrink: 0;
}
.editor-toolbar {
flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color));
}
.editor-container {
flex: 1;
overflow-y: auto;
background-color: rgb(var(--v-theme-background));
}
.v-btn--active {
background-color: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
:deep(.tiptap-editor) {
outline: none;
min-height: 300px;
p.is-editor-empty:first-child::before {
color: rgb(var(--v-theme-on-surface-variant));
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.3;
margin-top: 1em;
margin-bottom: 0.5em;
}
p {
margin-bottom: 0.5em;
}
ul, ol {
padding-left: 1.5em;
margin-bottom: 0.5em;
}
blockquote {
border-left: 3px solid rgb(var(--v-border-color));
padding-left: 1em;
margin-left: 0;
margin-bottom: 0.5em;
color: rgb(var(--v-theme-on-surface-variant));
}
a {
color: rgb(var(--v-theme-primary));
text-decoration: underline;
}
}
</style>
+116 -289
View File
@@ -1,12 +1,8 @@
<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'
// Props
interface Props {
@@ -27,74 +23,62 @@ const props = withDefaults(defineProps<Props>(), {
// Emits
const emit = defineEmits<{
open: [message: EntityObject]
reply: [message: EntityObject]
forward: [message: EntityObject]
move: [message: EntityObject]
delete: [message: EntityObject]
flag: [message: EntityObject, flag: string, value: boolean]
selectionMode: [message: EntityObject]
selectionToggleOne: [message: EntityObject]
selectionToggleAll: [value: boolean]
selectionClear: []
selectionMove: []
selectionDelete: []
selectionFlag: [flag: string, value: boolean]
toggleSelection: [message: EntityObject]
activateSelectionMode: [message: EntityObject]
toggleSelectAll: [value: boolean]
clearSelection: []
moveSelection: []
deleteSelection: []
}>()
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)
const contextMenuVisible = ref(false)
const contextMenuTarget = ref<[number, number] | undefined>(undefined)
const contextMenuMessage = ref<EntityObject | null>(null)
const LONG_PRESS_MS = 400
const selectedIdSet = computed(() => new Set(props.selectionList))
const selectionCount = computed(() => props.selectionList.length ?? 0)
const LONG_PRESS_MS = 450
// Sorted messages (newest first)
const sortedMessages = computed(() => {
return [...props.messages].sort((a, b) => {
const dateA = timeStamp(a) ?? 0
const dateB = timeStamp(b) ?? 0
return dateB - dateA
})
})
const selectedIdSet = computed(() => new Set(props.selectionList))
const isOpened = (message: EntityObject): boolean => {
if (!props.selectedMessage) return false
return (message.identifier === props.selectedMessage.identifier)
return (
message.provider === props.selectedMessage.provider &&
message.service === props.selectedMessage.service &&
message.collection === props.selectedMessage.collection &&
message.identifier === props.selectedMessage.identifier
)
}
const isSelected = (message: EntityObject): boolean => {
return selectedIdSet.value.has(message.identifier)
return selectedIdSet.value.has(
`${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier,
)
}
const timeStamp = (message: EntityObject): number | null => {
const timestamp = message.properties.received
|| message.properties.sent
|| message.modified
|| message.created
|| null
if (!timestamp) {
return null
// Check if message is unread
const isUnread = (message: EntityObject): boolean => {
return !message.properties.flags?.read
}
const timeValue = new Date(timestamp).getTime()
return Number.isNaN(timeValue) ? null : timeValue
// Check if message is flagged
const isFlagged = (message: EntityObject): boolean => {
return message.properties.flags?.flagged || false
}
const currentMessages = computed(() => props.messages ?? [])
const selectionCount = computed(() => props.selectionList.length)
const hasSelection = computed(() => selectionCount.value > 0)
const allCurrentMessagesSelected = computed(() => {
return currentMessages.value.length > 0 && currentMessages.value.every(message => isSelected(message))
})
// Format date for display
const formatDate = (date: Date | string | number | null | undefined): string => {
const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return ''
const messageDate = new Date(date)
if (Number.isNaN(messageDate.getTime())) return ''
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today)
@@ -131,32 +115,21 @@ const formatDate = (date: Date | string | number | null | undefined): string =>
})
}
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
return event.target instanceof Element && event.target.closest('.message-selection-checkbox, .message-item-menu-trigger, .message-item-menu-content') !== null
// Truncate text
const truncate = (text: string | null | undefined, length: number = 100): string => {
if (!text) return ''
return text.length > length ? text.substring(0, length) + '...' : text
}
const handleSelectionToggleOne = (message: EntityObject) => {
emit('selectionToggleOne', message)
}
const handleSelectionToggleAll = (value: boolean | null) => {
emit('selectionToggleAll', value === true)
}
const handleMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
const handleSelectionToggle = (message: EntityObject) => {
emit('toggleSelection', message)
}
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
if (event.shiftKey && !props.selectionMode) {
event.preventDefault()
event.stopPropagation()
emit('selectionMode', message)
emit('activateSelectionMode', message)
return
}
@@ -165,19 +138,20 @@ const handleMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObje
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (props.selectionMode) {
emit('selectionToggleOne', message)
emit('toggleSelection', message)
return
}
emit('open', message)
}
const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
if (!event.shiftKey || props.selectionMode) {
return
}
@@ -185,44 +159,14 @@ const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
event.preventDefault()
event.stopPropagation()
suppressNextClick.value = true
emit('selectionMode', message)
emit('activateSelectionMode', message)
}
const openContextMenu = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
const clearLongPressTimer = () => {
if (longPressTimer.value !== null) {
window.clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
event.preventDefault()
event.stopPropagation()
contextMenuMessage.value = message
contextMenuTarget.value = [event.clientX, event.clientY]
contextMenuVisible.value = true
}
const handleContextMenuReply = (message: EntityObject) => {
emit('reply', message)
}
const handleContextMenuForward = (message: EntityObject) => {
emit('forward', message)
}
const handleContextMenuMove = (message: EntityObject) => {
emit('move', message)
}
const handleContextMenuDelete = (message: EntityObject) => {
emit('delete', message)
}
const handleContextMenuFlag = (message: EntityObject, flag: string, value: boolean) => {
emit('flag', message, flag, value)
}
const getContextMenuMessage = (): EntityObject => {
return contextMenuMessage.value as EntityObject
}
const handleTouchStart = (message: EntityObject) => {
@@ -231,9 +175,9 @@ const handleTouchStart = (message: EntityObject) => {
longPressTimer.value = window.setTimeout(() => {
if (!props.selectionMode) {
emit('selectionMode', message)
emit('activateSelectionMode', message)
} else {
emit('selectionToggleOne', message)
emit('toggleSelection', message)
}
longPressActivated.value = true
@@ -249,32 +193,36 @@ const handleTouchMove = () => {
clearLongPressTimer()
}
const clearLongPressTimer = () => {
if (longPressTimer.value !== null) {
window.clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
}
const handleFlag = (flag: string, value: boolean) => {
if (props.selectionMode && selectionCount.value > 0) {
emit('selectionFlag', flag, value)
}
}
const handleRecipientClick = (message: EntityObject) => {
if (props.selectionMode) {
emit('selectionToggleOne', message)
return
}
emit('open', message)
}
onBeforeUnmount(() => {
clearLongPressTimer()
})
const handleSelectAllToggle = (value: boolean | null) => {
emit('toggleSelectAll', value === true)
}
// Sorted messages (newest first)
const sortedMessages = computed(() => {
return [...currentMessages.value].sort((a, b) => {
const dateA = a.properties.date ? new Date(a.properties.date).getTime() : 0
const dateB = b.properties.date ? new Date(b.properties.date).getTime() : 0
return dateB - dateA
})
})
// Read/Unread counts from collection properties
const unreadCount = computed(() => {
return props.selectedCollection?.properties.unread ?? 0
})
const totalCount = computed(() => {
return props.selectedCollection?.properties.total ?? 0
})
// True only when the collection explicitly provides total/unread counts
const hasCountData = computed(() => {
return props.selectedCollection?.properties.total != null
})
</script>
<template>
@@ -282,12 +230,12 @@ onBeforeUnmount(() => {
<!-- Header with folder name and counts -->
<div v-if="selectedCollection" class="message-list-header">
<div class="message-list-heading">
<h2 class="text-h6">{{ selectedCollection?.properties.label || 'Folder' }}</h2>
<h2 class="text-h6">{{ selectedCollection.properties.label || 'Folder' }}</h2>
<div class="folder-counts text-caption text-medium-emphasis">
<span v-if="selectedCollection?.properties.total != null">
<span class="unread-count">{{ selectedCollection?.properties.unread ?? 0 }}</span>
<span v-if="hasCountData">
<span class="unread-count">{{ unreadCount }}</span>
<span class="mx-1">/</span>
<span>{{ selectedCollection?.properties.total ?? 0 }}</span>
<span>{{ totalCount }}</span>
</span>
<span v-else-if="messages.length > 0">
{{ messages.length }} loaded
@@ -298,12 +246,15 @@ onBeforeUnmount(() => {
<div v-if="selectionMode && messages.length > 0" class="selection-summary">
<div class="selection-controls">
<v-checkbox-btn
:model-value="selectionCount !== 0"
:indeterminate="selectionCount > 0 && selectionCount !== messages.length"
:model-value="allCurrentMessagesSelected"
:indeterminate="hasSelection && !allCurrentMessagesSelected"
density="compact"
hide-details
@update:model-value="handleSelectionToggleAll"
@update:model-value="handleSelectAllToggle"
/>
<span class="text-caption text-medium-emphasis">
{{ selectionCount > 0 ? `${selectionCount} selected` : 'Select all loaded' }}
</span>
</div>
<div class="selection-actions">
@@ -311,8 +262,8 @@ onBeforeUnmount(() => {
size="small"
icon="mdi-folder-move-outline"
variant="text"
:disabled="selectionCount === 0"
@click="emit('selectionMove')"
:disabled="!hasSelection"
@click="emit('moveSelection')"
>
<v-icon>mdi-folder-move-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Move Selected</v-tooltip>
@@ -321,37 +272,18 @@ onBeforeUnmount(() => {
size="small"
icon="mdi-delete-outline"
variant="text"
:disabled="selectionCount === 0"
@click="emit('selectionDelete')"
:disabled="!hasSelection"
@click="emit('deleteSelection')"
>
<v-icon>mdi-delete-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Delete Selected</v-tooltip>
</v-btn>
<v-btn
size="small"
icon="mdi-email-open-outline"
variant="text"
:disabled="selectionCount === 0"
@click="handleFlag('seen', true)"
>
<v-icon>mdi-email-open-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Mark as Read</v-tooltip>
</v-btn>
<v-btn
size="small"
icon="mdi-email-outline"
variant="text"
:disabled="selectionCount === 0"
@click="handleFlag('seen', false)"
>
<v-icon>mdi-email-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Mark as Unread</v-tooltip>
</v-btn>
<v-btn
size="small"
icon="mdi-close"
variant="text"
@click="emit('selectionClear')"
:disabled="!hasSelection"
@click="emit('clearSelection')"
>
<v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="bottom">Clear Selected</v-tooltip>
@@ -371,7 +303,7 @@ onBeforeUnmount(() => {
</div>
<!-- Empty state -->
<div v-else-if="messages.length === 0" class="pa-8 text-center">
<div v-else-if="currentMessages.length === 0" class="pa-8 text-center">
<v-icon size="64" color="grey-lighten-1">mdi-email-outline</v-icon>
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
<div class="text-body-2 text-medium-emphasis">
@@ -383,27 +315,26 @@ onBeforeUnmount(() => {
<v-virtual-scroll
v-else
:items="sortedMessages"
:item-height="densityConfig.itemHeight"
:item-height="80"
class="message-virtual-scroll"
>
<template v-slot:default="{ item: message }">
<v-list-item
:key="message.identifier"
:key="`${message.provider}:${message.service}:${message.collection}:${message.identifier}`"
class="message-item"
:class="{
'opened': isOpened(message),
'selected': isSelected(message),
'selection-mode': selectionMode,
'unread': !message.properties.isRead
'unread': isUnread(message)
}"
@mousedown="handleMouseDown($event, message)"
@click="handleMouseClick($event, message)"
@contextmenu="openContextMenu($event, message)"
@mousedown="handleMessageMouseDown($event, message)"
@click="handleMessageMouseClick($event, message)"
@touchstart.passive="handleTouchStart(message)"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
@touchmove="handleTouchMove"
:lines="densityConfig.lines"
lines="three"
>
<template v-slot:prepend>
<div class="message-item-prepend">
@@ -413,7 +344,7 @@ onBeforeUnmount(() => {
density="compact"
hide-details
@click.stop
@update:model-value="handleSelectionToggleOne(message)"
@update:model-value="handleSelectionToggle(message)"
/>
<v-avatar size="40" color="primary">
@@ -425,40 +356,26 @@ onBeforeUnmount(() => {
</template>
<v-list-item-title class="d-flex align-center">
<span class="message-sender text-truncate">
<RecipientDetails
:address="message.properties.from"
@clicked="handleRecipientClick(message)"
>
<template #default="{ label }">
<span class="message-person-link text-truncate">{{ label }}</span>
</template>
</RecipientDetails>
<span class="flex-grow-1 text-truncate">
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }}
</span>
<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 class="text-caption text-medium-emphasis ml-2">
{{ formatDate(message.properties.date) }}
</span>
</v-list-item-title>
<v-list-item-subtitle v-if="densityConfig.showSubjectLine" class="text-truncate">
<v-list-item-subtitle class="text-truncate">
{{ message.properties.subject || '(No subject)' }}
</v-list-item-subtitle>
<v-list-item-subtitle v-if="densityConfig.showThirdLine" class="text-caption text-truncate">
{{ '' }}
<v-list-item-subtitle class="text-caption text-truncate">
{{ truncate(message.properties.snippet, 80) }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="message-item-append">
<div class="d-flex flex-column align-center">
<v-icon
v-if="message.properties.isFlagged"
v-if="isFlagged(message)"
size="small"
color="warning"
class="mb-1"
@@ -466,41 +383,18 @@ onBeforeUnmount(() => {
mdi-star
</v-icon>
<v-icon
v-if="message.properties.hasAttachments"
v-if="message.properties.attachments && message.properties.attachments.length > 0"
size="small"
color="grey"
>
mdi-paperclip
</v-icon>
</div>
<MessageListItemMenu
:message="message"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
</div>
</template>
</v-list-item>
<v-divider />
</template>
</v-virtual-scroll>
<MessageListItemMenu
v-if="contextMenuMessage"
v-model="contextMenuVisible"
:message="getContextMenuMessage()"
:target="contextMenuTarget"
:show-activator="false"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
</div>
</template>
@@ -585,31 +479,6 @@ onBeforeUnmount(() => {
min-width: 72px;
}
.message-item-append {
display: flex;
align-items: center;
gap: 4px;
}
@media (hover: hover) {
.message-item :deep(.message-item-menu-trigger) {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.message-item:hover :deep(.message-item-menu-trigger),
.message-item:focus-within :deep(.message-item-menu-trigger),
.message-item.opened :deep(.message-item-menu-trigger),
.message-item.selected :deep(.message-item-menu-trigger),
.message-item :deep(.message-item-menu-trigger[aria-expanded='true']) {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
.message-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.04);
}
@@ -628,48 +497,6 @@ onBeforeUnmount(() => {
:deep(.v-list-item-subtitle:first-of-type) {
font-weight: 600;
}
:deep(.v-list-item-title),
:deep(.v-list-item-subtitle:first-of-type),
:deep(.v-list-item-title .text-caption) {
color: rgb(var(--v-theme-on-surface));
}
}
.message-item.unread:hover {
background-color: rgba(var(--v-theme-primary), 0.1);
}
.message-item.unread.selected:not(.opened) {
background-color: rgba(var(--v-theme-primary), 0.14);
}
.message-sender {
flex: 0 1 auto;
min-width: 0;
}
.message-inline-subject {
flex: 1 1 auto;
min-width: 0;
}
.message-date {
flex-shrink: 0;
margin-left: auto;
}
.message-person-link {
display: inline-block;
max-width: 100%;
border-radius: 4px;
padding: 1px 4px;
margin: -1px -4px;
transition: background-color 0.2s ease;
}
.message-person-link:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
@media (max-width: 960px) {
-138
View File
@@ -1,138 +0,0 @@
<script setup lang="ts">
import { computed, getCurrentInstance, ref } from 'vue'
import type { EntityObject } from '@MailManager/models'
interface Props {
message: EntityObject
modelValue?: boolean
target?: [number, number] | undefined
showActivator?: boolean
}
const props = withDefaults(defineProps<Props>(), {
target: undefined,
showActivator: true,
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
reply: [message: EntityObject]
forward: [message: EntityObject]
move: [message: EntityObject]
delete: [message: EntityObject]
flag: [message: EntityObject, flag: string, value: boolean]
}>()
const isRead = computed(() => props.message.properties.isRead === true)
const isFlagged = computed(() => props.message.properties.isFlagged === true)
const localIsOpen = ref(false)
const instance = getCurrentInstance()
const isControlled = computed(() => {
const vnodeProps = instance?.vnode.props ?? {}
return Object.prototype.hasOwnProperty.call(vnodeProps, 'modelValue')
|| Object.prototype.hasOwnProperty.call(vnodeProps, 'onUpdate:modelValue')
})
const isOpen = computed({
get: () => (isControlled.value ? props.modelValue ?? false : localIsOpen.value),
set: (value: boolean) => {
if (!isControlled.value) {
localIsOpen.value = value
}
emit('update:modelValue', value)
},
})
const handleFlag = (flag: string, value: boolean) => {
emit('flag', props.message, flag, value)
isOpen.value = false
}
const handleReply = () => {
emit('reply', props.message)
isOpen.value = false
}
const handleForward = () => {
emit('forward', props.message)
isOpen.value = false
}
const handleMove = () => {
emit('move', props.message)
isOpen.value = false
}
const handleDelete = () => {
emit('delete', props.message)
isOpen.value = false
}
</script>
<template>
<v-menu
v-model="isOpen"
:target="target"
:absolute="target !== undefined"
:close-on-content-click="true"
location="bottom end"
content-class="message-item-menu-content"
>
<template v-if="showActivator" #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
class="message-item-menu-trigger"
icon="mdi-dots-vertical"
size="small"
variant="text"
@click.stop
@mousedown.stop
>
<v-icon>mdi-dots-vertical</v-icon>
<v-tooltip activator="parent" location="bottom">More Actions</v-tooltip>
</v-btn>
</template>
<v-list class="message-item-menu" density="compact" min-width="220">
<v-list-item
:prepend-icon="isRead ? 'mdi-email-outline' : 'mdi-email-open-outline'"
@click="handleFlag('seen', !isRead)"
>
<v-list-item-title>
{{ isRead ? 'Mark as unread' : 'Mark as read' }}
</v-list-item-title>
</v-list-item>
<v-list-item
:prepend-icon="isFlagged ? 'mdi-star' : 'mdi-star-outline'"
@click="handleFlag('flagged', !isFlagged)"
>
<v-list-item-title>
{{ isFlagged ? 'Unstar' : 'Star' }}
</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item prepend-icon="mdi-folder-move-outline" @click="handleMove">
<v-list-item-title>Move to...</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-reply" @click="handleReply">
<v-list-item-title>Reply</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-share" @click="handleForward">
<v-list-item-title>Forward</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item prepend-icon="mdi-delete-outline" @click="handleDelete">
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
+2 -75
View File
@@ -1,11 +1,8 @@
<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 { 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'
@@ -28,16 +25,11 @@ const emit = defineEmits<{
// User settings
const { getSetting } = useUser()
const mailUiStore = useMailUiStore()
// Security overrides
// Per-message 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
@@ -61,35 +53,12 @@ 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
@@ -125,14 +94,6 @@ const handleMove = () => {
}
}
const handleDownload = async (partIndex?: number) => {
if (!props.entity) {
return
}
await mailUiStore.downloadMessage(props.entity, partIndex)
}
const handleFlag = () => {
if (props.entity) {
emit('flag', props.entity)
@@ -142,22 +103,6 @@ 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>
@@ -180,18 +125,13 @@ const handleViewerDownload = (item: MediaViewerItem) => {
@move="handleMove"
@delete="handleDelete"
@flag="handleFlag"
@download="handleDownload"
@toggle-images="toggleImages"
@set-security-level="setSecurityLevel"
/>
<!-- Message content -->
<div class="message-content">
<ReaderHeader
:entity="props.entity"
@download="handleDownload"
@preview="handlePreview"
/>
<ReaderHeader :message="message!" />
<v-divider />
@@ -201,19 +141,6 @@ const handleViewerDownload = (item: MediaViewerItem) => {
: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>
+103 -15
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { CollectionPropertiesObject } from '@MailManager/models/collection'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
@@ -7,25 +9,23 @@ interface Props {
modelValue: boolean
service: ServiceObject
folder: CollectionObject
parentFolderLabel?: string
validateName?: (name: string) => string[]
loading?: boolean
errorMessage?: string
allFolders?: CollectionObject[]
}
const props = withDefaults(defineProps<Props>(), {
parentFolderLabel: 'Root',
validateName: () => [],
loading: false,
errorMessage: '',
allFolders: () => []
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: [folderName: string]
updated: [folder: CollectionObject]
}>()
const collectionsStore = useCollectionsStore()
const folderName = ref('')
const loading = ref(false)
const errorMessage = ref('')
const validationErrors = ref<string[]>([])
const dialogValue = computed({
@@ -37,13 +37,74 @@ const isValid = computed(() => {
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
})
const parentFolderLabel = computed(() => {
const parentId = props.folder.collection
if (parentId === null || parentId === undefined) return 'Root'
const parent = props.allFolders.find(
f =>
String(f.identifier) === String(parentId) &&
f.provider === props.folder.provider &&
String(f.service) === String(props.folder.service)
)
return parent?.properties.label || 'Root'
})
const validateFolderName = (name: string): string[] => {
const errors: string[] = []
if (!name || name.trim().length === 0) {
errors.push('Folder name is required')
return errors
}
if (name.length > 255) {
errors.push('Folder name too long (max 255 characters)')
}
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
errors.push('Folder name contains invalid characters')
}
if (props.service.provider === 'imap' && /[\/\\]/.test(name)) {
errors.push('IMAP folder names cannot contain / or \\')
}
if (name !== name.trim()) {
errors.push('Folder name cannot have leading or trailing spaces')
}
return errors
}
const checkDuplicateName = (name: string): boolean => {
const parentId = props.folder.collection ?? null
return props.allFolders.some(f => {
if (String(f.identifier) === String(props.folder.identifier)) return false
return (
f.properties.label === name &&
String(f.collection) === String(parentId) &&
f.provider === props.folder.provider &&
String(f.service) === String(props.folder.service)
)
})
}
watch(folderName, (newName) => {
validationErrors.value = props.validateName(newName)
errorMessage.value = ''
validationErrors.value = validateFolderName(newName)
if (validationErrors.value.length === 0 && newName.trim().length > 0 && checkDuplicateName(newName)) {
validationErrors.value.push('A folder with this name already exists in this location')
}
})
function resetForm() {
folderName.value = props.folder.properties.label || ''
errorMessage.value = ''
validationErrors.value = []
loading.value = false
}
watch(dialogValue, (isOpen) => {
@@ -53,27 +114,54 @@ watch(dialogValue, (isOpen) => {
}, { immediate: true })
const handleRename = async () => {
const errors = props.validateName(folderName.value)
const errors = validateFolderName(folderName.value)
if (errors.length > 0) {
validationErrors.value = errors
return
}
if (checkDuplicateName(folderName.value)) {
validationErrors.value = ['A folder with this name already exists in this location']
return
}
const newName = folderName.value.trim()
if (newName === props.folder.properties.label) {
dialogValue.value = false
return
}
emit('confirm', newName)
loading.value = true
errorMessage.value = ''
try {
const properties = new CollectionPropertiesObject()
properties.label = newName
properties.rank = props.folder.properties.rank ?? 0
properties.subscribed = props.folder.properties.subscribed ?? true
const updatedFolder = await collectionsStore.update(
props.folder.provider,
props.folder.service,
props.folder.identifier,
properties
)
emit('updated', updatedFolder)
dialogValue.value = false
resetForm()
} catch (error: any) {
console.error('[RenameFolderDialog] Failed to rename folder:', error)
errorMessage.value = error.message || 'Failed to rename folder. Please try again.'
} finally {
loading.value = false
}
}
const handleCancel = () => {
dialogValue.value = false
resetForm()
}
</script>
<template>
@@ -91,7 +179,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?.format() || 'Mail Account' }}
{{ service.label || service.primaryAddress || 'Mail Account' }}
</div>
</div>
-75
View File
@@ -1,75 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify'
import { useMailUiStore } from '@/stores/mailUiStore'
import FolderView from '@/components/FolderView.vue'
import type { CollectionObject } from '@MailManager/models/collection'
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
const mailUiStore = useMailUiStore()
const { sidebarVisible, selectedFolder } = storeToRefs(mailUiStore)
const handleCompose = () => mailUiStore.openComposer()
const handleFolderSelect = (folder: CollectionObject) => mailUiStore.selectFolder(folder)
const handleSettingsOpen = () => mailUiStore.settingsOpen()
</script>
<template>
<v-navigation-drawer
v-model="sidebarVisible"
contained
:permanent="sidebarVisible && !isMobile"
:temporary="isMobile"
width="280"
class="mail-sidebar"
>
<div class="pa-3 pb-0">
<!-- Primary action -->
<v-btn
block
color="primary"
variant="tonal"
prepend-icon="mdi-pencil"
class="justify-start"
@click="handleCompose"
>
Compose
</v-btn>
</div>
<!-- Folders -->
<FolderView
:selected-folder="selectedFolder"
@select="handleFolderSelect"
/>
<!-- Pinned utility footer -->
<template #append>
<v-divider />
<div class="pa-2">
<v-btn
block
variant="text"
size="small"
prepend-icon="mdi-cog"
class="justify-start text-medium-emphasis"
@click="handleSettingsOpen"
>
Settings
</v-btn>
</div>
</template>
</v-navigation-drawer>
</template>
<style scoped>
.mail-sidebar {
border-right: 1px solid rgb(var(--v-border-color));
overflow-y: auto;
}
</style>
-190
View File
@@ -1,190 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { MessageAddressInterface } from '@MailManager/types/message'
import { useMailUiStore } from '@/stores/mailUiStore'
import { ComposerMode } from '@/types/composer'
interface Props {
address?: MessageAddressInterface | null
}
const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
clicked: []
}>()
const mailUiStore = useMailUiStore()
const recipientLabel = computed(() => props.address?.label?.trim() || '')
const recipientAddress = computed(() => props.address?.address?.trim() || '')
const displayLabel = computed(() => recipientLabel.value || recipientAddress.value || 'Unknown Sender')
const formattedAddress = computed(() => {
if (recipientLabel.value && recipientAddress.value && recipientLabel.value !== recipientAddress.value) {
return `${recipientLabel.value} <${recipientAddress.value}>`
}
return recipientAddress.value || recipientLabel.value
})
const hasEmail = computed(() => recipientAddress.value.length > 0)
const showCopyResult = (message: string, color: 'success' | 'error') => {
mailUiStore.notify(message, color)
}
const copy = async (value: string, label: string) => {
if (!value) {
return
}
try {
await navigator.clipboard.writeText(value)
showCopyResult(`${label} copied`, 'success')
} catch (error) {
console.error('[Mail][RecipientDetails] Failed to copy text:', error)
showCopyResult(`Unable to copy ${label.toLowerCase()}`, 'error')
}
}
const handleCompose = () => {
if (!props.address) {
return
}
mailUiStore.openComposer(props.address, ComposerMode.Fresh)
}
const handleClick = () => {
emit('clicked')
}
</script>
<template>
<v-menu
:open-on-hover="true"
:open-on-click="false"
:open-delay="1000"
location="bottom start"
transition="slide-y-transition"
>
<template #activator="{ props: activatorProps }">
<span
v-bind="activatorProps"
class="address-activator"
@click="handleClick"
>
<slot
:label="displayLabel"
:name="recipientLabel"
:email="recipientAddress"
>
{{ displayLabel }}
</slot>
</span>
</template>
<v-card class="address-card" elevation="8" rounded="lg">
<div class="address-card-header">
<v-avatar size="40" color="primary">
<span class="text-white text-subtitle-2">
{{ displayLabel[0]?.toUpperCase() || 'U' }}
</span>
</v-avatar>
<div class="address-card-meta">
<div class="text-subtitle-2 font-weight-medium">{{ displayLabel }}</div>
<div v-if="recipientAddress" class="text-body-2 text-medium-emphasis">{{ recipientAddress }}</div>
</div>
</div>
<v-divider class="my-3" />
<div class="address-card-actions">
<v-btn
class="address-action-button"
size="small"
variant="tonal"
prepend-icon="mdi-pencil"
:disabled="!hasEmail"
@click="handleCompose"
>
Send Email
</v-btn>
<v-btn
class="address-action-button"
size="small"
variant="text"
:disabled="!hasEmail"
@click="copy(recipientAddress, 'Email address')"
>
<v-icon>mdi-content-copy</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Email</v-tooltip>
</v-btn>
<v-btn
class="address-action-button"
size="small"
variant="text"
:disabled="!formattedAddress"
@click="copy(formattedAddress, 'Address')"
>
<v-icon>mdi-card-account-details-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Address</v-tooltip>
</v-btn>
<v-btn
v-if="recipientLabel"
class="address-action-button"
size="small"
variant="text"
@click="copy(recipientLabel, 'Name')"
>
<v-icon>mdi-account-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Name</v-tooltip>
</v-btn>
</div>
</v-card>
</v-menu>
</template>
<style scoped lang="scss">
.address-activator {
display: inline-flex;
align-items: center;
max-width: 100%;
}
.address-card {
width: min(320px, calc(100vw - 32px));
padding: 16px;
}
.address-card-header {
display: flex;
align-items: center;
gap: 12px;
}
.address-card-meta {
min-width: 0;
}
.address-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.address-card-actions :deep(.address-action-button) {
height: 36px;
min-height: 36px;
width: 36px;
min-width: 36px;
padding: 0;
}
.address-card-actions :deep(.address-action-button:first-child) {
width: auto;
min-width: 0;
padding-inline: 12px;
}
</style>
@@ -1,43 +0,0 @@
<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>
-215
View File
@@ -1,215 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { PropType } from 'vue'
import { EditorContent, type Editor } from '@tiptap/vue-3'
defineProps({
editor: {
type: Object as PropType<Editor | null>,
default: null,
},
isBoldActive: {
type: Boolean,
required: true,
},
isItalicActive: {
type: Boolean,
required: true,
},
isUnderlineActive: {
type: Boolean,
required: true,
},
isBulletListActive: {
type: Boolean,
required: true,
},
isOrderedListActive: {
type: Boolean,
required: true,
},
isLinkActive: {
type: Boolean,
required: true,
},
})
const emit = defineEmits<{
bold: []
italic: []
underline: []
bulletList: []
orderedList: []
link: []
attach: [files: File[]]
}>()
const fileInput = ref<HTMLInputElement | null>(null)
function openFilePicker() {
fileInput.value?.click()
}
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement | null
const files = input?.files ? Array.from(input.files) : []
if (files.length > 0) {
emit('attach', files)
}
if (input) {
input.value = ''
}
}
</script>
<template>
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isBoldActive }"
@click="$emit('bold')"
>
<v-icon>mdi-format-bold</v-icon>
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isItalicActive }"
@click="$emit('italic')"
>
<v-icon>mdi-format-italic</v-icon>
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isUnderlineActive }"
@click="$emit('underline')"
>
<v-icon>mdi-format-underline</v-icon>
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isBulletListActive }"
@click="$emit('bulletList')"
>
<v-icon>mdi-format-list-bulleted</v-icon>
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isOrderedListActive }"
@click="$emit('orderedList')"
>
<v-icon>mdi-format-list-numbered</v-icon>
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isLinkActive }"
@click="$emit('link')"
>
<v-icon>mdi-link</v-icon>
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
</v-btn>
<v-spacer />
<input
ref="fileInput"
type="file"
multiple
class="sr-only"
@change="handleFileChange"
>
<v-btn icon size="small" @click="openFilePicker">
<v-icon>mdi-paperclip</v-icon>
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
</v-btn>
</v-toolbar>
<v-divider />
<div class="editor-container">
<EditorContent :editor="editor ?? undefined" />
</div>
</template>
<style scoped lang="scss">
.editor-toolbar {
flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color));
}
.editor-container {
flex: 1;
overflow-y: auto;
background-color: rgb(var(--v-theme-background));
}
.sr-only {
display: none;
}
.v-btn--active {
background-color: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
:deep(.tiptap-editor) {
outline: none;
min-height: 300px;
p.is-editor-empty:first-child::before {
color: rgb(var(--v-theme-on-surface-variant));
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.3;
margin-top: 1em;
margin-bottom: 0.5em;
}
p {
margin-bottom: 0.5em;
}
ul, ol {
padding-left: 1.5em;
margin-bottom: 0.5em;
}
blockquote {
border-left: 3px solid rgb(var(--v-border-color));
padding-left: 1em;
margin-left: 0;
margin-bottom: 0.5em;
color: rgb(var(--v-theme-on-surface-variant));
}
a {
color: rgb(var(--v-theme-primary));
text-decoration: underline;
}
}
</style>
@@ -1,156 +0,0 @@
<script setup lang="ts">
import { useRecipientLookupStore } from '@/stores/mailRecipientsStore'
import type { MessageAddressInterface } from '@MailManager/types/message'
interface Props {
to: MessageAddressInterface[]
cc: MessageAddressInterface[]
bcc: MessageAddressInterface[]
subject: string
showCc: boolean
showBcc: boolean
}
defineProps<Props>()
const emit = defineEmits<{
'update:to': [value: MessageAddressInterface[]]
'update:cc': [value: MessageAddressInterface[]]
'update:bcc': [value: MessageAddressInterface[]]
'update:subject': [value: string]
'toggle:cc': []
'toggle:bcc': []
}>()
const recipientLookup = useRecipientLookupStore()
/**
* Normalize combobox entries: typed strings become bare address objects;
* picked suggestions are already MessageAddressInterface and pass through.
*/
function normalize(entries: (MessageAddressInterface | string)[]): MessageAddressInterface[] {
return entries.map(entry =>
typeof entry === 'string' ? { address: entry } : entry,
)
}
function chipLabel(item: MessageAddressInterface): string {
return item.label ? `${item.label} <${item.address}>` : item.address
}
function itemTitle(item: MessageAddressInterface): string {
return item.label || item.address
}
</script>
<template>
<div class="composer-fields pa-4">
<v-combobox
:model-value="to"
label="To"
chips
multiple
closable-chips
return-object
no-filter
variant="outlined"
density="compact"
class="mb-2"
:items="recipientLookup.suggestions"
:loading="recipientLookup.searching"
:item-title="itemTitle"
@update:search="recipientLookup.search($event)"
@update:model-value="emit('update:to', normalize($event))"
>
<template #chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
</template>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
</template>
<template #append-inner>
<v-btn
size="x-small"
variant="text"
class="mr-1"
@click="$emit('toggle:cc')"
>
Cc
</v-btn>
<v-btn
size="x-small"
variant="text"
@click="$emit('toggle:bcc')"
>
Bcc
</v-btn>
</template>
</v-combobox>
<v-combobox
v-if="showCc"
:model-value="cc"
label="Cc"
chips
multiple
closable-chips
return-object
no-filter
variant="outlined"
density="compact"
class="mb-2"
:items="recipientLookup.suggestions"
:loading="recipientLookup.searching"
:item-title="itemTitle"
@update:search="recipientLookup.search($event)"
@update:model-value="emit('update:cc', normalize($event))"
>
<template #chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
</template>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
</template>
</v-combobox>
<v-combobox
v-if="showBcc"
:model-value="bcc"
label="Bcc"
chips
multiple
closable-chips
return-object
no-filter
variant="outlined"
density="compact"
class="mb-2"
:items="recipientLookup.suggestions"
:loading="recipientLookup.searching"
:item-title="itemTitle"
@update:search="recipientLookup.search($event)"
@update:model-value="emit('update:bcc', normalize($event))"
>
<template #chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
</template>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
</template>
</v-combobox>
<v-text-field
:model-value="subject"
label="Subject"
variant="outlined"
density="compact"
@update:model-value="$emit('update:subject', $event)"
/>
</div>
</template>
<style scoped lang="scss">
.composer-fields {
flex-shrink: 0;
}
</style>
@@ -1,83 +0,0 @@
<script setup lang="ts">
import type { ComposerSenderIdentity } from '@/types/composer';
import { computed, watch } from 'vue'
interface Props {
modelValue: ComposerSenderIdentity | null
options: ComposerSenderIdentity[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: ComposerSenderIdentity | null]
}>()
type SenderListItem = {
title: string
value: string
}
const items = computed<SenderListItem[]>(() => {
return props.options.map(option => ({
title: formatSenderLabel(option.label, option.address),
value: option.address,
}))
})
const selectedValue = computed(() => props.modelValue?.address ?? null)
const errorMessages = computed(() => {
if (props.options.length === 0) {
return ['No send-capable account is available.']
}
return []
})
// Auto-select (and keep selected) the only send-capable account, since
// there's no "from" UI shown for the user to pick it themselves.
watch(
() => props.options,
options => {
if (options.length === 1 && props.modelValue?.address !== options[0].address) {
emit('update:modelValue', options[0])
}
},
{ immediate: true },
)
function handleUpdate(value: string | null) {
const match = value ? props.options.find(o => o.address === value) ?? null : null
emit('update:modelValue', match)
}
function formatSenderLabel(label: string | null | undefined, address: string): string {
return label ? `${label} <${address}>` : address
}
</script>
<template>
<div v-if="options.length !== 1" class="composer-sender px-4 pt-4 pb-0">
<v-select
:model-value="selectedValue"
:items="items"
item-title="title"
item-value="value"
label="From"
variant="outlined"
density="compact"
:error="errorMessages.length > 0"
:error-messages="errorMessages"
:disabled="options.length === 0"
class="mb-2"
@update:model-value="handleUpdate"
/>
</div>
</template>
<style scoped lang="scss">
.composer-sender {
flex-shrink: 0;
}
</style>
@@ -1,57 +0,0 @@
<script setup lang="ts">
import { ComposerMode } from '@/types/composer'
interface Props {
mode: ComposerMode
status: string
canSend: boolean
sending: boolean
}
defineProps<Props>()
defineEmits<{
close: []
send: []
}>()
</script>
<template>
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
<v-btn
variant="text"
icon="mdi-close"
@click="$emit('close')"
>
<v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
</v-btn>
<v-toolbar-title>
{{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
</v-toolbar-title>
<v-spacer />
<span v-if="status" class="text-caption text-medium-emphasis mr-4">
{{ status }}
</span>
<v-btn
color="primary"
:disabled="!canSend"
:loading="sending"
prepend-icon="mdi-send"
@click="$emit('send')"
>
Send
</v-btn>
</v-toolbar>
</template>
<style scoped lang="scss">
.composer-toolbar {
flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color));
}
</style>
+19 -124
View File
@@ -1,69 +1,12 @@
<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 }
}
import { MessageObject } from '@MailManager/models/message'
interface Props {
entity: EntityObject | null
message: MessageObject
}
const props = defineProps<Props>()
const emit = defineEmits<{
download: [index: number]
preview: [index: number]
}>()
const mailUiStore = useMailUiStore()
const previewPopover = computed(() => mediaViewerComponent('popover'))
const message = computed(() => {
return props.entity?.properties ?? null
})
const randomKey = computed(() => {
return Math.random().toString(36).substring(2, 15)
})
const attachmentItems = computed<AttachmentItem[]>(() =>
(message.value?.attachments ?? []).map((attachment, index) => ({
id: attachment.partId || attachment.blobId || attachment.cid || `att-${index}`,
title: attachment.name || 'Attachment',
mime: attachment.type || '',
size: attachment.size ?? null,
meta: { index, attachment },
})),
)
const isPreviewable = (attachment: MessagePartInterface): boolean => {
return mediaViewerCanDisplay(attachment.type)
}
const handleAttachmentClick = (attachment: MessagePartInterface, index: number): void => {
if (isPreviewable(attachment)) {
emit('preview', index)
} else {
emit('download', index)
}
}
const handleViewerRetrieve = (item: AttachmentItem): Promise<Blob> => {
if (!props.entity) return Promise.reject(new Error('No message selected'))
return mailUiStore.attachmentBlob(props.entity, item.meta.attachment)
}
// Format date for display
const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return ''
@@ -79,6 +22,15 @@ 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'
}
</script>
<template>
@@ -94,14 +46,10 @@ const formatDate = (date: Date | string | null | undefined): string => {
<div class="flex-grow-1">
<div class="text-body-1 font-weight-medium">
<RecipientDetails :address="message?.from">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
{{ message?.from?.label || message?.from?.address || 'Unknown Sender' }}
</div>
<div class="text-caption text-medium-emphasis">
{{ formatDate(message?.received || message?.sent) }}
{{ formatDate(message?.date) }}
</div>
</div>
</div>
@@ -109,26 +57,12 @@ const formatDate = (date: Date | string | null | undefined): string => {
<!-- Recipients -->
<div v-if="message?.to && message?.to.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">To:</span>
<template v-for="(recipient, index) in message.to" :key="randomKey">
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.to.length - 1">, </span>
</template>
{{ message?.to.map(t => t.label || t.address).join(', ') }}
</div>
<div v-if="message?.cc && message?.cc.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">Cc:</span>
<template v-for="(recipient, index) in message.cc" :key="randomKey">
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.cc.length - 1">, </span>
</template>
{{ message?.cc.map(c => c.label || c.address).join(', ') }}
</div>
<!-- Attachments -->
@@ -137,34 +71,19 @@ const formatDate = (date: Date | string | null | undefined): string => {
Attachments ({{ message?.attachments.length }})
</div>
<div class="d-flex flex-wrap gap-2">
<div
v-for="(attachment, index) in message?.attachments"
:key="attachment.partId || attachment.blobId || attachment.cid || index"
class="attachment-item"
>
<v-chip
:prepend-icon="isPreviewable(attachment) ? 'mdi-eye-outline' : 'mdi-paperclip'"
v-for="(attachment, index) in message?.attachments"
:key="index"
prepend-icon="mdi-paperclip"
size="small"
variant="outlined"
class="attachment-chip"
:title="isPreviewable(attachment) ? 'Preview' : 'Download'"
@click="handleAttachmentClick(attachment, index)"
>
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
<span v-if="attachment.size != null" class="text-caption text-medium-emphasis ml-1">
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1">
({{ formatFileSize(attachment.size) }})
</span>
</v-chip>
<!-- Hover preview (provided by the media_viewer module) -->
<component
:is="previewPopover"
v-if="previewPopover && isPreviewable(attachment)"
:item="attachmentItems[index]"
:source="handleViewerRetrieve"
@expand="emit('preview', index)"
/>
</div>
</div>
</div>
</div>
@@ -179,15 +98,8 @@ const formatDate = (date: Date | string | null | undefined): string => {
gap: 0.5rem;
}
.attachment-item {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.attachment-chip {
max-width: 300px;
cursor: pointer;
.attachment-name {
overflow: hidden;
@@ -195,21 +107,4 @@ const formatDate = (date: Date | string | null | undefined): string => {
white-space: nowrap;
}
}
.attachment-error {
color: rgb(var(--v-theme-error));
margin-top: 0.25rem;
}
.contact-link {
display: inline-block;
border-radius: 4px;
padding: 1px 4px;
margin: -1px -4px;
transition: background-color 0.2s ease;
}
.contact-link:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
</style>
-23
View File
@@ -17,7 +17,6 @@ const emit = defineEmits<{
move: []
delete: []
flag: []
download: []
toggleImages: []
setSecurityLevel: [level: SecurityLevel]
}>()
@@ -162,28 +161,6 @@ const currentSecurityLevel = computed(() => {
<v-icon>mdi-delete-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Delete</v-tooltip>
</v-btn>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-dots-vertical"
variant="text"
>
<v-icon>mdi-dots-vertical</v-icon>
<v-tooltip activator="parent" location="bottom">More Actions</v-tooltip>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
prepend-icon="mdi-download"
@click="emit('download')"
>
<v-list-item-title>Download</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar>
</template>
+3 -4
View File
@@ -40,16 +40,15 @@ const handleAccountSaved = async () => {
<v-list-item
v-for="service in servicesStore.services"
:key="`${service.provider}:${service.identifier}`"
:class="{ 'opacity-60': !service.enabled }"
>
<template #prepend>
<v-avatar :color="service.enabled ? 'primary' : 'grey'">
<v-icon>{{ service.enabled ? 'mdi-email' : 'mdi-email-off' }}</v-icon>
<v-avatar color="primary">
<v-icon>mdi-email</v-icon>
</v-avatar>
</template>
<v-list-item-title>{{ service.label || 'Unnamed Account' }}</v-list-item-title>
<v-list-item-subtitle>{{ service.primaryAddress?.address || service.identifier }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ service.primaryAddress || service.identifier }}</v-list-item-subtitle>
<template #append>
<v-btn
@@ -1,44 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { messageReadDelayOptions, useMailSettingsStore } from '@/stores/mailSettingsStore'
const mailSettingsStore = useMailSettingsStore()
const { messageReadEnabled, messageReadDelay } = storeToRefs(mailSettingsStore)
</script>
<template>
<div class="pa-4">
<h3 class="text-h6 mb-4">Behaviours</h3>
<v-list>
<v-list-item>
<v-list-item-title>Mark messages as read automatically</v-list-item-title>
<v-list-item-subtitle>
Mark a message as read after it stays open for the configured delay
</v-list-item-subtitle>
<template #append>
<v-switch v-model="messageReadEnabled" color="primary" hide-details />
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Read delay</v-list-item-title>
<v-list-item-subtitle>
Choose how long a message must stay open before it is marked as read
</v-list-item-subtitle>
<template #append>
<v-select
v-model="messageReadDelay"
:items="messageReadDelayOptions"
item-title="title"
item-value="value"
density="compact"
variant="outlined"
:disabled="!messageReadEnabled"
style="width: 180px"
/>
</template>
</v-list-item>
</v-list>
</div>
</template>
+50 -43
View File
@@ -1,60 +1,67 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import {
folderViewModeOptions,
messageListDensityOptions,
messageListWidthOptions,
useMailSettingsStore,
} from '@/stores/mailSettingsStore'
import { ref, computed } from 'vue'
import { useUser } from '@KTXC'
const mailSettingsStore = useMailSettingsStore()
const { folderViewMode, messageListDensity, messageListWidth } = storeToRefs(mailSettingsStore)
type FolderViewMode = 'tree' | 'page'
const { settings, setSetting } = useUser()
const theme = ref('Auto')
const showPreview = ref(true)
const compactMode = ref(false)
const folderViewMode = computed({
get: () => {
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
},
set: (value: FolderViewMode) => setSetting('mail.folderViewMode', value)
})
</script>
<template>
<div class="pa-4">
<h3 class="text-h6 mb-4">Display Settings</h3>
<v-list>
<v-list-item>
<v-list-item-title>Theme</v-list-item-title>
<v-list-item-subtitle>Choose your preferred theme</v-list-item-subtitle>
<template #append>
<v-select
v-model="theme"
:items="['Light', 'Dark', 'Auto']"
density="compact"
variant="outlined"
style="width: 150px"
/>
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Message preview</v-list-item-title>
<v-list-item-subtitle>Show message preview in list</v-list-item-subtitle>
<template #append>
<v-switch v-model="showPreview" color="primary" hide-details />
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Compact mode</v-list-item-title>
<v-list-item-subtitle>Use compact message list layout</v-list-item-subtitle>
<template #append>
<v-switch v-model="compactMode" color="primary" hide-details />
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Folder navigation style</v-list-item-title>
<v-list-item-subtitle>Choose how folders are displayed</v-list-item-subtitle>
<template #append>
<v-select
v-model="folderViewMode"
:items="folderViewModeOptions"
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 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"
:items="[
{ value: 'tree', title: 'Tree' },
{ value: 'page', title: 'Page' }
]"
item-value="value"
item-title="title"
density="compact"
@@ -2,7 +2,6 @@
import { ref } from 'vue'
import DisplaySettings from './DisplaySettings.vue'
import AccountsSettings from './AccountsSettings.vue'
import BehaviorSettings from './BehaviorSettings.vue'
import SecuritySettings from './SecuritySettings.vue'
interface Props {
@@ -52,10 +51,6 @@ const handleClose = () => {
<v-icon start>mdi-palette</v-icon>
Display
</v-tab>
<v-tab value="behaviour">
<v-icon start>mdi-timer-cog-outline</v-icon>
Behaviours
</v-tab>
<v-tab value="security">
<v-icon start>mdi-shield-account</v-icon>
Security
@@ -73,10 +68,6 @@ const handleClose = () => {
<DisplaySettings />
</v-window-item>
<v-window-item value="behaviour">
<BehaviorSettings />
</v-window-item>
<v-window-item value="security">
<SecuritySettings />
</v-window-item>
+352
View File
@@ -0,0 +1,352 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC'
import { useMailStore } from '@/stores/mailStore'
import type { CollectionObject, EntityObject } from '@MailManager/models'
import type { EntityIdentifier } from '@MailManager/types/common'
import FolderTree from '@/components/FolderTree.vue'
import MessageList from '@/components/MessageList.vue'
import MessageReader from '@/components/MessageReader.vue'
import MessageComposer from '@/components/MessageComposer.vue'
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
// Vuetify display for responsive behavior
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
// Check if mail manager is available
const moduleStore = useModuleStore()
const isMailManagerAvailable = computed(() => {
return moduleStore.has('mail_manager') || moduleStore.has('MailManager')
})
// Mail module store
const mailStore = useMailStore()
// storeToRefs preserves reactivity for state and computed properties
const {
sidebarVisible,
settingsDialogVisible,
loading,
selectedFolder,
selectedMessage,
selectionList,
selectionMode,
composeMode,
composeReplyTo,
currentMessages,
moveDialogVisible,
moveDialogCandidates,
} = storeToRefs(mailStore)
// Complex store/composable objects accessed directly (not simple refs)
const { mailSync, entitiesStore } = mailStore
const lastSyncLabel = computed(() => {
if (!mailSync.lastSync) return ''
return `(Last: ${new Date(mailSync.lastSync).toLocaleTimeString()})`
})
// Initialize
onMounted(async () => {
if (!isMailManagerAvailable.value) return
await mailStore.initialize()
})
// Handlers — thin wrappers that delegate to the store
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder)
const handleMessageOpen = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value)
const handleMessageSelectionToggle = (message: EntityObject) => mailStore.toggleMessageSelection(message)
const handleSelectionModeActivate = (message: EntityObject) => mailStore.activateSelectionMode(message)
const handleSelectAllToggle = (value: boolean) => {
if (value) {
mailStore.selectAllCurrentMessages()
return
}
mailStore.clearSelection()
}
const handleSelectionClear = () => mailStore.deactivateSelectionMode()
const handleSelectionMove = () => mailStore.openMoveDialog()
const handleSelectionDelete = () => mailStore.deleteMessages([...selectionList.value])
const handleCompose = (message?: EntityObject) => mailStore.openCompose(message)
const handleComposeClose = () => mailStore.closeCompose()
const handleComposeSent = () => mailStore.afterSent()
const handleDelete = (message: EntityObject) => {
const id = `${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier
mailStore.deleteMessages([id])
}
const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message)
const handleMoveConfirm = async (target: CollectionObject) => { await mailStore.moveMessages(target, moveDialogCandidates.value ?? []) }
const handleMoveCancel = () => mailStore.closeMoveDialog()
const toggleSidebar = () => mailStore.toggleSidebar()
const handleSettingsOpen = () => mailStore.openSettings()
const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Folder "${folder.properties.label}" created`, 'success')
</script>
<template>
<!-- Manager Unavailable -->
<div v-if="!isMailManagerAvailable" class="mail-unavailable">
<v-alert
type="warning"
variant="outlined"
class="mail-unavailable-alert"
>
<v-icon size="64" color="warning" class="mb-6">mdi-email-off-outline</v-icon>
<h2 class="text-h5 font-weight-bold mb-4">Mail Manager Not Available</h2>
<p>
The Mail Manager module is not installed or enabled.
This module requires the <strong>mail_manager</strong> module to function properly.
</p>
<p class="mb-0 mt-2">
Please contact your system administrator to install and enable the
<code>mail_manager</code> module.
</p>
</v-alert>
</div>
<!-- Normal UI -->
<div v-else class="mail-container">
<!-- Top toolbar -->
<v-app-bar class="mail-toolbar" elevation="0" density="compact">
<v-app-bar-nav-icon
v-if="isMobile"
@click="toggleSidebar"
/>
<v-app-bar-title>Mail</v-app-bar-title>
<v-spacer />
<v-btn
icon="mdi-pencil"
@click="handleCompose()"
color="primary"
variant="text"
>
<v-icon>mdi-pencil</v-icon>
<v-tooltip activator="parent" location="bottom">Compose</v-tooltip>
</v-btn>
<v-btn
icon="mdi-refresh"
@click="mailSync.sync()"
:loading="mailSync.isRunning && entitiesStore.transceiving"
variant="text"
>
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="bottom">
Refresh {{ lastSyncLabel }}
</v-tooltip>
</v-btn>
<v-icon
v-if="mailSync.isRunning"
color="success"
size="small"
class="ml-2"
>
mdi-sync
</v-icon>
</v-app-bar>
<!-- Main content area -->
<div class="mail-content">
<!-- Folder tree sidebar -->
<v-navigation-drawer
v-model="sidebarVisible"
:permanent="!isMobile"
:temporary="isMobile"
width="280"
class="mail-sidebar"
>
<FolderTree
:selected-folder="selectedFolder"
@select="handleFolderSelect"
@folder-created="handleFolderCreated"
/>
<template #append>
<div class="pa-2">
<v-btn
block
variant="text"
prepend-icon="mdi-cog"
@click="handleSettingsOpen"
>
Settings
</v-btn>
</div>
</template>
</v-navigation-drawer>
<!-- Main area with message list and reader -->
<div class="mail-main">
<div class="mail-wrapper">
<!-- Message list panel -->
<div class="mail-list-panel">
<MessageList
:messages="currentMessages"
:selected-collection="selectedFolder"
:selected-message="selectedMessage"
:selection-list="selectionList"
:selection-mode="selectionMode"
:loading="loading"
@open="handleMessageOpen"
@toggle-selection="handleMessageSelectionToggle"
@activate-selection-mode="handleSelectionModeActivate"
@toggle-select-all="handleSelectAllToggle"
@clear-selection="handleSelectionClear"
@move-selection="handleSelectionMove"
@delete-selection="handleSelectionDelete"
/>
</div>
<!-- Reader/Composer panel -->
<div class="mail-reader-panel">
<MessageComposer
v-if="composeMode"
:reply-to="composeReplyTo"
:folder="selectedFolder"
@close="handleComposeClose"
@sent="handleComposeSent"
/>
<MessageReader
v-else
:entity="selectedMessage"
@compose="handleCompose"
@reply="handleCompose"
@move="handleMove"
@delete="handleDelete"
/>
</div>
</div>
</div>
</div>
<!-- Settings Dialog -->
<SettingsDialog v-model="settingsDialogVisible" />
<FolderSelectionDialog
v-model="moveDialogVisible"
:loading="loading"
title="Move To"
confirm-text="Move"
empty-text="No other folders are available in this account."
@select="handleMoveConfirm"
@cancel="handleMoveCancel"
/>
</div>
</template>
<style scoped lang="scss">
.mail-unavailable {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
padding: 48px;
text-align: center;
width: 100%;
}
.mail-unavailable-alert {
width: 100%;
text-align: left;
}
.mail-container {
display: flex;
flex-direction: column;
height: 100vh;
isolation: isolate;
}
.mail-toolbar {
flex-shrink: 0;
}
.mail-content {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.mail-sidebar {
border-right: 1px solid rgb(var(--v-border-color));
overflow-y: auto;
}
.mail-main {
flex: 1;
display: flex;
overflow: hidden;
min-width: 0;
}
.mail-wrapper {
flex: 1;
display: flex;
overflow: hidden;
}
.mail-list-panel {
width: 320px;
min-width: 280px;
max-width: 450px;
border-right: 1px solid rgb(var(--v-border-color));
overflow-y: auto;
display: flex;
flex-direction: column;
}
.mail-reader-panel {
flex: 1;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Responsive adjustments */
@media (max-width: 960px) {
.mail-wrapper {
flex-direction: column;
}
.mail-list-panel {
width: 100%;
max-width: 100%;
border-right: none;
border-bottom: 1px solid rgb(var(--v-border-color));
max-height: 50%;
}
.mail-reader-panel {
width: 100%;
}
}
</style>
-519
View File
@@ -1,519 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref, unref } from 'vue'
import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { useMailUiStore } from '@/stores/mailUiStore'
import type { CollectionObject, EntityObject } from '@MailManager/models'
import { ComposerMode } from '@/types/composer'
import MessageList from '@/components/MessageList.vue'
import MessageReader from '@/components/MessageReader.vue'
import MessageComposer from '@/components/MessageComposer.vue'
import CreateFolderDialog from '@/components/CreateFolderDialog.vue'
import DeleteFolderDialog from '@/components/DeleteFolderDialog.vue'
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
import RenameFolderDialog from '@/components/RenameFolderDialog.vue'
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
import Sidebar from '@/components/Sidebar.vue'
// Vuetify display for responsive behavior
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
// Check if mail manager is available
const moduleStore = useModuleStore()
const isManagerAvailable = computed(() => {
return moduleStore.has('mail_manager') || moduleStore.has('MailManager')
})
const collectionsStore = useCollectionsStore()
// Mail module store
const mailOperationsStore = useMailOperationsStore()
const mailUiStore = useMailUiStore()
const mailSettingsStore = useMailSettingsStore()
const { messageListWidth } = storeToRefs(mailSettingsStore)
// storeToRefs preserves reactivity for state and computed properties
const {
loading,
selectedMessage,
currentMessages,
} = storeToRefs(mailOperationsStore)
const {
settingsDialogVisible,
selectedFolder,
composerMode,
composerSource,
composerVisible,
selectionList,
selectionMode,
moveMessagesDialogVisible,
moveMessagesDialogService,
createFolderDialogVisible,
createFolderDialogService,
createFolderDialogLoading,
createFolderDialogError,
renameFolderDialogVisible,
renameFolderDialogService,
renameFolderDialogFolder,
renameFolderDialogLoading,
renameFolderDialogError,
moveFolderDialogVisible,
moveFolderDialogService,
deleteFolderDialogVisible,
deleteFolderDialogService,
deleteFolderDialogFolder,
deleteFolderDialogLoading,
deleteFolderDialogError,
} = storeToRefs(mailUiStore)
// Complex store/composable objects accessed directly (not simple refs)
const { mailSync, entitiesStore } = mailOperationsStore
// Message search (filters the current message list by subject/sender)
const searchQuery = ref('')
const filteredMessages = computed(() => {
const query = (searchQuery.value ?? '').trim().toLowerCase()
if (!query) return currentMessages.value
return currentMessages.value.filter((message: EntityObject) => {
const subject = (message.properties.subject || '').toLowerCase()
const fromLabel = (message.properties.from?.label || '').toLowerCase()
const fromAddress = (message.properties.from?.address || '').toLowerCase()
return subject.includes(query) || fromLabel.includes(query) || fromAddress.includes(query)
})
})
const lastSyncLabel = computed(() => {
const lastSync = unref(unref(mailSync.lastSync))
if (!(lastSync instanceof Date)) return ''
return `(Last: ${lastSync.toLocaleTimeString()})`
})
// Initialize
onMounted(async () => {
if (!isManagerAvailable.value) return
await mailUiStore.initialize()
})
// Handlers — thin wrappers that delegate to the store
const {
validateCreateFolderName,
validateRenameFolderName,
} = mailUiStore
const sidebarToggle = () => mailUiStore.sidebarToggle()
const handleFolderSelect = (folder: CollectionObject) => mailUiStore.selectFolder(folder)
const handleFolderCreateConfirm = async (folderName: string) => {
try {
const mutatedFolder = await mailUiStore.confirmCreateFolder(folderName)
if (mutatedFolder) {
handleFolderSelect(mutatedFolder)
}
} catch (error: unknown) {
console.error('[Mail] Failed to create folder:', error)
}
}
const handleFolderEditConfirm = async (folderName: string) => {
try {
const mutatedFolder = await mailUiStore.confirmRenameFolder(folderName)
if (mutatedFolder) {
handleFolderSelect(mutatedFolder)
}
} catch (error: unknown) {
console.error('[Mail] Failed to rename folder:', error)
}
}
const handleFolderDeleteConfirm = async () => {
try {
await mailUiStore.confirmDeleteFolder()
} catch (error: unknown) {
console.error('[Mail] Failed to delete folder:', error)
}
}
const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
try {
await mailUiStore.confirmMoveFolder(targetFolder)
} catch (error: unknown) {
console.error('[Mail] Failed to move folder:', error)
}
}
const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog()
const handleMessageOpen = (message: EntityObject) => {
mailOperationsStore.selectMessage(message)
if (isMobile.value) {
mailUiStore.sidebarHide()
}
}
const handleMessageComposeFresh = () => mailUiStore.openComposer()
const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Reply)
const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Forward)
const handleMessageComposeClose = () => mailUiStore.closeComposer()
const handleMessageFlag = async (message: EntityObject, flag: string, value: boolean) => {
await mailUiStore.flagMessage(message, flag, value)
}
const handleMessageDelete = async (message: EntityObject) => {
await mailUiStore.deleteMessages([message.identifier])
}
const handleMessageMove = (message: EntityObject) => mailUiStore.openMoveMessagesDialog(message)
const handleMessageMoveConfirm = async (target: CollectionObject) => { await mailUiStore.confirmMoveMessages(target) }
const handleMessageMoveCancel = () => mailUiStore.closeMoveMessagesDialog()
const handleMessageSelectionMode = (message: EntityObject) => mailUiStore.messageSelectionModeActivate(message)
const handleMessageSelectionToggleOne = (message: EntityObject) => mailUiStore.messageSelectionToggleOne(message)
const handleMessageSelectionToggleAll = (value: boolean) => {
mailUiStore.messageSelectionToggleAll(value)
}
const handleMessageSelectionClear = () => mailUiStore.messageSelectionModeDeactivate()
const handleMessageSelectionMove = () => mailUiStore.openMoveMessagesDialog()
const handleMessageSelectionFlag = (flag: string, value: boolean) => mailUiStore.flagSelectedMessages(flag, value)
const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
</script>
<template>
<!-- Manager Unavailable -->
<div v-if="!isManagerAvailable" class="mail-unavailable">
<v-alert
type="warning"
variant="outlined"
class="mail-unavailable-alert"
>
<v-icon size="64" color="warning" class="mb-6">mdi-email-off-outline</v-icon>
<h2 class="text-h5 font-weight-bold mb-4">Mail Manager Not Available</h2>
<p>
The Mail Manager module is not installed or enabled.
This module requires the <strong>mail_manager</strong> module to function properly.
</p>
<p class="mb-0 mt-2">
Please contact your system administrator to install and enable the
<code>mail_manager</code> module.
</p>
</v-alert>
</div>
<!-- Normal UI -->
<div v-else class="mail-container">
<!-- Top toolbar -->
<v-toolbar class="mail-toolbar" elevation="0" density="compact">
<template #prepend>
<v-btn
icon="mdi-menu"
variant="text"
@click="sidebarToggle"
/>
</template>
<v-toolbar-title class="d-flex align-center">
<v-icon size="24" color="primary" class="mr-2">mdi-email</v-icon>
<span class="text-subtitle-1 font-weight-bold">Mail</span>
</v-toolbar-title>
<v-spacer />
<!-- Search -->
<v-text-field
v-model="searchQuery"
density="compact"
variant="outlined"
placeholder="Search messages..."
prepend-inner-icon="mdi-magnify"
hide-details
single-line
clearable
class="mail-toolbar-search mx-4"
/>
<v-btn
icon="mdi-pencil"
@click="handleMessageComposeFresh()"
color="primary"
variant="text"
>
<v-icon>mdi-pencil</v-icon>
<v-tooltip activator="parent" location="bottom">Compose</v-tooltip>
</v-btn>
<v-btn
icon="mdi-refresh"
@click="mailSync.sync()"
:loading="mailSync.isRunning && entitiesStore.transceiving"
variant="text"
>
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="bottom">
Refresh {{ lastSyncLabel }}
</v-tooltip>
</v-btn>
<v-icon
v-if="mailSync.isRunning"
color="success"
size="small"
class="ml-2"
>
mdi-sync
</v-icon>
</v-toolbar>
<!-- Main content area -->
<div class="mail-content">
<!-- Folder tree sidebar -->
<Sidebar />
<!-- Main area with message list and reader -->
<div class="mail-main">
<div class="mail-wrapper">
<!-- Message list panel -->
<div
class="mail-list-panel"
:style="{ '--mail-list-width': messageListWidth + '%' }"
>
<MessageList
:messages="filteredMessages"
:selected-collection="selectedFolder"
:selected-message="selectedMessage"
:selection-list="selectionList"
:selection-mode="selectionMode"
:loading="loading"
@open="handleMessageOpen"
@reply="handleMessageComposeReply"
@forward="handleMessageComposeForward"
@move="handleMessageMove"
@delete="handleMessageDelete"
@flag="handleMessageFlag"
@selection-mode="handleMessageSelectionMode"
@selection-toggle-one="handleMessageSelectionToggleOne"
@selection-toggle-all="handleMessageSelectionToggleAll"
@selection-clear="handleMessageSelectionClear"
@selection-flag="handleMessageSelectionFlag"
@selection-move="handleMessageSelectionMove"
@selection-delete="handleMessageSelectionDelete"
/>
</div>
<!-- Reader/Composer panel -->
<div class="mail-reader-panel">
<MessageComposer
v-if="composerVisible"
:mode="composerMode"
:source="composerSource"
:folder="selectedFolder"
@close="handleMessageComposeClose"
/>
<MessageReader
v-else
:entity="selectedMessage"
@compose="handleMessageComposeFresh"
@reply="handleMessageComposeReply"
@forward="handleMessageComposeForward"
@move="handleMessageMove"
@delete="handleMessageDelete"
/>
</div>
</div>
</div>
</div>
<!-- Settings Dialog -->
<SettingsDialog v-model="settingsDialogVisible" />
<FolderSelectionDialog
v-if="moveMessagesDialogService"
v-model="moveMessagesDialogVisible"
:service="moveMessagesDialogService"
:loading="loading"
title="Move Messages To"
confirm-text="Move"
empty-text="No other folders are available in this account."
@select="handleMessageMoveConfirm"
@cancel="handleMessageMoveCancel"
/>
<FolderSelectionDialog
v-if="moveFolderDialogService"
v-model="moveFolderDialogVisible"
:service="moveFolderDialogService"
:loading="collectionsStore.transceiving"
title="Move Folder To"
confirm-text="Move"
empty-text="No other folders are available in this account."
:disabled-folder-keys="mailUiStore.moveFolderDialogInvalidFolderKeys"
@select="handleFolderMoveConfirm"
@cancel="handleFolderMoveCancel"
/>
<CreateFolderDialog
v-if="createFolderDialogService"
v-model="createFolderDialogVisible"
:service="createFolderDialogService"
:parent-folder-label="mailUiStore.createFolderDialogParentLabel"
:validate-name="validateCreateFolderName"
:loading="createFolderDialogLoading"
:error-message="createFolderDialogError"
@confirm="handleFolderCreateConfirm"
/>
<RenameFolderDialog
v-if="renameFolderDialogService && renameFolderDialogFolder"
v-model="renameFolderDialogVisible"
:service="renameFolderDialogService"
:folder="renameFolderDialogFolder"
:parent-folder-label="mailUiStore.renameFolderDialogParentLabel"
:validate-name="validateRenameFolderName"
:loading="renameFolderDialogLoading"
:error-message="renameFolderDialogError"
@confirm="handleFolderEditConfirm"
/>
<DeleteFolderDialog
v-if="deleteFolderDialogService && deleteFolderDialogFolder"
v-model="deleteFolderDialogVisible"
:service="deleteFolderDialogService"
:folder="deleteFolderDialogFolder"
:loading="deleteFolderDialogLoading"
:error-message="deleteFolderDialogError"
@confirm="handleFolderDeleteConfirm"
/>
</div>
</template>
<style scoped lang="scss">
.mail-unavailable {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 0;
padding: 48px;
text-align: center;
width: 100%;
}
.mail-unavailable-alert {
width: 100%;
text-align: left;
}
.mail-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
isolation: isolate;
}
.mail-toolbar {
flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
}
.mail-toolbar-search {
max-width: 300px;
}
@media (max-width: 960px) {
.mail-toolbar-search {
max-width: 180px;
margin-inline: 8px !important;
}
}
.mail-content {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
.mail-main {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
min-width: 0;
}
.mail-wrapper {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
overflow: hidden;
}
.mail-list-panel {
flex: 0 0 var(--mail-list-width, 35%);
width: var(--mail-list-width, 35%);
min-width: 280px;
max-width: 70%;
border-right: 1px solid rgb(var(--v-border-color));
overflow-y: auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.mail-reader-panel {
flex: 1;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Responsive adjustments */
@media (max-width: 960px) {
.mail-wrapper {
flex-direction: column;
}
.mail-list-panel {
flex: 0 0 auto;
width: 100%;
max-width: 100%;
border-right: none;
border-bottom: 1px solid rgb(var(--v-border-color));
max-height: 50%;
}
.mail-reader-panel {
width: 100%;
}
}
</style>
+2 -3
View File
@@ -2,11 +2,10 @@ const routes = [
{
name: 'mail',
path: '/',
component: () => import('@/pages/Main.vue'),
component: () => import('@/pages/MailPage.vue'),
meta: {
title: 'Mail',
requiresAuth: true,
layout: 'fixed'
requiresAuth: true
}
},
]
-71
View File
@@ -1,71 +0,0 @@
import { createFetchWrapper } from '@KTXC'
import type { ApiRequest, ApiResponse } from '@MailManager/types/common'
import type {
CompositionAttachmentAddRequest,
CompositionAttachmentAddResponse,
CompositionAttachmentRemoveRequest,
CompositionAttachmentRemoveResponse,
CompositionDiscardRequest,
CompositionDiscardResponse,
CompositionPatchRequest,
CompositionPatchResponse,
CompositionSendRequest,
CompositionSendResponse,
CompositionStageRequest,
CompositionStageResponse,
} from '@/types/composition'
const fetchWrapper = createFetchWrapper()
const API_URL = '/m/mail/compose/v1'
const API_VERSION = 1
function generateTransactionId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
async function post<TRequest, TResponse>(operation: string, data: TRequest): Promise<TResponse> {
const request: ApiRequest<TRequest> = {
version: API_VERSION,
transaction: generateTransactionId(),
operation,
data,
}
const response: ApiResponse<TResponse> = await fetchWrapper.post(API_URL, request)
if (response.status === 'error') {
const errorMessage = `[${operation}] ${response.data.message}${response.data.code ? ` (code: ${response.data.code})` : ''}`
throw new Error(errorMessage)
}
return response.data
}
const compositionService = {
async stage(request: CompositionStageRequest): Promise<CompositionStageResponse> {
return await post<CompositionStageRequest, CompositionStageResponse>('stage', request)
},
async patch(request: CompositionPatchRequest): Promise<CompositionPatchResponse> {
return await post<CompositionPatchRequest, CompositionPatchResponse>('patch', request)
},
async discard(request: CompositionDiscardRequest): Promise<CompositionDiscardResponse> {
return await post<CompositionDiscardRequest, CompositionDiscardResponse>('discard', request)
},
async send(request: CompositionSendRequest): Promise<CompositionSendResponse> {
return await post<CompositionSendRequest, CompositionSendResponse>('send', request)
},
async attachmentAdd(request: CompositionAttachmentAddRequest): Promise<CompositionAttachmentAddResponse> {
return await post<CompositionAttachmentAddRequest, CompositionAttachmentAddResponse>('attachment.add', request)
},
async attachmentRemove(request: CompositionAttachmentRemoveRequest): Promise<CompositionAttachmentRemoveResponse> {
return await post<CompositionAttachmentRemoveRequest, CompositionAttachmentRemoveResponse>('attachment.remove', request)
}
}
export default compositionService
-48
View File
@@ -1,48 +0,0 @@
/**
* mediaViewer — bridges the Mail UI to the standalone media_viewer module
*/
import { defineAsyncComponent } from 'vue'
import { useIntegrationStore } from '@KTXC'
const MEDIA_VIEWER_FORMAT_POINT = 'media_viewer_format'
const MEDIA_VIEWER_POINT = 'media_viewer'
function mimeMatchesPattern(mime: string, pattern: string): boolean {
if (pattern.endsWith('/*')) {
return mime.startsWith(pattern.slice(0, -1))
}
return mime === pattern
}
function mimeMatchesViewer(mime: string, mimeTypes?: string[], mimePatterns?: string[]): boolean {
if (mimeTypes?.includes(mime)) return true
if (mimePatterns) {
for (const pattern of mimePatterns) {
if (mimeMatchesPattern(mime, pattern)) return true
}
}
return false
}
/** True if a leaf viewer is registered for this MIME type. */
export function mediaViewerCanDisplay(mime: string | null | undefined): boolean {
if (!mime) return false
const viewers = useIntegrationStore().getItems(MEDIA_VIEWER_FORMAT_POINT)
return viewers.some(viewer =>
mimeMatchesViewer(
mime,
viewer.meta?.mimeTypes as string[] | undefined,
viewer.meta?.mimePatterns as string[] | undefined,
),
)
}
export function mediaViewerComponent(type: 'dialog' | 'popover' = 'dialog') {
// The integration store prefixes ids with the module handle
// (e.g. 'media_viewer.dialog'), so match on the suffix.
const entry = useIntegrationStore().getItems(MEDIA_VIEWER_POINT).find(i => i.id.endsWith(`.${type}`))
return entry?.component
? defineAsyncComponent(entry.component as () => Promise<unknown>)
: null
}
-110
View File
@@ -1,110 +0,0 @@
/**
* Recipient service
*/
import { entityService } from '@PeopleManager/services'
import { GroupObject, IndividualObject, OrganizationObject, type EntityObject } from '@PeopleManager/models'
import type { MessageAddressInterface } from '@MailManager/types/message'
/** Minimum query length before a lookup is worthwhile. */
const MIN_QUERY_LENGTH = 3
/** Default cap on the number of matches returned. */
const DEFAULT_LIMIT = 10
/**
* Derive a human-readable display name from a contact's properties.
*/
function displayName(properties: IndividualObject | OrganizationObject | GroupObject): string {
if (properties.label) {
return properties.label
}
if (properties instanceof IndividualObject) {
const parts = [properties.names?.given, properties.names?.family].filter(Boolean)
if (parts.length > 0) {
return parts.join(' ')
}
} else if (properties instanceof OrganizationObject) {
if (properties.names?.full) {
return properties.names.full
}
}
return ''
}
/**
* Flatten a contact entity into one recipient per email address.
* Groups (no addressable email) are skipped.
*/
function toRecipients(entity: EntityObject): MessageAddressInterface[] {
const properties = entity.properties
if (properties instanceof GroupObject) {
return []
}
const emails = properties.emails
if (!emails) {
return []
}
const label = displayName(properties)
const recipients: MessageAddressInterface[] = []
for (const email of Object.values(emails)) {
const address = email?.address?.trim()
if (!address) {
continue
}
recipients.push({ address, label: label || address })
}
return recipients
}
export const recipientService = {
/**
* Search the user's contacts for recipients matching a query.
*
* @param query - free-text fragment matched against contact names
* @param limit - maximum number of recipients to return
*
* @returns De-duplicated recipients (one per email address)
*/
async search(query: string, limit: number = DEFAULT_LIMIT): Promise<MessageAddressInterface[]> {
const trimmed = query.trim()
if (trimmed.length < MIN_QUERY_LENGTH) {
return []
}
const recipients: MessageAddressInterface[] = []
const seen = new Set<string>()
try {
// The "*" attribute defaults to LIKE, so a scalar string is all that is
// needed; range caps the scan to the first `limit` matches.
await entityService.listStream(
{ filter: { '*': trimmed }, range: { type: 'tally', anchor: 'absolute', position: 0, tally: limit } },
(entity: EntityObject) => {
for (const recipient of toRecipients(entity)) {
const key = recipient.address.toLowerCase()
if (seen.has(key)) {
continue
}
seen.add(key)
recipients.push(recipient)
}
},
)
} catch (error: any) {
console.error('[Mail][Recipient Service] - Search failed:', error)
return []
}
return recipients.slice(0, limit)
},
}
export default recipientService
-563
View File
@@ -1,563 +0,0 @@
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)
}
-624
View File
@@ -1,624 +0,0 @@
import { ref, computed, shallowRef } from 'vue'
import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync'
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity'
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
import { CollectionPropertiesObject } from '@MailManager/models/collection'
interface ComposerMessageInput {
to: string[]
cc: string[]
bcc: string[]
subject: string
body: {
html: string
text: string
}
}
export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
const servicesStore = useServicesStore()
const collectionsStore = useCollectionsStore()
const entitiesStore = useEntitiesStore()
// Background mail sync
const mailSyncController = useMailSync({
interval: 30000,
autoStart: false,
fetchDetails: true,
})
const mailSync = {
isRunning: mailSyncController.isRunning,
lastSync: mailSyncController.lastSync,
error: mailSyncController.error,
sync: mailSyncController.sync,
start: mailSyncController.start,
stop: mailSyncController.stop,
restart: mailSyncController.restart,
}
// ── General State ─────────────────-───────────────────────────────────────
const loading = ref(false)
const serviceFolderLoadingState = ref<Record<string, boolean>>({})
const serviceFolderLoadedState = ref<Record<string, boolean>>({})
const serviceFolderErrorState = ref<Record<string, string | null>>({})
const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null)
const composerSaving = ref(false)
const composerSending = ref(false)
const composerLastSaved = ref<Date | null>(null)
const composerDraftIdentifier = ref<EntityIdentifier | null>(null)
// ── Computed ──────────────────────────────────────────────────────────────
const currentMessages = computed(() => {
if (!selectedFolder.value) return []
const folder = selectedFolder.value
return entitiesStore.entities.filter(e =>
e.provider === folder.provider &&
String(e.service) === String(folder.service) &&
String(e.collection) === String(folder.identifier),
)
})
// ── Initialization ────────────────────────────────────────────────────────
async function initialize() {
loading.value = true
try {
await servicesStore.list()
const services = [...servicesStore.servicesEnabled]
await Promise.all(services.map(service => loadFoldersForService(service)))
} catch (error) {
console.error('[Mail][Operations] Failed to initialize:', error)
} finally {
loading.value = false
}
}
async function loadFoldersForService(service: ServiceObject) {
if (service.identifier === null) {
return
}
_setServiceFolderLoading(service.provider, service.identifier, true)
_setServiceFolderError(service.provider, service.identifier, null)
try {
// retrieve folders for service
await collectionsStore.collectionsForService(service.provider, service.identifier, true)
_setServiceFolderLoaded(service.provider, service.identifier, true)
_updateSyncSources()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load folders'
_setServiceFolderError(service.provider, service.identifier, message)
console.error(`[Mail][Operations] Failed to load folders for ${service.provider}:${String(service.identifier)}:`, error)
_updateSyncSources()
return {}
} finally {
_setServiceFolderLoading(service.provider, service.identifier, false)
}
}
// ── Helpers ──────────────────────────────────────────────────────────
function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier {
if (item instanceof ServiceObject) {
return `${item.provider}:${String(item.identifier)}` as ServiceIdentifier
}
return `${item.provider}:${String(item.service)}` as ServiceIdentifier
}
function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
if (!left || !right) {
return false
}
return left.provider === right.provider &&
String(left.service) === String(right.service) &&
String(left.identifier) === String(right.identifier)
}
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
serviceFolderLoadingState.value = {
...serviceFolderLoadingState.value,
[_serviceIdentifier({ provider, service })]: loadingState,
}
}
function _setServiceFolderLoaded(provider: string, service: string | number, loaded: boolean) {
serviceFolderLoadedState.value = {
...serviceFolderLoadedState.value,
[_serviceIdentifier({ provider, service })]: loaded,
}
}
function _setServiceFolderError(provider: string, service: string | number, error: string | null) {
serviceFolderErrorState.value = {
...serviceFolderErrorState.value,
[_serviceIdentifier({ provider, service })]: error,
}
}
function _updateSyncSources() {
mailSyncController.clearSources()
// Track the currently selected folder
if (selectedFolder.value) {
mailSyncController.addSource(selectedFolder.value.identifier)
}
// Always track inboxes for each account (for new-mail notifications)
servicesStore.servicesEnabled.forEach(service => {
if (service.identifier === null) {
return
}
const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter(
c =>
String(c.service) === String(service.identifier) &&
(c.properties.role === 'inbox' ||
String(c.identifier).toLowerCase() === 'inbox'),
)
inboxes.forEach(inbox => mailSyncController.addSource(inbox.identifier))
})
if (mailSyncController.sources.value.length > 0 && !mailSyncController.isRunning.value) {
mailSyncController.start()
}
}
function isServiceFolderLoading(provider: string, service: string | number) {
return serviceFolderLoadingState.value[_serviceIdentifier({ provider, service })] === true
}
function hasServiceFoldersLoaded(provider: string, service: string | number) {
return serviceFolderLoadedState.value[_serviceIdentifier({ provider, service })] === true
}
function getServiceFolderError(provider: string, service: string | number) {
return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
}
function _findDraftFolder(folder: CollectionObject): CollectionObject {
return collectionsStore.collectionsForService(folder.provider, folder.service).find(
candidate =>
candidate.provider === folder.provider &&
String(candidate.service) === String(folder.service) &&
(candidate.properties.role === 'drafts' ||
String(candidate.identifier).toLowerCase() === 'drafts' ||
candidate.properties.label.toLowerCase() === 'drafts'),
) ?? folder
}
function _toMessageAddresses(addresses: string[]): MessageAddressInterface[] | undefined {
const normalized = addresses
.map(address => address.trim())
.filter(address => address.length > 0)
if (normalized.length === 0) {
return undefined
}
return normalized.map(address => ({ address }))
}
function _toDraftBody(body: ComposerMessageInput['body']): MessagePartInterface | null {
const parts: MessagePartInterface[] = []
const text = body.text.trim()
const html = body.html.trim()
if (text.length > 0) {
parts.push({
type: 'text/plain',
content: text,
})
}
if (html.length > 0) {
parts.push({
type: 'text/html',
content: html,
})
}
if (parts.length === 0) {
return null
}
if (parts.length === 1) {
return parts[0]
}
return {
type: 'multipart/alternative',
subParts: parts,
}
}
function _toDraftProperties(message: ComposerMessageInput): MessageInterface {
return {
'@type': 'mail:message',
to: _toMessageAddresses(message.to),
cc: _toMessageAddresses(message.cc),
bcc: _toMessageAddresses(message.bcc),
subject: message.subject.trim() || null,
body: _toDraftBody(message.body),
flags: {
draft: true,
},
}
}
function resetComposerState() {
composerSaving.value = false
composerSending.value = false
composerLastSaved.value = null
composerDraftIdentifier.value = null
}
// ── Actions ───────────────────────────────────────────────────────────────
async function retrieveService(identifier: ServiceIdentifier, force: boolean = false): Promise<ServiceObject | null> {
let service = servicesStore.serviceByIdentifier(identifier)
if (service && !force) {
return service
}
try {
service = await servicesStore.serviceByIdentifier(identifier, true)
} catch (error) {
console.error(`[Mail][Operations] Failed to retrieve service ${identifier}:`, error)
throw error
}
if (!service) {
const message = `Service ${identifier} not found`
console.error(`[Mail][Operations] ${message}`)
throw new Error(message)
}
return service
}
async function selectFolder(folder: CollectionObject | null) {
selectedFolder.value = folder
selectedMessage.value = null
if (folder) {
try {
await entitiesStore.list([folder.identifier])
} catch (error) {
console.error('[Mail][Operations] Failed to load messages:', error)
}
}
_updateSyncSources()
}
function selectMessage(entity: EntityObject | null) {
selectedMessage.value = entity
}
async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) {
composerSaving.value = true
try {
const targetFolder = _findDraftFolder(folder)
const properties = _toDraftProperties(message)
const draft = composerDraftIdentifier.value
? await entitiesStore.update(composerDraftIdentifier.value, properties)
: await entitiesStore.create(targetFolder.identifier, properties)
composerDraftIdentifier.value = draft.identifier
composerLastSaved.value = new Date()
return draft
} catch (error) {
console.error('[Mail][Operations] Failed to save draft:', error)
throw error
} finally {
composerSaving.value = false
}
}
function findFoldersByRole(role: string): CollectionObject[] {
const normalizedRole = role.toLowerCase()
return servicesStore.servicesEnabled.flatMap(service => {
if (service.identifier === null) {
return []
}
return collectionsStore.collectionsForService(service.provider, service.identifier).filter(
folder =>
folder.provider === service.provider &&
String(folder.service) === String(service.identifier) &&
(folder.properties.role === normalizedRole ||
String(folder.identifier).toLowerCase() === normalizedRole),
)
})
}
async function sendComposerMessage(message: ComposerMessageInput) {
composerSending.value = true
const transmitRequest: EntityTransmitRequest = {
message: {
to: message.to.map(address => address.trim()).filter(address => address.length > 0),
cc: message.cc.map(address => address.trim()).filter(address => address.length > 0),
bcc: message.bcc.map(address => address.trim()).filter(address => address.length > 0),
subject: message.subject,
body: {
html: message.body.html,
text: message.body.text,
},
},
}
if (transmitRequest.message.cc?.length === 0) {
delete transmitRequest.message.cc
}
if (transmitRequest.message.bcc?.length === 0) {
delete transmitRequest.message.bcc
}
try {
const response = await entitiesStore.transmit(transmitRequest)
if (composerDraftIdentifier.value) {
try {
await entitiesStore.delete([composerDraftIdentifier.value])
} catch (error) {
console.error('[Mail][Operations] Failed to delete draft after send:', error)
}
}
resetComposerState()
return response
} catch (error) {
console.error('[Mail][Operations] Failed to send message:', error)
throw error
} finally {
composerSending.value = false
}
}
async function createFolder(service: ServiceObject, label: string, parentFolder: CollectionObject | null = null): Promise<CollectionObject> {
if (service.identifier === null) {
throw new Error('Cannot create folder for a service without an identifier')
}
const properties = new CollectionPropertiesObject()
properties.label = label.trim()
properties.rank = 0
properties.subscribed = true
const newFolder = await collectionsStore.create(
service.provider,
service.identifier,
properties,
parentFolder?.identifier,
)
return newFolder
}
async function renameFolder(folder: CollectionObject, label: string): Promise<CollectionObject> {
const properties = new CollectionPropertiesObject()
properties.label = label.trim()
properties.rank = folder.properties.rank ?? 0
properties.subscribed = folder.properties.subscribed ?? true
const updatedFolder = await collectionsStore.update(folder.identifier, properties)
if (_sameCollection(selectedFolder.value, folder)) {
selectedFolder.value = updatedFolder
}
return updatedFolder
}
async function moveFolder(source: CollectionObject, target: CollectionObject): Promise<CollectionObject> {
const movedFolder = await collectionsStore.move(target.identifier, source.identifier)
if (_sameCollection(selectedFolder.value, source)) {
selectedFolder.value = movedFolder
}
return movedFolder
}
async function deleteFolder(folder: CollectionObject): Promise<CollectionObject | boolean> {
const deletedFolder = await collectionsStore.delete(folder.identifier)
if (_sameCollection(selectedFolder.value, folder)) {
await selectFolder(null)
}
return deletedFolder
}
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
if (entityIdentifiers.length === 0) {
return null
}
loading.value = true
try {
return await entitiesStore.delete(entityIdentifiers)
} catch (error) {
console.error('[Mail][Operations] Failed to delete messages:', error)
throw error
} finally {
loading.value = false
}
}
async function flagMessages(entityIdentifiers: EntityIdentifier[], flags: Partial<NonNullable<MessageInterface['flags']>>) {
if (entityIdentifiers.length === 0) {
return null
}
try {
const patch = entitiesStore.fresh().properties
patch.flags = flags
return await entitiesStore.patch(patch, entityIdentifiers)
} catch (error) {
console.error('[Mail][Operations] Failed to update messages:', error)
throw error
}
}
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
(accumulator, identifier) => {
const entity = entitiesStore.entity(identifier)
if (!entity) {
return accumulator
}
// Only allow moving messages within the same service and disallow moving into the same folder
const canMove = entity.provider === target.provider &&
String(entity.service) === String(target.service) &&
String(entity.collection) !== String(target.identifier)
if (!canMove) {
return accumulator
}
accumulator.movableIdentifiers.push(identifier)
if (!accumulator.sourceCollections.some(
collection => String(collection) === String(entity.collection),
)) {
accumulator.sourceCollections.push(entity.collection)
}
return accumulator
},
{
movableIdentifiers: [] as EntityIdentifier[],
sourceCollections: [] as CollectionIdentifier[],
},
)
if (movableIdentifiers.length === 0) {
return null
}
loading.value = true
try {
const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
// update source collections to reflect moved messages
sourceCollections.push(target.identifier)
await collectionsStore.fetch(sourceCollections)
return { successes, failures }
} catch (error) {
console.error('[Mail][Operations] Failed to move messages:', error)
throw error
} finally {
loading.value = false
}
}
async function downloadMessage(entity: EntityObject, index?: number) {
const target = entity.identifier
let part = null
if (index !== undefined) {
part = entity.properties.attachments?.[Number(index)] ?? null
}
try {
await entitiesStore.download(target, part)
} catch (error) {
console.error(
index === undefined
? '[Mail][Operations] Failed to download message:'
: '[Mail][Operations] Failed to download attachment:',
error,
)
throw error
}
}
async function attachmentBlob(entity: EntityObject, part: MessagePartInterface): Promise<Blob> {
const selector: EntityBlobSelector = {
blobId: part.blobId ?? undefined,
partId: part.partId ?? undefined,
cid: part.cid ?? undefined,
}
const results = await entitiesStore.blobs(entity.identifier, [selector])
const blob = results[0]?.blob
if (!blob) {
throw new Error('Attachment content unavailable')
}
return blob
}
// ── Exports ───────────────────────────────────────────────────────────────
return {
// Sub-stores (forwarded for template convenience)
collectionsStore,
entitiesStore,
servicesStore,
mailSync,
// State
loading,
selectedMessage,
composerSaving,
composerSending,
composerLastSaved,
composerDraftIdentifier,
serviceFolderLoadingState,
serviceFolderLoadedState,
serviceFolderErrorState,
// Computed
currentMessages,
// Actions
retrieveService,
selectFolder,
selectMessage,
createFolder,
saveComposerDraft,
sendComposerMessage,
resetComposerState,
flagMessages,
deleteMessages,
deleteFolder,
moveMessages,
downloadMessage,
attachmentBlob,
moveFolder,
renameFolder,
isServiceFolderLoading,
hasServiceFoldersLoaded,
getServiceFolderError,
findFoldersByRole,
loadFoldersForService,
initialize,
}
})
-85
View File
@@ -1,85 +0,0 @@
/**
* Recipients Store
*/
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { recipientService } from '../services/recipientService'
import type { MessageAddressInterface } from '@MailManager/types/message'
/** Minimum characters before a lookup fires. */
const MIN_QUERY_LENGTH = 3
/** Debounce window for keystroke-driven searches. */
const DEBOUNCE_MS = 250
export const useRecipientLookupStore = defineStore('mailRecipientsStore', () => {
const suggestions = ref<MessageAddressInterface[]>([])
const searching = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// Monotonic token so out-of-order/stale responses are discarded.
let requestToken = 0
function cancelPending() {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
/**
* Clear suggestions and cancel any in-flight lookup.
*/
function clear() {
cancelPending()
requestToken++
suggestions.value = []
searching.value = false
}
/**
* Debounced contact search. Queries shorter than MIN_QUERY_LENGTH clear the
* suggestion list without hitting the backend.
*/
function search(query: string) {
const trimmed = (query ?? '').trim()
cancelPending()
if (trimmed.length < MIN_QUERY_LENGTH) {
requestToken++
suggestions.value = []
searching.value = false
return
}
searching.value = true
const token = ++requestToken
debounceTimer = setTimeout(async () => {
try {
const matches = await recipientService.search(trimmed)
if (token !== requestToken) {
return
}
suggestions.value = matches
} catch (error: any) {
if (token === requestToken) {
suggestions.value = []
}
console.error('[Mail][Recipient Lookup] - Search failed:', error)
} finally {
if (token === requestToken) {
searching.value = false
}
}
}, DEBOUNCE_MS)
}
return {
suggestions,
searching,
search,
clear,
}
})
-133
View File
@@ -1,133 +0,0 @@
import { computed } from 'vue'
import { defineStore } from 'pinia'
import { useUserStore } from '@KTXC'
const MESSAGE_READ_ENABLED_KEY = 'mail.behaviour.messageReadEnabled'
const MESSAGE_READ_DELAY_KEY = 'mail.behaviour.messageReadDelay'
const FOLDER_VIEW_MODE_KEY = 'mail.folderViewMode'
const MESSAGE_LIST_DENSITY_KEY = 'mail.messageListDensity'
const MESSAGE_LIST_WIDTH_KEY = 'mail.messageListWidth'
const DEFAULT_MESSAGE_READ_ENABLED = false
const DEFAULT_MESSAGE_READ_DELAY = 5
const DEFAULT_FOLDER_VIEW_MODE = 'tree'
const DEFAULT_MESSAGE_LIST_DENSITY = 'cozy'
const DEFAULT_MESSAGE_LIST_WIDTH = 35
export type FolderViewMode = 'tree' | 'page'
export type MessageListDensity = 'compact' | 'cozy' | 'comfortable'
export const messageListDensityOptions = [
{ value: 'compact', title: 'Compact' },
{ value: 'cozy', title: 'Cozy' },
{ value: 'comfortable', title: 'Comfortable' },
]
export const messageListDensityConfig: Record<MessageListDensity, {
itemHeight: number
lines: 'one' | 'two' | 'three'
showSubjectLine: boolean
showThirdLine: boolean
}> = {
compact: { itemHeight: 56, lines: 'one', showSubjectLine: false, showThirdLine: false },
cozy: { itemHeight: 72, lines: 'two', showSubjectLine: true, showThirdLine: false },
comfortable: { itemHeight: 96, lines: 'three', showSubjectLine: true, showThirdLine: true },
}
// Width of the message list panel, as a percentage of the split area. Only applies to
// the side-by-side (vertical reading pane) view; future layout views own their own sizing.
export type MessageListWidth = 25 | 35 | 50
export const messageListWidthOptions = [
{ value: 25, title: 'Narrow' },
{ value: 35, title: 'Medium' },
{ value: 50, title: 'Wide' },
]
export const messageReadDelayOptions = [
{ value: 2, title: '2 seconds' },
{ value: 5, title: '5 seconds' },
{ value: 10, title: '10 seconds' },
{ value: 30, title: '30 seconds' },
]
export const folderViewModeOptions = [
{ value: 'tree', title: 'Tree' },
{ value: 'page', title: 'Page' },
]
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') {
return value
}
return fallback
}
function normalizePositiveNumber(value: unknown, fallback: number): number {
const normalized = Number(value)
return Number.isFinite(normalized) && normalized > 0 ? normalized : fallback
}
function normalizeFolderViewMode(value: unknown, fallback: FolderViewMode): FolderViewMode {
return value === 'tree' || value === 'page' ? value : fallback
}
function normalizeMessageListDensity(value: unknown, fallback: MessageListDensity): MessageListDensity {
return value === 'compact' || value === 'cozy' || value === 'comfortable' ? value : fallback
}
function normalizeMessageListWidth(value: unknown, fallback: MessageListWidth): MessageListWidth {
return value === 25 || value === 35 || value === 50 ? value : fallback
}
export const useMailSettingsStore = defineStore('mailSettingsStore', () => {
const userStore = useUserStore()
const messageReadEnabled = computed({
get: () => normalizeBoolean(userStore.getSetting(MESSAGE_READ_ENABLED_KEY), DEFAULT_MESSAGE_READ_ENABLED),
set: (value: boolean) => userStore.setSetting(MESSAGE_READ_ENABLED_KEY, value),
})
const messageReadDelay = computed({
get: () => normalizePositiveNumber(userStore.getSetting(MESSAGE_READ_DELAY_KEY), DEFAULT_MESSAGE_READ_DELAY),
set: (value: number) => userStore.setSetting(
MESSAGE_READ_DELAY_KEY,
normalizePositiveNumber(value, DEFAULT_MESSAGE_READ_DELAY),
),
})
const folderViewMode = computed({
get: () => normalizeFolderViewMode(userStore.getSetting(FOLDER_VIEW_MODE_KEY), DEFAULT_FOLDER_VIEW_MODE),
set: (value: FolderViewMode) => userStore.setSetting(
FOLDER_VIEW_MODE_KEY,
normalizeFolderViewMode(value, DEFAULT_FOLDER_VIEW_MODE),
),
})
const messageListDensity = computed({
get: () => normalizeMessageListDensity(userStore.getSetting(MESSAGE_LIST_DENSITY_KEY), DEFAULT_MESSAGE_LIST_DENSITY),
set: (value: MessageListDensity) => userStore.setSetting(
MESSAGE_LIST_DENSITY_KEY,
normalizeMessageListDensity(value, DEFAULT_MESSAGE_LIST_DENSITY),
),
})
const messageListWidth = computed({
get: () => normalizeMessageListWidth(userStore.getSetting(MESSAGE_LIST_WIDTH_KEY), DEFAULT_MESSAGE_LIST_WIDTH),
set: (value: MessageListWidth) => userStore.setSetting(
MESSAGE_LIST_WIDTH_KEY,
normalizeMessageListWidth(value, DEFAULT_MESSAGE_LIST_WIDTH),
),
})
return {
folderViewMode,
messageListDensity,
messageListWidth,
messageReadEnabled,
messageReadDelay,
}
})
+593
View File
@@ -0,0 +1,593 @@
import { ref, computed, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync'
import { useSnackbar } from '@KTXC'
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models'
export const useMailStore = defineStore('mailStore', () => {
const servicesStore = useServicesStore()
const collectionsStore = useCollectionsStore()
const entitiesStore = useEntitiesStore()
const { showSnackbar } = useSnackbar()
// Background mail sync
const mailSyncController = useMailSync({
interval: 30000,
autoStart: false,
fetchDetails: true,
})
const mailSync = {
isRunning: mailSyncController.isRunning,
lastSync: mailSyncController.lastSync,
error: mailSyncController.error,
sync: mailSyncController.sync,
start: mailSyncController.start,
stop: mailSyncController.stop,
restart: mailSyncController.restart,
}
// ── General State ─────────────────-───────────────────────────────────────
const sidebarVisible = ref(true)
const settingsDialogVisible = ref(false)
const loading = ref(false)
const serviceFolderLoadingState = ref<Record<string, boolean>>({})
const serviceFolderLoadedState = ref<Record<string, boolean>>({})
const serviceFolderErrorState = ref<Record<string, string | null>>({})
// ── Selection State ───────────────────────────────────────────────────────
const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null)
const selectionMode = ref(false)
const selectionList = ref<EntityIdentifier[]>([])
// ── Compose State ─────────────────────────────────────────────────────────
const composeMode = ref(false)
const composeReplyTo = shallowRef<EntityObject | null>(null)
// ── Move State ────────────────────────────────────────────────────────────
const moveDialogVisible = ref(false)
const moveDialogService = ref<ServiceIdentifier | null>(null)
const moveDialogCandidates = ref<EntityIdentifier[] | null>(null)
// ── Computed ──────────────────────────────────────────────────────────────
const currentMessages = computed(() => {
if (!selectedFolder.value) return []
const folder = selectedFolder.value
return entitiesStore.entities.filter(e =>
e.provider === folder.provider &&
String(e.service) === String(folder.service) &&
String(e.collection) === String(folder.identifier),
)
})
// ── Initialization ────────────────────────────────────────────────────────
async function initialize() {
loading.value = true
try {
await servicesStore.list()
const services = [...servicesStore.servicesEnabled]
services.forEach(service => {
void loadFoldersForService(service,{ selectInbox: true })
})
} catch (error) {
console.error('[Mail] Failed to initialize:', error)
} finally {
loading.value = false
}
}
async function loadFoldersForService(
service: ServiceObject,
options: { selectInbox?: boolean } = {},
) {
if (service.identifier === null) {
return
}
_setServiceFolderLoading(service.provider, service.identifier, true)
_setServiceFolderError(service.provider, service.identifier, null)
try {
// retrieve folders for service
const collections = await collectionsStore.list({
[service.provider]: {
[String(service.identifier)]: true,
},
})
_setServiceFolderLoaded(service.provider, service.identifier, true)
if (options.selectInbox && !selectedFolder.value) {
const inbox = Object.values(collections).find(
folder =>
folder.provider === service.provider &&
String(folder.service) === String(service.identifier) &&
(folder.properties.role === 'inbox' ||
String(folder.identifier).toLowerCase() === 'inbox'),
)
if (inbox) {
await selectFolder(inbox)
}
}
_updateSyncSources()
return collections
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load folders'
_setServiceFolderError(service.provider, service.identifier, message)
console.error(
`[Mail] Failed to load folders for ${service.provider}:${String(service.identifier)}:`,
error,
)
_updateSyncSources()
return {}
} finally {
_setServiceFolderLoading(service.provider, service.identifier, false)
}
}
// ── Helpers ──────────────────────────────────────────────────────────
function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier {
return `${item.provider}:${String(item.service)}` as ServiceIdentifier
}
function _collectionIdentifier(item: CollectionObject | EntityObject): CollectionIdentifier {
return `${item.provider}:${String(item.service)}:${String(item.identifier)}` as CollectionIdentifier
}
function _entityIdentifier(item: EntityObject): EntityIdentifier {
return `${item.provider}:${String(item.service)}:${String(item.collection)}:${String(item.identifier)}` as EntityIdentifier
}
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
serviceFolderLoadingState.value = {
...serviceFolderLoadingState.value,
[_serviceIdentifier({ provider, service })]: loadingState,
}
}
function _setServiceFolderLoaded(provider: string, service: string | number, loaded: boolean) {
serviceFolderLoadedState.value = {
...serviceFolderLoadedState.value,
[_serviceIdentifier({ provider, service })]: loaded,
}
}
function _setServiceFolderError(provider: string, service: string | number, error: string | null) {
serviceFolderErrorState.value = {
...serviceFolderErrorState.value,
[_serviceIdentifier({ provider, service })]: error,
}
}
function _updateSyncSources() {
mailSyncController.clearSources()
// Track the currently selected folder
if (selectedFolder.value) {
mailSyncController.addSource({
provider: selectedFolder.value.provider,
service: selectedFolder.value.service,
collections: [selectedFolder.value.identifier],
})
}
// Always track inboxes for each account (for new-mail notifications)
servicesStore.servicesEnabled.forEach(service => {
const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter(
c =>
String(c.service) === String(service.identifier) &&
(c.properties.role === 'inbox' ||
String(c.identifier).toLowerCase() === 'inbox'),
)
if (inboxes.length > 0) {
mailSyncController.addSource({
provider: service.provider,
service: service.identifier as string | number,
collections: inboxes.map(inbox => inbox.identifier),
})
}
})
if (mailSyncController.sources.value.length > 0 && !mailSyncController.isRunning.value) {
mailSyncController.start()
}
}
function isServiceFolderLoading(provider: string, service: string | number) {
return serviceFolderLoadingState.value[_serviceIdentifier({ provider, service })] === true
}
function hasServiceFoldersLoaded(provider: string, service: string | number) {
return serviceFolderLoadedState.value[_serviceIdentifier({ provider, service })] === true
}
function getServiceFolderError(provider: string, service: string | number) {
return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
}
function _reloadFolderMessages(folder: CollectionObject) {
return entitiesStore.list({
[folder.provider]: {
[String(folder.service)]: {
[String(folder.identifier)]: true,
},
},
})
}
function _setSelectionList(nextIds: EntityIdentifier[]) {
selectionList.value = Array.from(new Set(nextIds))
if (selectionList.value.length === 0) {
selectionMode.value = false
}
}
function _reconcileSelection() {
if (!selectedFolder.value) {
clearSelection()
selectedMessage.value = null
return
}
const currentMessageIdentifiers = new Set(currentMessages.value.map(message => _entityIdentifier(message)))
const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
if (nextSelectedIds.length !== selectionList.value.length) {
_setSelectionList(nextSelectedIds)
}
if (selectedMessage.value && !currentMessageIdentifiers.has(_entityIdentifier(selectedMessage.value))) {
selectedMessage.value = null
}
}
function _formatMoveNotification(successCount: number, failureCount: number, targetFolder: CollectionObject) {
const folderLabel = targetFolder.properties.label || String(targetFolder.identifier)
if (failureCount === 0) {
return {
message: successCount === 1
? `Message moved to "${folderLabel}"`
: `${successCount} messages moved to "${folderLabel}"`,
color: 'success' as const,
}
}
return {
message: successCount === 0
? `Move failed for ${failureCount === 1 ? '1 message' : `${failureCount} messages`}`
: `Moved ${successCount} ${successCount === 1 ? 'message' : 'messages'} to "${folderLabel}". ${failureCount} failed.`,
color: successCount === 0 ? 'error' as const : 'warning' as const,
}
}
watch(currentMessages, () => {
_reconcileSelection()
})
// ── Actions ───────────────────────────────────────────────────────────────
async function selectFolder(folder: CollectionObject) {
selectedFolder.value = folder
selectedMessage.value = null
clearSelection()
selectionMode.value = false
composeMode.value = false
try {
await _reloadFolderMessages(folder)
} catch (error) {
console.error('[Mail] Failed to load messages:', error)
}
_updateSyncSources()
}
function clearSelectedFolder() {
selectedFolder.value = null
selectedMessage.value = null
clearSelection()
selectionMode.value = false
composeMode.value = false
composeReplyTo.value = null
_updateSyncSources()
}
function selectMessage(entity: EntityObject, closeSidebar = false) {
selectedMessage.value = entity
composeMode.value = false
if (closeSidebar) {
sidebarVisible.value = false
}
}
function openCompose(replyTo?: EntityObject) {
composeMode.value = true
composeReplyTo.value = replyTo ?? null
selectedMessage.value = null
}
function closeCompose() {
composeMode.value = false
composeReplyTo.value = null
}
async function afterSent() {
composeMode.value = false
composeReplyTo.value = null
// Reload the current folder so the sent message appears in Sent
if (selectedFolder.value) {
await selectFolder(selectedFolder.value)
}
}
function isMessageSelected(message: EntityObject) {
return selectionList.value.includes(_entityIdentifier(message))
}
function toggleMessageSelection(message: EntityObject) {
const identifier = _entityIdentifier(message)
selectionMode.value = true
if (selectionList.value.includes(identifier)) {
_setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier))
return
}
_setSelectionList([...selectionList.value, identifier])
}
function selectAllCurrentMessages() {
selectionMode.value = true
_setSelectionList(currentMessages.value.map(message => _entityIdentifier(message)))
}
function activateSelectionMode(message?: EntityObject) {
selectionMode.value = true
if (message) {
const identifier = _entityIdentifier(message)
if (!selectionList.value.includes(identifier)) {
_setSelectionList([...selectionList.value, identifier])
}
}
}
function deactivateSelectionMode() {
selectionMode.value = false
clearSelection()
}
function clearSelection() {
_setSelectionList([])
}
function openMoveDialog(entities?: EntityObject | EntityObject[]) {
moveDialogCandidates.value = []
if (entities) {
if (Array.isArray(entities)) {
moveDialogCandidates.value = entities.map(entity => _entityIdentifier(entity))
moveDialogService.value = _serviceIdentifier(entities[0])
} else {
moveDialogCandidates.value = [_entityIdentifier(entities)]
moveDialogService.value = _serviceIdentifier(entities)
}
} else {
moveDialogCandidates.value = selectionList.value
moveDialogService.value = _serviceIdentifier(selectedFolder.value)
}
moveDialogVisible.value = true
}
function closeMoveDialog() {
moveDialogVisible.value = false
moveDialogService.value = null
moveDialogCandidates.value = null
}
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
const movableIdentifiers = entityIdentifiers.filter(identifier => {
const entity = entitiesStore.entityByIdentifier(identifier)
if (!entity) {
return false
}
// Only allow moving messages within the same service and disallow moving into the same folder
return entity.provider === target.provider &&
String(entity.service) === String(target.service) &&
String(entity.collection) !== String(target.identifier)
})
if (movableIdentifiers.length === 0) {
closeMoveDialog()
return
}
loading.value = true
try {
const response = await entitiesStore.move(_collectionIdentifier(target), movableIdentifiers)
const operationSucceeded: EntityIdentifier[] = []
const operationFailures: EntityIdentifier[] = []
Object.entries(response).forEach(([sourceIdentifier, result]) => {
if (result.success) {
operationSucceeded.push(sourceIdentifier as EntityIdentifier)
return
}
operationFailures.push(sourceIdentifier as EntityIdentifier)
})
if (operationSucceeded.length === 0) {
throw new Error(operationFailures[0] ?? 'Failed to move messages')
}
if (selectedMessage.value && operationSucceeded.includes(_entityIdentifier(selectedMessage.value))) {
selectedMessage.value = null
}
clearSelection()
closeMoveDialog()
const notification = _formatMoveNotification(operationSucceeded.length, operationFailures.length, target)
notify(notification.message, notification.color)
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
console.error('[Mail] Failed to move messages:', error)
notify(messageText, 'error')
throw error
} finally {
loading.value = false
}
}
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
if (entityIdentifiers.length === 0) {
return
}
loading.value = true
try {
const response = await entitiesStore.delete(entityIdentifiers)
const operationSucceeded: EntityIdentifier[] = []
const operationFailures: EntityIdentifier[] = []
Object.entries(response).forEach(([sourceIdentifier, result]) => {
if (result.success) {
operationSucceeded.push(sourceIdentifier as EntityIdentifier)
return
}
operationFailures.push(sourceIdentifier as EntityIdentifier)
})
if (operationSucceeded.length === 0) {
throw new Error(operationFailures[0] ?? 'Failed to delete messages')
}
if (selectedMessage.value && operationSucceeded.includes(_entityIdentifier(selectedMessage.value))) {
selectedMessage.value = null
}
clearSelection()
const successCount = operationSucceeded.length
const failureCount = operationFailures.length
if (failureCount === 0) {
notify(
successCount === 1 ? 'Message deleted' : `${successCount} messages deleted`,
'success',
)
} else {
notify(
successCount === 0
? `Delete failed for ${failureCount === 1 ? '1 message' : `${failureCount} messages`}`
: `Deleted ${successCount} ${successCount === 1 ? 'message' : 'messages'}. ${failureCount} failed.`,
successCount === 0 ? 'error' : 'warning',
)
}
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to delete messages'
console.error('[Mail] Failed to delete messages:', error)
notify(messageText, 'error')
throw error
} finally {
loading.value = false
}
}
function toggleSidebar() {
sidebarVisible.value = !sidebarVisible.value
}
function openSettings() {
settingsDialogVisible.value = true
}
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
showSnackbar({ message, color })
}
// ── Exports ───────────────────────────────────────────────────────────────
return {
// Sub-stores (forwarded for template convenience)
collectionsStore,
entitiesStore,
servicesStore,
mailSync,
// State
sidebarVisible,
settingsDialogVisible,
loading,
selectedFolder,
selectedMessage,
selectionList,
selectionMode,
composeMode,
composeReplyTo,
moveDialogVisible,
moveDialogService,
moveDialogCandidates,
serviceFolderLoadingState,
serviceFolderLoadedState,
serviceFolderErrorState,
// Computed
currentMessages,
// Actions
selectFolder,
clearSelectedFolder,
selectMessage,
isMessageSelected,
activateSelectionMode,
deactivateSelectionMode,
toggleMessageSelection,
selectAllCurrentMessages,
clearSelection,
openCompose,
openMoveDialog,
closeMoveDialog,
closeCompose,
afterSent,
deleteMessages,
moveMessages,
toggleSidebar,
openSettings,
notify,
isServiceFolderLoading,
hasServiceFoldersLoaded,
getServiceFolderError,
loadFoldersForService,
initialize,
}
})
-791
View File
@@ -1,791 +0,0 @@
import { computed, ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { ComposerMode } from '@/types/composer'
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
import { EntityObject, type ServiceObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection'
import type { MessageInterface } from '@MailManager/types/message'
import type { MessageAddressInterface } from '@MailManager/types/message'
import { useSnackbar } from '@KTXC'
export const useMailUiStore = defineStore('mailUiStore', () => {
const collectionsStore = useCollectionsStore()
const mailOperationsStore = useMailOperationsStore()
const mailSettingsStore = useMailSettingsStore()
const { showSnackbar } = useSnackbar()
const sidebarVisible = ref(true)
const settingsDialogVisible = ref(false)
const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null)
const composerMode = ref<ComposerMode>(ComposerMode.Fresh)
const composerSource = shallowRef<EntityObject | MessageAddressInterface | null>(null)
const composerVisible = ref(false)
const selectionMode = ref(false)
const selectionList = ref<EntityIdentifier[]>([])
const moveMessagesDialogVisible = ref(false)
const moveMessagesDialogService = shallowRef<ServiceObject | null>(null)
const moveMessagesDialogCandidates = ref<EntityIdentifier[] | null>(null)
const createFolderDialogVisible = ref(false)
const createFolderDialogService = shallowRef<ServiceObject | null>(null)
const createFolderDialogParent = shallowRef<CollectionObject | null>(null)
const createFolderDialogLoading = ref(false)
const createFolderDialogError = ref('')
const renameFolderDialogVisible = ref(false)
const renameFolderDialogService = shallowRef<ServiceObject | null>(null)
const renameFolderDialogFolder = shallowRef<CollectionObject | null>(null)
const renameFolderDialogLoading = ref(false)
const renameFolderDialogError = ref('')
const moveFolderDialogVisible = ref(false)
const moveFolderDialogService = shallowRef<ServiceObject | null>(null)
const moveFolderDialogSource = shallowRef<CollectionObject | null>(null)
const deleteFolderDialogVisible = ref(false)
const deleteFolderDialogService = shallowRef<ServiceObject | null>(null)
const deleteFolderDialogFolder = shallowRef<CollectionObject | null>(null)
const deleteFolderDialogLoading = ref(false)
const deleteFolderDialogError = ref('')
const messageReadIdentifier = ref<EntityIdentifier | null>(null)
const messageReadTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const createFolderDialogParentLabel = computed(() => {
return createFolderDialogParent.value?.properties.label || 'Root'
})
const renameFolderDialogParentLabel = computed(() => {
const folder = renameFolderDialogFolder.value
if (!folder || folder.collection === null || folder.collection === undefined) {
return 'Root'
}
const parent = collectionsStore.collectionsInCollection(folder.provider, folder.service, null)
.flatMap(rootFolder => [rootFolder, ...collectionsStore.collectionsForService(folder.provider, folder.service)])
.find(candidate => String(candidate.identifier) === String(folder.collection))
return parent?.properties.label || 'Root'
})
const moveFolderDialogInvalidFolderKeys = computed(() => {
const sourceFolder = moveFolderDialogSource.value
if (!sourceFolder) {
return []
}
const invalidKeys = new Set<string>()
const queue = [sourceFolder]
while (queue.length > 0) {
const currentFolder = queue.shift()
if (!currentFolder) {
continue
}
invalidKeys.add(String(currentFolder.identifier))
collectionsStore
.collectionsInCollection(currentFolder.provider, currentFolder.service, currentFolder.identifier)
.forEach(childFolder => {
queue.push(childFolder)
})
}
return Array.from(invalidKeys)
})
watch(
() => mailOperationsStore.selectedMessage,
message => {
if (message) {
closeComposer()
}
selectMessage(message)
},
)
watch(
() => mailOperationsStore.currentMessages,
() => {
messageSelectionReconcile()
},
)
async function initialize() {
await mailOperationsStore.initialize()
if (!selectedFolder.value) {
const inbox = mailOperationsStore.findFoldersByRole('inbox')[0] ?? null
if (inbox) {
await selectFolder(inbox)
}
}
}
function sidebarToggle() {
sidebarVisible.value = !sidebarVisible.value
}
function sidebarHide() {
sidebarVisible.value = false
}
function settingsOpen() {
settingsDialogVisible.value = true
}
function settingsClose() {
settingsDialogVisible.value = false
}
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
showSnackbar({ message, color })
}
function notifyDeleteMessagesResult(successes: number, failures: number) {
if (failures === 0) {
notify(successes === 1 ? 'Message deleted' : `${successes} messages deleted`, 'success')
return
}
notify(
successes === 0
? `Delete failed for ${failures === 1 ? '1 message' : `${failures} messages`}`
: `Deleted ${successes} ${successes === 1 ? 'message' : 'messages'}. ${failures} failed.`,
successes === 0 ? 'error' : 'warning',
)
}
function notifyFlagMessagesResult(successes: number, failures: number) {
if (failures === 0) {
notify(successes === 1 ? 'Message updated' : `${successes} messages updated`, 'success')
return
}
notify(
successes === 0
? `Update failed for ${failures === 1 ? '1 message' : `${failures} messages`}`
: `Updated ${successes} ${successes === 1 ? 'message' : 'messages'}. ${failures} failed.`,
successes === 0 ? 'error' : 'warning',
)
}
function notifyMoveMessagesResult(successes: number, failures: number) {
if (failures === 0) {
notify(successes === 1 ? 'Message moved' : `${successes} messages moved`, 'success')
return
}
notify(
successes === 0
? `Move failed for ${failures === 1 ? '1 message' : `${failures} messages`}`
: `Moved ${successes} ${successes === 1 ? 'message' : 'messages'}. ${failures} failed.`,
successes === 0 ? 'error' : 'warning',
)
}
function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
if (!left || !right) {
return false
}
return left.provider === right.provider &&
String(left.service) === String(right.service) &&
String(left.identifier) === String(right.identifier)
}
async function selectFolder(folder: CollectionObject | null) {
closeComposer()
messageSelectionModeDeactivate()
clearMessageReadTimer()
selectedMessage.value = null
selectedFolder.value = folder
await mailOperationsStore.selectFolder(folder)
}
async function selectMessage(message: EntityObject | null) {
messageSelectionModeDeactivate()
createMessageReadTimer(message)
selectedMessage.value = message
}
function createMessageReadTimer(entity: EntityObject | null) {
clearMessageReadTimer()
if (!entity) {
return
}
if (entity.properties.isRead || !mailSettingsStore.messageReadEnabled) {
return
}
const delayMilliseconds = mailSettingsStore.messageReadDelay * 1000
if (delayMilliseconds <= 0) {
return
}
messageReadIdentifier.value = entity.identifier
messageReadTimer.value = setTimeout(() => {
void completeMessageRead(entity.identifier)
}, delayMilliseconds)
}
function clearMessageReadTimer() {
if (messageReadTimer.value !== null) {
clearTimeout(messageReadTimer.value)
}
messageReadTimer.value = null
messageReadIdentifier.value = null
}
async function completeMessageRead(identifier: EntityIdentifier) {
try {
if (selectedMessage.value && selectedMessage.value.identifier === identifier && selectedMessage.value.properties.isRead === false) {
await flagMessages([selectedMessage.value.identifier], { seen: true }, { notify: false })
}
} catch (error) {
console.error('[Mail][UI] Failed to auto-mark message as read:', error)
} finally {
clearMessageReadTimer()
}
}
function openCreateFolderDialog(service: ServiceObject, parentFolder: CollectionObject | null = null) {
createFolderDialogService.value = service
createFolderDialogParent.value = parentFolder
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
createFolderDialogVisible.value = true
}
function closeCreateFolderDialog() {
createFolderDialogVisible.value = false
createFolderDialogService.value = null
createFolderDialogParent.value = null
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
}
async function confirmCreateFolder(label: string) {
const service = createFolderDialogService.value
if (!service) {
return null
}
createFolderDialogLoading.value = true
createFolderDialogError.value = ''
try {
const folder = await mailOperationsStore.createFolder(service, label, createFolderDialogParent.value)
notify(`Folder "${folder.properties.label || label.trim()}" created`, 'success')
closeCreateFolderDialog()
return folder
} catch (error) {
createFolderDialogError.value = error instanceof Error ? error.message : 'Failed to create folder. Please try again.'
throw error
} finally {
createFolderDialogLoading.value = false
}
}
async function openRenameFolderDialog(target: CollectionObject) {
const service = await mailOperationsStore.retrieveService(target.service)
renameFolderDialogService.value = service
renameFolderDialogFolder.value = target
renameFolderDialogError.value = ''
renameFolderDialogLoading.value = false
renameFolderDialogVisible.value = true
}
function closeRenameFolderDialog() {
renameFolderDialogVisible.value = false
renameFolderDialogService.value = null
renameFolderDialogFolder.value = null
renameFolderDialogError.value = ''
renameFolderDialogLoading.value = false
}
async function confirmRenameFolder(label: string) {
const folder = renameFolderDialogFolder.value
if (!folder) {
return null
}
renameFolderDialogLoading.value = true
renameFolderDialogError.value = ''
try {
const updatedFolder = await mailOperationsStore.renameFolder(folder, label)
if (_sameCollection(selectedFolder.value, folder)) {
selectedFolder.value = updatedFolder
}
notify(
`Folder "${folder.properties.label || String(folder.identifier)}" renamed to "${updatedFolder.properties.label || label.trim()}"`,
'success',
)
closeRenameFolderDialog()
return updatedFolder
} catch (error) {
renameFolderDialogError.value = error instanceof Error ? error.message : 'Failed to rename folder. Please try again.'
throw error
} finally {
renameFolderDialogLoading.value = false
}
}
async function openMoveFolderDialog(source: CollectionObject) {
const service = await mailOperationsStore.retrieveService(source.service)
moveFolderDialogService.value = service
moveFolderDialogSource.value = source
moveFolderDialogVisible.value = true
}
function closeMoveFolderDialog() {
moveFolderDialogVisible.value = false
moveFolderDialogService.value = null
moveFolderDialogSource.value = null
}
async function confirmMoveFolder(target: CollectionObject) {
const source = moveFolderDialogSource.value
if (!source) {
return null
}
const movedFolder = await mailOperationsStore.moveFolder(source, target)
if (_sameCollection(selectedFolder.value, source)) {
selectedFolder.value = movedFolder
}
notify(
`Folder "${source.properties.label || String(source.identifier)}" moved to "${target.properties.label || String(target.identifier)}"`,
'success',
)
closeMoveFolderDialog()
return movedFolder
}
async function openDeleteFolderDialog(target: CollectionObject) {
const service = await mailOperationsStore.retrieveService(target.service)
deleteFolderDialogService.value = service
deleteFolderDialogFolder.value = target
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
deleteFolderDialogVisible.value = true
}
function closeDeleteFolderDialog() {
deleteFolderDialogVisible.value = false
deleteFolderDialogService.value = null
deleteFolderDialogFolder.value = null
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
}
async function confirmDeleteFolder() {
const folder = deleteFolderDialogFolder.value
if (!folder) {
return null
}
deleteFolderDialogLoading.value = true
deleteFolderDialogError.value = ''
try {
const deleted = await mailOperationsStore.deleteFolder(folder)
if (_sameCollection(selectedFolder.value, folder)) {
selectFolder(null)
}
notify(`Folder "${folder.properties.label || String(folder.identifier)}" deleted`, 'success')
closeDeleteFolderDialog()
return deleted
} catch (error) {
deleteFolderDialogError.value = error instanceof Error ? error.message : 'Failed to delete folder. Please try again.'
throw error
} finally {
deleteFolderDialogLoading.value = false
}
}
function validateFolderNameBase(service: ServiceObject, name: string): string[] {
const errors: string[] = []
if (!name || name.trim().length === 0) {
errors.push('Folder name is required')
return errors
}
if (name.length > 255) {
errors.push('Folder name too long (max 255 characters)')
}
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
errors.push('Folder name contains invalid characters')
}
if (service.provider === 'imap' && /[\/\\]/.test(name)) {
errors.push('IMAP folder names cannot contain / or \\')
}
if (name !== name.trim()) {
errors.push('Folder name cannot have leading or trailing spaces')
}
return errors
}
function validateCreateFolderName(name: string): string[] {
const service = createFolderDialogService.value
if (!service || service.identifier === null) {
return ['Folder service is unavailable']
}
const errors = validateFolderNameBase(service, name)
if (errors.length > 0) {
return errors
}
const parentIdentifier = createFolderDialogParent.value?.identifier ?? null
const duplicate = collectionsStore
.collectionsInCollection(service.provider, service.identifier, parentIdentifier)
.some(folder => folder.properties.label === name)
if (duplicate) {
errors.push('A folder with this name already exists in this location')
}
return errors
}
function validateRenameFolderName(name: string): string[] {
const service = renameFolderDialogService.value
const folder = renameFolderDialogFolder.value
if (!service || !folder || service.identifier === null) {
return ['Folder service is unavailable']
}
const errors = validateFolderNameBase(service, name)
if (errors.length > 0) {
return errors
}
const parentIdentifier = folder.collection ?? null
const duplicate = collectionsStore
.collectionsInCollection(service.provider, service.identifier, parentIdentifier)
.some(candidate =>
String(candidate.identifier) !== String(folder.identifier) &&
candidate.properties.label === name,
)
if (duplicate) {
errors.push('A folder with this name already exists in this location')
}
return errors
}
function setSelectionList(nextIds: EntityIdentifier[]) {
selectionList.value = Array.from(new Set(nextIds))
}
function openComposer(source?: EntityObject | MessageAddressInterface, mode: ComposerMode = ComposerMode.Fresh) {
mailOperationsStore.selectMessage(null)
composerSource.value = source ?? null
composerMode.value = mode
composerVisible.value = true
}
function closeComposer() {
composerMode.value = ComposerMode.Fresh
composerSource.value = null
composerVisible.value = false
}
function messageSelectionClear() {
setSelectionList([])
}
function messageSelectionModeActivate(message?: EntityObject) {
selectionMode.value = true
if (!message) {
return
}
const identifier = message.identifier
if (!selectionList.value.includes(identifier)) {
setSelectionList([...selectionList.value, identifier])
}
}
function messageSelectionModeDeactivate() {
selectionMode.value = false
messageSelectionClear()
}
function messageSelectionToggleOne(message: EntityObject) {
const identifier = message.identifier
selectionMode.value = true
if (selectionList.value.includes(identifier)) {
setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier))
return
}
setSelectionList([...selectionList.value, identifier])
}
function messageSelectionToggleAll(value: boolean) {
selectionMode.value = true
if (value) {
setSelectionList(mailOperationsStore.currentMessages.map(message => message.identifier))
} else {
setSelectionList([])
}
}
function messageSelectionReconcile() {
if (!selectedFolder.value) {
messageSelectionClear()
return
}
const currentMessageIdentifiers = new Set(mailOperationsStore.currentMessages.map(message => message.identifier))
const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
if (nextSelectedIds.length !== selectionList.value.length) {
setSelectionList(nextSelectedIds)
}
if (nextSelectedIds.length === 0) {
selectionMode.value = false
}
}
async function openMoveMessagesDialog(entities?: EntityObject | EntityObject[]) {
let moveMessagesServiceIdentifier = null as ServiceIdentifier | null
moveMessagesDialogCandidates.value = []
if (entities) {
if (Array.isArray(entities)) {
moveMessagesDialogCandidates.value = entities.map(entity => entity.identifier)
moveMessagesServiceIdentifier = entities[0]?.service as ServiceIdentifier || null
} else {
moveMessagesDialogCandidates.value = [entities.identifier]
moveMessagesServiceIdentifier = entities.service as ServiceIdentifier || null
}
} else {
moveMessagesDialogCandidates.value = [...selectionList.value]
moveMessagesServiceIdentifier = selectedFolder.value?.service as ServiceIdentifier || null
}
moveMessagesDialogService.value = await mailOperationsStore.retrieveService(moveMessagesServiceIdentifier)
moveMessagesDialogVisible.value = true
}
function closeMoveMessagesDialog() {
moveMessagesDialogVisible.value = false
moveMessagesDialogService.value = null
moveMessagesDialogCandidates.value = null
}
async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailOperationsStore.moveMessages>[0]) {
await moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? [])
messageSelectionModeDeactivate()
closeMoveMessagesDialog()
}
async function deleteSelectedMessages() {
await deleteMessages([...selectionList.value])
messageSelectionModeDeactivate()
}
async function flagSelectedMessages(flag: string, value: boolean) {
await flagMessages([...selectionList.value], { [flag]: value })
messageSelectionModeDeactivate()
}
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
try {
const result = await mailOperationsStore.deleteMessages(entityIdentifiers)
if (!result) {
return null
}
notifyDeleteMessagesResult(result.successes.length, result.failures.length)
return result
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to delete messages'
notify(messageText, 'error')
throw error
}
}
async function flagMessages(
entityIdentifiers: EntityIdentifier[],
flags: Partial<NonNullable<MessageInterface['flags']>>,
options: { notify?: boolean } = {},
) {
try {
const result = await mailOperationsStore.flagMessages(entityIdentifiers, flags)
if (!result) {
return null
}
if (options.notify ?? true) {
notifyFlagMessagesResult(result.successes.length, result.failures.length)
}
return result
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to update messages'
if (options.notify ?? true) {
notify(messageText, 'error')
}
throw error
}
}
async function flagMessage(entity: EntityObject, flag: string, value: boolean) {
return await flagMessages([entity.identifier], { [flag]: value })
}
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
try {
const result = await mailOperationsStore.moveMessages(target, entityIdentifiers)
if (!result) {
return null
}
notifyMoveMessagesResult(result.successes.length, result.failures.length)
return result
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
notify(messageText, 'error')
throw error
}
}
async function downloadMessage(entity: EntityObject, index?: number) {
try {
await mailOperationsStore.downloadMessage(entity, index)
} catch (error) {
const messageText = error instanceof Error
? error.message
: index === undefined
? 'Failed to download message'
: 'Failed to download attachment'
notify(messageText, 'error')
throw error
}
}
function attachmentBlob(entity: EntityObject, part: Parameters<typeof mailOperationsStore.attachmentBlob>[1]) {
return mailOperationsStore.attachmentBlob(entity, part)
}
return {
sidebarVisible,
settingsDialogVisible,
selectedFolder,
selectedMessage,
composerMode,
composerSource,
composerVisible,
selectionMode,
selectionList,
moveMessagesDialogVisible,
moveMessagesDialogService,
moveMessagesDialogCandidates,
createFolderDialogParentLabel,
createFolderDialogVisible,
createFolderDialogService,
createFolderDialogParent,
createFolderDialogLoading,
createFolderDialogError,
renameFolderDialogVisible,
renameFolderDialogService,
renameFolderDialogFolder,
renameFolderDialogParentLabel,
renameFolderDialogLoading,
renameFolderDialogError,
moveFolderDialogVisible,
moveFolderDialogService,
moveFolderDialogSource,
moveFolderDialogInvalidFolderKeys,
deleteFolderDialogVisible,
deleteFolderDialogService,
deleteFolderDialogFolder,
deleteFolderDialogLoading,
deleteFolderDialogError,
initialize,
sidebarToggle,
sidebarHide,
settingsOpen,
settingsClose,
notify,
selectFolder,
openComposer,
closeComposer,
deleteMessages,
flagMessages,
flagMessage,
moveMessages,
downloadMessage,
attachmentBlob,
messageSelectionModeActivate,
messageSelectionModeDeactivate,
messageSelectionToggleOne,
messageSelectionToggleAll,
messageSelectionClear,
validateCreateFolderName,
validateRenameFolderName,
openMoveMessagesDialog,
closeMoveMessagesDialog,
confirmMoveMessages,
deleteSelectedMessages,
openCreateFolderDialog,
closeCreateFolderDialog,
confirmCreateFolder,
openRenameFolderDialog,
closeRenameFolderDialog,
confirmRenameFolder,
openMoveFolderDialog,
closeMoveFolderDialog,
confirmMoveFolder,
openDeleteFolderDialog,
closeDeleteFolderDialog,
confirmDeleteFolder,
flagSelectedMessages,
}
})
+1 -6
View File
@@ -3,8 +3,7 @@
.mail-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
height: 100vh;
isolation: isolate;
}
@@ -15,7 +14,6 @@
.mail-content {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
@@ -27,7 +25,6 @@
.mail-main {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
min-width: 0;
@@ -35,8 +32,6 @@
.mail-wrapper {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
overflow: hidden;
}
-48
View File
@@ -1,48 +0,0 @@
import type { ServiceObject } from "@MailManager/models/service"
import type { EntityIdentifier } from "@MailManager/types/common"
import type { MessageAddressInterface } from "@MailManager/types/message"
export enum ComposerMode {
Fresh = 'fresh',
Reply = 'reply',
Forward = 'forward',
}
export interface ComposerDraftAttachment {
identifier: string
composition: string
origin: 'source' | 'upload'
name: string
type: string
size: number
source: EntityIdentifier | null
}
export interface ComposerDraftMessage {
to: MessageAddressInterface[]
cc: MessageAddressInterface[]
bcc: MessageAddressInterface[]
subject: string
body: {
html: string
text: string
}
}
export interface ComposerDraft {
action: ComposerMode
identifier: string
revision: number
sender: ComposerSenderIdentity
source: EntityIdentifier | null
stageStatus: 'local' | 'staged'
stagedAt: Date | null
message: ComposerDraftMessage
attachments: Record<string, ComposerDraftAttachment>
}
export interface ComposerSenderIdentity {
service: ServiceObject
address: string
label: string | null
}
-119
View File
@@ -1,119 +0,0 @@
import type { EntityIdentifier } from '@MailManager/types/common'
import type { MessageAddressInterface } from '@MailManager/types/message'
export interface CompositionSenderInterface {
provider: string
service: string | number | null
address: string
name: string | null
}
export interface CompositionMessageInterface {
to: MessageAddressInterface[]
cc: MessageAddressInterface[]
bcc: MessageAddressInterface[]
subject: string
body: {
text: string
html: string
}
}
export interface CompositionAttachmentInterface {
identifier: string
composition: string
origin: 'source' | 'upload'
name: string
type: string
size: number
source: EntityIdentifier | null
data?: string
}
export interface CompositionStageRequest {
identifier: string
action: 'fresh' | 'reply' | 'forward'
sender: CompositionSenderInterface
source?: EntityIdentifier | null
message: CompositionMessageInterface
}
export interface CompositionStageResponse {
identifier: string
revision: number
disposition: 'staged'
attachments: Record<string, CompositionAttachmentInterface>
}
export interface CompositionPatchRequest {
identifier: string
revision: number
sender: CompositionSenderInterface
message: CompositionMessageInterface
}
export interface CompositionPatchResponse {
identifier: string
revision: number
message: CompositionMessageInterface
}
export interface CompositionDiscardRequest {
identifier: string
}
export interface CompositionDiscardResponse {
identifier: string
disposition: 'discarded' | 'error'
error?: {
type: string
message: string
}
}
export interface CompositionSendRequest {
identifier: string
revision: number
sender: CompositionSenderInterface
message: CompositionMessageInterface
attachments: Record<string, CompositionAttachmentInterface>
}
export interface CompositionSendResponse {
identifier: string
disposition: 'sent' | 'error'
error?: {
type: string
message: string
}
}
export interface CompositionAttachmentAddRequest {
composition: string
attachments: Record<string, CompositionAttachmentInterface>
}
export interface CompositionAttachmentAddResponse {
disposition: 'added' | 'error'
error?: {
type: string
message: string
}
composition: string
attachments: Record<string, CompositionAttachmentInterface>
}
export interface CompositionAttachmentRemoveRequest {
composition: string
identifier: string
}
export interface CompositionAttachmentRemoveResponse {
disposition: 'removed' | 'error'
error?: {
type: string
message: string
}
composition: string
identifier: string
}
-9
View File
@@ -1,9 +0,0 @@
import type { MessagePartInterface } from "@MailManager/types/message"
export interface MediaViewerItem {
id: string
title: string
mime: string
size?: number | null
meta: { index: number; attachment: MessagePartInterface }
}
-21
View File
@@ -1,21 +0,0 @@
export function formatFileSize(bytes: number | null | undefined): string {
if (bytes == null || !Number.isFinite(bytes)) {
return ''
}
if (bytes < 1024) {
return `${Math.max(0, Math.round(bytes))} B`
}
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes / 1024
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
const precision = value >= 10 || unitIndex === 0 ? 1 : 2
return `${value.toFixed(precision)} ${units[unitIndex]}`
}
-30
View File
@@ -1,30 +0,0 @@
import { describe, it, expect } from 'vitest'
describe('Basic Tests', () => {
it('should perform basic assertion', () => {
expect(true).toBe(true)
})
it('should test array operations', () => {
const array = ['foo', 'bar', 'baz']
expect(array).toHaveLength(3)
expect(array).toContain('bar')
expect(array[0]).toBe('foo')
})
it('should test string operations', () => {
const string = 'Hello, World!'
expect(string).toContain('World')
expect(string.length).toBe(13)
})
it('should test object operations', () => {
const obj = { foo: 'bar', count: 42 }
expect(obj).toHaveProperty('foo')
expect(obj.foo).toBe('bar')
expect(obj.count).toBeGreaterThan(40)
})
})
-33
View File
@@ -1,33 +0,0 @@
import { fileURLToPath } from 'node:url'
import { defineConfig, configDefaults } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, '../../src'),
'@KTXC': path.resolve(__dirname, '../../../../core/src'),
},
},
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('../../', import.meta.url)),
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**',
],
},
},
})
-29
View File
@@ -1,29 +0,0 @@
<?php
namespace KTXT\Mail\Tests\Integration;
use PHPUnit\Framework\TestCase;
class BaseTest extends TestCase
{
public function testBasicAssertion(): void
{
$this->assertTrue(true);
}
public function testArrayOperations(): void
{
$array = ['foo' => 'bar'];
$this->assertArrayHasKey('foo', $array);
$this->assertEquals('bar', $array['foo']);
}
public function testStringOperations(): void
{
$string = 'Hello, World!';
$this->assertStringContainsString('World', $string);
$this->assertEquals(13, strlen($string));
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
namespace KTXT\Mail\Tests\Unit;
use PHPUnit\Framework\TestCase;
class BaseTest extends TestCase
{
public function testBasicAssertion(): void
{
$this->assertTrue(true);
}
public function testArrayOperations(): void
{
$array = ['foo' => 'bar'];
$this->assertArrayHasKey('foo', $array);
$this->assertEquals('bar', $array['foo']);
}
public function testStringOperations(): void
{
$string = 'Hello, World!';
$this->assertStringContainsString('World', $string);
$this->assertEquals(13, strlen($string));
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
require dirname(__DIR__, 2).'/vendor/autoload.php';
// When this module is checked out inside a full server (server/modules/<handle>,
// as it is in CI and in this monorepo checkout), also load the server's own
// core/shared autoloader so tests can reference framework (KTXC/KTXF) types.
// Standalone module checkouts without a server alongside them skip this.
define('SERVER_ROOT', dirname(__DIR__, 4));
$serverAutoload = SERVER_ROOT . '/vendor/autoload.php';
if (is_file($serverAutoload)) {
require $serverAutoload;
}
if (isset($_SERVER['APP_DEBUG']) && $_SERVER['APP_DEBUG']) {
umask(0000);
}
-41
View File
@@ -1,41 +0,0 @@
<?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>
+2 -12
View File
@@ -1,15 +1,6 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"src/**/*",
"src/**/*.vue",
"src/utile/**/*.ts",
"../../core/src/**/*.ts",
"../mail_manager/src/**/*.ts",
"../mail_manager/src/**/*.vue",
"../people_manager/src/**/*.ts",
"../people_manager/src/**/*.vue"
],
"include": ["src/**/*", "src/**/*.vue", "../../core/src/**/*.ts"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
@@ -17,8 +8,7 @@
"@/*": ["./src/*"],
"@KTXC": ["../../core/src/shared/index.ts"],
"@KTXC/*": ["../../core/src/*"],
"@MailManager/*": ["../mail_manager/src/*"],
"@PeopleManager/*": ["../people_manager/src/*"]
"@MailManager/*": ["../mail_manager/src/*"]
}
}
}
-5
View File
@@ -31,7 +31,6 @@ 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: {
@@ -50,7 +49,6 @@ export default defineConfig({
'pinia',
'@KTXC',
/^@MailManager\//,
/^@PeopleManager\//,
],
output: {
paths: (id) => {
@@ -58,9 +56,6 @@ 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) => {