Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dc61c220f |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+4
-2
@@ -15,14 +15,16 @@ node_modules/
|
||||
/vendor/
|
||||
/lib/vendor/
|
||||
coverage/
|
||||
*.cache
|
||||
phpunit.xml.cache
|
||||
.phpunit.result.cache
|
||||
.php-cs-fixer.cache
|
||||
.phpstan.cache
|
||||
.phpactor/
|
||||
|
||||
# Editors
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
.ideas/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
+1
-13
@@ -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
+4
-1698
File diff suppressed because it is too large
Load Diff
+121
-629
@@ -5,40 +5,23 @@ declare(strict_types=1);
|
||||
namespace KTXM\Mail;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use KTXF\Mail\Collection\CollectionBaseInterface;
|
||||
use KTXF\Mail\Collection\CollectionRoles;
|
||||
use KTXF\Mail\Object\Address;
|
||||
use KTXF\Mail\Object\AddressInterface;
|
||||
use KTXF\Mail\Object\MessagePart;
|
||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
||||
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
||||
use KTXF\Mail\Service\ServiceBaseInterface;
|
||||
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
||||
use KTXF\Mail\Submission\EntitySubmitResult;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||
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;
|
||||
use KTXM\DocumentsManager\Manager as DocumentsManager;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class CompositionManager {
|
||||
|
||||
private const DOCUMENTS_MODULE_HANDLE = 'documents_manager';
|
||||
|
||||
public function __construct(
|
||||
private readonly ContainerInterface $container,
|
||||
private readonly ModuleManager $moduleManager,
|
||||
private readonly CompositionStore $compositionStore,
|
||||
private readonly MailManager $mailManager,
|
||||
private readonly EventDispatcherInterface $events,
|
||||
) {}
|
||||
|
||||
public function stage(string $tenantId, string $userId, string $identifier, string $action, array $sender, array $message, ?string $source = null): array {
|
||||
@@ -55,19 +38,11 @@ class CompositionManager {
|
||||
'sender' => $sender,
|
||||
'message' => $message,
|
||||
'attachments' => [],
|
||||
'remote' => [
|
||||
'status' => 'dirty',
|
||||
'entity' => null,
|
||||
'error' => null,
|
||||
],
|
||||
];
|
||||
|
||||
// Construct the snapshot mutation that will run while the composition is locked.
|
||||
$saveComposition = function () use ($tenantId, $userId, $identifier, $action, $source, $snapshot): array {
|
||||
if ($action !== 'forward') {
|
||||
return $snapshot;
|
||||
}
|
||||
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $snapshot);
|
||||
|
||||
if ($action === 'forward') {
|
||||
$sourceIndentifier = ResourceIdentifier::fromString($source);
|
||||
if ($sourceIndentifier === null) {
|
||||
throw new InvalidArgumentException('Invalid source identifier');
|
||||
@@ -100,7 +75,7 @@ class CompositionManager {
|
||||
data: $data,
|
||||
);
|
||||
// supplement the attachment metadata with source information
|
||||
$meta['origin'] = 'message';
|
||||
$meta['origin'] = 'source';
|
||||
$meta['source'] = $source;
|
||||
$meta['partId'] = $attachment->getId();
|
||||
$meta['blobId'] = $attachment->getBlobId();
|
||||
@@ -113,300 +88,73 @@ class CompositionManager {
|
||||
$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;
|
||||
};
|
||||
|
||||
// Execute the mutation and atomically persist the snapshot it returns.
|
||||
$snapshot = $this->compositionStore->compositionSave(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$identifier,
|
||||
$saveComposition,
|
||||
) ?? $snapshot;
|
||||
$event = new CompositionSavedEvent($tenantId, $userId, $identifier, (int)$snapshot['revision']);
|
||||
$this->events->dispatch($event);
|
||||
|
||||
$response = $snapshot;
|
||||
unset($response['remote']);
|
||||
return $response;
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
public function patch(string $tenantId, string $userId, string $identifier, array $data): array {
|
||||
$result = [];
|
||||
// Construct the snapshot mutation that will run while the composition is locked.
|
||||
$patchComposition = function (?array $composed) use ($data, $identifier, &$result): ?array {
|
||||
if ($composed === null) {
|
||||
$result['disposition'] = 'error';
|
||||
$result['error'] = [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
];
|
||||
return null;
|
||||
}
|
||||
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
|
||||
|
||||
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 null;
|
||||
}
|
||||
|
||||
$composed['revision'] = $data['revision'];
|
||||
$composed['sender'] = $data['sender'] ?? [];
|
||||
$composed['message'] = $data['message'] ?? [];
|
||||
$composed = $this->markRemoteDirty($composed);
|
||||
$result = [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'patched',
|
||||
];
|
||||
|
||||
return $composed;
|
||||
};
|
||||
|
||||
// Execute the mutation and atomically persist the snapshot it returns.
|
||||
$composed = $this->compositionStore->compositionSave(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$identifier,
|
||||
$patchComposition,
|
||||
);
|
||||
if (($result['disposition'] ?? null) === 'patched' && $composed !== null) {
|
||||
$event = new CompositionSavedEvent($tenantId, $userId, $identifier, (int)($composed['revision'] ?? 0));
|
||||
$this->events->dispatch($event);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function save(string $tenantId, string $userId, string $identifier, array $data): array {
|
||||
// Construct the snapshot mutation that will run while the composition is locked.
|
||||
$saveComposition = function (?array $composed) use ($data): ?array {
|
||||
if ($composed === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($data['revision']) && is_int($data['revision'])) {
|
||||
$composed['revision'] = $data['revision'];
|
||||
}
|
||||
$composed['sender'] = $data['sender'];
|
||||
$composed['message'] = $data['message'];
|
||||
return $this->markRemoteDirty($composed);
|
||||
};
|
||||
|
||||
// Execute the mutation and atomically persist the snapshot it returns.
|
||||
$composed = $this->compositionStore->compositionSave(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$identifier,
|
||||
$saveComposition,
|
||||
);
|
||||
if ($composed === null) {
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
],
|
||||
$result['disposition'] = 'error';
|
||||
$result['error'] = [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
$synchronized = $this->synchronize($tenantId, $userId, $identifier);
|
||||
if ($synchronized['disposition'] !== 'saved') {
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_save_failed',
|
||||
'message' => (string)($synchronized['error'] ?? 'Draft could not be saved to the server'),
|
||||
],
|
||||
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;
|
||||
}
|
||||
|
||||
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
|
||||
return ['identifier' => $identifier, 'disposition' => 'saved'];
|
||||
// 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 {
|
||||
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
|
||||
if ($composed === null) {
|
||||
return ['identifier' => $identifier, 'disposition' => 'discarded'];
|
||||
}
|
||||
|
||||
try {
|
||||
$remoteEntity = $this->remoteEntityIdentifier($composed['remote']['entity'] ?? null);
|
||||
if ($remoteEntity !== null) {
|
||||
$outcomes = $this->mailManager->entityDelete($tenantId, $userId, $remoteEntity);
|
||||
$outcome = $outcomes[(string)$remoteEntity] ?? reset($outcomes);
|
||||
if (!is_array($outcome) || ($outcome['disposition'] ?? 'error') === 'error') {
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_discard_failed',
|
||||
'message' => is_array($outcome)
|
||||
? (string)($outcome['error'] ?? 'Remote draft could not be discarded')
|
||||
: 'Remote draft could not be discarded',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_discard_failed',
|
||||
'message' => $throwable->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
|
||||
return ['identifier' => $identifier, 'disposition' => 'discarded'];
|
||||
}
|
||||
|
||||
public function synchronize(string $tenantId, string $userId, string $identifier): array {
|
||||
$result = [
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'staged',
|
||||
'disposition' => $this->compositionStore->compositionDiscard($tenantId, $userId, $identifier),
|
||||
];
|
||||
|
||||
// Construct the synchronization operation that will run while the composition is locked.
|
||||
$synchronizeComposition = function (?array $composed) use ($tenantId, $userId, $identifier, &$result): ?array {
|
||||
if ($composed === null) {
|
||||
$result['error'] = 'Composition not found';
|
||||
return null;
|
||||
}
|
||||
|
||||
$remote = isset($composed['remote']) && is_array($composed['remote'])
|
||||
? $composed['remote']
|
||||
: ['status' => 'dirty', 'entity' => null, 'error' => null];
|
||||
|
||||
if (($remote['status'] ?? 'dirty') === 'synced') {
|
||||
$result['disposition'] = 'saved';
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$sender = isset($composed['sender']) && is_array($composed['sender'])
|
||||
? $composed['sender']
|
||||
: [];
|
||||
$senderAddress = (string)($sender['address'] ?? '');
|
||||
if ($senderAddress === '' || !filter_var($senderAddress, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Composition sender is invalid');
|
||||
}
|
||||
|
||||
$service = $this->mailManager->serviceFindByAddress($tenantId, $userId, $senderAddress);
|
||||
if ($service === null || $service->getEnabled() === false) {
|
||||
throw new RuntimeException("No enabled mail service handles '$senderAddress'");
|
||||
}
|
||||
if (!$service instanceof ServiceEntityMutableInterface) {
|
||||
throw new RuntimeException("Mail service '{$service->identifier()}' does not support draft mutations");
|
||||
}
|
||||
|
||||
$target = $this->resolveDraftCollection($service);
|
||||
$attachments = isset($composed['attachments']) && is_array($composed['attachments'])
|
||||
? array_values($composed['attachments'])
|
||||
: [];
|
||||
$properties = $this->buildMessageProperties(
|
||||
service: $service,
|
||||
sender: Address::fromArray($sender),
|
||||
message: is_array($composed['message'] ?? null) ? $composed['message'] : [],
|
||||
attachments: $attachments,
|
||||
composed: $composed,
|
||||
tenantId: $tenantId,
|
||||
userId: $userId,
|
||||
compositionId: $identifier,
|
||||
);
|
||||
$properties->setFlag('draft', true);
|
||||
$properties->setFlag('seen', true);
|
||||
|
||||
$remoteEntity = $this->remoteEntityIdentifier($remote['entity'] ?? null);
|
||||
if ($remoteEntity === null) {
|
||||
$entity = $this->mailManager->entityCreate($tenantId, $userId, $target, $properties);
|
||||
} else {
|
||||
if ($remoteEntity->provider() !== $service->provider()
|
||||
|| (string)$remoteEntity->service() !== (string)$service->identifier()) {
|
||||
throw new RuntimeException('Changing the draft service is not supported yet');
|
||||
}
|
||||
$entity = $this->mailManager->entityModify($tenantId, $userId, $remoteEntity, $properties);
|
||||
}
|
||||
|
||||
if ($entity->collection() === null || $entity->identifier() === null) {
|
||||
throw new RuntimeException('Provider returned an incomplete draft identifier');
|
||||
}
|
||||
|
||||
$composed['remote'] = [
|
||||
...$remote,
|
||||
'status' => 'synced',
|
||||
'entity' => (string)new EntityIdentifier(
|
||||
$entity->provider(),
|
||||
(string)$entity->service(),
|
||||
(string)$entity->collection(),
|
||||
(string)$entity->identifier(),
|
||||
),
|
||||
'error' => null,
|
||||
];
|
||||
$result['disposition'] = 'saved';
|
||||
} catch (Throwable $throwable) {
|
||||
$composed['remote'] = [
|
||||
...$remote,
|
||||
'status' => 'failed',
|
||||
'entity' => $remote['entity'] ?? null,
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
$result['error'] = $throwable->getMessage();
|
||||
}
|
||||
|
||||
return $composed;
|
||||
};
|
||||
|
||||
// Synchronize and atomically persist the resulting remote state.
|
||||
$this->compositionStore->compositionSave(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$identifier,
|
||||
$synchronizeComposition,
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function send(string $tenantId, string $userId, string $identifier, array $sender, array $message, array $attachments): array {
|
||||
// Construct the snapshot mutation that will run while the composition is locked.
|
||||
$saveComposition = function (?array $composed) use ($sender, $message): ?array {
|
||||
if ($composed === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$composed['sender'] = $sender;
|
||||
$composed['message'] = $message;
|
||||
return $this->markRemoteDirty($composed);
|
||||
};
|
||||
|
||||
// Execute the mutation and atomically persist the snapshot it returns.
|
||||
$composed = $this->compositionStore->compositionSave(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$identifier,
|
||||
$saveComposition,
|
||||
);
|
||||
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
|
||||
|
||||
if ($composed === null) {
|
||||
return [
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
],
|
||||
$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';
|
||||
@@ -440,14 +188,7 @@ class CompositionManager {
|
||||
];
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
$source = null;
|
||||
$properties = $this->buildMessageProperties(
|
||||
service: $service,
|
||||
sender: $senderObject,
|
||||
@@ -472,8 +213,6 @@ class CompositionManager {
|
||||
];
|
||||
}
|
||||
|
||||
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
|
||||
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'sent'
|
||||
@@ -481,7 +220,7 @@ class CompositionManager {
|
||||
}
|
||||
|
||||
private function buildMessageProperties(
|
||||
ServiceEntitySubmitInterface|ServiceEntityMutableInterface $service,
|
||||
ServiceEntitySubmitInterface $service,
|
||||
AddressInterface $sender,
|
||||
array $message,
|
||||
array $attachments,
|
||||
@@ -553,310 +292,6 @@ class CompositionManager {
|
||||
|
||||
return $properties;
|
||||
}
|
||||
|
||||
public function attachmentAdd(string $tenantId, string $userId, string $composition, array ...$attachments): array {
|
||||
$result = [
|
||||
'composition' => $composition,
|
||||
'attachments' => [],
|
||||
];
|
||||
$failed = [];
|
||||
$compositionChanged = false;
|
||||
// Construct the snapshot mutation that will run while the composition is locked.
|
||||
$addAttachments = function (?array $composed) use ($tenantId, $userId, $composition, $attachments, &$result, &$failed, &$compositionChanged): ?array {
|
||||
if ($composed === null) {
|
||||
$result['disposition'] = 'error';
|
||||
$result['error'] = [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
];
|
||||
return null;
|
||||
}
|
||||
|
||||
$uploads = [];
|
||||
$documents = [];
|
||||
foreach ($attachments as $attachment) {
|
||||
if (isset($attachment['origin']) && $attachment['origin'] === 'documents') {
|
||||
$documents[] = $attachment;
|
||||
} elseif (isset($attachment['origin']) && $attachment['origin'] === 'device') {
|
||||
$uploads[] = $attachment;
|
||||
}
|
||||
}
|
||||
if ($uploads !== []) {
|
||||
$this->attachmentAddFromDevice($tenantId, $userId, $composition, $uploads, $composed);
|
||||
}
|
||||
if ($documents !== []) {
|
||||
$failed = $this->attachmentAddFromDocuments($tenantId, $userId, $composition, $documents, $composed);
|
||||
}
|
||||
|
||||
$deviceAttachmentsChanged = $uploads !== [];
|
||||
$documentAttachmentsChanged = count($failed) < count($documents);
|
||||
if ($deviceAttachmentsChanged || $documentAttachmentsChanged) {
|
||||
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
|
||||
$composed = $this->markRemoteDirty($composed);
|
||||
$compositionChanged = true;
|
||||
}
|
||||
|
||||
return $composed;
|
||||
};
|
||||
|
||||
// Execute the mutation and atomically persist the snapshot it returns.
|
||||
$composed = $this->compositionStore->compositionSave(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$composition,
|
||||
$addAttachments,
|
||||
);
|
||||
|
||||
if ($composed === null) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// construct the result
|
||||
$result['attachments'] = $composed['attachments'];
|
||||
$result['failed'] = $failed;
|
||||
$result['disposition'] = match (true) {
|
||||
$failed === [] => 'added',
|
||||
$result['attachments'] !== [] => 'partial',
|
||||
default => 'error',
|
||||
};
|
||||
if ($result['disposition'] === 'error') {
|
||||
$result['error'] = [
|
||||
'type' => 'attachment_add_failed',
|
||||
'message' => 'None of the requested attachments could be added',
|
||||
];
|
||||
}
|
||||
if ($compositionChanged) {
|
||||
$result['revision'] = (int)($composed['revision'] ?? 0);
|
||||
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
|
||||
$this->events->dispatch($event);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage device-uploaded (base64 payload) attachment entries
|
||||
*/
|
||||
private function attachmentAddFromDevice(string $tenantId, string $userId, string $composition, array $uploads, array &$composed): void {
|
||||
foreach ($uploads as $attachment) {
|
||||
$identifier = (isset($attachment['identifier']) && is_string($attachment['identifier']) && $attachment['identifier'] !== '')
|
||||
? $attachment['identifier']
|
||||
: UUID::v4();
|
||||
|
||||
$decoded = base64_decode((string) ($attachment['data'] ?? ''), true);
|
||||
if ($decoded === false) {
|
||||
throw new InvalidArgumentException('Attachment payload is not valid base64');
|
||||
}
|
||||
|
||||
$data = new BinaryResource(
|
||||
$attachment['name'] ?? 'unknown.bin',
|
||||
$attachment['type'] ?? 'application/octet-stream',
|
||||
$this->stringToGenerator($decoded),
|
||||
);
|
||||
|
||||
$meta = $this->compositionStore->attachmentStageFromStream(
|
||||
tenantId: $tenantId,
|
||||
userId: $userId,
|
||||
compositionId: $composition,
|
||||
attachmentId: $identifier,
|
||||
data: $data,
|
||||
);
|
||||
$meta['origin'] = 'device';
|
||||
$composed['attachments'][$meta['identifier']] = $meta;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveDocumentsManager(): ?DocumentsManager {
|
||||
$module = $this->moduleManager->fetch(self::DOCUMENTS_MODULE_HANDLE);
|
||||
if ($module === null || !$module->enabled()) {
|
||||
return null;
|
||||
}
|
||||
if (!$this->container->has(DocumentsManager::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->container->get(DocumentsManager::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage documents-sourced attachment entries
|
||||
*/
|
||||
private function attachmentAddFromDocuments(string $tenantId, string $userId, string $composition, array $sourced, array &$composed): array {
|
||||
$failed = [];
|
||||
|
||||
// The documents module is an optional dependency, so we must resolve it
|
||||
// lazily and handle the case where it is not installed or enabled.
|
||||
$documentsManager = $this->resolveDocumentsManager();
|
||||
if ($documentsManager === null) {
|
||||
foreach ($sourced as $attachment) {
|
||||
$failed[] = [
|
||||
'identifier' => (string) ($attachment['source'] ?? ''),
|
||||
'message' => 'Documents module is not installed or enabled',
|
||||
];
|
||||
}
|
||||
return $failed;
|
||||
}
|
||||
|
||||
$requested = [];
|
||||
foreach ($sourced as $attachment) {
|
||||
$rawIdentifier = (string) $attachment['source'];
|
||||
try {
|
||||
$parsed = ResourceIdentifier::fromString($rawIdentifier);
|
||||
} catch (InvalidArgumentException) {
|
||||
$failed[] = ['identifier' => $rawIdentifier, 'message' => 'Invalid document identifier'];
|
||||
continue;
|
||||
}
|
||||
if (!$parsed instanceof EntityIdentifier) {
|
||||
$failed[] = ['identifier' => $rawIdentifier, 'message' => 'Invalid document identifier'];
|
||||
continue;
|
||||
}
|
||||
$requested[] = ['attachment' => $attachment, 'identifier' => $parsed];
|
||||
}
|
||||
|
||||
if ($requested === []) {
|
||||
return $failed;
|
||||
}
|
||||
|
||||
$entities = $documentsManager->entityFetchBulk($tenantId, $userId, ...array_column($requested, 'identifier'));
|
||||
$entitiesByUrn = [];
|
||||
foreach ($entities as $entity) {
|
||||
$entitiesByUrn[$entity->urn()] = $entity;
|
||||
}
|
||||
|
||||
foreach ($requested as ['attachment' => $attachment, 'identifier' => $entityIdentifier]) {
|
||||
$urn = (string) $entityIdentifier;
|
||||
$entity = $entitiesByUrn[$urn] ?? null;
|
||||
if ($entity === null) {
|
||||
$failed[] = ['identifier' => $urn, 'message' => 'Document not found or access denied'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$resource = $documentsManager->entityReadStream($tenantId, $userId, $entityIdentifier);
|
||||
if ($resource === null) {
|
||||
$failed[] = ['identifier' => $urn, 'message' => 'Document content could not be read'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$identifier = (isset($attachment['identifier']) && is_string($attachment['identifier']) && $attachment['identifier'] !== '')
|
||||
? $attachment['identifier']
|
||||
: UUID::v4();
|
||||
|
||||
$properties = $entity->getProperties();
|
||||
$data = new BinaryResource($properties->getLabel(), $properties->getMime(), $this->streamToGenerator($resource));
|
||||
|
||||
$meta = $this->compositionStore->attachmentStageFromStream(
|
||||
tenantId: $tenantId,
|
||||
userId: $userId,
|
||||
compositionId: $composition,
|
||||
attachmentId: $identifier,
|
||||
data: $data,
|
||||
);
|
||||
$meta['origin'] = 'documents';
|
||||
$meta['source'] = $urn;
|
||||
$composed['attachments'][$meta['identifier']] = $meta;
|
||||
}
|
||||
|
||||
return $failed;
|
||||
}
|
||||
|
||||
public function attachmentRemove(string $tenantId, string $userId, string $composition, string $identifier): array {
|
||||
$result = [
|
||||
'composition' => $composition,
|
||||
'identifier' => $identifier,
|
||||
];
|
||||
|
||||
// Construct the snapshot mutation that will run while the composition is locked.
|
||||
$removeAttachment = function (?array $composed) use ($identifier, &$result): ?array {
|
||||
if ($composed === null) {
|
||||
$result['disposition'] = 'error';
|
||||
$result['error'] = [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
];
|
||||
return null;
|
||||
}
|
||||
if (!isset($composed['attachments'][$identifier])) {
|
||||
$result['disposition'] = 'error';
|
||||
$result['error'] = [
|
||||
'type' => 'attachment_not_found',
|
||||
'message' => 'Attachment not found',
|
||||
];
|
||||
return null;
|
||||
}
|
||||
|
||||
unset($composed['attachments'][$identifier]);
|
||||
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
|
||||
$composed = $this->markRemoteDirty($composed);
|
||||
$result['disposition'] = 'removed';
|
||||
$result['revision'] = $composed['revision'];
|
||||
return $composed;
|
||||
};
|
||||
|
||||
// Execute the mutation and atomically persist the snapshot it returns.
|
||||
$composed = $this->compositionStore->compositionSave(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$composition,
|
||||
$removeAttachment,
|
||||
);
|
||||
if (($result['disposition'] ?? null) === 'removed' && $composed !== null) {
|
||||
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
|
||||
$this->events->dispatch($event);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function markRemoteDirty(array $composed): array {
|
||||
$remote = isset($composed['remote']) && is_array($composed['remote'])
|
||||
? $composed['remote']
|
||||
: [];
|
||||
|
||||
$remote['status'] = 'dirty';
|
||||
$remote['entity'] ??= null;
|
||||
$remote['error'] = null;
|
||||
$composed['remote'] = $remote;
|
||||
|
||||
return $composed;
|
||||
}
|
||||
|
||||
private function resolveDraftCollection(ServiceBaseInterface $service): CollectionIdentifier {
|
||||
$configuredTarget = $service->getAuxiliary()['draftTarget'] ?? null;
|
||||
if ($configuredTarget !== null && (string)$configuredTarget !== '') {
|
||||
return new CollectionIdentifier(
|
||||
$service->provider(),
|
||||
(string)$service->identifier(),
|
||||
(string)$configuredTarget,
|
||||
);
|
||||
}
|
||||
|
||||
$filter = $service->collectionListFilter();
|
||||
$filter->condition('role', CollectionRoles::Drafts->value);
|
||||
$collections = $service->collectionList('', $filter);
|
||||
$collection = reset($collections);
|
||||
if (!$collection instanceof CollectionBaseInterface || $collection->identifier() === null) {
|
||||
throw new RuntimeException("Mail service '{$service->identifier()}' has no Drafts collection");
|
||||
}
|
||||
|
||||
return new CollectionIdentifier(
|
||||
$service->provider(),
|
||||
(string)$service->identifier(),
|
||||
(string)$collection->identifier(),
|
||||
);
|
||||
}
|
||||
|
||||
private function remoteEntityIdentifier(mixed $value): ?EntityIdentifier {
|
||||
if (!is_string($value) || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$identifier = ResourceIdentifier::fromString($value);
|
||||
if (!$identifier instanceof EntityIdentifier) {
|
||||
throw new RuntimeException("Invalid remote draft identifier '$value'");
|
||||
}
|
||||
|
||||
return $identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array|string> $entries
|
||||
@@ -884,21 +319,78 @@ class CompositionManager {
|
||||
|
||||
return $addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an in-memory byte string in a single-chunk Generator.
|
||||
*/
|
||||
private function stringToGenerator(string $bytes): \Generator {
|
||||
yield $bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a raw PHP stream resource in a Generator yielding chunks, closing it once exhausted.
|
||||
*/
|
||||
private function streamToGenerator($resource): \Generator {
|
||||
while (!feof($resource)) {
|
||||
yield fread($resource, 65536);
|
||||
|
||||
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;
|
||||
}
|
||||
fclose($resource);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\Mail;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXF\Event\Event;
|
||||
|
||||
final class CompositionSavedEvent extends Event {
|
||||
|
||||
public function __construct(
|
||||
string $tenantId,
|
||||
private readonly string $userId,
|
||||
private readonly string $compositionId,
|
||||
private readonly int $revision,
|
||||
) {
|
||||
if ($tenantId === '' || $this->userId === '' || $this->compositionId === '') {
|
||||
throw new InvalidArgumentException('Composition save events require tenant, user, and composition identifiers');
|
||||
}
|
||||
if ($this->revision < 0) {
|
||||
throw new InvalidArgumentException('Composition save event revision cannot be negative');
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
self::class,
|
||||
[
|
||||
'userId' => $this->userId,
|
||||
'compositionId' => $this->compositionId,
|
||||
'revision' => $this->revision,
|
||||
],
|
||||
$tenantId,
|
||||
$this->userId,
|
||||
);
|
||||
}
|
||||
|
||||
public function userIdentifier(): string {
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function compositionIdentifier(): string {
|
||||
return $this->compositionId;
|
||||
}
|
||||
|
||||
public function revision(): int {
|
||||
return $this->revision;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\Mail;
|
||||
|
||||
final class CompositionSyncListener {
|
||||
|
||||
public function __construct(
|
||||
private readonly CompositionManager $compositionManager,
|
||||
) {}
|
||||
|
||||
public function onCompositionSaved(CompositionSavedEvent $event): void {
|
||||
$this->compositionManager->synchronize(
|
||||
$event->tenantIdentifier(),
|
||||
$event->userIdentifier(),
|
||||
$event->compositionIdentifier(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ 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 KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use KTXM\Mail\CompositionManager;
|
||||
@@ -18,8 +18,8 @@ use Throwable;
|
||||
final class CompositionController extends ControllerAbstract {
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly CompositionManager $compositionManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
@@ -31,8 +31,8 @@ final class CompositionController extends ControllerAbstract {
|
||||
string|null $operation = null,
|
||||
array|null $data = null,
|
||||
): Response {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$userId = $this->identityContext->identifier();
|
||||
$tenantId = $this->tenantIdentity->identifier();
|
||||
$userId = $this->userIdentity->identifier();
|
||||
|
||||
try {
|
||||
if ($operation === null) {
|
||||
@@ -42,7 +42,6 @@ final class CompositionController extends ControllerAbstract {
|
||||
$result = match ($operation) {
|
||||
'stage' => $this->stage($tenantId, $userId, $data),
|
||||
'patch' => $this->patch($tenantId, $userId, $data),
|
||||
'save' => $this->save($tenantId, $userId, $data),
|
||||
'discard' => $this->discard($tenantId, $userId, $data),
|
||||
'send' => $this->send($tenantId, $userId, $data),
|
||||
'attachment.add' => $this->attachmentAdd($tenantId, $userId, $data),
|
||||
@@ -80,8 +79,8 @@ final class CompositionController extends ControllerAbstract {
|
||||
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', 'replyAll', 'forward'], true)) {
|
||||
throw new InvalidArgumentException('Invalid parameter: action must be one of fresh, reply, replyAll, forward');
|
||||
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');
|
||||
@@ -89,8 +88,8 @@ final class CompositionController extends ControllerAbstract {
|
||||
if (!isset($data['message']) || !is_array($data['message'])) {
|
||||
throw new InvalidArgumentException('Invalid parameter: message must be an array');
|
||||
}
|
||||
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, replyAll, or forward action');
|
||||
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);
|
||||
@@ -109,21 +108,6 @@ final class CompositionController extends ControllerAbstract {
|
||||
|
||||
return $this->compositionManager->patch($tenantId, $userId, $data['identifier'], $data);
|
||||
}
|
||||
|
||||
private function save(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->save($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');
|
||||
|
||||
+14
-23
@@ -2,32 +2,17 @@
|
||||
|
||||
namespace KTXM\Mail;
|
||||
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistrarInterface;
|
||||
use KTXF\Module\Configuration\BrowserModuleContextInterface;
|
||||
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
|
||||
/**
|
||||
* Mail Module - Email Client
|
||||
*/
|
||||
class Module extends ModuleInstanceAbstract
|
||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly EventListenerRegistrarInterface $events,
|
||||
) {}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->events->listen(
|
||||
$this->handle(),
|
||||
CompositionSavedEvent::class,
|
||||
CompositionSyncListener::class,
|
||||
'onCompositionSaved',
|
||||
DeliveryMode::Deferred,
|
||||
);
|
||||
}
|
||||
public function __construct()
|
||||
{ }
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
@@ -80,10 +65,16 @@ class Module extends ModuleInstanceAbstract
|
||||
];
|
||||
}
|
||||
|
||||
public function configure(ModuleContextInterface $context): void
|
||||
public function registerBI(): array
|
||||
{
|
||||
if ($context instanceof BrowserModuleContextInterface) {
|
||||
$context->registerModule($this, 'Mail', 'static/module.mjs');
|
||||
}
|
||||
return [
|
||||
'handle' => $this->handle(),
|
||||
'namespace' => 'Mail',
|
||||
'version' => $this->version(),
|
||||
'label' => $this->label(),
|
||||
'author' => $this->author(),
|
||||
'description' => $this->description(),
|
||||
'boot' => 'static/module.mjs',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+64
-112
@@ -6,8 +6,6 @@ namespace KTXM\Mail\Stores;
|
||||
|
||||
use DI\Attribute\Inject;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use RuntimeException;
|
||||
use UnexpectedValueException;
|
||||
|
||||
final class CompositionStore {
|
||||
|
||||
@@ -22,121 +20,47 @@ final class CompositionStore {
|
||||
}
|
||||
|
||||
public function compositionFetch(string $tenantId, string $userId, string $draftId): ?array {
|
||||
return $this->withCompositionLock(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$draftId,
|
||||
LOCK_SH,
|
||||
fn(): ?array => $this->compositionRead($tenantId, $userId, $draftId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically read, mutate, and replace a composition snapshot.
|
||||
*
|
||||
* Returning null from the callback leaves the stored snapshot unchanged.
|
||||
* The callback receives null when the composition does not exist.
|
||||
*
|
||||
* @param callable(?array): ?array $update
|
||||
*/
|
||||
public function compositionSave(string $tenantId, string $userId, string $draftId, callable $update): ?array {
|
||||
return $this->withCompositionLock(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$draftId,
|
||||
LOCK_EX,
|
||||
function () use ($tenantId, $userId, $draftId, $update): ?array {
|
||||
$snapshot = $update($this->compositionRead($tenantId, $userId, $draftId));
|
||||
if ($snapshot === null) {
|
||||
return null;
|
||||
}
|
||||
if (!is_array($snapshot)) {
|
||||
throw new UnexpectedValueException('Composition save callback must return an array or null');
|
||||
}
|
||||
|
||||
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
$this->ensureDirectory($draftDir);
|
||||
|
||||
$temporaryPath = tempnam($draftDir, '.composition-');
|
||||
if ($temporaryPath === false) {
|
||||
throw new RuntimeException("Unable to create a temporary composition file for '$draftId'");
|
||||
}
|
||||
|
||||
try {
|
||||
$written = file_put_contents($temporaryPath, $json);
|
||||
if ($written !== strlen($json)) {
|
||||
throw new RuntimeException("Unable to write composition '$draftId'");
|
||||
}
|
||||
if (!rename($temporaryPath, $draftDir . '/' . self::COMPOSITION_FILENAME)) {
|
||||
throw new RuntimeException("Unable to replace composition '$draftId'");
|
||||
}
|
||||
} finally {
|
||||
if (is_file($temporaryPath)) {
|
||||
unlink($temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool {
|
||||
return $this->withCompositionLock(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$draftId,
|
||||
LOCK_EX,
|
||||
function () use ($tenantId, $userId, $draftId): bool {
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
if (!is_dir($draftDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->deleteDir($draftDir);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private function compositionRead(string $tenantId, string $userId, string $draftId): ?array {
|
||||
$messagePath = $this->draftDir($tenantId, $userId, $draftId) . '/' . self::COMPOSITION_FILENAME;
|
||||
if (!is_file($messagePath)) {
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
if (!is_dir($draftDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($messagePath);
|
||||
if ($contents === false) {
|
||||
throw new RuntimeException("Unable to read composition '$draftId'");
|
||||
$messagePath = $draftDir . '/' . self::COMPOSITION_FILENAME;
|
||||
if (!file_exists($messagePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($contents, true);
|
||||
$decoded = json_decode((string)file_get_contents($messagePath), true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function withCompositionLock(string $tenantId, string $userId, string $draftId, int $operation, callable $callback): mixed {
|
||||
$lockDir = $this->storagePath . '/.locks';
|
||||
$this->ensureDirectory($lockDir);
|
||||
$lockId = hash('sha256', $tenantId . "\0" . $userId . "\0" . $draftId);
|
||||
$handle = fopen($lockDir . '/' . $lockId . '.lock', 'c+b');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException("Unable to open composition lock for '$draftId'");
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($handle, $operation)) {
|
||||
throw new RuntimeException("Unable to lock composition '$draftId'");
|
||||
}
|
||||
|
||||
return $callback();
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
file_put_contents($draftDir . '/' . self::COMPOSITION_FILENAME, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
public function attachmentStageFromStream(string $tenantId, string $userId, string $compositionId, string $attachmentId, BinaryResource $data): array {
|
||||
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);
|
||||
@@ -167,6 +91,40 @@ final class CompositionStore {
|
||||
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)) {
|
||||
@@ -194,12 +152,6 @@ final class CompositionStore {
|
||||
return $this->storagePath . '/' . $tenantId . '/' . $userId . '/' . $draftId;
|
||||
}
|
||||
|
||||
private function ensureDirectory(string $path): void {
|
||||
if (!is_dir($path) && !mkdir($path, 0755, true) && !is_dir($path)) {
|
||||
throw new RuntimeException("Unable to create directory '$path'");
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteDir(string $path): void {
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
@@ -221,4 +173,4 @@ final class CompositionStore {
|
||||
|
||||
rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+520
-2307
File diff suppressed because it is too large
Load Diff
+3
-11
@@ -11,11 +11,7 @@
|
||||
"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",
|
||||
@@ -32,12 +28,8 @@
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"typescript": "~6.0.0",
|
||||
"typescript": "^6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vue-tsc": "^3.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"vitest": "^4.1.6"
|
||||
"vue-tsc": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ import type { CollectionObject, MessageAddressObject } from '@MailManager/models
|
||||
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
||||
import { useMailCompositionStore } from '@/stores/mailCompositionStore'
|
||||
import { ComposerMode } from '@/types/composer'
|
||||
import type { ComposerFormat } from '@/types/composer'
|
||||
import { documentsSelectorComponent } from '@/services/documentsSelector'
|
||||
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
|
||||
import ComposerSender from '@/components/composer/ComposerSender.vue'
|
||||
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
|
||||
@@ -49,13 +47,7 @@ const {
|
||||
// State
|
||||
const showCc = ref(false)
|
||||
const showBcc = ref(false)
|
||||
const format = ref<ComposerFormat>('rich')
|
||||
const textEdited = ref(false)
|
||||
const applyingDraftToEditor = ref(false)
|
||||
const showDocumentSelector = ref(false)
|
||||
const showCloseDialog = ref(false)
|
||||
const closeAction = ref<'save' | 'discard' | null>(null)
|
||||
const documentSelector = computed(() => documentsSelectorComponent())
|
||||
|
||||
const sender = computed({
|
||||
get: () => activeDraft.value?.sender ?? null,
|
||||
@@ -88,14 +80,6 @@ const subject = computed({
|
||||
|
||||
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
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -129,8 +113,6 @@ watch(
|
||||
compositionStore.openDraft(props.mode, props.source)
|
||||
showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0
|
||||
showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0
|
||||
format.value = 'rich'
|
||||
textEdited.value = false
|
||||
|
||||
applyingDraftToEditor.value = true
|
||||
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
|
||||
@@ -175,55 +157,17 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
// Handlers
|
||||
const handleClose = () => {
|
||||
showCloseDialog.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
closeAction.value = 'save'
|
||||
try {
|
||||
if (await compositionStore.saveDraft()) {
|
||||
showCloseDialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
} finally {
|
||||
closeAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscard = async () => {
|
||||
closeAction.value = 'discard'
|
||||
try {
|
||||
if (await compositionStore.discardDraft()) {
|
||||
showCloseDialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
} finally {
|
||||
closeAction.value = null
|
||||
}
|
||||
const handleClose = async () => {
|
||||
await compositionStore.closeDraft()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
await compositionStore.sendDraft()
|
||||
}
|
||||
|
||||
const handleAttachFromDevice = async (files: File[]) => {
|
||||
await compositionStore.addAttachmentsFromDevice(files)
|
||||
}
|
||||
|
||||
const handleAttachFromDocuments = () => {
|
||||
showDocumentSelector.value = true
|
||||
}
|
||||
|
||||
const handleDocumentsSelected = async (items: { kind: 'entity' | 'collection'; identifier: string; name: string; mimeType?: string; size?: number }[]) => {
|
||||
// The dialog defaults to entities-only (no `selectable` prop passed below), but
|
||||
// filter defensively since mail can't attach a folder as a single file blob.
|
||||
const files = items.filter(item => item.kind === 'entity')
|
||||
if (files.length === 0) {
|
||||
return
|
||||
}
|
||||
await compositionStore.addAttachmentsFromDocuments(files)
|
||||
showDocumentSelector.value = false
|
||||
const handleAttach = async (files: File[]) => {
|
||||
await compositionStore.addAttachments(files)
|
||||
}
|
||||
|
||||
const handleDetach = async (identifier: string) => {
|
||||
@@ -262,42 +206,6 @@ const toggleLink = () => {
|
||||
const isActive = (name: string, attrs?: any) => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -307,9 +215,7 @@ function escapeHtml(value: string): string {
|
||||
:status="stageStatus"
|
||||
:can-send="canSend"
|
||||
:sending="sending"
|
||||
:saving="saving"
|
||||
@close="handleClose"
|
||||
@save="handleSave"
|
||||
@send="handleSend"
|
||||
/>
|
||||
|
||||
@@ -346,8 +252,6 @@ function escapeHtml(value: string): string {
|
||||
|
||||
<ComposerEditor
|
||||
:editor="editor"
|
||||
:format="format"
|
||||
:text="text"
|
||||
:is-bold-active="isActive('bold')"
|
||||
:is-italic-active="isActive('italic')"
|
||||
:is-underline-active="isActive('underline')"
|
||||
@@ -360,52 +264,9 @@ function escapeHtml(value: string): string {
|
||||
@bullet-list="toggleBulletList"
|
||||
@ordered-list="toggleOrderedList"
|
||||
@link="toggleLink"
|
||||
@toggle-format="toggleFormat"
|
||||
@update:text="text = $event"
|
||||
@attach-from-device="handleAttachFromDevice"
|
||||
@attach-from-documents="handleAttachFromDocuments"
|
||||
@attach="handleAttach"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="documentSelector"
|
||||
v-if="documentSelector"
|
||||
v-model="showDocumentSelector"
|
||||
:selectable="['entity']"
|
||||
@select="handleDocumentsSelected"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="showCloseDialog" max-width="480" persistent>
|
||||
<v-card>
|
||||
<v-card-title>Save this draft?</v-card-title>
|
||||
<v-card-text>
|
||||
Save the draft to your mail server, or discard it permanently.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn :disabled="closeAction !== null" @click="showCloseDialog = false">
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="text"
|
||||
:loading="closeAction === 'discard'"
|
||||
:disabled="closeAction !== null || saving"
|
||||
@click="handleDiscard"
|
||||
>
|
||||
Discard
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="closeAction === 'save'"
|
||||
:disabled="closeAction !== null || saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
Save and close
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -16,14 +16,12 @@ interface Props {
|
||||
selectionMode?: boolean
|
||||
selectedCollection?: CollectionObject | null
|
||||
loading?: boolean
|
||||
searchActive?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
selectionList: () => [],
|
||||
selectionMode: false,
|
||||
searchActive: false,
|
||||
})
|
||||
|
||||
// Emits
|
||||
@@ -374,10 +372,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="messages.length === 0" class="pa-8 text-center">
|
||||
<v-icon size="64" color="grey-lighten-1">{{ searchActive ? 'mdi-email-search-outline' : 'mdi-email-outline' }}</v-icon>
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-email-outline</v-icon>
|
||||
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ searchActive ? 'No messages match your search' : 'This folder is empty' }}
|
||||
This folder is empty
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { usePreviewStore, useUser } from '@KTXC'
|
||||
import { useUser } from '@KTXC'
|
||||
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||
import type { EntityObject, MessageObject } from '@MailManager/models'
|
||||
import { SecurityLevel } from '@/utile/emailSanitizer'
|
||||
@@ -19,7 +19,6 @@ const props = defineProps<{
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
reply: [entity: EntityObject]
|
||||
replyAll: [entity: EntityObject]
|
||||
forward: [entity: EntityObject]
|
||||
move: [entity: EntityObject]
|
||||
delete: [entity: EntityObject]
|
||||
@@ -29,7 +28,6 @@ const emit = defineEmits<{
|
||||
|
||||
// User settings
|
||||
const { getSetting } = useUser()
|
||||
const previewStore = usePreviewStore()
|
||||
const mailUiStore = useMailUiStore()
|
||||
|
||||
// Security overrides
|
||||
@@ -66,7 +64,6 @@ const effectiveSecurityLevel = computed(() => {
|
||||
// Preview state
|
||||
const previewDialog = computed(() => mediaViewerComponent('dialog'))
|
||||
const previewItems = computed<MediaViewerItem[]>(() => {
|
||||
if (!previewStore.availability.storage) return []
|
||||
const attachments = message.value?.attachments ?? []
|
||||
const items: MediaViewerItem[] = []
|
||||
attachments.forEach((attachment, index) => {
|
||||
@@ -110,12 +107,6 @@ const handleReply = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleReplyAll = () => {
|
||||
if (props.entity) {
|
||||
emit('replyAll', props.entity)
|
||||
}
|
||||
}
|
||||
|
||||
const handleForward = () => {
|
||||
if (props.entity) {
|
||||
emit('forward', props.entity)
|
||||
@@ -161,7 +152,7 @@ const handlePreview = (index: number) => {
|
||||
|
||||
const handleViewerRetrieve = (item: MediaViewerItem): Promise<Blob> => {
|
||||
if (!props.entity) return Promise.reject(new Error('No message selected'))
|
||||
return mailUiStore.attachmentPreviewBlob(props.entity, item.meta.attachment, 'fullscreen')
|
||||
return mailUiStore.attachmentBlob(props.entity, item.meta.attachment)
|
||||
}
|
||||
|
||||
const handleViewerDownload = (item: MediaViewerItem) => {
|
||||
@@ -185,7 +176,6 @@ const handleViewerDownload = (item: MediaViewerItem) => {
|
||||
:security-level="effectiveSecurityLevel"
|
||||
:is-security-overridden="overrideSecurityLevel !== null"
|
||||
@reply="handleReply"
|
||||
@reply-all="handleReplyAll"
|
||||
@forward="handleForward"
|
||||
@move="handleMove"
|
||||
@delete="handleDelete"
|
||||
|
||||
@@ -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>
|
||||
@@ -2,21 +2,12 @@
|
||||
import { ref } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { EditorContent, type Editor } from '@tiptap/vue-3'
|
||||
import { documentsSelectorAvailable } from '@/services/documentsSelector'
|
||||
|
||||
defineProps({
|
||||
editor: {
|
||||
type: Object as PropType<Editor | null>,
|
||||
default: null,
|
||||
},
|
||||
format: {
|
||||
type: String as PropType<'rich' | 'plain'>,
|
||||
required: true,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isBoldActive: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
@@ -50,10 +41,7 @@ const emit = defineEmits<{
|
||||
bulletList: []
|
||||
orderedList: []
|
||||
link: []
|
||||
toggleFormat: []
|
||||
'update:text': [value: string]
|
||||
attachFromDevice: [files: File[]]
|
||||
attachFromDocuments: []
|
||||
attach: [files: File[]]
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -66,7 +54,7 @@ function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement | null
|
||||
const files = input?.files ? Array.from(input.files) : []
|
||||
if (files.length > 0) {
|
||||
emit('attachFromDevice', files)
|
||||
emit('attach', files)
|
||||
}
|
||||
if (input) {
|
||||
input.value = ''
|
||||
@@ -76,84 +64,68 @@ function handleFileChange(event: Event) {
|
||||
|
||||
<template>
|
||||
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
|
||||
<template v-if="format === 'rich'">
|
||||
<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-divider vertical class="mx-2" />
|
||||
</template>
|
||||
<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': format === 'plain' }"
|
||||
@click="$emit('toggleFormat')"
|
||||
:class="{ 'v-btn--active': isItalicActive }"
|
||||
@click="$emit('italic')"
|
||||
>
|
||||
<v-icon>{{ format === 'plain' ? 'mdi-format-text' : 'mdi-code-tags' }}</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">
|
||||
{{ format === 'plain' ? 'Rich Text' : 'Plain Text' }}
|
||||
</v-tooltip>
|
||||
<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 />
|
||||
@@ -170,27 +142,12 @@ function handleFileChange(event: Event) {
|
||||
<v-icon>mdi-paperclip</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="documentsSelectorAvailable()" icon size="small" @click="$emit('attachFromDocuments')">
|
||||
<v-icon>mdi-folder-search-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Attach from Documents</v-tooltip>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div class="editor-container">
|
||||
<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)"
|
||||
/>
|
||||
<EditorContent :editor="editor ?? undefined" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -215,11 +172,6 @@ function handleFileChange(event: Event) {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.text-editor :deep(textarea) {
|
||||
min-height: 300px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
:deep(.tiptap-editor) {
|
||||
outline: none;
|
||||
min-height: 300px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComposerSenderIdentity } from '@/types/composer';
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: ComposerSenderIdentity | null
|
||||
@@ -27,6 +27,11 @@ const items = computed<SenderListItem[]>(() => {
|
||||
|
||||
const selectedValue = computed(() => props.modelValue?.address ?? null)
|
||||
|
||||
const singleOptionLabel = computed(() => {
|
||||
const onlyOption = props.options[0]
|
||||
return onlyOption ? formatSenderLabel(onlyOption.label, onlyOption.address) : ''
|
||||
})
|
||||
|
||||
const errorMessages = computed(() => {
|
||||
if (props.options.length === 0) {
|
||||
return ['No send-capable account is available.']
|
||||
@@ -35,18 +40,6 @@ const errorMessages = computed(() => {
|
||||
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)
|
||||
@@ -58,8 +51,19 @@ function formatSenderLabel(label: string | null | undefined, address: string): s
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="options.length !== 1" class="composer-sender px-4 pt-4 pb-0">
|
||||
<div class="composer-sender px-4 pt-4 pb-0">
|
||||
<v-text-field
|
||||
v-if="options.length === 1"
|
||||
:model-value="singleOptionLabel"
|
||||
label="From"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
readonly
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-else
|
||||
:model-value="selectedValue"
|
||||
:items="items"
|
||||
item-title="title"
|
||||
|
||||
@@ -6,14 +6,12 @@ interface Props {
|
||||
status: string
|
||||
canSend: boolean
|
||||
sending: boolean
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
save: []
|
||||
send: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -30,7 +28,7 @@ defineEmits<{
|
||||
</v-btn>
|
||||
|
||||
<v-toolbar-title>
|
||||
{{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.ReplyAll ? 'Reply All' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
|
||||
{{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer />
|
||||
@@ -39,20 +37,9 @@ defineEmits<{
|
||||
{{ status }}
|
||||
</span>
|
||||
|
||||
<v-btn
|
||||
class="mr-2"
|
||||
variant="text"
|
||||
:loading="saving"
|
||||
:disabled="sending"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
Save
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
:disabled="!canSend || saving"
|
||||
:disabled="!canSend"
|
||||
:loading="sending"
|
||||
prepend-icon="mdi-send"
|
||||
@click="$emit('send')"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { usePreviewStore } from '@KTXC'
|
||||
import RecipientDetails from '@/components/common/RecipientDetails.vue'
|
||||
import { formatFileSize } from '@/utile/format'
|
||||
import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer'
|
||||
@@ -28,9 +27,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const mailUiStore = useMailUiStore()
|
||||
const previewStore = usePreviewStore()
|
||||
const previewsAvailable = computed(() => previewStore.availability.storage)
|
||||
const previewPopover = computed(() => previewsAvailable.value ? mediaViewerComponent('popover') : null)
|
||||
const previewPopover = computed(() => mediaViewerComponent('popover'))
|
||||
|
||||
const message = computed(() => {
|
||||
return props.entity?.properties ?? null
|
||||
@@ -51,7 +48,7 @@ const attachmentItems = computed<AttachmentItem[]>(() =>
|
||||
)
|
||||
|
||||
const isPreviewable = (attachment: MessagePartInterface): boolean => {
|
||||
return previewsAvailable.value && mediaViewerCanDisplay(attachment.type)
|
||||
return mediaViewerCanDisplay(attachment.type)
|
||||
}
|
||||
|
||||
const handleAttachmentClick = (attachment: MessagePartInterface, index: number): void => {
|
||||
@@ -64,7 +61,7 @@ const handleAttachmentClick = (attachment: MessagePartInterface, index: number):
|
||||
|
||||
const handleViewerRetrieve = (item: AttachmentItem): Promise<Blob> => {
|
||||
if (!props.entity) return Promise.reject(new Error('No message selected'))
|
||||
return mailUiStore.attachmentPreviewBlob(props.entity, item.meta.attachment, 'inline')
|
||||
return mailUiStore.attachmentBlob(props.entity, item.meta.attachment)
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
|
||||
@@ -13,7 +13,6 @@ const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
reply: []
|
||||
replyAll: []
|
||||
forward: []
|
||||
move: []
|
||||
delete: []
|
||||
@@ -48,7 +47,7 @@ const currentSecurityLevel = computed(() => {
|
||||
<v-btn
|
||||
icon="mdi-reply-all"
|
||||
variant="text"
|
||||
@click="emit('replyAll')"
|
||||
@click="emit('reply')"
|
||||
>
|
||||
<v-icon>mdi-reply-all</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Reply All</v-tooltip>
|
||||
|
||||
@@ -40,11 +40,10 @@ 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>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, unref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, unref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useModuleStore } from '@KTXC'
|
||||
@@ -17,7 +17,7 @@ import DeleteFolderDialog from '@/components/DeleteFolderDialog.vue'
|
||||
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
|
||||
import RenameFolderDialog from '@/components/RenameFolderDialog.vue'
|
||||
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
|
||||
import Sidebar from '@/components/Sidebar.vue'
|
||||
import FolderView from '@/components/FolderView.vue'
|
||||
|
||||
// Vuetify display for responsive behavior
|
||||
const display = useDisplay()
|
||||
@@ -41,11 +41,10 @@ const {
|
||||
loading,
|
||||
selectedMessage,
|
||||
currentMessages,
|
||||
searchResults,
|
||||
searching,
|
||||
} = storeToRefs(mailOperationsStore)
|
||||
|
||||
const {
|
||||
sidebarVisible,
|
||||
settingsDialogVisible,
|
||||
selectedFolder,
|
||||
composerMode,
|
||||
@@ -76,19 +75,21 @@ const {
|
||||
// Complex store/composable objects accessed directly (not simple refs)
|
||||
const { mailSync, entitiesStore } = mailOperationsStore
|
||||
|
||||
// Message search — debounced server-side search of the selected folder
|
||||
// Message search (filters the current message list by subject/sender)
|
||||
const searchQuery = ref('')
|
||||
|
||||
watch(searchQuery, query => mailOperationsStore.searchMessages(query))
|
||||
const filteredMessages = computed(() => {
|
||||
const query = (searchQuery.value ?? '').trim().toLowerCase()
|
||||
if (!query) return currentMessages.value
|
||||
|
||||
// Reset the search box when the folder changes so it doesn't show a stale
|
||||
// query for a folder whose results are no longer being filtered.
|
||||
watch(selectedFolder, () => {
|
||||
searchQuery.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 filteredMessages = computed(() => searchResults.value ?? currentMessages.value)
|
||||
|
||||
const lastSyncLabel = computed(() => {
|
||||
const lastSync = unref(unref(mailSync.lastSync))
|
||||
|
||||
@@ -110,6 +111,8 @@ const {
|
||||
|
||||
const sidebarToggle = () => mailUiStore.sidebarToggle()
|
||||
|
||||
const handleSettingsOpen = () => mailUiStore.settingsOpen()
|
||||
|
||||
const handleFolderSelect = (folder: CollectionObject) => mailUiStore.selectFolder(folder)
|
||||
|
||||
const handleFolderCreateConfirm = async (folderName: string) => {
|
||||
@@ -120,7 +123,7 @@ const handleFolderCreateConfirm = async (folderName: string) => {
|
||||
handleFolderSelect(mutatedFolder)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('[Mail] Failed to create folder:', error)
|
||||
console.error('[MailPage] Failed to create folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +135,7 @@ const handleFolderEditConfirm = async (folderName: string) => {
|
||||
handleFolderSelect(mutatedFolder)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('[Mail] Failed to rename folder:', error)
|
||||
console.error('[MailPage] Failed to rename folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +143,7 @@ const handleFolderDeleteConfirm = async () => {
|
||||
try {
|
||||
await mailUiStore.confirmDeleteFolder()
|
||||
} catch (error: unknown) {
|
||||
console.error('[Mail] Failed to delete folder:', error)
|
||||
console.error('[MailPage] Failed to delete folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +151,7 @@ const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
|
||||
try {
|
||||
await mailUiStore.confirmMoveFolder(targetFolder)
|
||||
} catch (error: unknown) {
|
||||
console.error('[Mail] Failed to move folder:', error)
|
||||
console.error('[MailPage] Failed to move folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,8 +170,6 @@ const handleMessageComposeFresh = () => mailUiStore.openComposer()
|
||||
|
||||
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 handleMessageComposeClose = () => mailUiStore.closeComposer()
|
||||
@@ -252,7 +253,6 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
variant="outlined"
|
||||
placeholder="Search messages..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:loading="searching"
|
||||
hide-details
|
||||
single-line
|
||||
clearable
|
||||
@@ -294,7 +294,33 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
<!-- Main content area -->
|
||||
<div class="mail-content">
|
||||
<!-- Folder tree sidebar -->
|
||||
<Sidebar />
|
||||
<v-navigation-drawer
|
||||
v-model="sidebarVisible"
|
||||
contained
|
||||
:permanent="sidebarVisible && !isMobile"
|
||||
:temporary="isMobile"
|
||||
width="280"
|
||||
class="mail-sidebar"
|
||||
>
|
||||
|
||||
<FolderView
|
||||
:selected-folder="selectedFolder"
|
||||
@select="handleFolderSelect"
|
||||
/>
|
||||
|
||||
<template #append>
|
||||
<div class="pa-2">
|
||||
<v-btn
|
||||
block
|
||||
variant="text"
|
||||
prepend-icon="mdi-cog"
|
||||
@click="handleSettingsOpen"
|
||||
>
|
||||
Settings
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- Main area with message list and reader -->
|
||||
<div class="mail-main">
|
||||
@@ -311,7 +337,6 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
:selection-list="selectionList"
|
||||
:selection-mode="selectionMode"
|
||||
:loading="loading"
|
||||
:search-active="searchResults !== null"
|
||||
@open="handleMessageOpen"
|
||||
@reply="handleMessageComposeReply"
|
||||
@forward="handleMessageComposeForward"
|
||||
@@ -343,7 +368,6 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
:entity="selectedMessage"
|
||||
@compose="handleMessageComposeFresh"
|
||||
@reply="handleMessageComposeReply"
|
||||
@reply-all="handleMessageComposeReplyAll"
|
||||
@forward="handleMessageComposeForward"
|
||||
@move="handleMessageMove"
|
||||
@delete="handleMessageDelete"
|
||||
@@ -466,6 +490,11 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mail-sidebar {
|
||||
border-right: 1px solid rgb(var(--v-border-color));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mail-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
+1
-1
@@ -2,7 +2,7 @@ const routes = [
|
||||
{
|
||||
name: 'mail',
|
||||
path: '/',
|
||||
component: () => import('@/pages/Main.vue'),
|
||||
component: () => import('@/pages/MailPage.vue'),
|
||||
meta: {
|
||||
title: 'Mail',
|
||||
requiresAuth: true,
|
||||
|
||||
@@ -9,8 +9,6 @@ import type {
|
||||
CompositionDiscardResponse,
|
||||
CompositionPatchRequest,
|
||||
CompositionPatchResponse,
|
||||
CompositionSaveRequest,
|
||||
CompositionSaveResponse,
|
||||
CompositionSendRequest,
|
||||
CompositionSendResponse,
|
||||
CompositionStageRequest,
|
||||
@@ -52,10 +50,6 @@ const compositionService = {
|
||||
return await post<CompositionPatchRequest, CompositionPatchResponse>('patch', request)
|
||||
},
|
||||
|
||||
async save(request: CompositionSaveRequest): Promise<CompositionSaveResponse> {
|
||||
return await post<CompositionSaveRequest, CompositionSaveResponse>('save', request)
|
||||
},
|
||||
|
||||
async discard(request: CompositionDiscardRequest): Promise<CompositionDiscardResponse> {
|
||||
return await post<CompositionDiscardRequest, CompositionDiscardResponse>('discard', request)
|
||||
},
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* documentsSelector — bridges the Mail composer to documents_manager's
|
||||
* document/collection picker dialog, resolved at runtime via the integration
|
||||
* store. documents_manager (not the end-user `documents` app) registers this,
|
||||
* since it's the module designed to be depended on by other feature modules;
|
||||
* this mirrors CompositionManager's own ModuleManager-based check on the
|
||||
* backend, which also targets documents_manager.
|
||||
*/
|
||||
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { useIntegrationStore } from '@KTXC'
|
||||
|
||||
const DOCUMENTS_SELECTOR_POINT = 'documents_selector'
|
||||
|
||||
/** True if a document selector dialog has been registered (i.e. documents_manager is enabled). */
|
||||
export function documentsSelectorAvailable(): boolean {
|
||||
return useIntegrationStore().getItems(DOCUMENTS_SELECTOR_POINT).length > 0
|
||||
}
|
||||
|
||||
export function documentsSelectorComponent() {
|
||||
// The integration store prefixes ids with the module handle
|
||||
// (e.g. 'documents_manager.dialog'), so match on the suffix.
|
||||
const entry = useIntegrationStore().getItems(DOCUMENTS_SELECTOR_POINT).find(i => i.id.endsWith('.dialog'))
|
||||
return entry?.component
|
||||
? defineAsyncComponent(entry.component as () => Promise<unknown>)
|
||||
: null
|
||||
}
|
||||
@@ -8,14 +8,12 @@ import type {
|
||||
CompositionAttachmentInterface,
|
||||
CompositionAttachmentRemoveRequest,
|
||||
CompositionPatchRequest,
|
||||
CompositionSaveRequest,
|
||||
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 { EntityIdentifier } from '@MailManager/types/common'
|
||||
import type { MessageAddressInterface } from '@MailManager/types/message'
|
||||
|
||||
export const useMailCompositionStore = defineStore('mailCompositionStore', () => {
|
||||
@@ -118,13 +116,16 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
return
|
||||
}
|
||||
|
||||
const ownAddresses = senderIdentities.value.map(identity => identity.address)
|
||||
activeDraft.value = buildDraft(mode, sender, source, ownAddresses)
|
||||
activeDraft.value = buildDraft(mode, sender, source)
|
||||
activeDraft.value.action = mode
|
||||
|
||||
stageDraft()
|
||||
}
|
||||
|
||||
function closeDraft() {
|
||||
return discardDraft()
|
||||
}
|
||||
|
||||
async function stageDraft() {
|
||||
if (!activeDraft.value) {
|
||||
return null
|
||||
@@ -187,73 +188,16 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
|
||||
async function discardDraft() {
|
||||
if (!activeDraft.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
autoSaveTimer = null
|
||||
return
|
||||
}
|
||||
|
||||
const draftId = activeDraft.value.identifier
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await compositionService.discard({ identifier: draftId })
|
||||
if (response.disposition === 'error') {
|
||||
mailUiStore.notify(`Failed to discard draft: ${response.error?.message || 'Unknown error'}`, 'error')
|
||||
return false
|
||||
}
|
||||
activeDraft.value = null
|
||||
return true
|
||||
await compositionService.discard({ identifier: draftId })
|
||||
} catch (error) {
|
||||
console.error('[Mail][Composer] Failed to discard staged draft:', error)
|
||||
mailUiStore.notify('Failed to discard draft', 'error')
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!activeDraft.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
autoSaveTimer = null
|
||||
}
|
||||
|
||||
const draft = activeDraft.value
|
||||
const request: CompositionSaveRequest = {
|
||||
identifier: draft.identifier,
|
||||
revision: draft.revision,
|
||||
sender: {
|
||||
provider: draft.sender.service.provider,
|
||||
service: draft.sender.service.identifier,
|
||||
address: draft.sender.address,
|
||||
name: draft.sender.label || null,
|
||||
},
|
||||
message: draft.message,
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await compositionService.save(request)
|
||||
if (response.disposition === 'error') {
|
||||
mailUiStore.notify(`Failed to save draft: ${response.error?.message || 'Unknown error'}`, 'error')
|
||||
return false
|
||||
}
|
||||
|
||||
activeDraft.value = null
|
||||
mailUiStore.notify('Draft saved', 'success')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[Mail][Composer] Failed to save draft:', error)
|
||||
mailUiStore.notify('Failed to save draft', 'error')
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +274,7 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
queueSave(false)
|
||||
}
|
||||
|
||||
async function addAttachmentsFromDevice(files: File[]) {
|
||||
async function addAttachments(files: File[]) {
|
||||
if (!activeDraft.value || files.length === 0) {
|
||||
return
|
||||
}
|
||||
@@ -344,7 +288,7 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
return [identifier, {
|
||||
identifier,
|
||||
composition,
|
||||
origin: 'device',
|
||||
origin: 'upload',
|
||||
name: file.name,
|
||||
type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
@@ -381,74 +325,6 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
source: attachment.source,
|
||||
}
|
||||
})
|
||||
|
||||
if (response.revision !== undefined) {
|
||||
activeDraft.value.revision = response.revision
|
||||
}
|
||||
}
|
||||
|
||||
async function addAttachmentsFromDocuments(items: { identifier: string; name: string; mimeType?: string; size?: number }[]) {
|
||||
if (!activeDraft.value || items.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const composition = activeDraft.value.identifier
|
||||
const attachments = Object.fromEntries(
|
||||
items.map(item => {
|
||||
const identifier = createIdentifier()
|
||||
return [identifier, {
|
||||
identifier,
|
||||
composition,
|
||||
origin: 'documents',
|
||||
name: item.name,
|
||||
type: item.mimeType || 'application/octet-stream',
|
||||
size: item.size ?? 0,
|
||||
source: item.identifier as EntityIdentifier,
|
||||
} 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 from documents:', response.error)
|
||||
mailUiStore.notify(`Failed to attach documents: ${response.error?.message || 'Unknown error'}`, '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,
|
||||
}
|
||||
})
|
||||
|
||||
if (response.revision !== undefined) {
|
||||
activeDraft.value.revision = response.revision
|
||||
}
|
||||
|
||||
if (response.failed?.length) {
|
||||
mailUiStore.notify(
|
||||
response.failed.length === 1
|
||||
? '1 document could not be attached'
|
||||
: `${response.failed.length} documents could not be attached`,
|
||||
'warning',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAttachment(identifier: string) {
|
||||
@@ -471,11 +347,8 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
if (response.disposition === 'error') {
|
||||
console.error('[Mail][Composer] Failed to remove attachment:', response.error)
|
||||
}
|
||||
|
||||
|
||||
delete activeDraft.value.attachments[attachment.identifier]
|
||||
if (response.revision !== undefined) {
|
||||
activeDraft.value.revision = response.revision
|
||||
}
|
||||
}
|
||||
|
||||
function queueSave(immediate: boolean) {
|
||||
@@ -545,23 +418,25 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
stageStatus,
|
||||
senderIdentities,
|
||||
openDraft,
|
||||
saveDraft,
|
||||
discardDraft,
|
||||
closeDraft,
|
||||
sendDraft,
|
||||
updateSender,
|
||||
updateRecipients,
|
||||
updateSubject,
|
||||
updateBody,
|
||||
addAttachmentsFromDevice,
|
||||
addAttachmentsFromDocuments,
|
||||
addAttachments,
|
||||
removeAttachment,
|
||||
flushSave,
|
||||
}
|
||||
})
|
||||
|
||||
function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source: EntityObject | MessageAddressObject | null | undefined, ownAddresses: string[]): ComposerDraft {
|
||||
function buildDraft(
|
||||
mode: ComposerMode,
|
||||
sender: ComposerSenderIdentity,
|
||||
source?: EntityObject | MessageAddressObject | null,
|
||||
): ComposerDraft {
|
||||
const composition = createIdentifier()
|
||||
const freshMessage = buildMessage(mode, source, ownAddresses)
|
||||
const freshMessage = buildMessage(mode, source)
|
||||
const freshAttachments = buildAttachments(composition, mode, source)
|
||||
const sourceIdentifier = source instanceof EntityObject ? source.identifier : null
|
||||
return {
|
||||
@@ -577,7 +452,10 @@ function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source:
|
||||
}
|
||||
}
|
||||
|
||||
function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressObject | null | undefined, ownAddresses: string[] = []): ComposerDraftMessage {
|
||||
function buildMessage(
|
||||
mode: ComposerMode,
|
||||
source: EntityObject | MessageAddressObject | null | undefined,
|
||||
): ComposerDraftMessage {
|
||||
|
||||
if (!source) {
|
||||
return emptyMessage()
|
||||
@@ -604,28 +482,12 @@ function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressO
|
||||
const sentAt = sourceMessage.sent || source.created || ''
|
||||
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
|
||||
|
||||
if (mode === ComposerMode.Reply || mode === ComposerMode.ReplyAll) {
|
||||
if (mode === ComposerMode.Reply) {
|
||||
const replyToAddr = sourceMessage.replyTo?.[0] ?? sourceMessage.from ?? null
|
||||
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 {
|
||||
to,
|
||||
cc,
|
||||
to: replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`,
|
||||
body: {
|
||||
@@ -648,7 +510,7 @@ function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressO
|
||||
}
|
||||
|
||||
function buildAttachments(composition: string, mode: ComposerMode, source?: EntityObject | MessageAddressObject | null): Record<string, ComposerDraftAttachment> {
|
||||
if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || mode === ComposerMode.ReplyAll || !(source instanceof EntityObject)) {
|
||||
if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || !(source instanceof EntityObject)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -659,7 +521,7 @@ function buildAttachments(composition: string, mode: ComposerMode, source?: Enti
|
||||
attachments[id] = {
|
||||
identifier: id,
|
||||
composition: composition,
|
||||
origin: 'message',
|
||||
origin: 'source',
|
||||
name: attachment.name || '',
|
||||
type: attachment.type || 'application/octet-stream',
|
||||
size: attachment.size || 0,
|
||||
|
||||
@@ -4,8 +4,7 @@ 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, ListFilter } from '@MailManager/types/common'
|
||||
import { ListFilterComparisonOperator, ListFilterConjunctionOperator } from '@MailManager/types/common'
|
||||
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'
|
||||
@@ -51,8 +50,6 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
||||
|
||||
const selectedFolder = shallowRef<CollectionObject | null>(null)
|
||||
const selectedMessage = shallowRef<EntityObject | null>(null)
|
||||
const searchResults = shallowRef<EntityObject[] | null>(null)
|
||||
const searching = ref(false)
|
||||
const composerSaving = ref(false)
|
||||
const composerSending = ref(false)
|
||||
const composerLastSaved = ref<Date | null>(null)
|
||||
@@ -296,7 +293,6 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
||||
}
|
||||
|
||||
async function selectFolder(folder: CollectionObject | null) {
|
||||
clearSearch()
|
||||
selectedFolder.value = folder
|
||||
selectedMessage.value = null
|
||||
|
||||
@@ -311,67 +307,6 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
||||
_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) {
|
||||
selectedMessage.value = entity
|
||||
}
|
||||
@@ -640,24 +575,6 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
@@ -670,8 +587,6 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
||||
// State
|
||||
loading,
|
||||
selectedMessage,
|
||||
searchResults,
|
||||
searching,
|
||||
composerSaving,
|
||||
composerSending,
|
||||
composerLastSaved,
|
||||
@@ -687,8 +602,6 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
||||
retrieveService,
|
||||
selectFolder,
|
||||
selectMessage,
|
||||
searchMessages,
|
||||
clearSearch,
|
||||
createFolder,
|
||||
saveComposerDraft,
|
||||
sendComposerMessage,
|
||||
@@ -699,7 +612,6 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
|
||||
moveMessages,
|
||||
downloadMessage,
|
||||
attachmentBlob,
|
||||
attachmentPreviewBlob,
|
||||
moveFolder,
|
||||
renameFolder,
|
||||
isServiceFolderLoading,
|
||||
|
||||
@@ -714,14 +714,6 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
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 {
|
||||
sidebarVisible,
|
||||
settingsDialogVisible,
|
||||
@@ -771,7 +763,6 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
moveMessages,
|
||||
downloadMessage,
|
||||
attachmentBlob,
|
||||
attachmentPreviewBlob,
|
||||
messageSelectionModeActivate,
|
||||
messageSelectionModeDeactivate,
|
||||
messageSelectionToggleOne,
|
||||
@@ -797,4 +788,4 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
confirmDeleteFolder,
|
||||
flagSelectedMessages,
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,17 +5,13 @@ import type { MessageAddressInterface } from "@MailManager/types/message"
|
||||
export enum ComposerMode {
|
||||
Fresh = 'fresh',
|
||||
Reply = 'reply',
|
||||
ReplyAll = 'replyAll',
|
||||
Forward = 'forward',
|
||||
}
|
||||
|
||||
/** Body editing format: rich (Tiptap/HTML) or plain text. */
|
||||
export type ComposerFormat = 'rich' | 'plain'
|
||||
|
||||
export interface ComposerDraftAttachment {
|
||||
identifier: string
|
||||
composition: string
|
||||
origin: 'device' | 'message' | 'documents'
|
||||
origin: 'source' | 'upload'
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface CompositionMessageInterface {
|
||||
export interface CompositionAttachmentInterface {
|
||||
identifier: string
|
||||
composition: string
|
||||
origin: 'device' | 'message' | 'documents'
|
||||
origin: 'source' | 'upload'
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
@@ -58,17 +58,6 @@ export interface CompositionPatchResponse {
|
||||
message: CompositionMessageInterface
|
||||
}
|
||||
|
||||
export interface CompositionSaveRequest extends CompositionPatchRequest {}
|
||||
|
||||
export interface CompositionSaveResponse {
|
||||
identifier: string
|
||||
disposition: 'saved' | 'error'
|
||||
error?: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CompositionDiscardRequest {
|
||||
identifier: string
|
||||
}
|
||||
@@ -105,18 +94,13 @@ export interface CompositionAttachmentAddRequest {
|
||||
}
|
||||
|
||||
export interface CompositionAttachmentAddResponse {
|
||||
disposition: 'added' | 'partial' | 'error'
|
||||
disposition: 'added' | 'error'
|
||||
error?: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
composition: string
|
||||
revision?: number
|
||||
attachments: Record<string, CompositionAttachmentInterface>
|
||||
failed?: {
|
||||
identifier: string
|
||||
message: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface CompositionAttachmentRemoveRequest {
|
||||
@@ -132,5 +116,4 @@ export interface CompositionAttachmentRemoveResponse {
|
||||
}
|
||||
composition: string
|
||||
identifier: string
|
||||
revision?: number
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Mail\Tests\Unit;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXC\Module\ModuleObject;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use KTXF\Documents\Entity\EntityBaseInterface;
|
||||
use KTXF\Documents\Entity\EntityPropertiesBaseInterface;
|
||||
use KTXM\Mail\CompositionManager;
|
||||
use KTXM\Mail\CompositionSavedEvent;
|
||||
use KTXM\Mail\Stores\CompositionStore;
|
||||
use PHPUnit\Framework\MockObject\Stub;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
// KTXM\MailManager\Manager and KTXM\DocumentsManager\Manager live in sibling
|
||||
// modules. This module's isolated test bootstrap only autoloads its own PSR-4
|
||||
// map plus the server's KTXC/KTXF namespaces (see tests/php/bootstrap.php), so
|
||||
// sibling-module classes are not otherwise resolvable here. Require them
|
||||
// directly so PHPUnit can reflect on the real classes to build mocks; both
|
||||
// files only depend on already-autoloadable KTXC/KTXF types. Skipped
|
||||
// gracefully in setUp() if this module is ever checked out standalone.
|
||||
$mailManagerFile = dirname(__DIR__, 4) . '/mail_manager/lib/Manager.php';
|
||||
$documentsManagerFile = dirname(__DIR__, 4) . '/documents_manager/lib/Manager.php';
|
||||
if (is_file($mailManagerFile)) {
|
||||
require_once $mailManagerFile;
|
||||
}
|
||||
if (is_file($documentsManagerFile)) {
|
||||
require_once $documentsManagerFile;
|
||||
}
|
||||
|
||||
final class CompositionManagerTest extends TestCase {
|
||||
|
||||
private const TENANT_ID = 'tenant-1';
|
||||
private const USER_ID = 'user-1';
|
||||
|
||||
private string $rootDir;
|
||||
private CompositionStore $compositionStore;
|
||||
|
||||
protected function setUp(): void {
|
||||
if (!class_exists(\KTXM\MailManager\Manager::class) || !class_exists(\KTXM\DocumentsManager\Manager::class)) {
|
||||
$this->markTestSkipped('mail_manager and documents_manager modules are not available alongside this checkout');
|
||||
}
|
||||
|
||||
$this->rootDir = sys_get_temp_dir() . '/ktrix-mail-composition-test-' . uniqid();
|
||||
mkdir($this->rootDir, 0755, true);
|
||||
$this->compositionStore = new CompositionStore($this->rootDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$this->deleteDir($this->rootDir);
|
||||
}
|
||||
|
||||
public function testStageInitializesDirtyRemoteState(): void {
|
||||
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||
$manager = $this->makeManager($documentsManager);
|
||||
|
||||
$response = $manager->stage(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
'draft-new',
|
||||
'new',
|
||||
['address' => 'sender@example.com'],
|
||||
['subject' => 'Draft'],
|
||||
);
|
||||
|
||||
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, 'draft-new');
|
||||
$this->assertArrayNotHasKey('remote', $response);
|
||||
$this->assertSame([
|
||||
'status' => 'dirty',
|
||||
'entity' => null,
|
||||
'error' => null,
|
||||
], $snapshot['remote']);
|
||||
}
|
||||
|
||||
public function testStageQueuesDraftSynchronizationAfterLocalSave(): void {
|
||||
$events = $this->createMock(EventDispatcherInterface::class);
|
||||
$events->expects($this->once())
|
||||
->method('dispatch')
|
||||
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
|
||||
$event->tenantIdentifier() === self::TENANT_ID
|
||||
&& $event->userIdentifier() === self::USER_ID
|
||||
&& $event->compositionIdentifier() === 'draft-event'
|
||||
&& $event->revision() === 1));
|
||||
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||
$manager = $this->makeManager($documentsManager, events: $events);
|
||||
|
||||
$result = $manager->stage(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
'draft-event',
|
||||
'fresh',
|
||||
['address' => 'sender@example.com'],
|
||||
['subject' => 'Draft'],
|
||||
);
|
||||
|
||||
$this->assertSame('staged', $result['disposition']);
|
||||
}
|
||||
|
||||
public function testStageSurfacesEventQueueingFailureAfterLocalSave(): void {
|
||||
$events = $this->createStub(EventDispatcherInterface::class);
|
||||
$events->method('dispatch')->willThrowException(new \RuntimeException('No active event scope'));
|
||||
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||
$manager = $this->makeManager($documentsManager, events: $events);
|
||||
|
||||
try {
|
||||
$manager->stage(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
'draft-event-failure',
|
||||
'fresh',
|
||||
['address' => 'sender@example.com'],
|
||||
['subject' => 'Draft'],
|
||||
);
|
||||
$this->fail('Event dispatch failure should be surfaced');
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->assertSame('No active event scope', $exception->getMessage());
|
||||
}
|
||||
|
||||
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, 'draft-event-failure');
|
||||
$this->assertSame('dirty', $snapshot['remote']['status']);
|
||||
}
|
||||
|
||||
public function testPatchMarksExistingRemoteStateDirty(): void {
|
||||
$compositionId = $this->stageEmptyComposition();
|
||||
$setSynced = static function (?array $snapshot): array {
|
||||
$snapshot['revision'] = 1;
|
||||
$snapshot['remote'] = [
|
||||
'status' => 'synced',
|
||||
'entity' => 'provider:service:drafts:123',
|
||||
'error' => 'old error',
|
||||
];
|
||||
return $snapshot;
|
||||
};
|
||||
$this->compositionStore->compositionSave(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
$compositionId,
|
||||
$setSynced,
|
||||
);
|
||||
|
||||
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||
$events = $this->createMock(EventDispatcherInterface::class);
|
||||
$events->expects($this->once())
|
||||
->method('dispatch')
|
||||
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
|
||||
$event->compositionIdentifier() === $compositionId
|
||||
&& $event->revision() === 2));
|
||||
$manager = $this->makeManager($documentsManager, events: $events);
|
||||
$result = $manager->patch(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||
'revision' => 2,
|
||||
'sender' => ['address' => 'sender@example.com'],
|
||||
'message' => ['subject' => 'Changed'],
|
||||
]);
|
||||
|
||||
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
|
||||
$this->assertSame('patched', $result['disposition']);
|
||||
$this->assertSame('dirty', $snapshot['remote']['status']);
|
||||
$this->assertSame('provider:service:drafts:123', $snapshot['remote']['entity']);
|
||||
$this->assertNull($snapshot['remote']['error']);
|
||||
}
|
||||
|
||||
public function testAttachmentAddFromDocumentsHappyPath(): 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'));
|
||||
|
||||
$events = $this->createMock(EventDispatcherInterface::class);
|
||||
$events->expects($this->once())
|
||||
->method('dispatch')
|
||||
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
|
||||
$event->compositionIdentifier() === $compositionId));
|
||||
$manager = $this->makeManager($documentsManager, events: $events);
|
||||
|
||||
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||
'identifier' => 'attach-1',
|
||||
'origin' => 'documents',
|
||||
'source' => 'default:personal:root:doc1',
|
||||
]);
|
||||
|
||||
$this->assertSame('added', $result['disposition']);
|
||||
$this->assertCount(1, $result['attachments']);
|
||||
$this->assertSame([], $result['failed']);
|
||||
$this->assertSame(1, $result['revision']);
|
||||
|
||||
$attachment = $result['attachments']['attach-1'];
|
||||
$this->assertSame('documents', $attachment['origin']);
|
||||
$this->assertSame('default:personal:root:doc1', $attachment['source']);
|
||||
$this->assertSame('hello.txt', $attachment['name']);
|
||||
$this->assertSame('text/plain', $attachment['type']);
|
||||
$this->assertSame(strlen('hello world'), $attachment['size']);
|
||||
|
||||
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
|
||||
$this->assertSame(1, $snapshot['revision']);
|
||||
$this->assertSame('dirty', $snapshot['remote']['status']);
|
||||
$this->assertNull($snapshot['remote']['entity']);
|
||||
$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 {
|
||||
$compositionId = $this->stageEmptyComposition();
|
||||
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||
// only doc1 is returned even though doc1 and doc2 are both requested
|
||||
$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);
|
||||
|
||||
$result = $manager->attachmentAdd(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
$compositionId,
|
||||
['identifier' => 'attach-1', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'],
|
||||
['identifier' => 'attach-2', 'origin' => 'documents', 'source' => 'default:personal:root:doc2'],
|
||||
);
|
||||
|
||||
$this->assertSame('partial', $result['disposition']);
|
||||
$this->assertCount(1, $result['attachments']);
|
||||
$this->assertCount(1, $result['failed']);
|
||||
$this->assertSame('default:personal:root:doc2', $result['failed'][0]['identifier']);
|
||||
}
|
||||
|
||||
public function testAttachmentAddMixedUploadAndDocuments(): 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);
|
||||
|
||||
$result = $manager->attachmentAdd(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
$compositionId,
|
||||
['identifier' => 'attach-upload', 'origin' => 'device', 'name' => 'upload.txt', 'type' => 'text/plain', 'data' => base64_encode('uploaded bytes')],
|
||||
['identifier' => 'attach-doc', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'],
|
||||
);
|
||||
|
||||
$this->assertSame('added', $result['disposition']);
|
||||
$this->assertCount(2, $result['attachments']);
|
||||
$this->assertSame('device', $result['attachments']['attach-upload']['origin']);
|
||||
$this->assertSame('documents', $result['attachments']['attach-doc']['origin']);
|
||||
}
|
||||
|
||||
public function testAttachmentAddCompositionNotFound(): void {
|
||||
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||
$manager = $this->makeManager($documentsManager);
|
||||
|
||||
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, 'unknown-composition', [
|
||||
'identifier' => 'attach-1',
|
||||
'origin' => 'documents',
|
||||
'source' => 'default:personal:root:doc1',
|
||||
]);
|
||||
|
||||
$this->assertSame('error', $result['disposition']);
|
||||
$this->assertSame('composition_not_found', $result['error']['type']);
|
||||
}
|
||||
|
||||
public function testAttachmentAddDocumentsModuleUnavailable(): void {
|
||||
$compositionId = $this->stageEmptyComposition();
|
||||
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||
// documents_manager is not installed/enabled: attachmentAdd must still
|
||||
// work for the composition itself, just report the sourced entry as failed
|
||||
// rather than throwing (e.g. from a failed DI resolution).
|
||||
$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->assertCount(0, $result['attachments']);
|
||||
$this->assertCount(1, $result['failed']);
|
||||
$this->assertSame('default:personal:root:doc1', $result['failed'][0]['identifier']);
|
||||
$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(
|
||||
Stub $documentsManager,
|
||||
bool $documentsModuleEnabled = true,
|
||||
?EventDispatcherInterface $events = null,
|
||||
): CompositionManager {
|
||||
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
||||
|
||||
$moduleManager = $this->createStub(ModuleManager::class);
|
||||
if ($documentsModuleEnabled) {
|
||||
$module = $this->createStub(ModuleObject::class);
|
||||
$module->method('enabled')->willReturn(true);
|
||||
$moduleManager->method('fetch')->willReturn($module);
|
||||
} else {
|
||||
$moduleManager->method('fetch')->willReturn(null);
|
||||
}
|
||||
|
||||
$container = $this->createStub(ContainerInterface::class);
|
||||
$container->method('has')->willReturn($documentsModuleEnabled);
|
||||
$container->method('get')->willReturn($documentsManager);
|
||||
|
||||
return new CompositionManager(
|
||||
container: $container,
|
||||
moduleManager: $moduleManager,
|
||||
compositionStore: $this->compositionStore,
|
||||
mailManager: $mailManager,
|
||||
events: $events ?? $this->createStub(EventDispatcherInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
private function stageEmptyComposition(): string {
|
||||
$compositionId = 'draft-' . uniqid();
|
||||
$saveComposition = static fn(): array => [
|
||||
'identifier' => $compositionId,
|
||||
'attachments' => [],
|
||||
];
|
||||
|
||||
$this->compositionStore->compositionSave(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
$compositionId,
|
||||
$saveComposition,
|
||||
);
|
||||
return $compositionId;
|
||||
}
|
||||
|
||||
private function makeEntity(string $urn, string $label, string $mime): EntityBaseInterface&Stub {
|
||||
$properties = $this->createStub(EntityPropertiesBaseInterface::class);
|
||||
$properties->method('getLabel')->willReturn($label);
|
||||
$properties->method('getMime')->willReturn($mime);
|
||||
$properties->method('size')->willReturn(0);
|
||||
|
||||
$entity = $this->createStub(EntityBaseInterface::class);
|
||||
$entity->method('urn')->willReturn($urn);
|
||||
$entity->method('getProperties')->willReturn($properties);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/** @return resource */
|
||||
private function makeStream(string $contents) {
|
||||
$resource = fopen('php://memory', 'r+');
|
||||
fwrite($resource, $contents);
|
||||
rewind($resource);
|
||||
return $resource;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Mail\Tests\Unit;
|
||||
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistrarInterface;
|
||||
use KTXM\Mail\CompositionSavedEvent;
|
||||
use KTXM\Mail\CompositionSyncListener;
|
||||
use KTXM\Mail\Module;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CompositionSavedEventTest extends TestCase {
|
||||
|
||||
public function testCarriesOnlySynchronizationCoordinates(): void {
|
||||
$event = new CompositionSavedEvent('tenant-1', 'user-1', 'draft-1', 7);
|
||||
|
||||
$this->assertSame(CompositionSavedEvent::class, $event->label());
|
||||
$this->assertSame('tenant-1', $event->tenantIdentifier());
|
||||
$this->assertSame('user-1', $event->userIdentifier());
|
||||
$this->assertSame('draft-1', $event->compositionIdentifier());
|
||||
$this->assertSame(7, $event->revision());
|
||||
$this->assertSame([
|
||||
'userId' => 'user-1',
|
||||
'compositionId' => 'draft-1',
|
||||
'revision' => 7,
|
||||
], $event->context());
|
||||
}
|
||||
|
||||
public function testModuleRegistersDeferredCompositionSyncListener(): void {
|
||||
$events = $this->createMock(EventListenerRegistrarInterface::class);
|
||||
$events->expects($this->once())
|
||||
->method('listen')
|
||||
->with(
|
||||
'mail',
|
||||
CompositionSavedEvent::class,
|
||||
CompositionSyncListener::class,
|
||||
'onCompositionSaved',
|
||||
DeliveryMode::Deferred,
|
||||
);
|
||||
|
||||
(new Module($events))->boot();
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Mail\Tests\Unit;
|
||||
|
||||
use JsonException;
|
||||
use KTXM\Mail\Stores\CompositionStore;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CompositionStoreTest extends TestCase {
|
||||
|
||||
private const TENANT_ID = 'tenant-1';
|
||||
private const USER_ID = 'user-1';
|
||||
private const COMPOSITION_ID = 'draft-1';
|
||||
|
||||
private string $rootDir;
|
||||
private CompositionStore $store;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->rootDir = sys_get_temp_dir() . '/ktrix-mail-store-test-' . uniqid('', true);
|
||||
mkdir($this->rootDir, 0755, true);
|
||||
$this->store = new CompositionStore($this->rootDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$this->deleteDir($this->rootDir);
|
||||
}
|
||||
|
||||
public function testFailedReplacementPreservesPreviousSnapshot(): void {
|
||||
$initial = ['identifier' => self::COMPOSITION_ID, 'revision' => 1];
|
||||
$this->saveSnapshot($initial);
|
||||
|
||||
$resource = fopen('php://memory', 'r');
|
||||
try {
|
||||
$saveInvalidSnapshot = static fn(): array => [
|
||||
'identifier' => self::COMPOSITION_ID,
|
||||
'invalid' => $resource,
|
||||
];
|
||||
$this->store->compositionSave(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
$saveInvalidSnapshot,
|
||||
);
|
||||
$this->fail('Saving a value that cannot be encoded as JSON should fail');
|
||||
} catch (JsonException) {
|
||||
$this->assertSame(
|
||||
$initial,
|
||||
$this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID),
|
||||
);
|
||||
} finally {
|
||||
fclose($resource);
|
||||
}
|
||||
}
|
||||
|
||||
public function testUpdateCanLeaveSnapshotUnchanged(): void {
|
||||
$initial = ['identifier' => self::COMPOSITION_ID, 'revision' => 1];
|
||||
$this->saveSnapshot($initial);
|
||||
|
||||
$leaveUnchanged = static fn(?array $snapshot): ?array => null;
|
||||
$updated = $this->store->compositionSave(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
$leaveUnchanged,
|
||||
);
|
||||
|
||||
$this->assertNull($updated);
|
||||
$this->assertSame(
|
||||
$initial,
|
||||
$this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID),
|
||||
);
|
||||
}
|
||||
|
||||
public function testConcurrentUpdatesDoNotLoseChanges(): void {
|
||||
if (!function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('The pcntl extension is required for the concurrency test');
|
||||
}
|
||||
|
||||
$this->saveSnapshot([
|
||||
'identifier' => self::COMPOSITION_ID,
|
||||
'updates' => 0,
|
||||
]);
|
||||
|
||||
$childPid = pcntl_fork();
|
||||
if ($childPid === -1) {
|
||||
$this->fail('Unable to fork the concurrency test process');
|
||||
}
|
||||
|
||||
if ($childPid === 0) {
|
||||
$this->incrementAfterDelay();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$this->incrementAfterDelay();
|
||||
pcntl_waitpid($childPid, $status);
|
||||
|
||||
$this->assertTrue(pcntl_wifexited($status));
|
||||
$this->assertSame(0, pcntl_wexitstatus($status));
|
||||
$this->assertSame(
|
||||
2,
|
||||
$this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID)['updates'],
|
||||
);
|
||||
}
|
||||
|
||||
private function incrementAfterDelay(): void {
|
||||
$incrementSnapshot = static function (?array $snapshot): array {
|
||||
usleep(200_000);
|
||||
$snapshot['updates']++;
|
||||
return $snapshot;
|
||||
};
|
||||
|
||||
$this->store->compositionSave(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
$incrementSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
private function saveSnapshot(array $snapshot): void {
|
||||
$saveSnapshot = static fn(): array => $snapshot;
|
||||
$this->store->compositionSave(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
$saveSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Mail\Tests\Unit;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use KTXF\Mail\Collection\CollectionBaseInterface;
|
||||
use KTXF\Mail\Entity\EntityBaseInterface;
|
||||
use KTXF\Mail\Entity\EntityMutableInterface;
|
||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
||||
use KTXF\Mail\Service\ServiceBaseInterface;
|
||||
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
||||
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
||||
use KTXF\Mail\Submission\EntitySubmitResult;
|
||||
use KTXF\Resource\Filter\IFilter;
|
||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||
use KTXM\Mail\CompositionManager;
|
||||
use KTXM\Mail\Stores\CompositionStore;
|
||||
use PHPUnit\Framework\MockObject\Stub;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
$mailManagerFile = dirname(__DIR__, 4) . '/mail_manager/lib/Manager.php';
|
||||
if (is_file($mailManagerFile)) {
|
||||
require_once $mailManagerFile;
|
||||
}
|
||||
|
||||
final class CompositionSynchronizationTest extends TestCase {
|
||||
|
||||
private const TENANT_ID = 'tenant-1';
|
||||
private const USER_ID = 'user-1';
|
||||
private const COMPOSITION_ID = 'draft-1';
|
||||
|
||||
private string $rootDir;
|
||||
private CompositionStore $compositionStore;
|
||||
|
||||
protected function setUp(): void {
|
||||
if (!class_exists(\KTXM\MailManager\Manager::class)) {
|
||||
$this->markTestSkipped('mail_manager module is not available alongside this checkout');
|
||||
}
|
||||
|
||||
$this->rootDir = sys_get_temp_dir() . '/ktrix-mail-sync-test-' . uniqid('', true);
|
||||
mkdir($this->rootDir, 0755, true);
|
||||
$this->compositionStore = new CompositionStore($this->rootDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$this->deleteDir($this->rootDir);
|
||||
}
|
||||
|
||||
public function testCreatesDraftInConfiguredCollection(): void {
|
||||
$this->stageComposition();
|
||||
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
|
||||
$service = $this->mutableService(['draftTarget' => 'drafts'], $properties);
|
||||
$created = $this->entity('provider', 'service', 'drafts', 'created-1');
|
||||
|
||||
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->method('serviceFindByAddress')->willReturn($service);
|
||||
$mailManager->expects($this->once())
|
||||
->method('entityCreate')
|
||||
->with(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
$this->callback(static fn(CollectionIdentifier $target): bool => (string)$target === 'provider:service:drafts'),
|
||||
$properties,
|
||||
)
|
||||
->willReturn($created);
|
||||
|
||||
$compositionManager = $this->compositionManager($mailManager);
|
||||
|
||||
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
|
||||
$replayed = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
|
||||
|
||||
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
|
||||
$this->assertSame('saved', $result['disposition']);
|
||||
$this->assertSame('saved', $replayed['disposition']);
|
||||
$this->assertSame('synced', $snapshot['remote']['status']);
|
||||
$this->assertSame('provider:service:drafts:created-1', $snapshot['remote']['entity']);
|
||||
$this->assertNull($snapshot['remote']['error']);
|
||||
}
|
||||
|
||||
public function testModifiesExistingDraftAndStoresAuthoritativeIdentifier(): void {
|
||||
$this->stageComposition('provider:service:drafts:old-1');
|
||||
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
|
||||
$service = $this->mutableService(['draftTarget' => 'drafts'], $properties);
|
||||
$modified = $this->entity('provider', 'service', 'drafts', 'replacement-2');
|
||||
|
||||
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->method('serviceFindByAddress')->willReturn($service);
|
||||
$mailManager->expects($this->once())
|
||||
->method('entityModify')
|
||||
->with(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
$this->callback(static fn(EntityIdentifier $target): bool => (string)$target === 'provider:service:drafts:old-1'),
|
||||
$properties,
|
||||
)
|
||||
->willReturn($modified);
|
||||
|
||||
$compositionManager = $this->compositionManager($mailManager);
|
||||
|
||||
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
|
||||
|
||||
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
|
||||
$this->assertSame('saved', $result['disposition']);
|
||||
$this->assertSame('provider:service:drafts:replacement-2', $snapshot['remote']['entity']);
|
||||
}
|
||||
|
||||
public function testResolvesDraftCollectionByRoleWhenNoTargetIsConfigured(): void {
|
||||
$this->stageComposition();
|
||||
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
|
||||
$service = $this->mutableService([], $properties);
|
||||
$filter = $this->createMock(IFilter::class);
|
||||
$filter->expects($this->once())->method('condition')->with('role', 'drafts');
|
||||
$service->method('collectionListFilter')->willReturn($filter);
|
||||
$collection = $this->createStub(CollectionBaseInterface::class);
|
||||
$collection->method('identifier')->willReturn('role-drafts');
|
||||
$service->method('collectionList')->willReturn([$collection]);
|
||||
|
||||
$created = $this->entity('provider', 'service', 'role-drafts', 'created-1');
|
||||
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->method('serviceFindByAddress')->willReturn($service);
|
||||
$mailManager->expects($this->once())
|
||||
->method('entityCreate')
|
||||
->with(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
$this->callback(static fn(CollectionIdentifier $target): bool => (string)$target === 'provider:service:role-drafts'),
|
||||
$properties,
|
||||
)
|
||||
->willReturn($created);
|
||||
|
||||
$compositionManager = $this->compositionManager($mailManager);
|
||||
|
||||
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
|
||||
|
||||
$this->assertSame('saved', $result['disposition']);
|
||||
}
|
||||
|
||||
public function testRecordsFailureWhileKeepingLocalDraftStaged(): void {
|
||||
$this->stageComposition();
|
||||
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->method('serviceFindByAddress')->willReturn(null);
|
||||
$compositionManager = $this->compositionManager($mailManager);
|
||||
|
||||
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
|
||||
|
||||
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
|
||||
$this->assertSame('staged', $result['disposition']);
|
||||
$this->assertArrayHasKey('error', $result);
|
||||
$this->assertSame('failed', $snapshot['remote']['status']);
|
||||
$this->assertSame($result['error'], $snapshot['remote']['error']);
|
||||
$this->assertNull($snapshot['remote']['entity']);
|
||||
}
|
||||
|
||||
public function testInlineSaveSynchronizesAndPurgesLocalComposition(): void {
|
||||
$this->stageComposition();
|
||||
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
|
||||
$service = $this->mutableService(['draftTarget' => 'drafts'], $properties);
|
||||
$created = $this->entity('provider', 'service', 'drafts', 'created-inline');
|
||||
|
||||
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->method('serviceFindByAddress')->willReturn($service);
|
||||
$mailManager->expects($this->once())
|
||||
->method('entityCreate')
|
||||
->willReturn($created);
|
||||
|
||||
$result = $this->compositionManager($mailManager)->save(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
[
|
||||
'revision' => 2,
|
||||
'sender' => ['address' => 'sender@example.com'],
|
||||
'message' => ['subject' => 'Saved inline'],
|
||||
],
|
||||
);
|
||||
|
||||
$this->assertSame('saved', $result['disposition']);
|
||||
$this->assertNull($this->compositionStore->compositionFetch(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
));
|
||||
}
|
||||
|
||||
public function testInlineSaveFailureKeepsLocalComposition(): void {
|
||||
$this->stageComposition();
|
||||
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->method('serviceFindByAddress')->willReturn(null);
|
||||
|
||||
$result = $this->compositionManager($mailManager)->save(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
[
|
||||
'revision' => 2,
|
||||
'sender' => ['address' => 'sender@example.com'],
|
||||
'message' => ['subject' => 'Keep this draft'],
|
||||
],
|
||||
);
|
||||
|
||||
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
|
||||
$this->assertSame('error', $result['disposition']);
|
||||
$this->assertSame('composition_save_failed', $result['error']['type']);
|
||||
$this->assertSame('Keep this draft', $snapshot['message']['subject']);
|
||||
$this->assertSame('failed', $snapshot['remote']['status']);
|
||||
}
|
||||
|
||||
public function testDiscardDeletesRemoteDraftBeforePurgingLocalComposition(): void {
|
||||
$remote = 'provider:service:drafts:remote-1';
|
||||
$this->stageComposition($remote);
|
||||
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->expects($this->once())
|
||||
->method('entityDelete')
|
||||
->with(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
$this->callback(static fn(EntityIdentifier $target): bool => (string)$target === $remote),
|
||||
)
|
||||
->willReturn([$remote => ['disposition' => 'deleted']]);
|
||||
|
||||
$result = $this->compositionManager($mailManager)->discard(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
);
|
||||
|
||||
$this->assertSame('discarded', $result['disposition']);
|
||||
$this->assertNull($this->compositionStore->compositionFetch(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
));
|
||||
}
|
||||
|
||||
public function testRemoteDiscardFailureKeepsLocalComposition(): void {
|
||||
$remote = 'provider:service:drafts:remote-1';
|
||||
$this->stageComposition($remote);
|
||||
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->method('entityDelete')
|
||||
->willReturn([$remote => ['disposition' => 'error', 'error' => 'Provider unavailable']]);
|
||||
|
||||
$result = $this->compositionManager($mailManager)->discard(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
);
|
||||
|
||||
$this->assertSame('error', $result['disposition']);
|
||||
$this->assertSame('Provider unavailable', $result['error']['message']);
|
||||
$this->assertNotNull($this->compositionStore->compositionFetch(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
));
|
||||
}
|
||||
|
||||
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 {
|
||||
$saveComposition = static fn(): array => [
|
||||
'identifier' => self::COMPOSITION_ID,
|
||||
'sender' => ['address' => 'sender@example.com'],
|
||||
'message' => ['subject' => 'Draft'],
|
||||
'attachments' => [],
|
||||
'remote' => [
|
||||
'status' => 'dirty',
|
||||
'entity' => $remoteEntity,
|
||||
'error' => null,
|
||||
],
|
||||
];
|
||||
$this->compositionStore->compositionSave(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
$saveComposition,
|
||||
);
|
||||
}
|
||||
|
||||
private function mutableService(array $auxiliary, MessagePropertiesMutableInterface $properties): ServiceBaseInterface&ServiceEntityMutableInterface&Stub {
|
||||
$service = $this->createStubForIntersectionOfInterfaces([
|
||||
ServiceBaseInterface::class,
|
||||
ServiceEntityMutableInterface::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('getAuxiliary')->willReturn($auxiliary);
|
||||
$service->method('entityFresh')->willReturn($entity);
|
||||
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 {
|
||||
return new CompositionManager(
|
||||
container: $this->createStub(ContainerInterface::class),
|
||||
moduleManager: $this->createStub(ModuleManager::class),
|
||||
compositionStore: $this->compositionStore,
|
||||
mailManager: $mailManager,
|
||||
events: $this->createStub(EventDispatcherInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
private function entity(string $provider, string $service, string $collection, string $identifier): EntityBaseInterface&Stub {
|
||||
$entity = $this->createStub(EntityBaseInterface::class);
|
||||
$entity->method('provider')->willReturn($provider);
|
||||
$entity->method('service')->willReturn($service);
|
||||
$entity->method('collection')->willReturn($collection);
|
||||
$entity->method('identifier')->willReturn($identifier);
|
||||
return $entity;
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
unlink($child);
|
||||
}
|
||||
}
|
||||
rmdir($path);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user