27 Commits

Author SHA1 Message Date
Sebastian 23612dcb3e chore(deps): update dependency phpunit/phpunit to v12.5.35
Build Test / test (pull_request) Successful in 1m1s
JS Unit Tests / test (pull_request) Successful in 1m15s
PHP Unit Tests / test (pull_request) Successful in 1m24s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m25s
2026-09-10 03:05:02 +00:00
Sebastian 3fdb7b7a2e refactor(module): register integrations through runtime contexts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian d8d5f9661f fix: delete superseded draft UID after sending via SMTP
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-25 22:41:12 -04:00
Sebastian ab5871aad7 feat: implement message create and update
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-25 21:45:32 -04:00
Sebastian 0e99bb0fd2 feat: listen to user life cycle events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-09 22:37:56 -04:00
Sebastian f4edaade10 fix: primary address saving
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-28 21:03:31 -04:00
Sebastian 8624ee9d83 fix: disable auto complete
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-28 20:52:46 -04:00
Sebastian 98f5f85242 refactor: migrate to scoped execution contexts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-27 00:52:29 -04:00
Sebastian 067c748b98 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.8' (#59) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #59
2026-07-24 14:16:21 +00:00
Sebastian fba0f06677 Merge pull request 'chore(deps): update dependency vuetify to v4.1.6' (#60) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #60
2026-07-24 14:16:15 +00:00
Sebastian bfab500d5e Merge pull request 'chore(deps): update dependency vue-router to v5.2.0' (#61) from renovate/vue-router-5.x-lockfile into main
Reviewed-on: #61
2026-07-24 14:16:08 +00:00
Sebastian ef6962c73b chore(deps): update dependency vue-router to v5.2.0
Build Test / test (pull_request) Successful in 47s
JS Unit Tests / test (pull_request) Successful in 48s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m41s
PHP Unit Tests / test (pull_request) Successful in 1m42s
2026-07-24 14:10:13 +00:00
Sebastian 680fe80c33 chore(deps): update dependency vuetify to v4.1.6
JS Unit Tests / test (pull_request) Successful in 59s
Build Test / test (pull_request) Successful in 1m5s
PHP Unit Tests / test (pull_request) Successful in 1m36s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m33s
2026-07-24 14:10:08 +00:00
Sebastian ab33d2c38b chore(deps): update dependency vue-tsc to v3.3.8
Build Test / test (pull_request) Successful in 1m8s
JS Unit Tests / test (pull_request) Successful in 1m9s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m1s
PHP Unit Tests / test (pull_request) Successful in 2m31s
2026-07-24 14:10:00 +00:00
Sebastian 82120075b3 Merge pull request 'chore(deps): update mongo docker tag to v8' (#58) from renovate/mongo-8.x into main
Reviewed-on: #58
2026-07-24 14:07:33 +00:00
Sebastian 54e1c496d7 chore(deps): update mongo docker tag to v8
JS Unit Tests / test (pull_request) Successful in 1m15s
Build Test / test (pull_request) Successful in 1m16s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m9s
PHP Unit Tests / test (pull_request) Successful in 2m28s
2026-07-24 14:04:20 +00:00
Sebastian d7d95967e0 Merge pull request 'chore: implement js test suites' (#57) from chore/js-test-suites into main
Reviewed-on: #57
2026-07-24 04:47:57 +00:00
Sebastian 6048c03dfe fix: checkout companion module in CI for cross-module type imports
Build Test / test (pull_request) Successful in 37s
JS Unit Tests / test (pull_request) Successful in 38s
PHP Unit Tests / test (pull_request) Successful in 1m13s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m21s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-24 00:46:21 -04:00
Sebastian ec8012a528 chore: implement js test suites
JS Unit Tests / test (pull_request) Successful in 38s
Build Test / test (pull_request) Failing after 38s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m34s
PHP Unit Tests / test (pull_request) Successful in 2m12s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-24 00:33:54 -04:00
Sebastian 6e440d018d Merge pull request 'chore(deps): update dependency vue-router to v5.2.0' (#54) from renovate/vue-router-5.x-lockfile into main
Reviewed-on: #54
2026-07-24 03:26:26 +00:00
Sebastian 7dee3d58df Merge pull request 'chore(deps): update dependency typescript to v7' (#55) from renovate/typescript-7.x into main
Reviewed-on: #55
2026-07-24 03:26:17 +00:00
Sebastian eeace9d613 Merge pull request 'chore(deps): update dependency vuetify to v4.1.6' (#53) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #53
2026-07-24 03:20:46 +00:00
Sebastian 894eeedd17 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.8' (#52) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #52
2026-07-24 03:20:02 +00:00
Sebastian 75a04809eb chore(deps): update dependency typescript to v7
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m11s
PHP Unit Tests / test (pull_request) Successful in 1m33s
2026-07-24 03:10:59 +00:00
Sebastian 264d036321 chore(deps): update dependency vue-router to v5.2.0
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m21s
PHP Unit Tests / test (pull_request) Successful in 2m14s
2026-07-24 03:10:53 +00:00
Sebastian 0f0a3046ac chore(deps): update dependency vuetify to v4.1.6
PHP Unit Tests / test (pull_request) Successful in 1m9s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m14s
2026-07-24 03:10:49 +00:00
Sebastian fc1e4250d7 chore(deps): update dependency vue-tsc to v3.3.8
PHP Unit Tests / test (pull_request) Successful in 1m14s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m48s
2026-07-24 03:10:42 +00:00
21 changed files with 2145 additions and 320 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Build Test
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Install Mail Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/mail_manager
ref: main
path: server/modules/mail_manager
github-server-url: https://git.ktrix.dev
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/provider_imap
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/provider_imap
- name: Build
run: npm run build
working-directory: server/modules/provider_imap
+50
View File
@@ -0,0 +1,50 @@
name: JS Unit Tests
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Install Mail Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/mail_manager
ref: main
path: server/modules/mail_manager
github-server-url: https://git.ktrix.dev
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/provider_imap
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/provider_imap
- name: Run tests
run: npm run test:unit
working-directory: server/modules/provider_imap
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
services:
mongo:
image: mongo:7
image: mongo:8
options: >-
--health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\""
--health-interval 5s
+1 -1
View File
@@ -21,7 +21,7 @@
"ext-ctype": "*",
"psr/log": "^1.0|^2.0|^3.0",
"doctrine/lexer": "^3.0",
"symfony/mime": "^8.0",
"symfony/mime": "^7.0",
"egulias/email-validator": "^4.0"
},
"require-dev": {
Generated
+46 -34
View File
@@ -621,20 +621,20 @@
"packages-dev": [
{
"name": "myclabs/deep-copy",
"version": "1.13.4",
"version": "1.14.0",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
"reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
"reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
"php": "^8.0"
},
"conflict": {
"doctrine/collections": "<1.6.8",
@@ -669,15 +669,15 @@
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
"source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
"source": "https://github.com/myclabs/DeepCopy/tree/1.14.0"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
"url": "https://github.com/mnapoli",
"type": "github"
}
],
"time": "2025-08-01T08:46:24+00:00"
"time": "2026-08-11T10:17:44+00:00"
},
{
"name": "nikic/php-parser",
@@ -944,23 +944,23 @@
},
{
"name": "phpunit/php-file-iterator",
"version": "6.0.1",
"version": "6.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
"reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5"
"reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
"reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5",
"reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5",
"shasum": ""
},
"require": {
"php": ">=8.3"
},
"require-dev": {
"phpunit/phpunit": "^12.0"
"phpunit/phpunit": "^12.5.33"
},
"type": "library",
"extra": {
@@ -993,7 +993,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1"
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2"
},
"funding": [
{
@@ -1013,7 +1013,7 @@
"type": "tidelift"
}
],
"time": "2026-02-02T14:04:18+00:00"
"time": "2026-08-25T14:40:53+00:00"
},
{
"name": "phpunit/php-invoker",
@@ -1201,16 +1201,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.5.31",
"version": "12.5.35",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d"
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d",
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
"shasum": ""
},
"require": {
@@ -1220,18 +1220,18 @@
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.13.4",
"myclabs/deep-copy": "^1.14.0",
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.3",
"phpunit/php-code-coverage": "^12.5.7",
"phpunit/php-file-iterator": "^6.0.1",
"phpunit/php-file-iterator": "^6.0.2",
"phpunit/php-invoker": "^6.0.0",
"phpunit/php-text-template": "^5.0.0",
"phpunit/php-timer": "^8.0.0",
"sebastian/cli-parser": "^4.2.1",
"sebastian/comparator": "^7.1.8",
"sebastian/diff": "^7.0.0",
"sebastian/diff": "^7.0.1",
"sebastian/environment": "^8.1.2",
"sebastian/exporter": "^7.0.3",
"sebastian/global-state": "^8.0.3",
@@ -1279,7 +1279,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.35"
},
"funding": [
{
@@ -1287,7 +1287,7 @@
"type": "other"
}
],
"time": "2026-07-06T14:54:16+00:00"
"time": "2026-09-09T04:48:50+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -1510,24 +1510,24 @@
},
{
"name": "sebastian/diff",
"version": "7.0.0",
"version": "7.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/diff.git",
"reference": "7ab1ea946c012266ca32390913653d844ecd085f"
"reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f",
"reference": "7ab1ea946c012266ca32390913653d844ecd085f",
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
"reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
"shasum": ""
},
"require": {
"php": ">=8.3"
},
"require-dev": {
"phpunit/phpunit": "^12.0",
"symfony/process": "^7.2"
"phpunit/phpunit": "^12.5.33",
"symfony/process": "^7.4.17"
},
"type": "library",
"extra": {
@@ -1565,15 +1565,27 @@
"support": {
"issues": "https://github.com/sebastianbergmann/diff/issues",
"security": "https://github.com/sebastianbergmann/diff/security/policy",
"source": "https://github.com/sebastianbergmann/diff/tree/7.0.0"
"source": "https://github.com/sebastianbergmann/diff/tree/7.0.1"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/diff",
"type": "tidelift"
}
],
"time": "2025-02-07T04:55:46+00:00"
"time": "2026-08-25T15:35:54+00:00"
},
{
"name": "sebastian/environment",
@@ -2315,5 +2327,5 @@
"platform-overrides": {
"php": "8.3"
},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}
+3 -3
View File
@@ -13,7 +13,7 @@ use KTXM\ProviderImap\Providers\Provider;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
use KTXM\ProviderImap\Providers\ServiceLocation;
use KTXC\SessionTenant;
use KTXC\Context\TenantContext;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -41,7 +41,7 @@ class ConnectCommand extends Command
{
public function __construct(
private readonly Provider $provider,
private readonly SessionTenant $sessionTenant,
private readonly TenantContext $tenantContext,
) {
parent::__construct();
}
@@ -186,7 +186,7 @@ class ConnectCommand extends Command
// ── Persist ──────────────────────────────────────────────────────────
$this->sessionTenant->configureById($tenantId);
$this->tenantContext->resolveIdentifier($tenantId);
$label = $io->ask('Service label', $username);
if ($label) {
+4 -4
View File
@@ -11,7 +11,7 @@ namespace KTXM\ProviderImap\Console;
use KTXM\ProviderImap\Providers\Provider;
use KTXM\ProviderImap\Providers\Service;
use KTXC\SessionTenant;
use KTXC\Context\TenantContext;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -37,7 +37,7 @@ class DisconnectCommand extends Command
{
public function __construct(
private readonly Provider $provider,
private readonly SessionTenant $sessionTenant,
private readonly TenantContext $tenantContext,
) {
parent::__construct();
}
@@ -86,7 +86,7 @@ class DisconnectCommand extends Command
$userId = (string) ($input->getOption('user') ?? '');
if ($tenantId !== '' && $userId !== '') {
$this->sessionTenant->configureById($tenantId);
$this->tenantContext->resolveIdentifier($tenantId);
$services = $this->provider->serviceList($tenantId, $userId);
if (empty($services)) {
@@ -130,7 +130,7 @@ class DisconnectCommand extends Command
}
// ── Fetch service for display ────────────────────────────────────────
$this->sessionTenant->configureById($tenantId);
$this->tenantContext->resolveIdentifier($tenantId);
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
if ($service === null) {
+3 -3
View File
@@ -14,7 +14,7 @@ use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
use KTXM\ProviderImap\Providers\ServiceLocation;
use KTXM\ProviderImap\Service\Discovery;
use KTXC\SessionTenant;
use KTXC\Context\TenantContext;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -43,7 +43,7 @@ class DiscoverCommand extends Command
public function __construct(
private readonly Provider $provider,
private readonly Discovery $discovery,
private readonly SessionTenant $sessionTenant,
private readonly TenantContext $tenantContext,
) {
parent::__construct();
}
@@ -210,7 +210,7 @@ class DiscoverCommand extends Command
// ── Persist ──────────────────────────────────────────────────────────
$this->sessionTenant->configureById($tenantId);
$this->tenantContext->resolveIdentifier($tenantId);
$label = $io->ask('Service label', $address);
if ($label) {
+4 -4
View File
@@ -9,7 +9,7 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Console;
use KTXC\SessionTenant;
use KTXC\Context\TenantContext;
use KTXM\ProviderImap\Client\Command\SelectCommand;
use KTXM\ProviderImap\Client\FetchOptions;
use KTXM\ProviderImap\Client\FetchTarget;
@@ -41,7 +41,7 @@ class TestCommand extends Command
{
public function __construct(
private readonly Provider $provider,
private readonly SessionTenant $sessionTenant,
private readonly TenantContext $tenantContext,
) {
parent::__construct();
}
@@ -108,7 +108,7 @@ class TestCommand extends Command
return Command::FAILURE;
}
$this->sessionTenant->configureById($tenantId);
$this->tenantContext->resolveIdentifier($tenantId);
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
if ($service === null) {
@@ -269,7 +269,7 @@ class TestCommand extends Command
return;
}
$this->sessionTenant->configureById($tenantId);
$this->tenantContext->resolveIdentifier($tenantId);
$services = $this->provider->serviceList($tenantId, $userId);
if ($services === []) {
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Listeners;
use KTXC\User\Event\UserCreatedEvent;
use KTXC\User\Event\UserDeletingEvent;
use KTXM\ProviderImap\Stores\MessageStore;
use KTXM\ProviderImap\Stores\ServiceStore;
final class UserEventListener
{
public function __construct(
private readonly ServiceStore $serviceStore,
private readonly MessageStore $messageStore,
) {
}
public function onUserCreated(UserCreatedEvent $event): void
{
// TODO: implement provisioning configuration
}
public function onUserDeleting(UserDeletingEvent $event): void
{
$tenantId = $event->tenantIdentifier();
$userId = $event->userIdentifier();
foreach (array_keys($this->serviceStore->list($tenantId, $userId)) as $serviceId) {
$this->messageStore->deleteByServiceId((string) $serviceId);
}
$this->serviceStore->deleteByUser($tenantId, $userId);
}
}
+28 -22
View File
@@ -10,14 +10,20 @@ declare(strict_types=1);
namespace KTXM\ProviderImap;
use KTXC\Resource\ProviderManager;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleConsoleInterface;
use KTXC\User\Event\UserCreatedEvent;
use KTXC\User\Event\UserDeletingEvent;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXF\Module\Configuration\BrowserModuleContextInterface;
use KTXF\Module\Configuration\ConsoleModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextInterface;
use KTXF\Module\ModuleInstanceAbstract;
use KTXF\Resource\Provider\ProviderInterface;
use KTXM\ProviderImap\Console\ConnectCommand;
use KTXM\ProviderImap\Console\DiscoverCommand;
use KTXM\ProviderImap\Console\DisconnectCommand;
use KTXM\ProviderImap\Console\TestCommand;
use KTXM\ProviderImap\Listeners\UserEventListener;
use KTXM\ProviderImap\Providers\Provider as MailProvider;
/**
@@ -25,10 +31,11 @@ use KTXM\ProviderImap\Providers\Provider as MailProvider;
*
* Registers the IMAP mail provider with the Ktrix provider manager.
*/
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
class Module extends ModuleInstanceAbstract
{
public function __construct(
private readonly ProviderManager $providerManager,
private readonly EventListenerRegistrarInterface $events,
) {}
public function handle(): string
@@ -69,29 +76,28 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
public function boot(): void
{
// Register listeners
$this->events->listen($this->handle(), UserCreatedEvent::class, UserEventListener::class, 'onUserCreated', DeliveryMode::Deferred);
$this->events->listen($this->handle(), UserDeletingEvent::class, UserEventListener::class, 'onUserDeleting', DeliveryMode::Deferred);
// Register providers
$this->providerManager->register(ProviderInterface::TYPE_MAIL, 'imap', MailProvider::class);
}
public function registerCI(): array
public function configure(ModuleContextInterface $context): void
{
return [
DiscoverCommand::class,
ConnectCommand::class,
DisconnectCommand::class,
TestCommand::class,
];
}
if ($context instanceof BrowserModuleContextInterface) {
$context->registerModule($this, 'ProviderImap', 'static/module.mjs');
}
public function registerBI(): array
{
return [
'handle' => $this->handle(),
'namespace' => 'ProviderImap',
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
if ($context instanceof ConsoleModuleContextInterface) {
foreach ([
DiscoverCommand::class,
ConnectCommand::class,
DisconnectCommand::class,
TestCommand::class,
] as $command) {
$context->registerCommand($command);
}
}
}
}
+21
View File
@@ -11,6 +11,7 @@ namespace KTXM\ProviderImap\Providers;
use KTXM\ProviderImap\Client\Message;
use KTXF\Mail\Entity\EntityMutableAbstract;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
/**
* Mail Entity Resource Implementation
@@ -42,6 +43,26 @@ class EntityResource extends EntityMutableAbstract {
return $this;
}
/**
* Populate an entity returned by a create or replacement operation.
*/
public function fromMutation(
string $mailbox,
string|int $identifier,
MessagePropertiesMutableInterface $properties,
): static {
$this->data['collection'] = $mailbox;
$this->data['identifier'] = $identifier;
if ($properties instanceof MessageProperties) {
$this->setProperties($properties);
} else {
$this->getProperties()->jsonDeserialize($properties->jsonSerialize());
}
return $this;
}
/**
* @inheritDoc
*/
+98 -6
View File
@@ -102,8 +102,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
],
self::CAPABILITY_ENTITY_FETCH => true,
self::CAPABILITY_ENTITY_EXTANT => true,
self::CAPABILITY_ENTITY_CREATE => false,
self::CAPABILITY_ENTITY_MODIFY => false,
self::CAPABILITY_ENTITY_CREATE => true,
self::CAPABILITY_ENTITY_MODIFY => true,
self::CAPABILITY_ENTITY_PATCH => true,
self::CAPABILITY_ENTITY_DELETE => true,
self::CAPABILITY_ENTITY_MOVE => true,
@@ -200,8 +200,9 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
if (isset($data[self::PROPERTY_IDENTITY])) {
$this->setIdentity($this->freshIdentity(null, $data[self::PROPERTY_IDENTITY]));
}
if (isset($data[self::PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::PROPERTY_PRIMARY_ADDRESS])) {
$this->setPrimaryAddress(new Address($data[self::PROPERTY_PRIMARY_ADDRESS]));
if (isset($data[self::PROPERTY_PRIMARY_ADDRESS])) {
$value = $data[self::PROPERTY_PRIMARY_ADDRESS];
$this->setPrimaryAddress(is_array($value) ? Address::fromArray($value) : new Address((string)$value));
}
if (isset($data[self::PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::PROPERTY_SECONDARY_ADDRESSES])) {
$this->setSecondaryAddresses(array_map(
@@ -674,10 +675,27 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
// ignore — the message was already delivered
}
// Best-effort: IMAP has no native "submit existing draft" operation, so the
// message above was always sent fresh. If it originated from a synced draft,
// remove the now-superseded draft UID. A failure here must not turn a
// successful delivery into an error; the draft is simply left for a later
// discard or retry.
if ($source !== null
&& $source->provider() === $this->provider()
&& $source->service() === $this->identifier()) {
try {
$this->initialize();
$this->remoteService->entityDestroy($source->collection(), (int) $source->entity());
} catch (\Throwable) {
// ignore — the message was already delivered
}
}
return new EntitySubmitResult(
disposition: EntitySubmitResult::DISPOSITION_SENT,
transportId: $queueId !== '' ? $queueId : null,
sentEntity: $sentEntity,
sourceDraft: $source instanceof EntityIdentifier ? $source : null,
);
}
@@ -700,12 +718,45 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource
{
throw new \RuntimeException('Entity creation is not supported in this service');
if ($target->provider() !== $this->provider() || (string)$target->service() !== (string)$this->identifier()) {
throw new \InvalidArgumentException('Target collection does not belong to this service: ' . (string)$target);
}
$this->initialize();
[$nativeMessage, $nativeFlags] = $this->messagePayload($properties, $options);
$created = $this->remoteService->entityCreate(
(string)$target->collection(),
$nativeMessage,
$nativeFlags,
);
if ($created === null || $created <= 0) {
throw new \RuntimeException('IMAP APPEND did not return a valid UID');
}
return $this->entityFresh()->fromMutation((string)$target->collection(), $created, $properties);
}
public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityResource
{
throw new \RuntimeException('Entity modification is not supported in this service');
if ($target->provider() !== $this->provider() || (string)$target->service() !== (string)$this->identifier()) {
throw new \InvalidArgumentException('Target entity does not belong to this service: ' . (string)$target);
}
$this->initialize();
[$nativeMessage, $nativeFlags] = $this->messagePayload($properties);
$modified = $this->remoteService->entityReplace(
(string)$target->collection(),
(int)$target->entity(),
$nativeMessage,
$nativeFlags,
);
if ($modified === null || $modified <= 0) {
throw new \RuntimeException('IMAP replacement did not return a valid UID');
}
return $this->entityFresh()->fromMutation((string)$target->collection(), $modified, $properties);
}
public function entityPatch(MessagePropertiesMutableInterface $properties, EntityIdentifier ...$targets): array
@@ -890,5 +941,46 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
}
return $list;
}
/**
* Convert canonical message flags to IMAP system flags.
*
* @return string[]
*/
private function messageFlags(MessagePropertiesMutableInterface $properties): array
{
$flags = [];
foreach ($properties->getFlags() as $flag => $enabled) {
if ($enabled !== true) {
continue;
}
$flags[] = match (strtolower((string)$flag)) {
'seen' => '\\Seen',
'flagged' => '\\Flagged',
'answered' => '\\Answered',
'draft' => '\\Draft',
'deleted' => '\\Deleted',
default => (string)$flag,
};
}
return array_values(array_unique($flags));
}
/**
* Convert message properties into the native IMAP append payload.
*
* @return array{0: string, 1: string[]}
*/
private function messagePayload(MessagePropertiesMutableInterface $properties, array $options = []): array
{
$flags = $this->messageFlags($properties);
if (isset($options['flags']) && is_array($options['flags'])) {
$flags = array_values(array_unique([...$flags, ...$options['flags']]));
}
return [(new MessageBuilder())->build($properties), $flags];
}
}
+39 -2
View File
@@ -11,7 +11,6 @@ namespace KTXM\ProviderImap\Service\Remote;
use DateTimeImmutable;
use Generator;
use KTXC\Server;
use KTXC\Logger\PlainFileLogger;
use KTXM\ProviderImap\Client\Client as ImapClient;
use KTXM\ProviderImap\Client\Command\AppendCommand;
@@ -132,7 +131,7 @@ class RemoteMailService
return null;
}
$logDir = Server::getInstance()?->logDir() ?? __DIR__ . '/../../../../../var/log';
$logDir = dirname(__DIR__, 5) . '/var/logs/provider_imap';
return new PlainFileLogger($logDir . '/' . $channel, $this->service->identifier());
}
@@ -491,6 +490,44 @@ class RemoteMailService
return $this->imapClient()->perform(new AppendCommand($collection, $rawMessage, $flags));
}
/**
* Append a replacement message, then permanently remove the superseded UID.
* The replacement is removed on cleanup failure so the old UID remains
* authoritative for a later retry.
*
* @param string[] $flags
*/
public function entityReplace(
string $collection,
int $identifier,
string $rawMessage,
array $flags = [],
): ?int {
if ($identifier <= 0) {
throw new RuntimeException('A valid IMAP UID is required for replacement');
}
$replacement = $this->entityCreate($collection, $rawMessage, $flags);
if ($replacement === null || $replacement <= 0) {
throw new RuntimeException('IMAP replacement append did not return a valid UID');
}
try {
$deleted = $this->entityDestroy($collection, $identifier);
if (($deleted[$identifier] ?? false) !== true) {
throw new RuntimeException('Failed to delete the superseded IMAP entity');
}
} catch (\Throwable $error) {
try {
$this->entityDestroy($collection, $replacement);
} catch (\Throwable) {
}
throw $error;
}
return $replacement;
}
/**
* Modify message flags for one or more messages.
*
+11
View File
@@ -177,4 +177,15 @@ class ServiceStore
return $result->getDeletedCount() > 0;
}
/**
* Delete every service owned by a user.
*/
public function deleteByUser(string $tenantId, string $userId): void
{
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteMany([
'tid' => $tenantId,
'uid' => $userId,
]);
}
}
+1606 -235
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -11,7 +11,11 @@
"dev": "vite build --mode development --config vite.config.ts",
"watch": "vite build --mode development --watch --config vite.config.ts",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"test": "vitest run --config tests/js/vitest.config.ts",
"test:unit": "vitest run --config tests/js/vitest.config.ts",
"test:watch": "vitest watch --config tests/js/vitest.config.ts",
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
},
"dependencies": {
"pinia": "^3.0.0",
@@ -24,6 +28,10 @@
"@vue/tsconfig": "^0.9.0",
"typescript": "~6.0.0",
"vite": "^8.0.0",
"vue-tsc": "^3.0.5"
"vue-tsc": "^3.0.5",
"@vitest/coverage-v8": "^4.1.6",
"@vue/test-utils": "^2.4.10",
"jsdom": "^29.1.1",
"vitest": "^4.1.6"
}
}
}
+2 -2
View File
@@ -115,7 +115,7 @@ function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boo
variant="outlined"
prepend-inner-icon="mdi-account"
class="mb-4"
autocomplete="username"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:rules="[rules.required]"
@@ -130,7 +130,7 @@ function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boo
variant="outlined"
prepend-inner-icon="mdi-lock"
class="mb-4"
autocomplete="current-password"
autocomplete="new-password"
:rules="[rules.required]"
/>
</div>
+30
View File
@@ -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)
})
})
+33
View File
@@ -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/**',
],
},
},
})
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace KTXT\ProviderImap\Tests\Unit;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class RemoteMailServiceTest extends TestCase {
public function testEntityReplaceReturnsAuthoritativeReplacementUid(): void {
$service = new RemoteMailServiceStub($this->createStub(Service::class));
$service->appendUid = 202;
$service->destroyResults = [[101 => true]];
$result = $service->entityReplace('Drafts', 101, 'raw message', ['\\Draft']);
$this->assertSame(202, $result);
$this->assertSame([
['Drafts', 'raw message', ['\\Draft']],
], $service->createCalls);
$this->assertSame([
['Drafts', [101]],
], $service->destroyCalls);
}
public function testEntityReplaceRemovesReplacementWhenOldUidCannotBeDeleted(): void {
$service = new RemoteMailServiceStub($this->createStub(Service::class));
$service->appendUid = 202;
$service->destroyResults = [
[101 => false],
[202 => true],
];
try {
$service->entityReplace('Drafts', 101, 'raw message', ['\\Draft']);
$this->fail('Expected superseded message cleanup failure');
} catch (RuntimeException $exception) {
$this->assertSame('Failed to delete the superseded IMAP entity', $exception->getMessage());
}
$this->assertSame([
['Drafts', [101]],
['Drafts', [202]],
], $service->destroyCalls);
}
}
final class RemoteMailServiceStub extends RemoteMailService {
public ?int $appendUid = null;
public array $createCalls = [];
public array $destroyCalls = [];
public array $destroyResults = [];
public function entityCreate(string $collection, string $rawMessage, array $flags = []): ?int {
$this->createCalls[] = [$collection, $rawMessage, $flags];
return $this->appendUid;
}
public function entityDestroy(string $collection, int ...$uids): array {
$this->destroyCalls[] = [$collection, $uids];
return array_shift($this->destroyResults) ?? [];
}
}