Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d6521272c | |||
| 9179492026 | |||
| dc60b5147e | |||
| 874668e67f | |||
| 6af5704e5e | |||
| 59838af3db | |||
| 0bfb0c6aaf | |||
| 3c50de8ff4 | |||
| 9978c54431 | |||
| ae2f5c26ad | |||
| 0d5b13c6d2 | |||
| 2521e9bc9d | |||
| 282c82b2c9 | |||
| f413dc7576 | |||
| cffec87011 | |||
| bdb09ceb96 | |||
| 0780f0b7d9 | |||
| 2cd5cbca12 | |||
| 2b8e8598fe | |||
| a30837a130 | |||
| 0a43bb8539 | |||
| c84924a5a1 | |||
| 4d7844e90a | |||
| e08f37b2cc | |||
| e8495800e6 | |||
| cbcb6d1dcb | |||
| 3d40bfdf76 | |||
| a3cfb4f40d | |||
| 27ddd22a2d | |||
| 8440a3daec | |||
| 39ab6cf745 | |||
| 0b71c51dd5 | |||
| 6094dd4cd1 | |||
| ee74991175 | |||
| f7596976da | |||
| b0f388161a | |||
| df1ed31ef9 | |||
| 0b8c50b93f | |||
| 6061be119c | |||
| 69fc2054d8 | |||
| cbc2fcd7c7 | |||
| c6339ad084 | |||
| c736e3ebf9 | |||
| 681e059870 | |||
| fcc8f924d3 | |||
| c2de0aa7c0 | |||
| 9c3ab2e65d | |||
| 0f0719f82f | |||
| cdf9d87adc | |||
| d9f61437a4 | |||
| db3f35b8f7 | |||
| 9399c06ec1 | |||
| 81027d5914 | |||
| 2b51266392 | |||
| 400e9d8806 | |||
| 8d40dc8242 |
@@ -0,0 +1,59 @@
|
|||||||
|
name: PHP Integration Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Integration Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:8
|
||||||
|
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_manager
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Install module dependencies
|
||||||
|
run: composer install --prefer-dist --no-progress
|
||||||
|
working-directory: server/modules/mail_manager
|
||||||
|
|
||||||
|
- name: Install and enable module
|
||||||
|
working-directory: server
|
||||||
|
run: |
|
||||||
|
php bin/console module:install mail_manager
|
||||||
|
php bin/console module:enable mail_manager
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
working-directory: server/modules/mail_manager
|
||||||
|
run: composer test:integration
|
||||||
@@ -25,11 +25,17 @@ jobs:
|
|||||||
tools: composer:v2
|
tools: composer:v2
|
||||||
|
|
||||||
- name: Install Renovate
|
- name: Install Renovate
|
||||||
run: npm install -g renovate
|
run: |
|
||||||
|
npm install --global --no-audit --fund=false \
|
||||||
|
--prefix "${{ runner.temp }}/renovate-npm" \
|
||||||
|
--cache "${{ runner.temp }}/renovate-npm-cache" \
|
||||||
|
renovate
|
||||||
|
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
|
||||||
|
|
||||||
- name: Run Renovate
|
- name: Run Renovate
|
||||||
env:
|
env:
|
||||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
RENOVATE_PLATFORM: gitea
|
RENOVATE_PLATFORM: gitea
|
||||||
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
|
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
|
||||||
run: renovate ${{ gitea.repository }}
|
run: |
|
||||||
|
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
|
||||||
|
|||||||
+1
-5
@@ -14,11 +14,7 @@ node_modules/
|
|||||||
# Backend development
|
# Backend development
|
||||||
/lib/vendor/
|
/lib/vendor/
|
||||||
coverage/
|
coverage/
|
||||||
phpunit.xml.cache
|
*.cache
|
||||||
.phpunit.cache
|
|
||||||
.phpunit.result.cache
|
|
||||||
.php-cs-fixer.cache
|
|
||||||
.phpstan.cache
|
|
||||||
.phpactor/
|
.phpactor/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
|
|||||||
+6
-5
@@ -10,16 +10,16 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"optimize-autoloader": true,
|
"optimize-autoloader": true,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "8.2"
|
"php": "8.3"
|
||||||
},
|
},
|
||||||
"autoloader-suffix": "MailManager",
|
"autoloader-suffix": "MailManager",
|
||||||
"vendor-dir": "lib/vendor"
|
"vendor-dir": "lib/vendor"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.2 <=8.5"
|
"php": ">=8.3 <=8.5"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^11.0"
|
"phpunit/phpunit": "^12.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
@@ -36,7 +36,8 @@
|
|||||||
],
|
],
|
||||||
"post-update-cmd": [
|
"post-update-cmd": [
|
||||||
],
|
],
|
||||||
"test:unit": "phpunit --configuration tests/php/phpunit.unit.xml --colors=always --testdox",
|
"test:unit": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --colors=always --testdox",
|
||||||
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.unit.xml --coverage-html .phpunit.coverage --coverage-text"
|
"test:integration": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Integration Tests\" --colors=always --testdox",
|
||||||
|
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --coverage-html .phpunit.coverage --coverage-text"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+273
-361
File diff suppressed because it is too large
Load Diff
@@ -14,8 +14,8 @@ use KTXC\Http\Response\JsonResponse;
|
|||||||
use KTXC\Http\Response\Response;
|
use KTXC\Http\Response\Response;
|
||||||
use KTXC\Http\Response\StreamedNdJsonResponse;
|
use KTXC\Http\Response\StreamedNdJsonResponse;
|
||||||
use KTXC\Http\Response\StreamedResponse;
|
use KTXC\Http\Response\StreamedResponse;
|
||||||
use KTXC\SessionIdentity;
|
use KTXC\Context\IdentityContextInterface;
|
||||||
use KTXC\SessionTenant;
|
use KTXC\Context\TenantContextInterface;
|
||||||
use KTXF\Controller\ControllerAbstract;
|
use KTXF\Controller\ControllerAbstract;
|
||||||
use KTXF\Json\JsonSerializable;
|
use KTXF\Json\JsonSerializable;
|
||||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||||
@@ -24,6 +24,7 @@ use KTXF\Resource\Identifier\ResourceIdentifier;
|
|||||||
use KTXF\Resource\Identifier\ResourceIdentifiers;
|
use KTXF\Resource\Identifier\ResourceIdentifiers;
|
||||||
use KTXF\Resource\Identifier\ServiceIdentifier;
|
use KTXF\Resource\Identifier\ServiceIdentifier;
|
||||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||||
|
use KTXF\Resource\SystemIdentity;
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
use KTXM\MailManager\Manager;
|
use KTXM\MailManager\Manager;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -50,8 +51,8 @@ class DefaultController extends ControllerAbstract {
|
|||||||
private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array';
|
private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SessionTenant $tenantIdentity,
|
private readonly TenantContextInterface $tenantContext,
|
||||||
private readonly SessionIdentity $userIdentity,
|
private readonly IdentityContextInterface $identityContext,
|
||||||
private Manager $manager,
|
private Manager $manager,
|
||||||
private readonly LoggerInterface $logger
|
private readonly LoggerInterface $logger
|
||||||
) {}
|
) {}
|
||||||
@@ -79,8 +80,26 @@ class DefaultController extends ControllerAbstract {
|
|||||||
): Response {
|
): Response {
|
||||||
|
|
||||||
// authorize request
|
// authorize request
|
||||||
$tenantId = $this->tenantIdentity->identifier();
|
$tenantId = $this->tenantContext->identifier();
|
||||||
$userId = $this->userIdentity->identifier();
|
$userId = $this->identityContext->identifier();
|
||||||
|
|
||||||
|
// acting-user override: only the reserved system context is permitted,
|
||||||
|
// gated on the system mail management permission
|
||||||
|
if ($user !== null && $user !== $userId) {
|
||||||
|
if ($user !== SystemIdentity::USER || !$this->identityContext->hasPermission('mail_manager.system')) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'version' => $version,
|
||||||
|
'transaction' => $transaction,
|
||||||
|
'operation' => $operation,
|
||||||
|
'status' => 'error',
|
||||||
|
'data' => [
|
||||||
|
'code' => JsonResponse::HTTP_FORBIDDEN,
|
||||||
|
'message' => 'Not permitted to act as user: ' . $user
|
||||||
|
]
|
||||||
|
], JsonResponse::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
$userId = $user;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -153,14 +172,15 @@ class DefaultController extends ControllerAbstract {
|
|||||||
'entity.fetch' => $this->entityFetch($tenantId, $userId, $data),
|
'entity.fetch' => $this->entityFetch($tenantId, $userId, $data),
|
||||||
'entity.extant' => $this->entityExtant($tenantId, $userId, $data),
|
'entity.extant' => $this->entityExtant($tenantId, $userId, $data),
|
||||||
'entity.delta' => $this->entityDelta($tenantId, $userId, $data),
|
'entity.delta' => $this->entityDelta($tenantId, $userId, $data),
|
||||||
'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
'entity.create' => $this->entityCreate($tenantId, $userId, $data),
|
||||||
'entity.update' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
'entity.update' => $this->entityUpdate($tenantId, $userId, $data),
|
||||||
'entity.delete' => $this->entityDelete($tenantId, $userId, $data),
|
'entity.delete' => $this->entityDelete($tenantId, $userId, $data),
|
||||||
'entity.patch' => $this->entityPatch($tenantId, $userId, $data),
|
'entity.patch' => $this->entityPatch($tenantId, $userId, $data),
|
||||||
'entity.move' => $this->entityMove($tenantId, $userId, $data),
|
'entity.move' => $this->entityMove($tenantId, $userId, $data),
|
||||||
'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
||||||
'entity.submit' => $this->entitySubmit($tenantId, $userId, $data),
|
'entity.submit' => $this->entitySubmit($tenantId, $userId, $data),
|
||||||
'entity.download' => $this->entityDownload($tenantId, $userId, $data),
|
'entity.download' => $this->entityDownload($tenantId, $userId, $data),
|
||||||
|
'entity.preview' => $this->entityPreview($tenantId, $userId, $data),
|
||||||
'entity.blobs' => $this->entityBlobs($tenantId, $userId, $data),
|
'entity.blobs' => $this->entityBlobs($tenantId, $userId, $data),
|
||||||
|
|
||||||
default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation)
|
default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation)
|
||||||
@@ -732,6 +752,55 @@ class DefaultController extends ControllerAbstract {
|
|||||||
return $this->manager->entityDelta($tenantId, $userId, $targets);
|
return $this->manager->entityDelta($tenantId, $userId, $targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function entityCreate(string $tenantId, string $userId, array $data = []): mixed {
|
||||||
|
if (!isset($data['target'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
|
||||||
|
}
|
||||||
|
if (!is_string($data['target'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
|
||||||
|
}
|
||||||
|
if (!isset($data['properties'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
||||||
|
}
|
||||||
|
if (!is_array($data['properties'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = ResourceIdentifier::fromString($data['target']);
|
||||||
|
if (!$target instanceof CollectionIdentifier) {
|
||||||
|
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection');
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = $data['options'] ?? [];
|
||||||
|
if (!is_array($options)) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->manager->entityCreate($tenantId, $userId, $target, $data['properties'], $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entityUpdate(string $tenantId, string $userId, array $data = []): mixed {
|
||||||
|
if (!isset($data['target'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
|
||||||
|
}
|
||||||
|
if (!is_string($data['target'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
|
||||||
|
}
|
||||||
|
if (!isset($data['properties'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
||||||
|
}
|
||||||
|
if (!is_array($data['properties'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = ResourceIdentifier::fromString($data['target']);
|
||||||
|
if (!$target instanceof EntityIdentifier) {
|
||||||
|
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection:entity');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->manager->entityModify($tenantId, $userId, $target, $data['properties']);
|
||||||
|
}
|
||||||
|
|
||||||
private function entityPatch(string $tenantId, string $userId, array $data): mixed {
|
private function entityPatch(string $tenantId, string $userId, array $data): mixed {
|
||||||
if (!isset($data['targets'])) {
|
if (!isset($data['targets'])) {
|
||||||
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
|
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
|
||||||
@@ -900,4 +969,36 @@ class DefaultController extends ControllerAbstract {
|
|||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function entityPreview(string $tenantId, string $userId, array $data): Response {
|
||||||
|
if (!isset($data['target']) || !is_string($data['target'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
|
||||||
|
}
|
||||||
|
if (!isset($data['part']) || !is_array($data['part'])) {
|
||||||
|
throw new InvalidArgumentException('Attachment preview requires a part');
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = ResourceIdentifier::fromString($data['target']);
|
||||||
|
if (!$target instanceof EntityIdentifier) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
|
||||||
|
}
|
||||||
|
$preview = $this->manager->entityPreview(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$target,
|
||||||
|
$data['part'],
|
||||||
|
isset($data['variant']) && is_string($data['variant']) ? $data['variant'] : 'inline',
|
||||||
|
);
|
||||||
|
if ($preview === null) {
|
||||||
|
return new Response('', Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StreamedResponse($preview->resource->stream(), Response::HTTP_OK, [
|
||||||
|
'Content-Type' => $preview->resource->mimeType(),
|
||||||
|
'Content-Length' => (string) $preview->size,
|
||||||
|
'ETag' => '"' . trim($preview->etag, '"') . '"',
|
||||||
|
'Cache-Control' => 'private, max-age=3600',
|
||||||
|
'X-Content-Type-Options' => 'nosniff',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\MailManager\Daemon;
|
|
||||||
|
|
||||||
use KTXM\MailManager\Manager;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mail Queue Daemon
|
|
||||||
*
|
|
||||||
* Long-running worker process for processing queued mail messages.
|
|
||||||
* Supports graceful shutdown via signals and configurable batch processing.
|
|
||||||
*
|
|
||||||
* @since 2025.05.01
|
|
||||||
*/
|
|
||||||
class MailDaemon {
|
|
||||||
|
|
||||||
private bool $running = false;
|
|
||||||
private bool $shutdown = false;
|
|
||||||
private bool $reload = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Manager $manager Mail manager
|
|
||||||
* @param LoggerInterface $logger Logger
|
|
||||||
* @param int $pollInterval Seconds between queue polls when idle
|
|
||||||
* @param int $batchSize Messages to process per batch
|
|
||||||
* @param int|null $maxMemory Maximum memory usage in bytes before restart
|
|
||||||
* @param array<string>|null $tenants Specific tenants to process (null = all)
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private Manager $manager,
|
|
||||||
private LoggerInterface $logger,
|
|
||||||
private int $pollInterval = 5,
|
|
||||||
private int $batchSize = 50,
|
|
||||||
private ?int $maxMemory = null,
|
|
||||||
private ?array $tenants = null,
|
|
||||||
) {
|
|
||||||
// Set default max memory to 128MB
|
|
||||||
$this->maxMemory = $this->maxMemory ?? (128 * 1024 * 1024);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the daemon main loop
|
|
||||||
*
|
|
||||||
* @since 2025.05.01
|
|
||||||
*/
|
|
||||||
public function run(): void {
|
|
||||||
$this->running = true;
|
|
||||||
$this->shutdown = false;
|
|
||||||
|
|
||||||
$this->setupSignalHandlers();
|
|
||||||
|
|
||||||
$this->logger->info('Mail daemon starting', [
|
|
||||||
'pollInterval' => $this->pollInterval,
|
|
||||||
'batchSize' => $this->batchSize,
|
|
||||||
'maxMemory' => $this->formatBytes($this->maxMemory),
|
|
||||||
'tenants' => $this->tenants ?? 'all',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$consecutiveEmpty = 0;
|
|
||||||
|
|
||||||
while (!$this->shutdown) {
|
|
||||||
// Handle reload signal
|
|
||||||
if ($this->reload) {
|
|
||||||
$this->handleReload();
|
|
||||||
$this->reload = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check memory usage
|
|
||||||
if ($this->isMemoryExceeded()) {
|
|
||||||
$this->logger->warning('Memory limit exceeded, shutting down for restart', [
|
|
||||||
'current' => $this->formatBytes(memory_get_usage(true)),
|
|
||||||
'limit' => $this->formatBytes($this->maxMemory),
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process queues
|
|
||||||
$processed = $this->processTenants();
|
|
||||||
|
|
||||||
if ($processed === 0) {
|
|
||||||
$consecutiveEmpty++;
|
|
||||||
// Exponential backoff up to poll interval
|
|
||||||
$sleepTime = min($consecutiveEmpty, $this->pollInterval);
|
|
||||||
$this->sleep($sleepTime);
|
|
||||||
} else {
|
|
||||||
$consecutiveEmpty = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch pending signals
|
|
||||||
pcntl_signal_dispatch();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->logger->info('Mail daemon stopped');
|
|
||||||
$this->running = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request graceful shutdown
|
|
||||||
*
|
|
||||||
* @since 2025.05.01
|
|
||||||
*/
|
|
||||||
public function stop(): void {
|
|
||||||
$this->shutdown = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if daemon is running
|
|
||||||
*
|
|
||||||
* @since 2025.05.01
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function isRunning(): bool {
|
|
||||||
return $this->running;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process all tenants and return total messages processed
|
|
||||||
*/
|
|
||||||
private function processTenants(): int {
|
|
||||||
$totalProcessed = 0;
|
|
||||||
$tenants = $this->tenants ?? $this->discoverTenants();
|
|
||||||
|
|
||||||
foreach ($tenants as $tenantId) {
|
|
||||||
if ($this->shutdown) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $this->manager->queueProcess($tenantId, $this->batchSize);
|
|
||||||
$totalProcessed += $result['processed'] + $result['failed'];
|
|
||||||
|
|
||||||
if ($result['processed'] > 0 || $result['failed'] > 0) {
|
|
||||||
$this->logger->debug('Processed tenant queue', [
|
|
||||||
'tenant' => $tenantId,
|
|
||||||
'processed' => $result['processed'],
|
|
||||||
'failed' => $result['failed'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $totalProcessed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover all tenants with mail queues
|
|
||||||
*
|
|
||||||
* @return array<string>
|
|
||||||
*/
|
|
||||||
private function discoverTenants(): array {
|
|
||||||
// This would need to be implemented based on your tenant discovery mechanism
|
|
||||||
// For now, return empty array - specific tenants should be configured
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup signal handlers for graceful shutdown
|
|
||||||
*/
|
|
||||||
private function setupSignalHandlers(): void {
|
|
||||||
if (!function_exists('pcntl_signal')) {
|
|
||||||
$this->logger->warning('PCNTL extension not available, signal handling disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pcntl_signal(SIGTERM, function() {
|
|
||||||
$this->logger->info('Received SIGTERM, initiating graceful shutdown');
|
|
||||||
$this->shutdown = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
pcntl_signal(SIGINT, function() {
|
|
||||||
$this->logger->info('Received SIGINT, initiating graceful shutdown');
|
|
||||||
$this->shutdown = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
pcntl_signal(SIGHUP, function() {
|
|
||||||
$this->logger->info('Received SIGHUP, will reload configuration');
|
|
||||||
$this->reload = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle configuration reload
|
|
||||||
*/
|
|
||||||
private function handleReload(): void {
|
|
||||||
$this->logger->info('Reloading configuration');
|
|
||||||
// Configuration reload logic would go here
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if memory limit has been exceeded
|
|
||||||
*/
|
|
||||||
private function isMemoryExceeded(): bool {
|
|
||||||
if ($this->maxMemory === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return memory_get_usage(true) > $this->maxMemory;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sleep with signal dispatch
|
|
||||||
*/
|
|
||||||
private function sleep(int $seconds): void {
|
|
||||||
for ($i = 0; $i < $seconds; $i++) {
|
|
||||||
if ($this->shutdown) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sleep(1);
|
|
||||||
pcntl_signal_dispatch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format bytes to human readable string
|
|
||||||
*/
|
|
||||||
private function formatBytes(?int $bytes): string {
|
|
||||||
if ($bytes === null) {
|
|
||||||
return 'unlimited';
|
|
||||||
}
|
|
||||||
|
|
||||||
$units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
$i = 0;
|
|
||||||
while ($bytes >= 1024 && $i < count($units) - 1) {
|
|
||||||
$bytes /= 1024;
|
|
||||||
$i++;
|
|
||||||
}
|
|
||||||
return round($bytes, 2) . ' ' . $units[$i];
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\MailManager\Daemon;
|
|
||||||
|
|
||||||
use KTXM\MailManager\Queue\JobStatus;
|
|
||||||
use KTXM\MailManager\Queue\MailQueue;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mail Queue CLI
|
|
||||||
*
|
|
||||||
* Command-line interface for mail queue management operations.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* php mail-queue.php list <tenant> [--status=pending]
|
|
||||||
* php mail-queue.php retry <jobId>
|
|
||||||
* php mail-queue.php retry-all <tenant> --status=failed
|
|
||||||
* php mail-queue.php purge <tenant> --status=complete --older-than=7d
|
|
||||||
* php mail-queue.php stats <tenant>
|
|
||||||
*
|
|
||||||
* @since 2025.05.01
|
|
||||||
*/
|
|
||||||
class MailQueueCli {
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private MailQueue $queue,
|
|
||||||
private LoggerInterface $logger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run CLI command
|
|
||||||
*
|
|
||||||
* @param array<string> $args Command line arguments
|
|
||||||
*
|
|
||||||
* @return int Exit code
|
|
||||||
*/
|
|
||||||
public function run(array $args): int {
|
|
||||||
$command = $args[1] ?? 'help';
|
|
||||||
|
|
||||||
return match($command) {
|
|
||||||
'list' => $this->commandList($args),
|
|
||||||
'retry' => $this->commandRetry($args),
|
|
||||||
'retry-all' => $this->commandRetryAll($args),
|
|
||||||
'purge' => $this->commandPurge($args),
|
|
||||||
'stats' => $this->commandStats($args),
|
|
||||||
'help', '--help', '-h' => $this->commandHelp(),
|
|
||||||
default => $this->commandHelp(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List jobs in queue
|
|
||||||
*/
|
|
||||||
private function commandList(array $args): int {
|
|
||||||
$tenantId = $args[2] ?? null;
|
|
||||||
if ($tenantId === null) {
|
|
||||||
echo "Error: tenant ID required\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = $this->parseOption($args, 'status');
|
|
||||||
$statusEnum = $status !== null ? JobStatus::tryFrom($status) : null;
|
|
||||||
$limit = (int)($this->parseOption($args, 'limit') ?? 100);
|
|
||||||
|
|
||||||
$jobs = $this->queue->listJobs($tenantId, $statusEnum, $limit);
|
|
||||||
|
|
||||||
if (empty($jobs)) {
|
|
||||||
echo "No jobs found\n";
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
echo sprintf("%-36s %-12s %-8s %-20s %s\n",
|
|
||||||
'JOB ID', 'STATUS', 'ATTEMPTS', 'CREATED', 'SUBJECT');
|
|
||||||
echo str_repeat('-', 100) . "\n";
|
|
||||||
|
|
||||||
foreach ($jobs as $job) {
|
|
||||||
$subject = substr($job->message->getSubject(), 0, 30);
|
|
||||||
echo sprintf("%-36s %-12s %-8d %-20s %s\n",
|
|
||||||
$job->id,
|
|
||||||
$job->status->value,
|
|
||||||
$job->attempts,
|
|
||||||
$job->created?->format('Y-m-d H:i:s') ?? '-',
|
|
||||||
$subject
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry a specific job
|
|
||||||
*/
|
|
||||||
private function commandRetry(array $args): int {
|
|
||||||
$jobId = $args[2] ?? null;
|
|
||||||
if ($jobId === null) {
|
|
||||||
echo "Error: job ID required\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->queue->retry($jobId)) {
|
|
||||||
echo "Job $jobId queued for retry\n";
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Failed to retry job $jobId (not found or not failed)\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry all failed jobs for a tenant
|
|
||||||
*/
|
|
||||||
private function commandRetryAll(array $args): int {
|
|
||||||
$tenantId = $args[2] ?? null;
|
|
||||||
if ($tenantId === null) {
|
|
||||||
echo "Error: tenant ID required\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$jobs = $this->queue->listJobs($tenantId, JobStatus::Failed);
|
|
||||||
$retried = 0;
|
|
||||||
|
|
||||||
foreach ($jobs as $job) {
|
|
||||||
if ($this->queue->retry($job->id)) {
|
|
||||||
$retried++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Retried $retried jobs\n";
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Purge old jobs
|
|
||||||
*/
|
|
||||||
private function commandPurge(array $args): int {
|
|
||||||
$tenantId = $args[2] ?? null;
|
|
||||||
if ($tenantId === null) {
|
|
||||||
echo "Error: tenant ID required\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = $this->parseOption($args, 'status') ?? 'complete';
|
|
||||||
$statusEnum = JobStatus::tryFrom($status);
|
|
||||||
if ($statusEnum === null) {
|
|
||||||
echo "Error: invalid status '$status'\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$olderThan = $this->parseOption($args, 'older-than') ?? '7d';
|
|
||||||
$seconds = $this->parseDuration($olderThan);
|
|
||||||
|
|
||||||
$purged = $this->queue->purge($tenantId, $statusEnum, $seconds);
|
|
||||||
echo "Purged $purged jobs\n";
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show queue statistics
|
|
||||||
*/
|
|
||||||
private function commandStats(array $args): int {
|
|
||||||
$tenantId = $args[2] ?? null;
|
|
||||||
if ($tenantId === null) {
|
|
||||||
echo "Error: tenant ID required\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stats = $this->queue->stats($tenantId);
|
|
||||||
|
|
||||||
echo "Queue Statistics for $tenantId:\n";
|
|
||||||
echo " Pending: {$stats['pending']}\n";
|
|
||||||
echo " Processing: {$stats['processing']}\n";
|
|
||||||
echo " Complete: {$stats['complete']}\n";
|
|
||||||
echo " Failed: {$stats['failed']}\n";
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show help message
|
|
||||||
*/
|
|
||||||
private function commandHelp(): int {
|
|
||||||
echo <<<HELP
|
|
||||||
Mail Queue CLI
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
mail-queue <command> [options]
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
list <tenant> List jobs in queue
|
|
||||||
--status=<status> Filter by status (pending, processing, complete, failed)
|
|
||||||
--limit=<n> Maximum jobs to show (default: 100)
|
|
||||||
|
|
||||||
retry <jobId> Retry a specific failed job
|
|
||||||
|
|
||||||
retry-all <tenant> Retry all failed jobs for a tenant
|
|
||||||
|
|
||||||
purge <tenant> Purge old jobs
|
|
||||||
--status=<status> Status to purge (default: complete)
|
|
||||||
--older-than=<duration> Age threshold (default: 7d, e.g., 1h, 30d)
|
|
||||||
|
|
||||||
stats <tenant> Show queue statistics
|
|
||||||
|
|
||||||
help Show this help message
|
|
||||||
|
|
||||||
HELP;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a command line option
|
|
||||||
*/
|
|
||||||
private function parseOption(array $args, string $name): ?string {
|
|
||||||
foreach ($args as $arg) {
|
|
||||||
if (str_starts_with($arg, "--$name=")) {
|
|
||||||
return substr($arg, strlen("--$name="));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a duration string to seconds
|
|
||||||
*/
|
|
||||||
private function parseDuration(string $duration): int {
|
|
||||||
preg_match('/^(\d+)([smhd])?$/', $duration, $matches);
|
|
||||||
$value = (int)($matches[1] ?? 0);
|
|
||||||
$unit = $matches[2] ?? 's';
|
|
||||||
|
|
||||||
return match($unit) {
|
|
||||||
's' => $value,
|
|
||||||
'm' => $value * 60,
|
|
||||||
'h' => $value * 3600,
|
|
||||||
'd' => $value * 86400,
|
|
||||||
default => $value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+124
-41
@@ -5,10 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace KTXM\MailManager;
|
namespace KTXM\MailManager;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use KTXC\Preview\Preview;
|
||||||
|
use KTXC\Preview\PreviewManager;
|
||||||
use KTXC\Resource\ProviderManager;
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXF\Preview\PreviewSource;
|
||||||
use KTXF\Mail\Collection\CollectionBaseInterface;
|
use KTXF\Mail\Collection\CollectionBaseInterface;
|
||||||
use KTXF\Mail\Collection\CollectionPropertiesMutableInterface;
|
use KTXF\Mail\Collection\CollectionPropertiesMutableInterface;
|
||||||
use KTXF\Mail\Collection\ICollectionBase;
|
use KTXF\Mail\Collection\ICollectionBase;
|
||||||
|
use KTXF\Mail\Entity\EntityBaseInterface;
|
||||||
use KTXF\Mail\Entity\IMessageBase;
|
use KTXF\Mail\Entity\IMessageBase;
|
||||||
use KTXF\Mail\Object\Address;
|
use KTXF\Mail\Object\Address;
|
||||||
use KTXF\Mail\Object\AddressInterface;
|
use KTXF\Mail\Object\AddressInterface;
|
||||||
@@ -32,7 +36,6 @@ use KTXF\Resource\Identifier\EntityIdentifierInterface;
|
|||||||
use KTXF\Resource\Identifier\ResourceIdentifiers;
|
use KTXF\Resource\Identifier\ResourceIdentifiers;
|
||||||
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
|
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
|
||||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||||
use KTXF\Resource\Range\RangeAnchorType;
|
|
||||||
use KTXF\Resource\Range\RangeType;
|
use KTXF\Resource\Range\RangeType;
|
||||||
use KTXF\Resource\Sort\ISort;
|
use KTXF\Resource\Sort\ISort;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -47,6 +50,7 @@ class Manager {
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
private ProviderManager $providerManager,
|
private ProviderManager $providerManager,
|
||||||
|
private PreviewManager $previewManager,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,7 +241,14 @@ class Manager {
|
|||||||
$serviceId = $provider->serviceCreate($tenantId, $userId, $service);
|
$serviceId = $provider->serviceCreate($tenantId, $userId, $service);
|
||||||
|
|
||||||
// Fetch and return the created service
|
// Fetch and return the created service
|
||||||
return $provider->serviceFetch($tenantId, $userId, $serviceId);
|
$createdService = $provider->serviceFetch($tenantId, $userId, $serviceId);
|
||||||
|
if ($createdService === null) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"Provider '$providerId' created service '$serviceId', but it could not be fetched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $createdService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -779,20 +790,7 @@ class Manager {
|
|||||||
// construct range for entities
|
// construct range for entities
|
||||||
$entityRange = null;
|
$entityRange = null;
|
||||||
if ($range !== null && $range !== [] && isset($range['type'])) {
|
if ($range !== null && $range !== [] && isset($range['type'])) {
|
||||||
$entityRange = $service->entityListRange(RangeType::from($range['type']));
|
$entityRange = $service->entityListRange(RangeType::from($range['type']))->jsonDeserialize($range);
|
||||||
// Cast to IRangeTally if the range type is TALLY
|
|
||||||
if ($entityRange->type() === RangeType::TALLY) {
|
|
||||||
/** @var IRangeTally $entityRange */
|
|
||||||
if (isset($range['anchor'])) {
|
|
||||||
$entityRange->setAnchor(RangeAnchorType::from($range['anchor']));
|
|
||||||
}
|
|
||||||
if (isset($range['position'])) {
|
|
||||||
$entityRange->setPosition($range['position']);
|
|
||||||
}
|
|
||||||
if (isset($range['tally'])) {
|
|
||||||
$entityRange->setTally($range['tally']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// retrieve entities for each collection
|
// retrieve entities for each collection
|
||||||
foreach ($collectionSelected as $collectionId) {
|
foreach ($collectionSelected as $collectionId) {
|
||||||
@@ -868,19 +866,7 @@ class Manager {
|
|||||||
// construct range for entities
|
// construct range for entities
|
||||||
$entityRange = null;
|
$entityRange = null;
|
||||||
if ($range !== null && $range !== [] && isset($range['type'])) {
|
if ($range !== null && $range !== [] && isset($range['type'])) {
|
||||||
$entityRange = $service->entityListRange(RangeType::from($range['type']));
|
$entityRange = $service->entityListRange(RangeType::from($range['type']))->jsonDeserialize($range);
|
||||||
if ($entityRange->type() === RangeType::TALLY) {
|
|
||||||
/** @var IRangeTally $entityRange */
|
|
||||||
if (isset($range['anchor'])) {
|
|
||||||
$entityRange->setAnchor(RangeAnchorType::from($range['anchor']));
|
|
||||||
}
|
|
||||||
if (isset($range['position'])) {
|
|
||||||
$entityRange->setPosition($range['position']);
|
|
||||||
}
|
|
||||||
if (isset($range['tally'])) {
|
|
||||||
$entityRange->setTally($range['tally']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// yield entities for each collection individually
|
// yield entities for each collection individually
|
||||||
foreach ($collectionSelected as $collectionId) {
|
foreach ($collectionSelected as $collectionId) {
|
||||||
@@ -952,13 +938,6 @@ class Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function entityDownload(string $tenantId, string $userId, EntityIdentifier $targetEntity, array|null $targetPart): BinaryResource {
|
|
||||||
// retrieve service
|
|
||||||
$service = $this->serviceFetch($tenantId, $userId, $targetEntity->provider(), $targetEntity->service());
|
|
||||||
// download entity
|
|
||||||
return $service->entityDownload($targetEntity, $targetPart);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if messages exist
|
* Check if messages exist
|
||||||
*
|
*
|
||||||
@@ -1148,6 +1127,63 @@ class Manager {
|
|||||||
return $operationOutcome;
|
return $operationOutcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new entity in a collection.
|
||||||
|
*
|
||||||
|
* @param MessagePropertiesMutableInterface|array $properties Entity properties
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function entityCreate(string $tenantId, string $userId, CollectionIdentifier $target, MessagePropertiesMutableInterface|array $properties, array $options = []): EntityBaseInterface
|
||||||
|
{
|
||||||
|
$service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());
|
||||||
|
|
||||||
|
if ($service->getEnabled() === false) {
|
||||||
|
throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled");
|
||||||
|
}
|
||||||
|
if ($service instanceof ServiceEntityMutableInterface === false) {
|
||||||
|
throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity mutations");
|
||||||
|
}
|
||||||
|
if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_CREATE)) {
|
||||||
|
throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of creating entities");
|
||||||
|
}
|
||||||
|
if ($properties instanceof MessagePropertiesMutableInterface === false) {
|
||||||
|
$properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $service->entityCreate($target, $properties, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modify an existing entity.
|
||||||
|
*
|
||||||
|
* The returned entity identifier is authoritative and may differ from the
|
||||||
|
* target identifier for providers that use replacement semantics.
|
||||||
|
*
|
||||||
|
* @param MessagePropertiesMutableInterface|array $properties Entity properties
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function entityModify(string $tenantId, string $userId, EntityIdentifier $target, MessagePropertiesMutableInterface|array $properties): EntityBaseInterface
|
||||||
|
{
|
||||||
|
$service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());
|
||||||
|
|
||||||
|
if ($service->getEnabled() === false) {
|
||||||
|
throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled");
|
||||||
|
}
|
||||||
|
if ($service instanceof ServiceEntityMutableInterface === false) {
|
||||||
|
throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity mutations");
|
||||||
|
}
|
||||||
|
if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_MODIFY)) {
|
||||||
|
throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of modifying entities");
|
||||||
|
}
|
||||||
|
if ($properties instanceof MessagePropertiesMutableInterface === false) {
|
||||||
|
$properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $service->entityModify($target, $properties);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes entities
|
* Deletes entities
|
||||||
*
|
*
|
||||||
@@ -1290,9 +1326,13 @@ class Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function entitySubmit(string $tenantId, string $userId, AddressInterface|string $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|array|null $message = null): EntitySubmitResult {
|
public function entitySubmit(string $tenantId, string $userId, AddressInterface|string $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|array|null $message = null): EntitySubmitResult {
|
||||||
$service = $this->serviceFindByAddress($tenantId, $userId, $sender);
|
if ($sender instanceof AddressInterface === false) {
|
||||||
|
$sender = new Address($sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = $this->serviceFindByAddress($tenantId, $userId, $sender->getAddress());
|
||||||
if ($service === null || $service->getEnabled() === false) {
|
if ($service === null || $service->getEnabled() === false) {
|
||||||
throw new InvalidArgumentException("Service not found for sender '{$sender}' or service is disabled");
|
throw new InvalidArgumentException("Service not found for sender '{$sender->getAddress()}' or service is disabled");
|
||||||
}
|
}
|
||||||
if ($service instanceof ServiceEntitySubmitInterface === false) {
|
if ($service instanceof ServiceEntitySubmitInterface === false) {
|
||||||
throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity submission");
|
throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity submission");
|
||||||
@@ -1302,9 +1342,6 @@ class Manager {
|
|||||||
throw new InvalidArgumentException("At least one of source or message must be provided for entity submission");
|
throw new InvalidArgumentException("At least one of source or message must be provided for entity submission");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($sender instanceof AddressInterface === false) {
|
|
||||||
$sender = new Address($sender);
|
|
||||||
}
|
|
||||||
if ($message !== null && $message instanceof MessagePropertiesMutableInterface === false) {
|
if ($message !== null && $message instanceof MessagePropertiesMutableInterface === false) {
|
||||||
$message = $service->entityFresh()->getProperties()->jsonDeserialize($message);
|
$message = $service->entityFresh()->getProperties()->jsonDeserialize($message);
|
||||||
}
|
}
|
||||||
@@ -1312,4 +1349,50 @@ class Manager {
|
|||||||
return $service->entitySubmit($sender, $source, $message);
|
return $service->entitySubmit($sender, $source, $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function entityDownload(string $tenantId, string $userId, EntityIdentifier $targetEntity, array|null $targetPart): BinaryResource {
|
||||||
|
// retrieve service
|
||||||
|
$service = $this->serviceFetch($tenantId, $userId, $targetEntity->provider(), $targetEntity->service());
|
||||||
|
// download entity
|
||||||
|
return $service->entityDownload($targetEntity, $targetPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an authorized attachment and lazily generate or retrieve its preview.
|
||||||
|
*/
|
||||||
|
public function entityPreview(string $tenantId, string $userId, EntityIdentifier $targetEntity, array $part, string $variant = 'inline'): ?Preview {
|
||||||
|
$mimeType = $part['type'] ?? null;
|
||||||
|
$size = $part['size'] ?? null;
|
||||||
|
if (!is_string($mimeType) || ($size !== null && !is_int($size))) {
|
||||||
|
throw new InvalidArgumentException('Attachment preview requires a MIME type and optional integer size');
|
||||||
|
}
|
||||||
|
|
||||||
|
$selector = array_filter([
|
||||||
|
'blobId' => $part['blobId'] ?? null,
|
||||||
|
'partId' => $part['partId'] ?? null,
|
||||||
|
'cid' => $part['cid'] ?? null,
|
||||||
|
], static fn(mixed $value): bool => is_string($value) && $value !== '');
|
||||||
|
if ($selector === []) {
|
||||||
|
throw new InvalidArgumentException('Attachment preview requires a blob, part, or content identifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachmentIdentity = hash('sha256', json_encode($selector, JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return $this->previewManager->fetch($tenantId, new PreviewSource(
|
||||||
|
sourceType: 'mail-attachment',
|
||||||
|
identity: (string) $targetEntity . ':' . $attachmentIdentity,
|
||||||
|
// Attachments are immutable once delivered, so there is no revision to track;
|
||||||
|
// existence and access are enforced by entityDownload() (via serviceFetch) below,
|
||||||
|
// which only runs on a cache miss.
|
||||||
|
signature: 'immutable',
|
||||||
|
mimeType: $mimeType,
|
||||||
|
size: $size,
|
||||||
|
resourceFactory: fn(): BinaryResource => $this->entityDownload(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$targetEntity,
|
||||||
|
$selector,
|
||||||
|
),
|
||||||
|
), $variant);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-12
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
namespace KTXM\MailManager;
|
namespace KTXM\MailManager;
|
||||||
|
|
||||||
use KTXF\Module\ModuleBrowserInterface;
|
use KTXF\Module\Configuration\BrowserModuleContextInterface;
|
||||||
|
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||||
use KTXF\Module\ModuleInstanceAbstract;
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,7 +12,7 @@ use KTXF\Module\ModuleInstanceAbstract;
|
|||||||
* Provides unified mail sending and management across multiple providers
|
* Provides unified mail sending and management across multiple providers
|
||||||
* with context-aware service discovery and queued delivery.
|
* with context-aware service discovery and queued delivery.
|
||||||
*/
|
*/
|
||||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
class Module extends ModuleInstanceAbstract
|
||||||
{
|
{
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
@@ -50,18 +51,18 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
|||||||
'description' => 'View and access the mail manager module',
|
'description' => 'View and access the mail manager module',
|
||||||
'group' => 'Mail Management'
|
'group' => 'Mail Management'
|
||||||
],
|
],
|
||||||
|
'mail_manager.system' => [
|
||||||
|
'label' => 'Manage System Mail',
|
||||||
|
'description' => 'Manage system mail accounts and routing rules (act in the reserved system user context)',
|
||||||
|
'group' => 'Mail Management'
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function registerBI(): array {
|
public function configure(ModuleContextInterface $context): void
|
||||||
return [
|
{
|
||||||
'handle' => $this->handle(),
|
if ($context instanceof BrowserModuleContextInterface) {
|
||||||
'namespace' => 'MailManager',
|
$context->registerModule($this, 'MailManager', 'static/module.mjs');
|
||||||
'version' => $this->version(),
|
}
|
||||||
'label' => $this->label(),
|
|
||||||
'author' => $this->author(),
|
|
||||||
'description' => $this->description(),
|
|
||||||
'boot' => 'static/module.mjs',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\MailManager\Queue;
|
|
||||||
|
|
||||||
use JsonSerializable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mail Job Status
|
|
||||||
*
|
|
||||||
* Status states for queued mail jobs.
|
|
||||||
*
|
|
||||||
* @since 2025.05.01
|
|
||||||
*/
|
|
||||||
enum JobStatus: string implements JsonSerializable {
|
|
||||||
|
|
||||||
case Pending = 'pending';
|
|
||||||
case Processing = 'processing';
|
|
||||||
case Complete = 'complete';
|
|
||||||
case Failed = 'failed';
|
|
||||||
|
|
||||||
public function jsonSerialize(): string {
|
|
||||||
return $this->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\MailManager\Queue;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use KTXF\Mail\Entity\IMessageMutable;
|
|
||||||
use KTXF\Mail\Queue\SendOptions;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mail Job
|
|
||||||
*
|
|
||||||
* Represents a queued mail job with metadata and message content.
|
|
||||||
*
|
|
||||||
* @since 2025.05.01
|
|
||||||
*/
|
|
||||||
class MailJob {
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $id,
|
|
||||||
public readonly string $tenantId,
|
|
||||||
public readonly string $providerId,
|
|
||||||
public readonly string|int $serviceId,
|
|
||||||
public readonly IMessageMutable $message,
|
|
||||||
public readonly SendOptions $options,
|
|
||||||
public JobStatus $status = JobStatus::Pending,
|
|
||||||
public int $attempts = 0,
|
|
||||||
public ?string $lastError = null,
|
|
||||||
public ?string $messageId = null,
|
|
||||||
public ?DateTimeImmutable $created = null,
|
|
||||||
public ?DateTimeImmutable $scheduled = null,
|
|
||||||
public ?DateTimeImmutable $lastAttempt = null,
|
|
||||||
public ?DateTimeImmutable $completed = null,
|
|
||||||
) {
|
|
||||||
$this->created = $this->created ?? new DateTimeImmutable();
|
|
||||||
$this->scheduled = $this->scheduled ?? $this->calculateScheduledTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate when this job should be processed
|
|
||||||
*
|
|
||||||
* @return DateTimeImmutable
|
|
||||||
*/
|
|
||||||
private function calculateScheduledTime(): DateTimeImmutable {
|
|
||||||
$scheduled = $this->created ?? new DateTimeImmutable();
|
|
||||||
|
|
||||||
if ($this->options->delaySeconds !== null && $this->options->delaySeconds > 0) {
|
|
||||||
$scheduled = $scheduled->modify("+{$this->options->delaySeconds} seconds");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $scheduled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the job is ready to be processed
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function isReady(): bool {
|
|
||||||
if ($this->status !== JobStatus::Pending) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->scheduled === null || $this->scheduled <= new DateTimeImmutable();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the job can be retried
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function canRetry(): bool {
|
|
||||||
return $this->attempts < $this->options->retryCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get retry delay in seconds based on attempt count (exponential backoff)
|
|
||||||
*
|
|
||||||
* @return int
|
|
||||||
*/
|
|
||||||
public function getRetryDelay(): int {
|
|
||||||
// Exponential backoff: 30s, 60s, 120s, 240s, ...
|
|
||||||
return min(30 * (2 ** $this->attempts), 3600);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialize job metadata for storage
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function toMetaArray(): array {
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'tenantId' => $this->tenantId,
|
|
||||||
'providerId' => $this->providerId,
|
|
||||||
'serviceId' => $this->serviceId,
|
|
||||||
'options' => $this->options->jsonSerialize(),
|
|
||||||
'status' => $this->status->value,
|
|
||||||
'attempts' => $this->attempts,
|
|
||||||
'lastError' => $this->lastError,
|
|
||||||
'messageId' => $this->messageId,
|
|
||||||
'created' => $this->created?->format('c'),
|
|
||||||
'scheduled' => $this->scheduled?->format('c'),
|
|
||||||
'lastAttempt' => $this->lastAttempt?->format('c'),
|
|
||||||
'completed' => $this->completed?->format('c'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,492 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\MailManager\Queue;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DI\Attribute\Inject;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
use KTXF\Mail\Entity\IMessageMutable;
|
|
||||||
use KTXF\Mail\Entity\Message;
|
|
||||||
use KTXF\Mail\Queue\SendOptions;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* File-Based Mail Queue
|
|
||||||
*
|
|
||||||
* Stores mail queue jobs on disk with atomic operations using file locks.
|
|
||||||
*
|
|
||||||
* Structure:
|
|
||||||
* storage/{tenantId}/mail/queue/
|
|
||||||
* pending/{jobId}/
|
|
||||||
* meta.json
|
|
||||||
* message.json
|
|
||||||
* processing/{jobId}/...
|
|
||||||
* complete/{jobId}/...
|
|
||||||
* failed/{jobId}/...
|
|
||||||
*
|
|
||||||
* @since 2025.05.01
|
|
||||||
*/
|
|
||||||
class MailQueueFile {
|
|
||||||
|
|
||||||
private const DIR_PENDING = 'pending';
|
|
||||||
private const DIR_PROCESSING = 'processing';
|
|
||||||
private const DIR_COMPLETE = 'complete';
|
|
||||||
private const DIR_FAILED = 'failed';
|
|
||||||
private string $storagePath;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private LoggerInterface $logger,
|
|
||||||
#[Inject('rootDir')] private readonly string $rootDir,
|
|
||||||
) {
|
|
||||||
$this->storagePath = $this->rootDir . '/var/cache/mail_manager/queue';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @inheritDoc
|
|
||||||
*/
|
|
||||||
public function enqueue(
|
|
||||||
string $tenantId,
|
|
||||||
string $providerId,
|
|
||||||
string|int $serviceId,
|
|
||||||
IMessageMutable $message,
|
|
||||||
SendOptions $options
|
|
||||||
): string {
|
|
||||||
$jobId = $this->generateJobId();
|
|
||||||
|
|
||||||
$job = new MailJob(
|
|
||||||
id: $jobId,
|
|
||||||
tenantId: $tenantId,
|
|
||||||
providerId: $providerId,
|
|
||||||
serviceId: $serviceId,
|
|
||||||
message: $message,
|
|
||||||
options: $options,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->writeJob($job, self::DIR_PENDING);
|
|
||||||
|
|
||||||
return $jobId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @inheritDoc
|
|
||||||
*/
|
|
||||||
public function dequeue(string $tenantId, int $limit = 50): array {
|
|
||||||
$pendingDir = $this->getQueueDir($tenantId, self::DIR_PENDING);
|
|
||||||
|
|
||||||
if (!is_dir($pendingDir)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$jobs = [];
|
|
||||||
$entries = scandir($pendingDir);
|
|
||||||
|
|
||||||
// Sort by priority (read meta files)
|
|
||||||
$jobsWithPriority = [];
|
|
||||||
foreach ($entries as $entry) {
|
|
||||||
if ($entry === '.' || $entry === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$jobDir = $pendingDir . '/' . $entry;
|
|
||||||
if (!is_dir($jobDir)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$metaFile = $jobDir . '/meta.json';
|
|
||||||
if (!file_exists($metaFile)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$meta = json_decode(file_get_contents($metaFile), true);
|
|
||||||
if ($meta === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheduled = isset($meta['scheduled']) ? new DateTimeImmutable($meta['scheduled']) : null;
|
|
||||||
if ($scheduled !== null && $scheduled > new DateTimeImmutable()) {
|
|
||||||
continue; // Not ready yet
|
|
||||||
}
|
|
||||||
|
|
||||||
$jobsWithPriority[] = [
|
|
||||||
'id' => $entry,
|
|
||||||
'priority' => $meta['options']['priority'] ?? 0,
|
|
||||||
'created' => $meta['created'] ?? '',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by priority (desc) then by created (asc)
|
|
||||||
usort($jobsWithPriority, function($a, $b) {
|
|
||||||
if ($a['priority'] !== $b['priority']) {
|
|
||||||
return $b['priority'] <=> $a['priority'];
|
|
||||||
}
|
|
||||||
return $a['created'] <=> $b['created'];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Take up to limit and move to processing
|
|
||||||
$jobsWithPriority = array_slice($jobsWithPriority, 0, $limit);
|
|
||||||
|
|
||||||
foreach ($jobsWithPriority as $jobInfo) {
|
|
||||||
$job = $this->loadJob($tenantId, $jobInfo['id'], self::DIR_PENDING);
|
|
||||||
if ($job === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move to processing
|
|
||||||
$this->moveJob($tenantId, $jobInfo['id'], self::DIR_PENDING, self::DIR_PROCESSING);
|
|
||||||
$job->status = JobStatus::Processing;
|
|
||||||
$job->lastAttempt = new DateTimeImmutable();
|
|
||||||
$job->attempts++;
|
|
||||||
$this->updateJobMeta($tenantId, $jobInfo['id'], $job, self::DIR_PROCESSING);
|
|
||||||
|
|
||||||
$jobs[] = $job;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $jobs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @inheritDoc
|
|
||||||
*/
|
|
||||||
public function acknowledge(string $jobId, string $messageId): void {
|
|
||||||
$job = $this->findJobById($jobId);
|
|
||||||
if ($job === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$job->status = JobStatus::Complete;
|
|
||||||
$job->messageId = $messageId;
|
|
||||||
$job->completed = new DateTimeImmutable();
|
|
||||||
|
|
||||||
$this->moveJob($job->tenantId, $jobId, self::DIR_PROCESSING, self::DIR_COMPLETE);
|
|
||||||
$this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_COMPLETE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @inheritDoc
|
|
||||||
*/
|
|
||||||
public function reject(string $jobId, string $error, bool $retry = true): void {
|
|
||||||
$job = $this->findJobById($jobId);
|
|
||||||
if ($job === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$job->lastError = $error;
|
|
||||||
|
|
||||||
if ($retry && $job->canRetry()) {
|
|
||||||
// Move back to pending with delay
|
|
||||||
$job->status = JobStatus::Pending;
|
|
||||||
$job->scheduled = (new DateTimeImmutable())->modify('+' . $job->getRetryDelay() . ' seconds');
|
|
||||||
|
|
||||||
$this->moveJob($job->tenantId, $jobId, self::DIR_PROCESSING, self::DIR_PENDING);
|
|
||||||
$this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_PENDING);
|
|
||||||
} else {
|
|
||||||
// Move to failed
|
|
||||||
$job->status = JobStatus::Failed;
|
|
||||||
$job->completed = new DateTimeImmutable();
|
|
||||||
|
|
||||||
$this->moveJob($job->tenantId, $jobId, self::DIR_PROCESSING, self::DIR_FAILED);
|
|
||||||
$this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_FAILED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @inheritDoc
|
|
||||||
*/
|
|
||||||
public function getJob(string $jobId): ?MailJob {
|
|
||||||
return $this->findJobById($jobId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @inheritDoc
|
|
||||||
*/
|
|
||||||
public function listJobs(string $tenantId, ?JobStatus $status = null, int $limit = 100, int $offset = 0): array {
|
|
||||||
$dirs = $status !== null
|
|
||||||
? [$this->statusToDir($status)]
|
|
||||||
: [self::DIR_PENDING, self::DIR_PROCESSING, self::DIR_COMPLETE, self::DIR_FAILED];
|
|
||||||
|
|
||||||
$jobs = [];
|
|
||||||
|
|
||||||
foreach ($dirs as $dir) {
|
|
||||||
$queueDir = $this->getQueueDir($tenantId, $dir);
|
|
||||||
if (!is_dir($queueDir)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (scandir($queueDir) as $entry) {
|
|
||||||
if ($entry === '.' || $entry === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$job = $this->loadJob($tenantId, $entry, $dir);
|
|
||||||
if ($job !== null) {
|
|
||||||
$jobs[] = $job;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by created desc
|
|
||||||
usort($jobs, fn($a, $b) => ($b->created?->getTimestamp() ?? 0) <=> ($a->created?->getTimestamp() ?? 0));
|
|
||||||
|
|
||||||
return array_slice($jobs, $offset, $limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @inheritDoc
|
|
||||||
*/
|
|
||||||
public function retry(string $jobId): bool {
|
|
||||||
$job = $this->findJobById($jobId);
|
|
||||||
if ($job === null || $job->status !== JobStatus::Failed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$job->status = JobStatus::Pending;
|
|
||||||
$job->attempts = 0;
|
|
||||||
$job->lastError = null;
|
|
||||||
$job->scheduled = new DateTimeImmutable();
|
|
||||||
|
|
||||||
$this->moveJob($job->tenantId, $jobId, self::DIR_FAILED, self::DIR_PENDING);
|
|
||||||
$this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_PENDING);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @inheritDoc
|
|
||||||
*/
|
|
||||||
public function purge(string $tenantId, JobStatus $status, int $olderThanSeconds): int {
|
|
||||||
$dir = $this->statusToDir($status);
|
|
||||||
$queueDir = $this->getQueueDir($tenantId, $dir);
|
|
||||||
|
|
||||||
if (!is_dir($queueDir)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$threshold = new DateTimeImmutable("-{$olderThanSeconds} seconds");
|
|
||||||
$purged = 0;
|
|
||||||
|
|
||||||
foreach (scandir($queueDir) as $entry) {
|
|
||||||
if ($entry === '.' || $entry === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$jobDir = $queueDir . '/' . $entry;
|
|
||||||
$metaFile = $jobDir . '/meta.json';
|
|
||||||
|
|
||||||
if (!file_exists($metaFile)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$meta = json_decode(file_get_contents($metaFile), true);
|
|
||||||
$completed = isset($meta['completed']) ? new DateTimeImmutable($meta['completed']) : null;
|
|
||||||
|
|
||||||
if ($completed !== null && $completed < $threshold) {
|
|
||||||
$this->deleteJobDir($jobDir);
|
|
||||||
$purged++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $purged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @inheritDoc
|
|
||||||
*/
|
|
||||||
public function stats(string $tenantId): array {
|
|
||||||
$stats = [
|
|
||||||
'pending' => 0,
|
|
||||||
'processing' => 0,
|
|
||||||
'complete' => 0,
|
|
||||||
'failed' => 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($stats as $status => $_) {
|
|
||||||
$dir = $this->getQueueDir($tenantId, $status);
|
|
||||||
if (is_dir($dir)) {
|
|
||||||
$stats[$status] = count(array_filter(
|
|
||||||
scandir($dir),
|
|
||||||
fn($e) => $e !== '.' && $e !== '..'
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a unique job ID
|
|
||||||
*/
|
|
||||||
private function generateJobId(): string {
|
|
||||||
return sprintf(
|
|
||||||
'%08x-%04x-%04x-%04x-%012x',
|
|
||||||
time(),
|
|
||||||
mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0x0fff) | 0x4000,
|
|
||||||
mt_rand(0, 0x3fff) | 0x8000,
|
|
||||||
mt_rand(0, 0xffffffffffff)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the queue directory path for a tenant and status
|
|
||||||
*/
|
|
||||||
private function getQueueDir(string $tenantId, string $status): string {
|
|
||||||
return $this->storagePath . '/' . $tenantId . '/mail/queue/' . $status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write a job to disk
|
|
||||||
*/
|
|
||||||
private function writeJob(MailJob $job, string $status): void {
|
|
||||||
$jobDir = $this->getQueueDir($job->tenantId, $status) . '/' . $job->id;
|
|
||||||
|
|
||||||
if (!is_dir($jobDir)) {
|
|
||||||
mkdir($jobDir, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write meta
|
|
||||||
$metaFile = $jobDir . '/meta.json';
|
|
||||||
file_put_contents($metaFile, json_encode($job->toMetaArray(), JSON_PRETTY_PRINT));
|
|
||||||
|
|
||||||
// Write message
|
|
||||||
$messageFile = $jobDir . '/message.json';
|
|
||||||
file_put_contents($messageFile, json_encode($job->message, JSON_PRETTY_PRINT));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a job from disk
|
|
||||||
*/
|
|
||||||
private function loadJob(string $tenantId, string $jobId, string $status): ?MailJob {
|
|
||||||
$jobDir = $this->getQueueDir($tenantId, $status) . '/' . $jobId;
|
|
||||||
$metaFile = $jobDir . '/meta.json';
|
|
||||||
$messageFile = $jobDir . '/message.json';
|
|
||||||
|
|
||||||
if (!file_exists($metaFile) || !file_exists($messageFile)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$meta = json_decode(file_get_contents($metaFile), true);
|
|
||||||
$messageData = json_decode(file_get_contents($messageFile), true);
|
|
||||||
|
|
||||||
if ($meta === null || $messageData === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = Message::fromArray($messageData);
|
|
||||||
|
|
||||||
return new MailJob(
|
|
||||||
id: $meta['id'],
|
|
||||||
tenantId: $meta['tenantId'],
|
|
||||||
providerId: $meta['providerId'],
|
|
||||||
serviceId: $meta['serviceId'],
|
|
||||||
message: $message,
|
|
||||||
options: new SendOptions(
|
|
||||||
immediate: $meta['options']['immediate'] ?? false,
|
|
||||||
priority: $meta['options']['priority'] ?? 0,
|
|
||||||
retryCount: $meta['options']['retryCount'] ?? 3,
|
|
||||||
delaySeconds: $meta['options']['delaySeconds'] ?? null,
|
|
||||||
),
|
|
||||||
status: JobStatus::from($meta['status']),
|
|
||||||
attempts: $meta['attempts'] ?? 0,
|
|
||||||
lastError: $meta['lastError'] ?? null,
|
|
||||||
messageId: $meta['messageId'] ?? null,
|
|
||||||
created: isset($meta['created']) ? new DateTimeImmutable($meta['created']) : null,
|
|
||||||
scheduled: isset($meta['scheduled']) ? new DateTimeImmutable($meta['scheduled']) : null,
|
|
||||||
lastAttempt: isset($meta['lastAttempt']) ? new DateTimeImmutable($meta['lastAttempt']) : null,
|
|
||||||
completed: isset($meta['completed']) ? new DateTimeImmutable($meta['completed']) : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move a job between status directories
|
|
||||||
*/
|
|
||||||
private function moveJob(string $tenantId, string $jobId, string $fromStatus, string $toStatus): void {
|
|
||||||
$fromDir = $this->getQueueDir($tenantId, $fromStatus) . '/' . $jobId;
|
|
||||||
$toDir = $this->getQueueDir($tenantId, $toStatus) . '/' . $jobId;
|
|
||||||
|
|
||||||
if (!is_dir($fromDir)) {
|
|
||||||
throw new RuntimeException("Job directory not found: $fromDir");
|
|
||||||
}
|
|
||||||
|
|
||||||
$toParent = dirname($toDir);
|
|
||||||
if (!is_dir($toParent)) {
|
|
||||||
mkdir($toParent, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
rename($fromDir, $toDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update job metadata
|
|
||||||
*/
|
|
||||||
private function updateJobMeta(string $tenantId, string $jobId, MailJob $job, string $status): void {
|
|
||||||
$metaFile = $this->getQueueDir($tenantId, $status) . '/' . $jobId . '/meta.json';
|
|
||||||
file_put_contents($metaFile, json_encode($job->toMetaArray(), JSON_PRETTY_PRINT));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a job by ID across all status directories
|
|
||||||
*/
|
|
||||||
private function findJobById(string $jobId): ?MailJob {
|
|
||||||
// We need to search across all tenants and statuses
|
|
||||||
// This is inefficient - in production, consider caching or indexing
|
|
||||||
$tenantsDir = $this->storagePath;
|
|
||||||
|
|
||||||
if (!is_dir($tenantsDir)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (scandir($tenantsDir) as $tenantId) {
|
|
||||||
if ($tenantId === '.' || $tenantId === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ([self::DIR_PENDING, self::DIR_PROCESSING, self::DIR_COMPLETE, self::DIR_FAILED] as $status) {
|
|
||||||
$job = $this->loadJob($tenantId, $jobId, $status);
|
|
||||||
if ($job !== null) {
|
|
||||||
return $job;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a job directory recursively
|
|
||||||
*/
|
|
||||||
private function deleteJobDir(string $dir): void {
|
|
||||||
if (!is_dir($dir)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (scandir($dir) as $file) {
|
|
||||||
if ($file === '.' || $file === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$path = $dir . '/' . $file;
|
|
||||||
is_dir($path) ? $this->deleteJobDir($path) : unlink($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
rmdir($dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert JobStatus to directory name
|
|
||||||
*/
|
|
||||||
private function statusToDir(JobStatus $status): string {
|
|
||||||
return match($status) {
|
|
||||||
JobStatus::Pending => self::DIR_PENDING,
|
|
||||||
JobStatus::Processing => self::DIR_PROCESSING,
|
|
||||||
JobStatus::Complete => self::DIR_COMPLETE,
|
|
||||||
JobStatus::Failed => self::DIR_FAILED,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
Generated
+1270
-529
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -18,18 +18,21 @@
|
|||||||
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
|
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^3.0.0",
|
"pinia": "^4.0.0",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^5.0.0",
|
"vue-router": "^5.2.0",
|
||||||
"vuetify": "^4.0.0"
|
"vuetify": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"@vitest/ui": "^4.0.18",
|
"@vitest/ui": "^4.0.18",
|
||||||
|
"@vue/test-utils": "^2.4.10",
|
||||||
"@vue/tsconfig": "^0.9.0",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
"vue-tsc": "^3.0.5"
|
"vue-tsc": "^3.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const MANUAL_STEPS = {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
|
user?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -242,7 +243,8 @@ async function handleDiscover() {
|
|||||||
discoverSecret.value || undefined,
|
discoverSecret.value || undefined,
|
||||||
discoverHostname.value || undefined,
|
discoverHostname.value || undefined,
|
||||||
identifier,
|
identifier,
|
||||||
(service) => { discoveredService = service }
|
(service) => { discoveredService = service },
|
||||||
|
props.user
|
||||||
)
|
)
|
||||||
|
|
||||||
// Success - check if we got results for this provider
|
// Success - check if we got results for this provider
|
||||||
@@ -384,7 +386,8 @@ async function testConnection() {
|
|||||||
selectedProvider.value.identifier,
|
selectedProvider.value.identifier,
|
||||||
null,
|
null,
|
||||||
selectedService.value.location,
|
selectedService.value.location,
|
||||||
selectedService.value.identity
|
selectedService.value.identity,
|
||||||
|
props.user
|
||||||
)
|
)
|
||||||
|
|
||||||
return testResult
|
return testResult
|
||||||
@@ -410,7 +413,8 @@ async function saveAccount() {
|
|||||||
|
|
||||||
await servicesStore.create(
|
await servicesStore.create(
|
||||||
selectedProvider.value.identifier,
|
selectedProvider.value.identifier,
|
||||||
accountData
|
accountData,
|
||||||
|
props.user
|
||||||
)
|
)
|
||||||
|
|
||||||
emit('saved')
|
emit('saved')
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const props = defineProps<{
|
|||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
serviceProvider: string
|
serviceProvider: string
|
||||||
serviceIdentifier: string | number
|
serviceIdentifier: string | number
|
||||||
|
user?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -103,7 +104,10 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const [provider, service] = await Promise.all([
|
const [provider, service] = await Promise.all([
|
||||||
providersStore.provider(props.serviceProvider) ?? providersStore.fetch(props.serviceProvider),
|
providersStore.provider(props.serviceProvider) ?? providersStore.fetch(props.serviceProvider),
|
||||||
servicesStore.service(props.serviceProvider, props.serviceIdentifier) ?? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier)
|
// acting-user context always fetches fresh, bypassing the shared cache
|
||||||
|
props.user
|
||||||
|
? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier, props.user)
|
||||||
|
: servicesStore.service(props.serviceProvider, props.serviceIdentifier) ?? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier)
|
||||||
])
|
])
|
||||||
|
|
||||||
localProvider.value = provider.clone()
|
localProvider.value = provider.clone()
|
||||||
@@ -161,12 +165,16 @@ async function testConnection() {
|
|||||||
localService.value.provider,
|
localService.value.provider,
|
||||||
null,
|
null,
|
||||||
localService.value.location,
|
localService.value.location,
|
||||||
localService.value.identity
|
localService.value.identity,
|
||||||
|
props.user
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
testResult = await servicesStore.test(
|
testResult = await servicesStore.test(
|
||||||
localService.value.provider,
|
localService.value.provider,
|
||||||
localService.value.identifier
|
localService.value.identifier,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
props.user
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +207,8 @@ async function saveAccount() {
|
|||||||
localService.value.provider,
|
localService.value.provider,
|
||||||
localService.value.identifier as string | number,
|
localService.value.identifier as string | number,
|
||||||
true, // delta update
|
true, // delta update
|
||||||
localService.value
|
localService.value,
|
||||||
|
props.user
|
||||||
)
|
)
|
||||||
|
|
||||||
emit('saved')
|
emit('saved')
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
name: 'mail-accounts',
|
name: 'mail-accounts',
|
||||||
path: '/accounts',
|
path: '/accounts',
|
||||||
component: () => import('@/pages/AccountsPage.vue'),
|
component: () => import('@/pages/Main.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Mail Accounts',
|
title: 'Mail Accounts',
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Entity management service
|
* Entity management service
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { transceivePost, transceiveStream, transceiveDownload } from './transceive';
|
import { transceivePost, transceiveStream, transceiveDownload, transceiveBlob } from './transceive';
|
||||||
import type {
|
import type {
|
||||||
EntityFetchRequest,
|
EntityFetchRequest,
|
||||||
EntityFetchResponse,
|
EntityFetchResponse,
|
||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
EntityPatchResponse,
|
EntityPatchResponse,
|
||||||
EntityPatchRequest,
|
EntityPatchRequest,
|
||||||
EntityDownloadRequest,
|
EntityDownloadRequest,
|
||||||
|
EntityPreviewRequest,
|
||||||
EntityBlobsRequest,
|
EntityBlobsRequest,
|
||||||
EntityBlobsResponse,
|
EntityBlobsResponse,
|
||||||
EntityBlobsWireResponse,
|
EntityBlobsWireResponse,
|
||||||
@@ -231,6 +232,10 @@ export const entityService = {
|
|||||||
return transceiveDownload<EntityDownloadRequest>('entity.download', request);
|
return transceiveDownload<EntityDownloadRequest>('entity.download', request);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async preview(request: EntityPreviewRequest): Promise<Blob> {
|
||||||
|
return await transceiveBlob<EntityPreviewRequest>('entity.preview', request);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch one or more message parts (attachments) inline for rendering.
|
* Fetch one or more message parts (attachments) inline for rendering.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ export { serviceService } from './serviceService';
|
|||||||
export { collectionService } from './collectionService';
|
export { collectionService } from './collectionService';
|
||||||
export { entityService } from './entityService';
|
export { entityService } from './entityService';
|
||||||
|
|
||||||
// Type exports
|
export * from '../types/common';
|
||||||
export type * from '../types/common';
|
export * from '../types/provider';
|
||||||
export type * from '../types/provider';
|
export * from '../types/service';
|
||||||
export type * from '../types/service';
|
export * from '../types/collection';
|
||||||
export type * from '../types/collection';
|
export * from '../types/entity';
|
||||||
export type * from '../types/entity';
|
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ export const serviceService = {
|
|||||||
*
|
*
|
||||||
* @returns Promise with service object list grouped by provider and keyed by service identifier
|
* @returns Promise with service object list grouped by provider and keyed by service identifier
|
||||||
*/
|
*/
|
||||||
async list(request: ServiceListRequest = {}): Promise<Record<string, Record<string, ServiceObject>>> {
|
async list(request: ServiceListRequest = {}, user?: string): Promise<Record<string, Record<string, ServiceObject>>> {
|
||||||
const response = await transceivePost<ServiceListRequest, ServiceListResponse>('service.list', request);
|
const response = await transceivePost<ServiceListRequest, ServiceListResponse>('service.list', request, user);
|
||||||
|
|
||||||
// Convert nested response to ServiceObject instances
|
// Convert nested response to ServiceObject instances
|
||||||
const providerList: Record<string, Record<string, ServiceObject>> = {};
|
const providerList: Record<string, Record<string, ServiceObject>> = {};
|
||||||
@@ -70,8 +70,8 @@ export const serviceService = {
|
|||||||
*
|
*
|
||||||
* @returns Promise with service object
|
* @returns Promise with service object
|
||||||
*/
|
*/
|
||||||
async fetch(request: ServiceFetchRequest): Promise<ServiceObject> {
|
async fetch(request: ServiceFetchRequest, user?: string): Promise<ServiceObject> {
|
||||||
const response = await transceivePost<ServiceFetchRequest, ServiceFetchResponse>('service.fetch', request);
|
const response = await transceivePost<ServiceFetchRequest, ServiceFetchResponse>('service.fetch', request, user);
|
||||||
return createServiceObject(response);
|
return createServiceObject(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -82,8 +82,8 @@ export const serviceService = {
|
|||||||
*
|
*
|
||||||
* @returns Promise with service availability status
|
* @returns Promise with service availability status
|
||||||
*/
|
*/
|
||||||
async extant(request: ServiceExtantRequest): Promise<ServiceExtantResponse> {
|
async extant(request: ServiceExtantRequest, user?: string): Promise<ServiceExtantResponse> {
|
||||||
return await transceivePost<ServiceExtantRequest, ServiceExtantResponse>('service.extant', request);
|
return await transceivePost<ServiceExtantRequest, ServiceExtantResponse>('service.extant', request, user);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,7 +96,8 @@ export const serviceService = {
|
|||||||
*/
|
*/
|
||||||
async discover(
|
async discover(
|
||||||
request: ServiceDiscoverRequest,
|
request: ServiceDiscoverRequest,
|
||||||
onService: (service: ServiceObject) => void
|
onService: (service: ServiceObject) => void,
|
||||||
|
user?: string
|
||||||
): Promise<{ total: number }> {
|
): Promise<{ total: number }> {
|
||||||
return await transceiveStream<ServiceDiscoverRequest, ServiceDiscoverResponse>(
|
return await transceiveStream<ServiceDiscoverRequest, ServiceDiscoverResponse>(
|
||||||
'service.discover',
|
'service.discover',
|
||||||
@@ -107,11 +108,12 @@ export const serviceService = {
|
|||||||
provider: service.provider,
|
provider: service.provider,
|
||||||
identifier: null,
|
identifier: null,
|
||||||
label: null,
|
label: null,
|
||||||
enabled: false,
|
enabled: true,
|
||||||
location: service.location,
|
location: service.location,
|
||||||
};
|
};
|
||||||
onService(createServiceObject(serviceData));
|
onService(createServiceObject(serviceData));
|
||||||
}
|
},
|
||||||
|
user
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -121,8 +123,8 @@ export const serviceService = {
|
|||||||
* @param request - Service test request
|
* @param request - Service test request
|
||||||
* @returns Promise with test results
|
* @returns Promise with test results
|
||||||
*/
|
*/
|
||||||
async test(request: ServiceTestRequest): Promise<ServiceTestResponse> {
|
async test(request: ServiceTestRequest, user?: string): Promise<ServiceTestResponse> {
|
||||||
return await transceivePost<ServiceTestRequest, ServiceTestResponse>('service.test', request);
|
return await transceivePost<ServiceTestRequest, ServiceTestResponse>('service.test', request, user);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,8 +134,8 @@ export const serviceService = {
|
|||||||
*
|
*
|
||||||
* @returns Promise with created service object
|
* @returns Promise with created service object
|
||||||
*/
|
*/
|
||||||
async create(request: ServiceCreateRequest): Promise<ServiceObject> {
|
async create(request: ServiceCreateRequest, user?: string): Promise<ServiceObject> {
|
||||||
const response = await transceivePost<ServiceCreateRequest, ServiceCreateResponse>('service.create', request);
|
const response = await transceivePost<ServiceCreateRequest, ServiceCreateResponse>('service.create', request, user);
|
||||||
return createServiceObject(response);
|
return createServiceObject(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -144,8 +146,8 @@ export const serviceService = {
|
|||||||
*
|
*
|
||||||
* @returns Promise with updated service object
|
* @returns Promise with updated service object
|
||||||
*/
|
*/
|
||||||
async update(request: ServiceUpdateRequest): Promise<ServiceObject> {
|
async update(request: ServiceUpdateRequest, user?: string): Promise<ServiceObject> {
|
||||||
const response = await transceivePost<ServiceUpdateRequest, ServiceUpdateResponse>('service.update', request);
|
const response = await transceivePost<ServiceUpdateRequest, ServiceUpdateResponse>('service.update', request, user);
|
||||||
return createServiceObject(response);
|
return createServiceObject(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -156,8 +158,8 @@ export const serviceService = {
|
|||||||
*
|
*
|
||||||
* @returns Promise with deletion result
|
* @returns Promise with deletion result
|
||||||
*/
|
*/
|
||||||
async delete(request: { provider: string; identifier: string | number }): Promise<any> {
|
async delete(request: { provider: string; identifier: string | number }, user?: string): Promise<any> {
|
||||||
return await transceivePost<ServiceDeleteRequest, ServiceDeleteResponse>('service.delete', request);
|
return await transceivePost<ServiceDeleteRequest, ServiceDeleteResponse>('service.delete', request, user);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,28 @@ export function transceiveDownload<TRequest>(
|
|||||||
return { transaction: request.transaction };
|
return { transaction: request.transaction };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function transceiveBlob<TRequest>(operation: string, data: TRequest): Promise<Blob> {
|
||||||
|
const request: ApiRequest<TRequest> = {
|
||||||
|
version: API_VERSION,
|
||||||
|
transaction: generateTransactionId(),
|
||||||
|
operation,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
let blob: Blob | null = null;
|
||||||
|
await fetchWrapper.post(API_URL, request, {
|
||||||
|
headers: { 'Accept': '*/*' },
|
||||||
|
onStream: async (response: Response) => {
|
||||||
|
if (response.status === 204) {
|
||||||
|
throw new Error(`[${operation}] Preview unavailable`);
|
||||||
|
}
|
||||||
|
blob = await response.blob();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (blob === null) throw new Error(`[${operation}] Empty preview response`);
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function appendHiddenField(form: HTMLFormElement, name: string, value: string): void {
|
function appendHiddenField(form: HTMLFormElement, name: string, value: string): void {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'hidden';
|
input.type = 'hidden';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
EntityBlobSelector,
|
EntityBlobSelector,
|
||||||
EntityBlobsResponse,
|
EntityBlobsResponse,
|
||||||
EntityDownloadRequest,
|
EntityDownloadRequest,
|
||||||
|
EntityPreviewRequest,
|
||||||
EntityTransmitRequest,
|
EntityTransmitRequest,
|
||||||
EntityTransmitResponse,
|
EntityTransmitResponse,
|
||||||
} from '../types/entity'
|
} from '../types/entity'
|
||||||
@@ -518,6 +519,10 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function preview(request: EntityPreviewRequest): Promise<Blob> {
|
||||||
|
return await entityService.preview(request)
|
||||||
|
}
|
||||||
|
|
||||||
// Return public API
|
// Return public API
|
||||||
return {
|
return {
|
||||||
// State (readonly)
|
// State (readonly)
|
||||||
@@ -540,6 +545,7 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
|||||||
move,
|
move,
|
||||||
transmit,
|
transmit,
|
||||||
download,
|
download,
|
||||||
|
preview,
|
||||||
blobs,
|
blobs,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+36
-23
@@ -131,10 +131,10 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
*
|
*
|
||||||
* @returns Promise with service object list keyed by provider and service identifier
|
* @returns Promise with service object list keyed by provider and service identifier
|
||||||
*/
|
*/
|
||||||
async function list(targets?: ServiceIdentifier[] | CollectionIdentifier[]): Promise<Record<string, ServiceObject>> {
|
async function list(targets?: ServiceIdentifier[] | CollectionIdentifier[], user?: string): Promise<Record<string, ServiceObject>> {
|
||||||
transceiving.value = true
|
transceiving.value = true
|
||||||
try {
|
try {
|
||||||
const response = await serviceService.list({ targets })
|
const response = await serviceService.list({ targets }, user)
|
||||||
|
|
||||||
// Flatten nested structure: provider-id: { service-id: object } -> "provider-id:service-id": object
|
// Flatten nested structure: provider-id: { service-id: object } -> "provider-id:service-id": object
|
||||||
const services: Record<string, ServiceObject> = {}
|
const services: Record<string, ServiceObject> = {}
|
||||||
@@ -145,8 +145,10 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge retrieved services into state
|
// Merge retrieved services into state (acting-user context stays out of the shared cache)
|
||||||
_services.value = { ..._services.value, ...services }
|
if (!user) {
|
||||||
|
_services.value = { ..._services.value, ...services }
|
||||||
|
}
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(services).length, 'services')
|
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(services).length, 'services')
|
||||||
return services
|
return services
|
||||||
@@ -166,14 +168,16 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
*
|
*
|
||||||
* @returns Promise with service object
|
* @returns Promise with service object
|
||||||
*/
|
*/
|
||||||
async function fetch(provider: string, identifier: string | number): Promise<ServiceObject> {
|
async function fetch(provider: string, identifier: string | number, user?: string): Promise<ServiceObject> {
|
||||||
transceiving.value = true
|
transceiving.value = true
|
||||||
try {
|
try {
|
||||||
const service = await serviceService.fetch({ provider, identifier })
|
const service = await serviceService.fetch({ provider, identifier }, user)
|
||||||
|
|
||||||
// Merge fetched service into state
|
// Merge fetched service into state (acting-user context stays out of the shared cache)
|
||||||
const key = identifierKey(service.provider, service.identifier)
|
const key = identifierKey(service.provider, service.identifier)
|
||||||
_services.value[key] = service
|
if (!user) {
|
||||||
|
_services.value[key] = service
|
||||||
|
}
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully fetched service:', key)
|
console.debug('[Mail Manager][Store] - Successfully fetched service:', key)
|
||||||
return service
|
return service
|
||||||
@@ -192,10 +196,10 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
*
|
*
|
||||||
* @returns Promise with service availability status
|
* @returns Promise with service availability status
|
||||||
*/
|
*/
|
||||||
async function extant(targets: ServiceIdentifier[]) {
|
async function extant(targets: ServiceIdentifier[], user?: string) {
|
||||||
transceiving.value = true
|
transceiving.value = true
|
||||||
try {
|
try {
|
||||||
const response = await serviceService.extant({ targets })
|
const response = await serviceService.extant({ targets }, user)
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully checked', targets?.length ?? 0, 'services')
|
console.debug('[Mail Manager][Store] - Successfully checked', targets?.length ?? 0, 'services')
|
||||||
return response
|
return response
|
||||||
@@ -215,14 +219,16 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
*
|
*
|
||||||
* @returns Promise with created service object
|
* @returns Promise with created service object
|
||||||
*/
|
*/
|
||||||
async function create(provider: string, data: Partial<ServiceInterface>): Promise<ServiceObject> {
|
async function create(provider: string, data: Partial<ServiceInterface>, user?: string): Promise<ServiceObject> {
|
||||||
transceiving.value = true
|
transceiving.value = true
|
||||||
try {
|
try {
|
||||||
const service = await serviceService.create({ provider, data })
|
const service = await serviceService.create({ provider, data }, user)
|
||||||
|
|
||||||
// Merge created service into state
|
// Merge created service into state (acting-user context stays out of the shared cache)
|
||||||
const key = identifierKey(service.provider, service.identifier)
|
const key = identifierKey(service.provider, service.identifier)
|
||||||
_services.value[key] = service
|
if (!user) {
|
||||||
|
_services.value[key] = service
|
||||||
|
}
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully created service:', key)
|
console.debug('[Mail Manager][Store] - Successfully created service:', key)
|
||||||
return service
|
return service
|
||||||
@@ -244,7 +250,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
*
|
*
|
||||||
* @returns Promise with updated service object
|
* @returns Promise with updated service object
|
||||||
*/
|
*/
|
||||||
async function update(provider: string, identifier: string | number, delta: boolean, data: ServiceObject | Partial<ServiceInterface>): Promise<ServiceObject> {
|
async function update(provider: string, identifier: string | number, delta: boolean, data: ServiceObject | Partial<ServiceInterface>, user?: string): Promise<ServiceObject> {
|
||||||
transceiving.value = true
|
transceiving.value = true
|
||||||
try {
|
try {
|
||||||
// convert ServiceObject to JSON if needed
|
// convert ServiceObject to JSON if needed
|
||||||
@@ -255,11 +261,13 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
payload = data
|
payload = data
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = await serviceService.update({ provider, identifier, delta, data: payload })
|
const service = await serviceService.update({ provider, identifier, delta, data: payload }, user)
|
||||||
|
|
||||||
// Merge updated service into state
|
// Merge updated service into state (acting-user context stays out of the shared cache)
|
||||||
const key = identifierKey(service.provider, service.identifier)
|
const key = identifierKey(service.provider, service.identifier)
|
||||||
_services.value[key] = service
|
if (!user) {
|
||||||
|
_services.value[key] = service
|
||||||
|
}
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully updated service:', key)
|
console.debug('[Mail Manager][Store] - Successfully updated service:', key)
|
||||||
return service
|
return service
|
||||||
@@ -279,14 +287,16 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
*
|
*
|
||||||
* @returns Promise with deletion result
|
* @returns Promise with deletion result
|
||||||
*/
|
*/
|
||||||
async function remove(provider: string, identifier: string | number): Promise<any> {
|
async function remove(provider: string, identifier: string | number, user?: string): Promise<any> {
|
||||||
transceiving.value = true
|
transceiving.value = true
|
||||||
try {
|
try {
|
||||||
await serviceService.delete({ provider, identifier })
|
await serviceService.delete({ provider, identifier }, user)
|
||||||
|
|
||||||
// Remove deleted service from state
|
// Remove deleted service from state
|
||||||
const key = identifierKey(provider, identifier)
|
const key = identifierKey(provider, identifier)
|
||||||
delete _services.value[key]
|
if (!user) {
|
||||||
|
delete _services.value[key]
|
||||||
|
}
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully deleted service:', key)
|
console.debug('[Mail Manager][Store] - Successfully deleted service:', key)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -314,6 +324,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
location: string | undefined,
|
location: string | undefined,
|
||||||
provider: string | undefined,
|
provider: string | undefined,
|
||||||
onService?: (service: ServiceObject) => void,
|
onService?: (service: ServiceObject) => void,
|
||||||
|
user?: string,
|
||||||
): Promise<{ total: number }> {
|
): Promise<{ total: number }> {
|
||||||
transceiving.value = true
|
transceiving.value = true
|
||||||
|
|
||||||
@@ -322,7 +333,8 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
{ identity, secret, location, provider },
|
{ identity, secret, location, provider },
|
||||||
(service: ServiceObject) => {
|
(service: ServiceObject) => {
|
||||||
onService?.(service)
|
onService?.(service)
|
||||||
}
|
},
|
||||||
|
user
|
||||||
)
|
)
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully discovered', result.total, 'services')
|
console.debug('[Mail Manager][Store] - Successfully discovered', result.total, 'services')
|
||||||
@@ -350,6 +362,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
identifier?: string | number | null,
|
identifier?: string | number | null,
|
||||||
location?: ServiceLocation | Location | null,
|
location?: ServiceLocation | Location | null,
|
||||||
identity?: ServiceIdentity | Identity | null,
|
identity?: ServiceIdentity | Identity | null,
|
||||||
|
user?: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
transceiving.value = true
|
transceiving.value = true
|
||||||
try {
|
try {
|
||||||
@@ -372,7 +385,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
identity = identity.toJson()
|
identity = identity.toJson()
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await serviceService.test({ provider, identifier, location, identity })
|
const response = await serviceService.test({ provider, identifier, location, identity }, user)
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully tested service:', provider, identifier || location)
|
console.debug('[Mail Manager][Store] - Successfully tested service:', provider, identifier || location)
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ export interface EntityDownloadRequest {
|
|||||||
filename?: string | null;
|
filename?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntityPreviewRequest {
|
||||||
|
target: EntityIdentifier;
|
||||||
|
part: EntityBlobSelector & { type: string; size?: number | null };
|
||||||
|
variant?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntityBlobsRequest {
|
export interface EntityBlobsRequest {
|
||||||
target: EntityIdentifier;
|
target: EntityIdentifier;
|
||||||
parts: EntityBlobSelector[];
|
parts: EntityBlobSelector[];
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('Basic Tests', () => {
|
||||||
|
it('should perform basic assertion', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test array operations', () => {
|
||||||
|
const array = ['foo', 'bar', 'baz']
|
||||||
|
|
||||||
|
expect(array).toHaveLength(3)
|
||||||
|
expect(array).toContain('bar')
|
||||||
|
expect(array[0]).toBe('foo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test string operations', () => {
|
||||||
|
const string = 'Hello, World!'
|
||||||
|
|
||||||
|
expect(string).toContain('World')
|
||||||
|
expect(string.length).toBe(13)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test object operations', () => {
|
||||||
|
const obj = { foo: 'bar', count: 42 }
|
||||||
|
|
||||||
|
expect(obj).toHaveProperty('foo')
|
||||||
|
expect(obj.foo).toBe('bar')
|
||||||
|
expect(obj.count).toBeGreaterThan(40)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { defineConfig, configDefaults } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '../../src'),
|
||||||
|
'@KTXC': path.resolve(__dirname, '../../../../core/src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
|
root: fileURLToPath(new URL('../../', import.meta.url)),
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: [
|
||||||
|
'node_modules/',
|
||||||
|
'tests/',
|
||||||
|
'**/*.d.ts',
|
||||||
|
'**/*.config.*',
|
||||||
|
'**/dist/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXT\MailManager\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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
require dirname(__DIR__, 2).'/lib/vendor/autoload.php';
|
require dirname(__DIR__, 2).'/lib/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']) {
|
if (isset($_SERVER['APP_DEBUG']) && $_SERVER['APP_DEBUG']) {
|
||||||
umask(0000);
|
umask(0000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
<testsuite name="Unit Tests">
|
<testsuite name="Unit Tests">
|
||||||
<directory>unit</directory>
|
<directory>unit</directory>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
|
<testsuite name="Integration Tests">
|
||||||
|
<directory>Integration</directory>
|
||||||
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
|
|
||||||
<source ignoreSuppressionOfDeprecations="true"
|
<source ignoreSuppressionOfDeprecations="true"
|
||||||
@@ -29,8 +32,7 @@
|
|||||||
restrictWarnings="true"
|
restrictWarnings="true"
|
||||||
>
|
>
|
||||||
<include>
|
<include>
|
||||||
<directory>../../core/lib</directory>
|
<directory>../../lib</directory>
|
||||||
<directory>../../shared/lib</directory>
|
|
||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
|
|
||||||
Reference in New Issue
Block a user