Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 539284d5b5 | |||
| 5c9e13f87b | |||
| 9508106d4f | |||
| da1e8e52b0 | |||
| 74d3654515 | |||
| 704e01fece | |||
| 45cb0bd817 | |||
| 33c44d62db | |||
| a5eb104bd7 | |||
| 03862ed0cf | |||
| 0c7f103353 |
Generated
+46
-34
@@ -9,20 +9,20 @@
|
|||||||
"packages-dev": [
|
"packages-dev": [
|
||||||
{
|
{
|
||||||
"name": "myclabs/deep-copy",
|
"name": "myclabs/deep-copy",
|
||||||
"version": "1.13.4",
|
"version": "1.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/myclabs/DeepCopy.git",
|
"url": "https://github.com/myclabs/DeepCopy.git",
|
||||||
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
|
"reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
|
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
|
||||||
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
|
"reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^7.1 || ^8.0"
|
"php": "^8.0"
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
"doctrine/collections": "<1.6.8",
|
"doctrine/collections": "<1.6.8",
|
||||||
@@ -57,15 +57,15 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/myclabs/DeepCopy/issues",
|
"issues": "https://github.com/myclabs/DeepCopy/issues",
|
||||||
"source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
|
"source": "https://github.com/myclabs/DeepCopy/tree/1.14.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
|
"url": "https://github.com/mnapoli",
|
||||||
"type": "tidelift"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-08-01T08:46:24+00:00"
|
"time": "2026-08-11T10:17:44+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nikic/php-parser",
|
"name": "nikic/php-parser",
|
||||||
@@ -332,23 +332,23 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-file-iterator",
|
"name": "phpunit/php-file-iterator",
|
||||||
"version": "6.0.1",
|
"version": "6.0.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
|
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
|
||||||
"reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5"
|
"reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
|
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5",
|
||||||
"reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
|
"reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3"
|
"php": ">=8.3"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^12.0"
|
"phpunit/phpunit": "^12.5.33"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@@ -381,7 +381,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
|
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
|
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1"
|
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -401,7 +401,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-02-02T14:04:18+00:00"
|
"time": "2026-08-25T14:40:53+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-invoker",
|
"name": "phpunit/php-invoker",
|
||||||
@@ -589,16 +589,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/phpunit",
|
"name": "phpunit/phpunit",
|
||||||
"version": "12.5.31",
|
"version": "12.5.35",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||||
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d"
|
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d",
|
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
|
||||||
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d",
|
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -608,18 +608,18 @@
|
|||||||
"ext-libxml": "*",
|
"ext-libxml": "*",
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"ext-xmlwriter": "*",
|
"ext-xmlwriter": "*",
|
||||||
"myclabs/deep-copy": "^1.13.4",
|
"myclabs/deep-copy": "^1.14.0",
|
||||||
"phar-io/manifest": "^2.0.4",
|
"phar-io/manifest": "^2.0.4",
|
||||||
"phar-io/version": "^3.2.1",
|
"phar-io/version": "^3.2.1",
|
||||||
"php": ">=8.3",
|
"php": ">=8.3",
|
||||||
"phpunit/php-code-coverage": "^12.5.7",
|
"phpunit/php-code-coverage": "^12.5.7",
|
||||||
"phpunit/php-file-iterator": "^6.0.1",
|
"phpunit/php-file-iterator": "^6.0.2",
|
||||||
"phpunit/php-invoker": "^6.0.0",
|
"phpunit/php-invoker": "^6.0.0",
|
||||||
"phpunit/php-text-template": "^5.0.0",
|
"phpunit/php-text-template": "^5.0.0",
|
||||||
"phpunit/php-timer": "^8.0.0",
|
"phpunit/php-timer": "^8.0.0",
|
||||||
"sebastian/cli-parser": "^4.2.1",
|
"sebastian/cli-parser": "^4.2.1",
|
||||||
"sebastian/comparator": "^7.1.8",
|
"sebastian/comparator": "^7.1.8",
|
||||||
"sebastian/diff": "^7.0.0",
|
"sebastian/diff": "^7.0.1",
|
||||||
"sebastian/environment": "^8.1.2",
|
"sebastian/environment": "^8.1.2",
|
||||||
"sebastian/exporter": "^7.0.3",
|
"sebastian/exporter": "^7.0.3",
|
||||||
"sebastian/global-state": "^8.0.3",
|
"sebastian/global-state": "^8.0.3",
|
||||||
@@ -667,7 +667,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31"
|
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.35"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -675,7 +675,7 @@
|
|||||||
"type": "other"
|
"type": "other"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-06T14:54:16+00:00"
|
"time": "2026-09-09T04:48:50+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/cli-parser",
|
"name": "sebastian/cli-parser",
|
||||||
@@ -898,24 +898,24 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/diff",
|
"name": "sebastian/diff",
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/diff.git",
|
"url": "https://github.com/sebastianbergmann/diff.git",
|
||||||
"reference": "7ab1ea946c012266ca32390913653d844ecd085f"
|
"reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f",
|
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
|
||||||
"reference": "7ab1ea946c012266ca32390913653d844ecd085f",
|
"reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3"
|
"php": ">=8.3"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^12.0",
|
"phpunit/phpunit": "^12.5.33",
|
||||||
"symfony/process": "^7.2"
|
"symfony/process": "^7.4.17"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@@ -953,15 +953,27 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/diff/issues",
|
"issues": "https://github.com/sebastianbergmann/diff/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/diff/security/policy",
|
"security": "https://github.com/sebastianbergmann/diff/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/diff/tree/7.0.0"
|
"source": "https://github.com/sebastianbergmann/diff/tree/7.0.1"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"url": "https://github.com/sebastianbergmann",
|
"url": "https://github.com/sebastianbergmann",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://liberapay.com/sebastianbergmann",
|
||||||
|
"type": "liberapay"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://thanks.dev/u/gh/sebastianbergmann",
|
||||||
|
"type": "thanks_dev"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/sebastian/diff",
|
||||||
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-02-07T04:55:46+00:00"
|
"time": "2026-08-25T15:35:54+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/environment",
|
"name": "sebastian/environment",
|
||||||
@@ -1698,5 +1710,5 @@
|
|||||||
"php": ">=8.3"
|
"php": ">=8.3"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,7 +440,14 @@ class CompositionManager {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$source = null;
|
// Reuse the synced remote draft (if any) so the provider can natively clean it up
|
||||||
|
$remoteEntity = $this->remoteEntityIdentifier($composed['remote']['entity'] ?? null);
|
||||||
|
$source = ($remoteEntity !== null
|
||||||
|
&& $remoteEntity->provider() === $service->provider()
|
||||||
|
&& (string)$remoteEntity->service() === (string)$service->identifier())
|
||||||
|
? $remoteEntity
|
||||||
|
: null;
|
||||||
|
|
||||||
$properties = $this->buildMessageProperties(
|
$properties = $this->buildMessageProperties(
|
||||||
service: $service,
|
service: $service,
|
||||||
sender: $senderObject,
|
sender: $senderObject,
|
||||||
@@ -465,6 +472,8 @@ class CompositionManager {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'identifier' => $identifier,
|
'identifier' => $identifier,
|
||||||
'disposition' => 'sent'
|
'disposition' => 'sent'
|
||||||
@@ -582,6 +591,7 @@ class CompositionManager {
|
|||||||
$deviceAttachmentsChanged = $uploads !== [];
|
$deviceAttachmentsChanged = $uploads !== [];
|
||||||
$documentAttachmentsChanged = count($failed) < count($documents);
|
$documentAttachmentsChanged = count($failed) < count($documents);
|
||||||
if ($deviceAttachmentsChanged || $documentAttachmentsChanged) {
|
if ($deviceAttachmentsChanged || $documentAttachmentsChanged) {
|
||||||
|
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
|
||||||
$composed = $this->markRemoteDirty($composed);
|
$composed = $this->markRemoteDirty($composed);
|
||||||
$compositionChanged = true;
|
$compositionChanged = true;
|
||||||
}
|
}
|
||||||
@@ -616,6 +626,7 @@ class CompositionManager {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
if ($compositionChanged) {
|
if ($compositionChanged) {
|
||||||
|
$result['revision'] = (int)($composed['revision'] ?? 0);
|
||||||
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
|
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
}
|
}
|
||||||
@@ -774,8 +785,10 @@ class CompositionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unset($composed['attachments'][$identifier]);
|
unset($composed['attachments'][$identifier]);
|
||||||
|
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
|
||||||
$composed = $this->markRemoteDirty($composed);
|
$composed = $this->markRemoteDirty($composed);
|
||||||
$result['disposition'] = 'removed';
|
$result['disposition'] = 'removed';
|
||||||
|
$result['revision'] = $composed['revision'];
|
||||||
return $composed;
|
return $composed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ final class CompositionController extends ControllerAbstract {
|
|||||||
if (!isset($data['action']) || !is_string($data['action'])) {
|
if (!isset($data['action']) || !is_string($data['action'])) {
|
||||||
throw new InvalidArgumentException('Invalid parameter: action must be a string');
|
throw new InvalidArgumentException('Invalid parameter: action must be a string');
|
||||||
}
|
}
|
||||||
if (!in_array($data['action'], ['fresh', 'reply', 'forward'], true)) {
|
if (!in_array($data['action'], ['fresh', 'reply', 'replyAll', 'forward'], true)) {
|
||||||
throw new InvalidArgumentException('Invalid parameter: action must be one of fresh, reply, forward');
|
throw new InvalidArgumentException('Invalid parameter: action must be one of fresh, reply, replyAll, forward');
|
||||||
}
|
}
|
||||||
if (!isset($data['sender']) || !is_array($data['sender'])) {
|
if (!isset($data['sender']) || !is_array($data['sender'])) {
|
||||||
throw new InvalidArgumentException('Invalid parameter: sender must be an array');
|
throw new InvalidArgumentException('Invalid parameter: sender must be an array');
|
||||||
@@ -89,8 +89,8 @@ final class CompositionController extends ControllerAbstract {
|
|||||||
if (!isset($data['message']) || !is_array($data['message'])) {
|
if (!isset($data['message']) || !is_array($data['message'])) {
|
||||||
throw new InvalidArgumentException('Invalid parameter: message must be an array');
|
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'] === '')) {
|
if (($data['action'] === 'reply' || $data['action'] === 'replyAll' || $data['action'] === 'forward') && (!isset($data['source']) || !is_string($data['source']) || $data['source'] === '')) {
|
||||||
throw new InvalidArgumentException('Invalid parameter: source must be a non-empty string for reply or forward action');
|
throw new InvalidArgumentException('Invalid parameter: source must be a non-empty string for reply, replyAll, or forward action');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->compositionManager->stage($tenantId, $userId, $data['identifier'], $data['action'], $data['sender'], $data['message'], $data['source'] ?? null);
|
return $this->compositionManager->stage($tenantId, $userId, $data['identifier'], $data['action'], $data['sender'], $data['message'], $data['source'] ?? null);
|
||||||
|
|||||||
+7
-12
@@ -4,13 +4,14 @@ namespace KTXM\Mail;
|
|||||||
|
|
||||||
use KTXF\Event\DeliveryMode;
|
use KTXF\Event\DeliveryMode;
|
||||||
use KTXF\Event\EventListenerRegistrarInterface;
|
use KTXF\Event\EventListenerRegistrarInterface;
|
||||||
use KTXF\Module\ModuleBrowserInterface;
|
use KTXF\Module\Configuration\BrowserModuleContextInterface;
|
||||||
|
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||||
use KTXF\Module\ModuleInstanceAbstract;
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mail Module - Email Client
|
* Mail Module - Email Client
|
||||||
*/
|
*/
|
||||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
class Module extends ModuleInstanceAbstract
|
||||||
{
|
{
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -79,16 +80,10 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function registerBI(): array
|
public function configure(ModuleContextInterface $context): void
|
||||||
{
|
{
|
||||||
return [
|
if ($context instanceof BrowserModuleContextInterface) {
|
||||||
'handle' => $this->handle(),
|
$context->registerModule($this, 'Mail', 'static/module.mjs');
|
||||||
'namespace' => 'Mail',
|
}
|
||||||
'version' => $this->version(),
|
|
||||||
'label' => $this->label(),
|
|
||||||
'author' => $this->author(),
|
|
||||||
'description' => $this->description(),
|
|
||||||
'boot' => 'static/module.mjs',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { CollectionObject, MessageAddressObject } from '@MailManager/models
|
|||||||
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
||||||
import { useMailCompositionStore } from '@/stores/mailCompositionStore'
|
import { useMailCompositionStore } from '@/stores/mailCompositionStore'
|
||||||
import { ComposerMode } from '@/types/composer'
|
import { ComposerMode } from '@/types/composer'
|
||||||
|
import type { ComposerFormat } from '@/types/composer'
|
||||||
import { documentsSelectorComponent } from '@/services/documentsSelector'
|
import { documentsSelectorComponent } from '@/services/documentsSelector'
|
||||||
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
|
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
|
||||||
import ComposerSender from '@/components/composer/ComposerSender.vue'
|
import ComposerSender from '@/components/composer/ComposerSender.vue'
|
||||||
@@ -48,6 +49,8 @@ const {
|
|||||||
// State
|
// State
|
||||||
const showCc = ref(false)
|
const showCc = ref(false)
|
||||||
const showBcc = ref(false)
|
const showBcc = ref(false)
|
||||||
|
const format = ref<ComposerFormat>('rich')
|
||||||
|
const textEdited = ref(false)
|
||||||
const applyingDraftToEditor = ref(false)
|
const applyingDraftToEditor = ref(false)
|
||||||
const showDocumentSelector = ref(false)
|
const showDocumentSelector = ref(false)
|
||||||
const showCloseDialog = ref(false)
|
const showCloseDialog = ref(false)
|
||||||
@@ -85,6 +88,14 @@ const subject = computed({
|
|||||||
|
|
||||||
const attachments = computed(() => activeDraft.value?.attachments ?? {})
|
const attachments = computed(() => activeDraft.value?.attachments ?? {})
|
||||||
|
|
||||||
|
const text = computed({
|
||||||
|
get: () => activeDraft.value?.message.body.text ?? '',
|
||||||
|
set: value => {
|
||||||
|
textEdited.value = true
|
||||||
|
compositionStore.updateBody({ text: value, html: textToHtml(value) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Initialize Tiptap editor
|
// Initialize Tiptap editor
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -118,6 +129,8 @@ watch(
|
|||||||
compositionStore.openDraft(props.mode, props.source)
|
compositionStore.openDraft(props.mode, props.source)
|
||||||
showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0
|
showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0
|
||||||
showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0
|
showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0
|
||||||
|
format.value = 'rich'
|
||||||
|
textEdited.value = false
|
||||||
|
|
||||||
applyingDraftToEditor.value = true
|
applyingDraftToEditor.value = true
|
||||||
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
|
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
|
||||||
@@ -249,6 +262,42 @@ const toggleLink = () => {
|
|||||||
const isActive = (name: string, attrs?: any) => {
|
const isActive = (name: string, attrs?: any) => {
|
||||||
return editor.value?.isActive(name, attrs) || false
|
return editor.value?.isActive(name, attrs) || false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleFormat = () => {
|
||||||
|
if (format.value === 'rich') {
|
||||||
|
if (editor.value) {
|
||||||
|
compositionStore.updateBody({
|
||||||
|
html: activeDraft.value?.message.body.html ?? '',
|
||||||
|
text: editor.value.getText(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
textEdited.value = false
|
||||||
|
format.value = 'plain'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format.value = 'rich'
|
||||||
|
if (textEdited.value && editor.value) {
|
||||||
|
applyingDraftToEditor.value = true
|
||||||
|
editor.value.commands.setContent(activeDraft.value?.message.body.html || '')
|
||||||
|
applyingDraftToEditor.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToHtml(text: string): string {
|
||||||
|
if (!text) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(line => `<p>${escapeHtml(line) || '<br>'}</p>`)
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -297,6 +346,8 @@ const isActive = (name: string, attrs?: any) => {
|
|||||||
|
|
||||||
<ComposerEditor
|
<ComposerEditor
|
||||||
:editor="editor"
|
:editor="editor"
|
||||||
|
:format="format"
|
||||||
|
:text="text"
|
||||||
:is-bold-active="isActive('bold')"
|
:is-bold-active="isActive('bold')"
|
||||||
:is-italic-active="isActive('italic')"
|
:is-italic-active="isActive('italic')"
|
||||||
:is-underline-active="isActive('underline')"
|
:is-underline-active="isActive('underline')"
|
||||||
@@ -309,6 +360,8 @@ const isActive = (name: string, attrs?: any) => {
|
|||||||
@bullet-list="toggleBulletList"
|
@bullet-list="toggleBulletList"
|
||||||
@ordered-list="toggleOrderedList"
|
@ordered-list="toggleOrderedList"
|
||||||
@link="toggleLink"
|
@link="toggleLink"
|
||||||
|
@toggle-format="toggleFormat"
|
||||||
|
@update:text="text = $event"
|
||||||
@attach-from-device="handleAttachFromDevice"
|
@attach-from-device="handleAttachFromDevice"
|
||||||
@attach-from-documents="handleAttachFromDocuments"
|
@attach-from-documents="handleAttachFromDocuments"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ interface Props {
|
|||||||
selectionMode?: boolean
|
selectionMode?: boolean
|
||||||
selectedCollection?: CollectionObject | null
|
selectedCollection?: CollectionObject | null
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
searchActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
loading: false,
|
loading: false,
|
||||||
selectionList: () => [],
|
selectionList: () => [],
|
||||||
selectionMode: false,
|
selectionMode: false,
|
||||||
|
searchActive: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
@@ -372,10 +374,10 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div v-else-if="messages.length === 0" class="pa-8 text-center">
|
<div v-else-if="messages.length === 0" class="pa-8 text-center">
|
||||||
<v-icon size="64" color="grey-lighten-1">mdi-email-outline</v-icon>
|
<v-icon size="64" color="grey-lighten-1">{{ searchActive ? 'mdi-email-search-outline' : 'mdi-email-outline' }}</v-icon>
|
||||||
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
|
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
|
||||||
<div class="text-body-2 text-medium-emphasis">
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
This folder is empty
|
{{ searchActive ? 'No messages match your search' : 'This folder is empty' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useUser } from '@KTXC'
|
import { usePreviewStore, useUser } from '@KTXC'
|
||||||
import { useMailUiStore } from '@/stores/mailUiStore'
|
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||||
import type { EntityObject, MessageObject } from '@MailManager/models'
|
import type { EntityObject, MessageObject } from '@MailManager/models'
|
||||||
import { SecurityLevel } from '@/utile/emailSanitizer'
|
import { SecurityLevel } from '@/utile/emailSanitizer'
|
||||||
@@ -19,6 +19,7 @@ const props = defineProps<{
|
|||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
reply: [entity: EntityObject]
|
reply: [entity: EntityObject]
|
||||||
|
replyAll: [entity: EntityObject]
|
||||||
forward: [entity: EntityObject]
|
forward: [entity: EntityObject]
|
||||||
move: [entity: EntityObject]
|
move: [entity: EntityObject]
|
||||||
delete: [entity: EntityObject]
|
delete: [entity: EntityObject]
|
||||||
@@ -28,6 +29,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
// User settings
|
// User settings
|
||||||
const { getSetting } = useUser()
|
const { getSetting } = useUser()
|
||||||
|
const previewStore = usePreviewStore()
|
||||||
const mailUiStore = useMailUiStore()
|
const mailUiStore = useMailUiStore()
|
||||||
|
|
||||||
// Security overrides
|
// Security overrides
|
||||||
@@ -64,6 +66,7 @@ const effectiveSecurityLevel = computed(() => {
|
|||||||
// Preview state
|
// Preview state
|
||||||
const previewDialog = computed(() => mediaViewerComponent('dialog'))
|
const previewDialog = computed(() => mediaViewerComponent('dialog'))
|
||||||
const previewItems = computed<MediaViewerItem[]>(() => {
|
const previewItems = computed<MediaViewerItem[]>(() => {
|
||||||
|
if (!previewStore.availability.storage) return []
|
||||||
const attachments = message.value?.attachments ?? []
|
const attachments = message.value?.attachments ?? []
|
||||||
const items: MediaViewerItem[] = []
|
const items: MediaViewerItem[] = []
|
||||||
attachments.forEach((attachment, index) => {
|
attachments.forEach((attachment, index) => {
|
||||||
@@ -107,6 +110,12 @@ const handleReply = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReplyAll = () => {
|
||||||
|
if (props.entity) {
|
||||||
|
emit('replyAll', props.entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleForward = () => {
|
const handleForward = () => {
|
||||||
if (props.entity) {
|
if (props.entity) {
|
||||||
emit('forward', props.entity)
|
emit('forward', props.entity)
|
||||||
@@ -152,7 +161,7 @@ const handlePreview = (index: number) => {
|
|||||||
|
|
||||||
const handleViewerRetrieve = (item: MediaViewerItem): Promise<Blob> => {
|
const handleViewerRetrieve = (item: MediaViewerItem): Promise<Blob> => {
|
||||||
if (!props.entity) return Promise.reject(new Error('No message selected'))
|
if (!props.entity) return Promise.reject(new Error('No message selected'))
|
||||||
return mailUiStore.attachmentBlob(props.entity, item.meta.attachment)
|
return mailUiStore.attachmentPreviewBlob(props.entity, item.meta.attachment, 'fullscreen')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleViewerDownload = (item: MediaViewerItem) => {
|
const handleViewerDownload = (item: MediaViewerItem) => {
|
||||||
@@ -176,6 +185,7 @@ const handleViewerDownload = (item: MediaViewerItem) => {
|
|||||||
:security-level="effectiveSecurityLevel"
|
:security-level="effectiveSecurityLevel"
|
||||||
:is-security-overridden="overrideSecurityLevel !== null"
|
:is-security-overridden="overrideSecurityLevel !== null"
|
||||||
@reply="handleReply"
|
@reply="handleReply"
|
||||||
|
@reply-all="handleReplyAll"
|
||||||
@forward="handleForward"
|
@forward="handleForward"
|
||||||
@move="handleMove"
|
@move="handleMove"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ defineProps({
|
|||||||
type: Object as PropType<Editor | null>,
|
type: Object as PropType<Editor | null>,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
format: {
|
||||||
|
type: String as PropType<'rich' | 'plain'>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
isBoldActive: {
|
isBoldActive: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -42,6 +50,8 @@ const emit = defineEmits<{
|
|||||||
bulletList: []
|
bulletList: []
|
||||||
orderedList: []
|
orderedList: []
|
||||||
link: []
|
link: []
|
||||||
|
toggleFormat: []
|
||||||
|
'update:text': [value: string]
|
||||||
attachFromDevice: [files: File[]]
|
attachFromDevice: [files: File[]]
|
||||||
attachFromDocuments: []
|
attachFromDocuments: []
|
||||||
}>()
|
}>()
|
||||||
@@ -66,68 +76,84 @@ function handleFileChange(event: Event) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
|
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
|
||||||
<v-btn
|
<template v-if="format === 'rich'">
|
||||||
icon
|
<v-btn
|
||||||
size="small"
|
icon
|
||||||
:class="{ 'v-btn--active': isBoldActive }"
|
size="small"
|
||||||
@click="$emit('bold')"
|
:class="{ 'v-btn--active': isBoldActive }"
|
||||||
>
|
@click="$emit('bold')"
|
||||||
<v-icon>mdi-format-bold</v-icon>
|
>
|
||||||
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
|
<v-icon>mdi-format-bold</v-icon>
|
||||||
</v-btn>
|
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isItalicActive }"
|
||||||
|
@click="$emit('italic')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-italic</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isUnderlineActive }"
|
||||||
|
@click="$emit('underline')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-underline</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-divider vertical class="mx-2" />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isBulletListActive }"
|
||||||
|
@click="$emit('bulletList')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-list-bulleted</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isOrderedListActive }"
|
||||||
|
@click="$emit('orderedList')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-list-numbered</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-divider vertical class="mx-2" />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isLinkActive }"
|
||||||
|
@click="$emit('link')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-link</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-divider vertical class="mx-2" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
icon
|
icon
|
||||||
size="small"
|
size="small"
|
||||||
:class="{ 'v-btn--active': isItalicActive }"
|
:class="{ 'v-btn--active': format === 'plain' }"
|
||||||
@click="$emit('italic')"
|
@click="$emit('toggleFormat')"
|
||||||
>
|
>
|
||||||
<v-icon>mdi-format-italic</v-icon>
|
<v-icon>{{ format === 'plain' ? 'mdi-format-text' : 'mdi-code-tags' }}</v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">
|
||||||
</v-btn>
|
{{ format === 'plain' ? 'Rich Text' : 'Plain Text' }}
|
||||||
|
</v-tooltip>
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:class="{ 'v-btn--active': isUnderlineActive }"
|
|
||||||
@click="$emit('underline')"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-format-underline</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-divider vertical class="mx-2" />
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:class="{ 'v-btn--active': isBulletListActive }"
|
|
||||||
@click="$emit('bulletList')"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-format-list-bulleted</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:class="{ 'v-btn--active': isOrderedListActive }"
|
|
||||||
@click="$emit('orderedList')"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-format-list-numbered</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-divider vertical class="mx-2" />
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:class="{ 'v-btn--active': isLinkActive }"
|
|
||||||
@click="$emit('link')"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-link</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
|
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
@@ -154,7 +180,17 @@ function handleFileChange(event: Event) {
|
|||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<EditorContent :editor="editor ?? undefined" />
|
<EditorContent v-if="format === 'rich'" :editor="editor ?? undefined" />
|
||||||
|
<v-textarea
|
||||||
|
v-else
|
||||||
|
:model-value="text"
|
||||||
|
variant="plain"
|
||||||
|
auto-grow
|
||||||
|
hide-details
|
||||||
|
placeholder="Write your message..."
|
||||||
|
class="text-editor pa-4"
|
||||||
|
@update:model-value="$emit('update:text', $event)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -179,6 +215,11 @@ function handleFileChange(event: Event) {
|
|||||||
color: rgb(var(--v-theme-primary));
|
color: rgb(var(--v-theme-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-editor :deep(textarea) {
|
||||||
|
min-height: 300px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.tiptap-editor) {
|
:deep(.tiptap-editor) {
|
||||||
outline: none;
|
outline: none;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ defineEmits<{
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-toolbar-title>
|
<v-toolbar-title>
|
||||||
{{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
|
{{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.ReplyAll ? 'Reply All' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
|
||||||
</v-toolbar-title>
|
</v-toolbar-title>
|
||||||
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { usePreviewStore } from '@KTXC'
|
||||||
import RecipientDetails from '@/components/common/RecipientDetails.vue'
|
import RecipientDetails from '@/components/common/RecipientDetails.vue'
|
||||||
import { formatFileSize } from '@/utile/format'
|
import { formatFileSize } from '@/utile/format'
|
||||||
import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer'
|
import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer'
|
||||||
@@ -27,7 +28,9 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const mailUiStore = useMailUiStore()
|
const mailUiStore = useMailUiStore()
|
||||||
const previewPopover = computed(() => mediaViewerComponent('popover'))
|
const previewStore = usePreviewStore()
|
||||||
|
const previewsAvailable = computed(() => previewStore.availability.storage)
|
||||||
|
const previewPopover = computed(() => previewsAvailable.value ? mediaViewerComponent('popover') : null)
|
||||||
|
|
||||||
const message = computed(() => {
|
const message = computed(() => {
|
||||||
return props.entity?.properties ?? null
|
return props.entity?.properties ?? null
|
||||||
@@ -48,7 +51,7 @@ const attachmentItems = computed<AttachmentItem[]>(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isPreviewable = (attachment: MessagePartInterface): boolean => {
|
const isPreviewable = (attachment: MessagePartInterface): boolean => {
|
||||||
return mediaViewerCanDisplay(attachment.type)
|
return previewsAvailable.value && mediaViewerCanDisplay(attachment.type)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAttachmentClick = (attachment: MessagePartInterface, index: number): void => {
|
const handleAttachmentClick = (attachment: MessagePartInterface, index: number): void => {
|
||||||
@@ -61,7 +64,7 @@ const handleAttachmentClick = (attachment: MessagePartInterface, index: number):
|
|||||||
|
|
||||||
const handleViewerRetrieve = (item: AttachmentItem): Promise<Blob> => {
|
const handleViewerRetrieve = (item: AttachmentItem): Promise<Blob> => {
|
||||||
if (!props.entity) return Promise.reject(new Error('No message selected'))
|
if (!props.entity) return Promise.reject(new Error('No message selected'))
|
||||||
return mailUiStore.attachmentBlob(props.entity, item.meta.attachment)
|
return mailUiStore.attachmentPreviewBlob(props.entity, item.meta.attachment, 'inline')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format date for display
|
// Format date for display
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const props = defineProps<Props>()
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
reply: []
|
reply: []
|
||||||
|
replyAll: []
|
||||||
forward: []
|
forward: []
|
||||||
move: []
|
move: []
|
||||||
delete: []
|
delete: []
|
||||||
@@ -47,7 +48,7 @@ const currentSecurityLevel = computed(() => {
|
|||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-reply-all"
|
icon="mdi-reply-all"
|
||||||
variant="text"
|
variant="text"
|
||||||
@click="emit('reply')"
|
@click="emit('replyAll')"
|
||||||
>
|
>
|
||||||
<v-icon>mdi-reply-all</v-icon>
|
<v-icon>mdi-reply-all</v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">Reply All</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">Reply All</v-tooltip>
|
||||||
|
|||||||
+16
-11
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, unref } from 'vue'
|
import { computed, onMounted, ref, unref, watch } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useModuleStore } from '@KTXC'
|
import { useModuleStore } from '@KTXC'
|
||||||
@@ -41,6 +41,8 @@ const {
|
|||||||
loading,
|
loading,
|
||||||
selectedMessage,
|
selectedMessage,
|
||||||
currentMessages,
|
currentMessages,
|
||||||
|
searchResults,
|
||||||
|
searching,
|
||||||
} = storeToRefs(mailOperationsStore)
|
} = storeToRefs(mailOperationsStore)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -74,21 +76,19 @@ const {
|
|||||||
// Complex store/composable objects accessed directly (not simple refs)
|
// Complex store/composable objects accessed directly (not simple refs)
|
||||||
const { mailSync, entitiesStore } = mailOperationsStore
|
const { mailSync, entitiesStore } = mailOperationsStore
|
||||||
|
|
||||||
// Message search (filters the current message list by subject/sender)
|
// Message search — debounced server-side search of the selected folder
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
const filteredMessages = computed(() => {
|
watch(searchQuery, query => mailOperationsStore.searchMessages(query))
|
||||||
const query = (searchQuery.value ?? '').trim().toLowerCase()
|
|
||||||
if (!query) return currentMessages.value
|
|
||||||
|
|
||||||
return currentMessages.value.filter((message: EntityObject) => {
|
// Reset the search box when the folder changes so it doesn't show a stale
|
||||||
const subject = (message.properties.subject || '').toLowerCase()
|
// query for a folder whose results are no longer being filtered.
|
||||||
const fromLabel = (message.properties.from?.label || '').toLowerCase()
|
watch(selectedFolder, () => {
|
||||||
const fromAddress = (message.properties.from?.address || '').toLowerCase()
|
searchQuery.value = ''
|
||||||
return subject.includes(query) || fromLabel.includes(query) || fromAddress.includes(query)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filteredMessages = computed(() => searchResults.value ?? currentMessages.value)
|
||||||
|
|
||||||
const lastSyncLabel = computed(() => {
|
const lastSyncLabel = computed(() => {
|
||||||
const lastSync = unref(unref(mailSync.lastSync))
|
const lastSync = unref(unref(mailSync.lastSync))
|
||||||
|
|
||||||
@@ -167,6 +167,8 @@ const handleMessageComposeFresh = () => mailUiStore.openComposer()
|
|||||||
|
|
||||||
const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Reply)
|
const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Reply)
|
||||||
|
|
||||||
|
const handleMessageComposeReplyAll = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.ReplyAll)
|
||||||
|
|
||||||
const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Forward)
|
const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Forward)
|
||||||
|
|
||||||
const handleMessageComposeClose = () => mailUiStore.closeComposer()
|
const handleMessageComposeClose = () => mailUiStore.closeComposer()
|
||||||
@@ -250,6 +252,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
placeholder="Search messages..."
|
placeholder="Search messages..."
|
||||||
prepend-inner-icon="mdi-magnify"
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
:loading="searching"
|
||||||
hide-details
|
hide-details
|
||||||
single-line
|
single-line
|
||||||
clearable
|
clearable
|
||||||
@@ -308,6 +311,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
|||||||
:selection-list="selectionList"
|
:selection-list="selectionList"
|
||||||
:selection-mode="selectionMode"
|
:selection-mode="selectionMode"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
:search-active="searchResults !== null"
|
||||||
@open="handleMessageOpen"
|
@open="handleMessageOpen"
|
||||||
@reply="handleMessageComposeReply"
|
@reply="handleMessageComposeReply"
|
||||||
@forward="handleMessageComposeForward"
|
@forward="handleMessageComposeForward"
|
||||||
@@ -339,6 +343,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
|||||||
:entity="selectedMessage"
|
:entity="selectedMessage"
|
||||||
@compose="handleMessageComposeFresh"
|
@compose="handleMessageComposeFresh"
|
||||||
@reply="handleMessageComposeReply"
|
@reply="handleMessageComposeReply"
|
||||||
|
@reply-all="handleMessageComposeReplyAll"
|
||||||
@forward="handleMessageComposeForward"
|
@forward="handleMessageComposeForward"
|
||||||
@move="handleMessageMove"
|
@move="handleMessageMove"
|
||||||
@delete="handleMessageDelete"
|
@delete="handleMessageDelete"
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
activeDraft.value = buildDraft(mode, sender, source)
|
const ownAddresses = senderIdentities.value.map(identity => identity.address)
|
||||||
|
activeDraft.value = buildDraft(mode, sender, source, ownAddresses)
|
||||||
activeDraft.value.action = mode
|
activeDraft.value.action = mode
|
||||||
|
|
||||||
stageDraft()
|
stageDraft()
|
||||||
@@ -380,6 +381,10 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
|||||||
source: attachment.source,
|
source: attachment.source,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (response.revision !== undefined) {
|
||||||
|
activeDraft.value.revision = response.revision
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addAttachmentsFromDocuments(items: { identifier: string; name: string; mimeType?: string; size?: number }[]) {
|
async function addAttachmentsFromDocuments(items: { identifier: string; name: string; mimeType?: string; size?: number }[]) {
|
||||||
@@ -432,6 +437,10 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (response.revision !== undefined) {
|
||||||
|
activeDraft.value.revision = response.revision
|
||||||
|
}
|
||||||
|
|
||||||
if (response.failed?.length) {
|
if (response.failed?.length) {
|
||||||
mailUiStore.notify(
|
mailUiStore.notify(
|
||||||
response.failed.length === 1
|
response.failed.length === 1
|
||||||
@@ -464,6 +473,9 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete activeDraft.value.attachments[attachment.identifier]
|
delete activeDraft.value.attachments[attachment.identifier]
|
||||||
|
if (response.revision !== undefined) {
|
||||||
|
activeDraft.value.revision = response.revision
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueSave(immediate: boolean) {
|
function queueSave(immediate: boolean) {
|
||||||
@@ -547,9 +559,9 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source?: EntityObject | MessageAddressObject | null): ComposerDraft {
|
function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source: EntityObject | MessageAddressObject | null | undefined, ownAddresses: string[]): ComposerDraft {
|
||||||
const composition = createIdentifier()
|
const composition = createIdentifier()
|
||||||
const freshMessage = buildMessage(mode, source)
|
const freshMessage = buildMessage(mode, source, ownAddresses)
|
||||||
const freshAttachments = buildAttachments(composition, mode, source)
|
const freshAttachments = buildAttachments(composition, mode, source)
|
||||||
const sourceIdentifier = source instanceof EntityObject ? source.identifier : null
|
const sourceIdentifier = source instanceof EntityObject ? source.identifier : null
|
||||||
return {
|
return {
|
||||||
@@ -565,7 +577,7 @@ function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressObject | null | undefined): ComposerDraftMessage {
|
function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressObject | null | undefined, ownAddresses: string[] = []): ComposerDraftMessage {
|
||||||
|
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return emptyMessage()
|
return emptyMessage()
|
||||||
@@ -592,12 +604,28 @@ function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressO
|
|||||||
const sentAt = sourceMessage.sent || source.created || ''
|
const sentAt = sourceMessage.sent || source.created || ''
|
||||||
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
|
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
|
||||||
|
|
||||||
if (mode === ComposerMode.Reply) {
|
if (mode === ComposerMode.Reply || mode === ComposerMode.ReplyAll) {
|
||||||
const replyToAddr = sourceMessage.replyTo?.[0] ?? sourceMessage.from ?? null
|
const replyToAddr = sourceMessage.replyTo?.[0] ?? sourceMessage.from ?? null
|
||||||
const replyToAddress = replyToAddr?.address || ''
|
const replyToAddress = replyToAddr?.address || ''
|
||||||
|
const to: MessageAddressInterface[] = replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : []
|
||||||
|
|
||||||
|
let cc: MessageAddressInterface[] = []
|
||||||
|
if (mode === ComposerMode.ReplyAll) {
|
||||||
|
const excluded = new Set([replyToAddress.toLowerCase(), ...ownAddresses.map(addr => addr.toLowerCase())])
|
||||||
|
const seen = new Set<string>()
|
||||||
|
cc = [...(sourceMessage.to || []), ...(sourceMessage.cc || [])].filter(recipient => {
|
||||||
|
const address = recipient.address?.toLowerCase() || ''
|
||||||
|
if (!address || excluded.has(address) || seen.has(address)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen.add(address)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
to: replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : [],
|
to,
|
||||||
cc: [],
|
cc,
|
||||||
bcc: [],
|
bcc: [],
|
||||||
subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`,
|
subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`,
|
||||||
body: {
|
body: {
|
||||||
@@ -620,7 +648,7 @@ function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressO
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAttachments(composition: string, mode: ComposerMode, source?: EntityObject | MessageAddressObject | null): Record<string, ComposerDraftAttachment> {
|
function buildAttachments(composition: string, mode: ComposerMode, source?: EntityObject | MessageAddressObject | null): Record<string, ComposerDraftAttachment> {
|
||||||
if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || !(source instanceof EntityObject)) {
|
if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || mode === ComposerMode.ReplyAll || !(source instanceof EntityObject)) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
|||||||
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
|
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
|
||||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||||
import { useMailSync } from '@MailManager/composables/useMailSync'
|
import { useMailSync } from '@MailManager/composables/useMailSync'
|
||||||
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier, ListFilter } from '@MailManager/types/common'
|
||||||
|
import { ListFilterComparisonOperator, ListFilterConjunctionOperator } from '@MailManager/types/common'
|
||||||
import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity'
|
import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity'
|
||||||
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
|
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
|
||||||
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
|
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
|
||||||
@@ -50,6 +51,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
|||||||
|
|
||||||
const selectedFolder = shallowRef<CollectionObject | null>(null)
|
const selectedFolder = shallowRef<CollectionObject | null>(null)
|
||||||
const selectedMessage = shallowRef<EntityObject | null>(null)
|
const selectedMessage = shallowRef<EntityObject | null>(null)
|
||||||
|
const searchResults = shallowRef<EntityObject[] | null>(null)
|
||||||
|
const searching = ref(false)
|
||||||
const composerSaving = ref(false)
|
const composerSaving = ref(false)
|
||||||
const composerSending = ref(false)
|
const composerSending = ref(false)
|
||||||
const composerLastSaved = ref<Date | null>(null)
|
const composerLastSaved = ref<Date | null>(null)
|
||||||
@@ -293,6 +296,7 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectFolder(folder: CollectionObject | null) {
|
async function selectFolder(folder: CollectionObject | null) {
|
||||||
|
clearSearch()
|
||||||
selectedFolder.value = folder
|
selectedFolder.value = folder
|
||||||
selectedMessage.value = null
|
selectedMessage.value = null
|
||||||
|
|
||||||
@@ -307,6 +311,67 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
|||||||
_updateSyncSources()
|
_updateSyncSources()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300
|
||||||
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
// Monotonic token so out-of-order/stale search responses are discarded.
|
||||||
|
let searchToken = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced server-side search of the selected folder. Clearing the query
|
||||||
|
* (or the folder) reverts the message list back to `currentMessages`.
|
||||||
|
*/
|
||||||
|
function searchMessages(query: string) {
|
||||||
|
const trimmed = (query ?? '').trim()
|
||||||
|
|
||||||
|
if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed || !selectedFolder.value) {
|
||||||
|
searchToken++
|
||||||
|
searchResults.value = null
|
||||||
|
searching.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = selectedFolder.value
|
||||||
|
// 'from'/'to'/'subject'/'body' are the attributes both IMAP and JMAP
|
||||||
|
// backends whitelist for entity search (see their entityListFilter
|
||||||
|
// capability); OR them together for a single-box search.
|
||||||
|
const filter: ListFilter = {
|
||||||
|
from: [trimmed, ListFilterComparisonOperator.LIKE],
|
||||||
|
to: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
|
||||||
|
subject: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
|
||||||
|
body: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
|
||||||
|
}
|
||||||
|
searching.value = true
|
||||||
|
const token = ++searchToken
|
||||||
|
|
||||||
|
searchDebounceTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const results = await entitiesStore.list([folder.identifier], filter)
|
||||||
|
if (token !== searchToken) return
|
||||||
|
searchResults.value = Object.values(results)
|
||||||
|
} catch (error) {
|
||||||
|
if (token === searchToken) searchResults.value = []
|
||||||
|
console.error('[Mail][Operations] Failed to search messages:', error)
|
||||||
|
} finally {
|
||||||
|
if (token === searchToken) searching.value = false
|
||||||
|
}
|
||||||
|
}, SEARCH_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
searchToken++
|
||||||
|
searchResults.value = null
|
||||||
|
searching.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function selectMessage(entity: EntityObject | null) {
|
function selectMessage(entity: EntityObject | null) {
|
||||||
selectedMessage.value = entity
|
selectedMessage.value = entity
|
||||||
}
|
}
|
||||||
@@ -575,6 +640,24 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
|||||||
return blob
|
return blob
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function attachmentPreviewBlob(
|
||||||
|
entity: EntityObject,
|
||||||
|
part: MessagePartInterface,
|
||||||
|
variant: 'inline' | 'fullscreen' = 'inline',
|
||||||
|
): Promise<Blob> {
|
||||||
|
return entitiesStore.preview({
|
||||||
|
target: entity.identifier,
|
||||||
|
part: {
|
||||||
|
blobId: part.blobId ?? undefined,
|
||||||
|
partId: part.partId ?? undefined,
|
||||||
|
cid: part.cid ?? undefined,
|
||||||
|
type: part.type || 'application/octet-stream',
|
||||||
|
size: part.size ?? null,
|
||||||
|
},
|
||||||
|
variant,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Exports ───────────────────────────────────────────────────────────────
|
// ── Exports ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -587,6 +670,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
|||||||
// State
|
// State
|
||||||
loading,
|
loading,
|
||||||
selectedMessage,
|
selectedMessage,
|
||||||
|
searchResults,
|
||||||
|
searching,
|
||||||
composerSaving,
|
composerSaving,
|
||||||
composerSending,
|
composerSending,
|
||||||
composerLastSaved,
|
composerLastSaved,
|
||||||
@@ -602,6 +687,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
|||||||
retrieveService,
|
retrieveService,
|
||||||
selectFolder,
|
selectFolder,
|
||||||
selectMessage,
|
selectMessage,
|
||||||
|
searchMessages,
|
||||||
|
clearSearch,
|
||||||
createFolder,
|
createFolder,
|
||||||
saveComposerDraft,
|
saveComposerDraft,
|
||||||
sendComposerMessage,
|
sendComposerMessage,
|
||||||
@@ -612,6 +699,7 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
|||||||
moveMessages,
|
moveMessages,
|
||||||
downloadMessage,
|
downloadMessage,
|
||||||
attachmentBlob,
|
attachmentBlob,
|
||||||
|
attachmentPreviewBlob,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
isServiceFolderLoading,
|
isServiceFolderLoading,
|
||||||
|
|||||||
@@ -714,6 +714,14 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
|||||||
return mailOperationsStore.attachmentBlob(entity, part)
|
return mailOperationsStore.attachmentBlob(entity, part)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachmentPreviewBlob(
|
||||||
|
entity: EntityObject,
|
||||||
|
part: Parameters<typeof mailOperationsStore.attachmentPreviewBlob>[1],
|
||||||
|
variant: 'inline' | 'fullscreen' = 'inline',
|
||||||
|
) {
|
||||||
|
return mailOperationsStore.attachmentPreviewBlob(entity, part, variant)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sidebarVisible,
|
sidebarVisible,
|
||||||
settingsDialogVisible,
|
settingsDialogVisible,
|
||||||
@@ -763,6 +771,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
|||||||
moveMessages,
|
moveMessages,
|
||||||
downloadMessage,
|
downloadMessage,
|
||||||
attachmentBlob,
|
attachmentBlob,
|
||||||
|
attachmentPreviewBlob,
|
||||||
messageSelectionModeActivate,
|
messageSelectionModeActivate,
|
||||||
messageSelectionModeDeactivate,
|
messageSelectionModeDeactivate,
|
||||||
messageSelectionToggleOne,
|
messageSelectionToggleOne,
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import type { MessageAddressInterface } from "@MailManager/types/message"
|
|||||||
export enum ComposerMode {
|
export enum ComposerMode {
|
||||||
Fresh = 'fresh',
|
Fresh = 'fresh',
|
||||||
Reply = 'reply',
|
Reply = 'reply',
|
||||||
|
ReplyAll = 'replyAll',
|
||||||
Forward = 'forward',
|
Forward = 'forward',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Body editing format: rich (Tiptap/HTML) or plain text. */
|
||||||
|
export type ComposerFormat = 'rich' | 'plain'
|
||||||
|
|
||||||
export interface ComposerDraftAttachment {
|
export interface ComposerDraftAttachment {
|
||||||
identifier: string
|
identifier: string
|
||||||
composition: string
|
composition: string
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export interface CompositionAttachmentAddResponse {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
composition: string
|
composition: string
|
||||||
|
revision?: number
|
||||||
attachments: Record<string, CompositionAttachmentInterface>
|
attachments: Record<string, CompositionAttachmentInterface>
|
||||||
failed?: {
|
failed?: {
|
||||||
identifier: string
|
identifier: string
|
||||||
@@ -131,4 +132,5 @@ export interface CompositionAttachmentRemoveResponse {
|
|||||||
}
|
}
|
||||||
composition: string
|
composition: string
|
||||||
identifier: string
|
identifier: string
|
||||||
|
revision?: number
|
||||||
}
|
}
|
||||||
@@ -187,6 +187,7 @@ final class CompositionManagerTest extends TestCase {
|
|||||||
$this->assertSame('added', $result['disposition']);
|
$this->assertSame('added', $result['disposition']);
|
||||||
$this->assertCount(1, $result['attachments']);
|
$this->assertCount(1, $result['attachments']);
|
||||||
$this->assertSame([], $result['failed']);
|
$this->assertSame([], $result['failed']);
|
||||||
|
$this->assertSame(1, $result['revision']);
|
||||||
|
|
||||||
$attachment = $result['attachments']['attach-1'];
|
$attachment = $result['attachments']['attach-1'];
|
||||||
$this->assertSame('documents', $attachment['origin']);
|
$this->assertSame('documents', $attachment['origin']);
|
||||||
@@ -196,11 +197,58 @@ final class CompositionManagerTest extends TestCase {
|
|||||||
$this->assertSame(strlen('hello world'), $attachment['size']);
|
$this->assertSame(strlen('hello world'), $attachment['size']);
|
||||||
|
|
||||||
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
|
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
|
||||||
|
$this->assertSame(1, $snapshot['revision']);
|
||||||
$this->assertSame('dirty', $snapshot['remote']['status']);
|
$this->assertSame('dirty', $snapshot['remote']['status']);
|
||||||
$this->assertNull($snapshot['remote']['entity']);
|
$this->assertNull($snapshot['remote']['entity']);
|
||||||
$this->assertNull($snapshot['remote']['error']);
|
$this->assertNull($snapshot['remote']['error']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testAttachmentAddIncrementsRevisionOnSubsequentAdd(): void {
|
||||||
|
$compositionId = $this->stageEmptyComposition();
|
||||||
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||||
|
$documentsManager->method('entityFetchBulk')->willReturn([
|
||||||
|
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
|
||||||
|
$this->makeEntity('default:personal:root:doc2', 'world.txt', 'text/plain'),
|
||||||
|
]);
|
||||||
|
$documentsManager->method('entityReadStream')->willReturnCallback(
|
||||||
|
fn(): mixed => $this->makeStream('hello world'),
|
||||||
|
);
|
||||||
|
$manager = $this->makeManager($documentsManager);
|
||||||
|
|
||||||
|
$manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||||
|
'identifier' => 'attach-1',
|
||||||
|
'origin' => 'documents',
|
||||||
|
'source' => 'default:personal:root:doc1',
|
||||||
|
]);
|
||||||
|
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||||
|
'identifier' => 'attach-2',
|
||||||
|
'origin' => 'documents',
|
||||||
|
'source' => 'default:personal:root:doc2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(2, $result['revision']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttachmentAddDoesNotChangeRevisionWhenNothingIsAdded(): void {
|
||||||
|
$compositionId = $this->stageEmptyComposition();
|
||||||
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||||
|
// documents_manager unavailable means the sourced attachment fails
|
||||||
|
// entirely, so no attachment is actually added to the composition.
|
||||||
|
$manager = $this->makeManager($documentsManager, documentsModuleEnabled: false);
|
||||||
|
|
||||||
|
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||||
|
'identifier' => 'attach-1',
|
||||||
|
'origin' => 'documents',
|
||||||
|
'source' => 'default:personal:root:doc1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('error', $result['disposition']);
|
||||||
|
$this->assertArrayNotHasKey('revision', $result);
|
||||||
|
|
||||||
|
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
|
||||||
|
$this->assertArrayNotHasKey('revision', $snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
public function testAttachmentAddFromDocumentsPartialFailure(): void {
|
public function testAttachmentAddFromDocumentsPartialFailure(): void {
|
||||||
$compositionId = $this->stageEmptyComposition();
|
$compositionId = $this->stageEmptyComposition();
|
||||||
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||||
@@ -285,6 +333,45 @@ final class CompositionManagerTest extends TestCase {
|
|||||||
$this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']);
|
$this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testAttachmentRemoveIncrementsRevision(): void {
|
||||||
|
$compositionId = $this->stageEmptyComposition();
|
||||||
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||||
|
$documentsManager->method('entityFetchBulk')->willReturn([
|
||||||
|
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
|
||||||
|
]);
|
||||||
|
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
|
||||||
|
$manager = $this->makeManager($documentsManager);
|
||||||
|
|
||||||
|
$added = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||||
|
'identifier' => 'attach-1',
|
||||||
|
'origin' => 'documents',
|
||||||
|
'source' => 'default:personal:root:doc1',
|
||||||
|
]);
|
||||||
|
$this->assertSame(1, $added['revision']);
|
||||||
|
|
||||||
|
$result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'attach-1');
|
||||||
|
|
||||||
|
$this->assertSame('removed', $result['disposition']);
|
||||||
|
$this->assertSame(2, $result['revision']);
|
||||||
|
|
||||||
|
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
|
||||||
|
$this->assertSame(2, $snapshot['revision']);
|
||||||
|
$this->assertArrayNotHasKey('attach-1', $snapshot['attachments']);
|
||||||
|
$this->assertSame('dirty', $snapshot['remote']['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttachmentRemoveNotFoundDoesNotChangeRevision(): void {
|
||||||
|
$compositionId = $this->stageEmptyComposition();
|
||||||
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||||
|
$manager = $this->makeManager($documentsManager);
|
||||||
|
|
||||||
|
$result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'missing-attachment');
|
||||||
|
|
||||||
|
$this->assertSame('error', $result['disposition']);
|
||||||
|
$this->assertSame('attachment_not_found', $result['error']['type']);
|
||||||
|
$this->assertArrayNotHasKey('revision', $result);
|
||||||
|
}
|
||||||
|
|
||||||
private function makeManager(
|
private function makeManager(
|
||||||
Stub $documentsManager,
|
Stub $documentsManager,
|
||||||
bool $documentsModuleEnabled = true,
|
bool $documentsModuleEnabled = true,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ use KTXF\Mail\Entity\EntityMutableInterface;
|
|||||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
||||||
use KTXF\Mail\Service\ServiceBaseInterface;
|
use KTXF\Mail\Service\ServiceBaseInterface;
|
||||||
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
||||||
|
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
||||||
|
use KTXF\Mail\Submission\EntitySubmitResult;
|
||||||
use KTXF\Resource\Filter\IFilter;
|
use KTXF\Resource\Filter\IFilter;
|
||||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||||
@@ -257,6 +259,133 @@ final class CompositionSynchronizationTest extends TestCase {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSendPurgesLocalCompositionOnSuccess(): void {
|
||||||
|
$this->stageComposition();
|
||||||
|
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
|
||||||
|
$service = $this->submitService($properties);
|
||||||
|
$service->method('entitySubmit')->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
|
||||||
|
|
||||||
|
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
||||||
|
$mailManager->method('serviceFindByAddress')->willReturn($service);
|
||||||
|
|
||||||
|
$result = $this->compositionManager($mailManager)->send(
|
||||||
|
self::TENANT_ID,
|
||||||
|
self::USER_ID,
|
||||||
|
self::COMPOSITION_ID,
|
||||||
|
['address' => 'sender@example.com'],
|
||||||
|
['subject' => 'Sent draft'],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('sent', $result['disposition']);
|
||||||
|
$this->assertNull($this->compositionStore->compositionFetch(
|
||||||
|
self::TENANT_ID,
|
||||||
|
self::USER_ID,
|
||||||
|
self::COMPOSITION_ID,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSendReusesSyncedRemoteDraftAsSubmissionSource(): void {
|
||||||
|
$this->stageComposition('provider:service:drafts:remote-1');
|
||||||
|
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
|
||||||
|
$service = $this->createMockForIntersectionOfInterfaces([
|
||||||
|
ServiceBaseInterface::class,
|
||||||
|
ServiceEntitySubmitInterface::class,
|
||||||
|
]);
|
||||||
|
$entity = $this->createStub(EntityMutableInterface::class);
|
||||||
|
$entity->method('getProperties')->willReturn($properties);
|
||||||
|
$service->method('provider')->willReturn('provider');
|
||||||
|
$service->method('identifier')->willReturn('service');
|
||||||
|
$service->method('getEnabled')->willReturn(true);
|
||||||
|
$service->method('entityFresh')->willReturn($entity);
|
||||||
|
$service->expects($this->once())
|
||||||
|
->method('entitySubmit')
|
||||||
|
->with(
|
||||||
|
$this->anything(),
|
||||||
|
$this->callback(static fn(?EntityIdentifier $source): bool => $source !== null
|
||||||
|
&& (string)$source === 'provider:service:drafts:remote-1'),
|
||||||
|
$properties,
|
||||||
|
)
|
||||||
|
->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
|
||||||
|
|
||||||
|
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
||||||
|
$mailManager->method('serviceFindByAddress')->willReturn($service);
|
||||||
|
|
||||||
|
$result = $this->compositionManager($mailManager)->send(
|
||||||
|
self::TENANT_ID,
|
||||||
|
self::USER_ID,
|
||||||
|
self::COMPOSITION_ID,
|
||||||
|
['address' => 'sender@example.com'],
|
||||||
|
['subject' => 'Sent draft'],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('sent', $result['disposition']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSendIgnoresRemoteDraftBelongingToAnotherService(): void {
|
||||||
|
$this->stageComposition('provider:other-service:drafts:remote-1');
|
||||||
|
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
|
||||||
|
$service = $this->createMockForIntersectionOfInterfaces([
|
||||||
|
ServiceBaseInterface::class,
|
||||||
|
ServiceEntitySubmitInterface::class,
|
||||||
|
]);
|
||||||
|
$entity = $this->createStub(EntityMutableInterface::class);
|
||||||
|
$entity->method('getProperties')->willReturn($properties);
|
||||||
|
$service->method('provider')->willReturn('provider');
|
||||||
|
$service->method('identifier')->willReturn('service');
|
||||||
|
$service->method('getEnabled')->willReturn(true);
|
||||||
|
$service->method('entityFresh')->willReturn($entity);
|
||||||
|
$service->expects($this->once())
|
||||||
|
->method('entitySubmit')
|
||||||
|
->with($this->anything(), $this->isNull(), $properties)
|
||||||
|
->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
|
||||||
|
|
||||||
|
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
||||||
|
$mailManager->method('serviceFindByAddress')->willReturn($service);
|
||||||
|
|
||||||
|
$result = $this->compositionManager($mailManager)->send(
|
||||||
|
self::TENANT_ID,
|
||||||
|
self::USER_ID,
|
||||||
|
self::COMPOSITION_ID,
|
||||||
|
['address' => 'sender@example.com'],
|
||||||
|
['subject' => 'Sent draft'],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('sent', $result['disposition']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSendFailureKeepsLocalComposition(): void {
|
||||||
|
$this->stageComposition();
|
||||||
|
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
|
||||||
|
$service = $this->submitService($properties);
|
||||||
|
$service->method('entitySubmit')->willReturn(new EntitySubmitResult(
|
||||||
|
EntitySubmitResult::DISPOSITION_ERROR,
|
||||||
|
errorMessage: 'Transport unavailable',
|
||||||
|
));
|
||||||
|
|
||||||
|
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
||||||
|
$mailManager->method('serviceFindByAddress')->willReturn($service);
|
||||||
|
|
||||||
|
$result = $this->compositionManager($mailManager)->send(
|
||||||
|
self::TENANT_ID,
|
||||||
|
self::USER_ID,
|
||||||
|
self::COMPOSITION_ID,
|
||||||
|
['address' => 'sender@example.com'],
|
||||||
|
['subject' => 'Keep this draft'],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('error', $result['disposition']);
|
||||||
|
$this->assertSame('Transport unavailable', $result['error']['message']);
|
||||||
|
$this->assertNotNull($this->compositionStore->compositionFetch(
|
||||||
|
self::TENANT_ID,
|
||||||
|
self::USER_ID,
|
||||||
|
self::COMPOSITION_ID,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
private function stageComposition(?string $remoteEntity = null): void {
|
private function stageComposition(?string $remoteEntity = null): void {
|
||||||
$saveComposition = static fn(): array => [
|
$saveComposition = static fn(): array => [
|
||||||
'identifier' => self::COMPOSITION_ID,
|
'identifier' => self::COMPOSITION_ID,
|
||||||
@@ -292,6 +421,20 @@ final class CompositionSynchronizationTest extends TestCase {
|
|||||||
return $service;
|
return $service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function submitService(MessagePropertiesMutableInterface $properties): ServiceBaseInterface&ServiceEntitySubmitInterface&Stub {
|
||||||
|
$service = $this->createStubForIntersectionOfInterfaces([
|
||||||
|
ServiceBaseInterface::class,
|
||||||
|
ServiceEntitySubmitInterface::class,
|
||||||
|
]);
|
||||||
|
$entity = $this->createStub(EntityMutableInterface::class);
|
||||||
|
$entity->method('getProperties')->willReturn($properties);
|
||||||
|
$service->method('provider')->willReturn('provider');
|
||||||
|
$service->method('identifier')->willReturn('service');
|
||||||
|
$service->method('getEnabled')->willReturn(true);
|
||||||
|
$service->method('entityFresh')->willReturn($entity);
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
|
||||||
private function compositionManager(\KTXM\MailManager\Manager $mailManager): CompositionManager {
|
private function compositionManager(\KTXM\MailManager\Manager $mailManager): CompositionManager {
|
||||||
return new CompositionManager(
|
return new CompositionManager(
|
||||||
container: $this->createStub(ContainerInterface::class),
|
container: $this->createStub(ContainerInterface::class),
|
||||||
|
|||||||
Reference in New Issue
Block a user