1 Commits

Author SHA1 Message Date
Sebastian 402a347961 chore(deps): update dependency jsdom to v30
Build Test / test (pull_request) Successful in 40s
JS Unit Tests / test (pull_request) Successful in 31s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m5s
PHP Unit Tests / test (pull_request) Successful in 1m44s
2026-07-28 03:04:25 +00:00
9 changed files with 1207 additions and 217 deletions
+2 -51
View File
@@ -172,8 +172,8 @@ class DefaultController extends ControllerAbstract {
'entity.fetch' => $this->entityFetch($tenantId, $userId, $data),
'entity.extant' => $this->entityExtant($tenantId, $userId, $data),
'entity.delta' => $this->entityDelta($tenantId, $userId, $data),
'entity.create' => $this->entityCreate($tenantId, $userId, $data),
'entity.update' => $this->entityUpdate($tenantId, $userId, $data),
'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.update' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.delete' => $this->entityDelete($tenantId, $userId, $data),
'entity.patch' => $this->entityPatch($tenantId, $userId, $data),
'entity.move' => $this->entityMove($tenantId, $userId, $data),
@@ -751,55 +751,6 @@ class DefaultController extends ControllerAbstract {
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 {
if (!isset($data['targets'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
+237
View File
@@ -0,0 +1,237 @@
<?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];
}
}
+246
View File
@@ -0,0 +1,246 @@
<?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,
};
}
}
-58
View File
@@ -9,7 +9,6 @@ use KTXC\Resource\ProviderManager;
use KTXF\Mail\Collection\CollectionBaseInterface;
use KTXF\Mail\Collection\CollectionPropertiesMutableInterface;
use KTXF\Mail\Collection\ICollectionBase;
use KTXF\Mail\Entity\EntityBaseInterface;
use KTXF\Mail\Entity\IMessageBase;
use KTXF\Mail\Object\Address;
use KTXF\Mail\Object\AddressInterface;
@@ -1130,63 +1129,6 @@ class Manager {
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
*
+32
View File
@@ -0,0 +1,32 @@
<?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;
}
}
+115
View File
@@ -0,0 +1,115 @@
<?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'),
];
}
}
+492
View File
@@ -0,0 +1,492 @@
<?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,
};
}
}
+82 -107
View File
@@ -20,7 +20,7 @@
"@vitest/ui": "^4.0.18",
"@vue/test-utils": "^2.4.10",
"@vue/tsconfig": "^0.9.0",
"jsdom": "^29.1.1",
"jsdom": "^30.0.0",
"typescript": "~6.0.0",
"vite": "^8.0.0",
"vitest": "^4.0.18",
@@ -28,48 +28,38 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-calc": "^3.2.1",
"@csstools/css-color-parser": "^4.1.9",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz",
"integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/generator": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
@@ -710,8 +700,6 @@
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
@@ -727,8 +715,6 @@
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -738,8 +724,6 @@
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
@@ -772,14 +756,12 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
"version": "4.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.9",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -793,8 +775,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.10",
"vitest": "4.1.10"
"@vitest/browser": "4.1.9",
"vitest": "4.1.9"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -803,16 +785,14 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"version": "4.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -821,13 +801,11 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"version": "4.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"@vitest/spy": "4.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -849,8 +827,6 @@
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -858,9 +834,7 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"version": "4.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -871,13 +845,11 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"version": "4.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.9",
"pathe": "^2.0.3"
},
"funding": {
@@ -885,14 +857,12 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"version": "4.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -901,9 +871,7 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"version": "4.1.9",
"dev": true,
"license": "MIT",
"funding": {
@@ -911,13 +879,11 @@
}
},
"node_modules/@vitest/ui": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz",
"integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==",
"version": "4.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.9",
"fflate": "^0.8.2",
"flatted": "^3.4.2",
"pathe": "^2.0.3",
@@ -929,17 +895,15 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.1.10"
"vitest": "4.1.9"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"version": "4.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/pretty-format": "4.1.9",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -1205,8 +1169,6 @@
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1292,8 +1254,6 @@
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1354,8 +1314,6 @@
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
@@ -1677,37 +1635,39 @@
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"version": "30.0.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.0.tgz",
"integrity": "sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@asamuzakjp/css-color": "^6.0.5",
"@asamuzakjp/dom-selector": "^8.2.5",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"@csstools/css-syntax-patches-for-csstree": "^1.1.6",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"tough-cookie": "^6.0.2",
"undici": "^8.7.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"whatwg-url": "^17.1.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
@@ -1715,6 +1675,21 @@
}
}
},
"node_modules/jsdom/node_modules/whatwg-url": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2699,11 +2674,13 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.28.0",
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
"node": ">=22.19.0"
}
},
"node_modules/unplugin": {
@@ -2855,19 +2832,17 @@
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"version": "4.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -2895,12 +2870,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+1 -1
View File
@@ -29,7 +29,7 @@
"@vitest/ui": "^4.0.18",
"@vue/test-utils": "^2.4.10",
"@vue/tsconfig": "^0.9.0",
"jsdom": "^29.1.1",
"jsdom": "^30.0.0",
"typescript": "~6.0.0",
"vite": "^8.0.0",
"vitest": "^4.0.18",