From c745a16c819658f6b6c66dd9c242cc9d82232e75 Mon Sep 17 00:00:00 2001 From: Dennis Hermsmeier Date: Tue, 26 May 2026 15:36:29 +0200 Subject: [PATCH 1/5] chore: add Psalm static analysis (composer analyse + CI job) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires Psalm in as a static-analysis safety net for the production code and the test suite. Runs locally via `composer analyse` and in CI as a new parallel job alongside the unit-test matrix. Configuration: - errorLevel="6", a middle-ground bar that catches real bugs without drowning in MixedReturnStatement noise from Roundcube interop. - projectFiles: imapsync.php + lib/ + tests/. extraFiles: the rcube_imap_generic / rcube_plugin / rcube_utils / rcmail / html-* classes under dist/roundcubemail-1.7.0/program/lib/Roundcube/ so Psalm can resolve symbols without analysing them. - A small baseline (psalm-baseline.xml) covers three brownfield type-shape complaints (NullArgument in imapsync.php, the bool-vs-int return mismatch where we hand back rcube_imap_generic's append() result, and the same kind of issue in FakeImapClient's nextUid helper). All three are Roundcube-interop quirks, not real bugs — they get baselined now and can be tightened later. With the baseline in place: "No errors found!", 97% inferred type coverage. CI: - New static-analysis job runs in parallel to unit-tests (no needs:) so type feedback arrives without waiting for the PHP matrix. - composer dist:fetch runs first so Roundcube symbols resolve. Docs: - AGENTS.md layout block + Testing subsection + freshness paragraph reference the new check. - README dev-commands list adds composer analyse. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ AGENTS.md | 13 +++++++++---- README.md | 1 + composer.json | 5 ++++- psalm-baseline.xml | 24 ++++++++++++++++++++++++ psalm.xml | 25 +++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 psalm-baseline.xml create mode 100644 psalm.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f05598..49c241a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,29 @@ jobs: - name: Run unit tests run: composer test:unit -- --colors=never + static-analysis: + name: Static analysis (Psalm) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: composer:v2 + extensions: imap, intl, mbstring + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Fetch Roundcube source for type resolution + run: composer dist:fetch + + - name: Run Psalm + run: composer analyse + integration-tests: name: Integration tests (Dovecot via Docker) runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 8b5afb4..bfed407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,8 @@ future worker mode — must use an out-of-session store (file, DB, Roundcube cac ├── LICENSE # MIT ├── README.md # user-facing documentation ├── composer.json # plugin manifest (Roundcube plugin-installer type) +├── psalm.xml # Psalm static-analysis configuration +├── psalm-baseline.xml # current Psalm baseline for brownfield issues ├── imapsync.php # plugin entry class (extends rcube_plugin) ├── imapsync.js # client-side: form handling, result rendering ├── config.inc.php.dist # default plugin config, copied by user to config.inc.php @@ -91,7 +93,7 @@ future worker mode — must use an out-of-session store (file, DB, Roundcube cac ├── .gitattributes # export-ignore tests/, dist/, .github/, etc. ├── .github/ │ └── workflows/ -│ ├── ci.yml # unit + integration tests, lint, docs-freshness +│ ├── ci.yml # unit + integration tests, Psalm, lint, docs-freshness │ └── release.yml # tag → tarball + zip + checksums on GitHub Releases └── dist/ # gitignored — local Roundcube source for reference (see below) ``` @@ -203,6 +205,8 @@ global rules win. call. - Run with `composer test:unit` (unit only) or `composer test:integration` (Docker, real Dovecot) from the repo root. CI runs both. +- Run `composer analyse` for Psalm static analysis over `imapsync.php`, `lib/`, and `tests/`; + it uses the Roundcube source in `dist/` for type resolution. ### Security expectations @@ -332,9 +336,9 @@ pre-commit check. GitHub Actions (`.github/workflows/ci.yml`) runs the deterministic subset of these checks (config-key consistency, locale-key consistency) on every push and PR as the `docs-freshness` -job, alongside `unit-tests`, `integration-tests`, and PHP linting. A failing freshness check -blocks the PR. CI is a safety net, not a substitute — run the checks locally before pushing so -you don't burn a round trip on something a 30-second grep would have caught. +job, alongside `unit-tests`, `static-analysis`, `integration-tests`, and PHP linting. A failing +freshness check blocks the PR. CI is a safety net, not a substitute — run the checks locally +before pushing so you don't burn a round trip on something a 30-second grep would have caught. --- @@ -397,6 +401,7 @@ Test suite: ```bash composer install +composer analyse # Psalm static analysis (uses dist/ for Roundcube symbols) composer test:unit # unit tests (fast, no network, no Docker) composer test:integration # integration tests (requires a running Docker daemon) ``` diff --git a/README.md b/README.md index 038aa43..1c83578 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ composer install composer test:unit # in-memory unit tests, no network, no Docker composer test:integration # Dovecot integration suite, requires a running Docker daemon composer test # alias for test:unit +composer analyse # Psalm static analysis over plugin code and tests ``` The unit tests do not touch the network — the sync engine takes an injected IMAP-client diff --git a/composer.json b/composer.json index 5831a3a..0a82018 100644 --- a/composer.json +++ b/composer.json @@ -29,18 +29,21 @@ }, "require-dev": { "phpunit/phpunit": "^10.5", - "testcontainers/testcontainers": "^1.0" + "testcontainers/testcontainers": "^1.0", + "vimeo/psalm": "^5.0" }, "scripts": { "test": "@test:unit", "test:unit": "phpunit", "test:integration": "phpunit --configuration phpunit.integration.xml.dist", + "analyse": "sh -c 'if php -r \"exit(PHP_VERSION_ID >= 80400 ? 0 : 1);\"; then docker run --rm -v \"$PWD\":/app -w /app php:8.3-cli php vendor/bin/psalm --no-progress; else psalm --no-progress; fi'", "dist:fetch": "sh -c 'if [ -d dist/roundcubemail-1.7.0 ]; then echo \"dist/roundcubemail-1.7.0 already present, skipping.\"; exit 0; fi; curl -fLsS -o dist/roundcubemail-1.7.0-complete.tar.gz https://github.com/roundcube/roundcubemail/releases/download/1.7.0/roundcubemail-1.7.0-complete.tar.gz && tar -xzf dist/roundcubemail-1.7.0-complete.tar.gz -C dist && rm dist/roundcubemail-1.7.0-complete.tar.gz && echo \"dist/roundcubemail-1.7.0 ready.\"'" }, "scripts-descriptions": { "test": "Alias for test:unit.", "test:unit": "Run the in-memory unit-test suite (fast, no Docker).", "test:integration": "Run the Dovecot integration suite (requires a running Docker daemon).", + "analyse": "Run Psalm static analysis over imapsync.php, lib/, and tests/.", "dist:fetch": "Download and extract Roundcube 1.7.0 source into dist/ for local reference. Idempotent." }, "autoload": { diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 0000000..673a236 --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + folders[$folder])) + 1]]> + + + + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..d9d1cc7 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + From 98053d4566c2861dab7b69c1ed4b619635503ef5 Mon Sep 17 00:00:00 2001 From: Dennis Hermsmeier Date: Tue, 26 May 2026 16:26:16 +0200 Subject: [PATCH 2/5] fix(psalm): bump to ^6.0 so the unit-test matrix on PHP 8.4 installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI revealed that Psalm 5.x caps PHP support at 8.3 (require: "^7.4 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0"), which made composer install fail on the PHP 8.4 unit-test matrix job before any test could run. Psalm 6 supports PHP 8.1-8.4 cleanly. Bump the dev dep to ^6.0 and drop the Docker-fallback hack from the analyse script (it was working around exactly this incompatibility but only at run time, not at install time, so it didn't help the matrix anyway). Regenerate psalm-baseline.xml against Psalm 6 — the new strict checks (MissingOverrideAttribute, ClassMustBeFinal, additional InvalidReturnType shapes) add ~25 brownfield entries to the baseline. With the rebuild in place: "No errors found!", 96% type inference. Unit tests still green locally (24/24). Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.json | 4 ++-- psalm-baseline.xml | 45 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 0a82018..1266ab6 100644 --- a/composer.json +++ b/composer.json @@ -30,13 +30,13 @@ "require-dev": { "phpunit/phpunit": "^10.5", "testcontainers/testcontainers": "^1.0", - "vimeo/psalm": "^5.0" + "vimeo/psalm": "^6.0" }, "scripts": { "test": "@test:unit", "test:unit": "phpunit", "test:integration": "phpunit --configuration phpunit.integration.xml.dist", - "analyse": "sh -c 'if php -r \"exit(PHP_VERSION_ID >= 80400 ? 0 : 1);\"; then docker run --rm -v \"$PWD\":/app -w /app php:8.3-cli php vendor/bin/psalm --no-progress; else psalm --no-progress; fi'", + "analyse": "psalm --no-progress", "dist:fetch": "sh -c 'if [ -d dist/roundcubemail-1.7.0 ]; then echo \"dist/roundcubemail-1.7.0 already present, skipping.\"; exit 0; fi; curl -fLsS -o dist/roundcubemail-1.7.0-complete.tar.gz https://github.com/roundcube/roundcubemail/releases/download/1.7.0/roundcubemail-1.7.0-complete.tar.gz && tar -xzf dist/roundcubemail-1.7.0-complete.tar.gz -C dist && rm dist/roundcubemail-1.7.0-complete.tar.gz && echo \"dist/roundcubemail-1.7.0 ready.\"'" }, "scripts-descriptions": { diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 673a236..d988917 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,6 +1,9 @@ - + + + + @@ -12,6 +15,20 @@ + + + + + + + + + + + + + + @@ -20,5 +37,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + From d96395f7a77332eba247dc7ad82d194f1c17f2cc Mon Sep 17 00:00:00 2001 From: Dennis Hermsmeier Date: Tue, 26 May 2026 16:28:39 +0200 Subject: [PATCH 3/5] ci: standardize on PHP 8.4 (Roundcube's current target) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the unit-test matrix (8.1/8.2/8.3/8.4 → 8.4 only) and bumps the integration-tests and static-analysis jobs from 8.3 to 8.4. Roundcube's current standard target is 8.4. Testing the older versions in CI gave us coverage we never used in production. The composer.json still declares ">=8.1" so installs on older PHP versions remain unblocked, but CI no longer validates them. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49c241a..c604867 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,19 +15,15 @@ concurrency: jobs: unit-tests: - name: Unit tests (PHP ${{ matrix.php }}) + name: Unit tests runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: ['8.1', '8.2', '8.3', '8.4'] steps: - uses: actions/checkout@v4 - - name: Set up PHP ${{ matrix.php }} + - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php }} + php-version: '8.4' coverage: none tools: composer:v2 extensions: imap, intl, mbstring @@ -53,7 +49,7 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: '8.4' coverage: none tools: composer:v2 extensions: imap, intl, mbstring @@ -77,7 +73,7 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: '8.4' coverage: none tools: composer:v2 extensions: imap, intl, mbstring From 2f00d5b41f68221f6772106e872b5879c9757476 Mon Sep 17 00:00:00 2001 From: Dennis Hermsmeier Date: Tue, 26 May 2026 16:47:35 +0200 Subject: [PATCH 4/5] chore(psalm): fix the brownfield baseline and drop it entirely The 67-line psalm-baseline.xml from the Psalm 6 bump was a known-debt file: it covered real issues that we were just suppressing. This commit fixes them all and removes the baseline so future regressions fail the build cleanly. Fixes: - `#[\Override]` attribute added to every method that overrides a parent or implements an interface method (init, the 12 methods on RoundcubeImapSyncGenericClient and FakeImapClient that implement the RoundcubeImapSyncClient interface, the four PHPUnit lifecycle hooks setUpBeforeClass / tearDownAfterClass on the two integration test classes). Auto-applied via `psalm --alter`. - `imapsync.php`: addFormRow passed `null` as the first arg to `html_table::add`, which Psalm reads as "no column attributes" but the signature types as `array|string`. Use `''` to mean the same thing without the null-into-non-nullable shape mismatch. - `lib/RoundcubeImapSyncClient.php::appendMessage`: cast the return to `(bool)`. `rcube_imap_generic::append()` returns mixed: false on failure, true on plain success, or the appended UID (string) when the server supports UIDPLUS. Our interface only contracts a bool, so collapse the success cases. - `tests/Fakes/FakeImapClient.php::nextUid`: narrow the inner message-array type via a local `@var non-empty-array` annotation so `max(array_keys(...))` infers as `int` instead of `float|int`. `psalm-baseline.xml` and the `errorBaseline` attribute in `psalm.xml` are gone. `composer analyse` now returns "No errors found!" against the raw codebase. 24 unit + 7 integration tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- imapsync.php | 3 +- lib/RoundcubeImapSyncClient.php | 18 ++++- psalm-baseline.xml | 67 ------------------- psalm.xml | 1 - tests/Fakes/FakeImapClient.php | 17 ++++- .../PreflightAndQuotaIntegrationTest.php | 2 + .../Integration/SyncEngineIntegrationTest.php | 2 + 7 files changed, 39 insertions(+), 71 deletions(-) delete mode 100644 psalm-baseline.xml diff --git a/imapsync.php b/imapsync.php index 0e9da90..b57a6bc 100644 --- a/imapsync.php +++ b/imapsync.php @@ -15,6 +15,7 @@ class imapsync extends rcube_plugin private rcmail $rc; + #[\Override] public function init(): void { $this->rc = rcmail::get_instance(); @@ -278,7 +279,7 @@ public function action_status(): void private function addFormRow(html_table $table, string $fieldId, string $labelKey, string $field): void { $table->add('title', html::label($fieldId, rcube::Q($this->gettext($labelKey)))); - $table->add(null, $field); + $table->add('', $field); } private function createJobFromRequest(): RoundcubeImapSyncJob diff --git a/lib/RoundcubeImapSyncClient.php b/lib/RoundcubeImapSyncClient.php index e13ae66..63c267a 100644 --- a/lib/RoundcubeImapSyncClient.php +++ b/lib/RoundcubeImapSyncClient.php @@ -35,6 +35,7 @@ public function __construct(private readonly rcube_imap_generic $imap) { } + #[\Override] public function connect(string $host, int $port, string $user, string $password, array $options): void { $options['port'] = $port; @@ -44,11 +45,13 @@ public function connect(string $host, int $port, string $user, string $password, } } + #[\Override] public function close(): void { $this->imap->closeConnection(); } + #[\Override] public function listFolders(): array { $folders = $this->imap->listMailboxes('', '*'); @@ -59,6 +62,7 @@ public function listFolders(): array return array_values(array_map('strval', $folders)); } + #[\Override] public function getHierarchyDelimiter(): string { $delimiter = $this->imap->getHierarchyDelimiter(); @@ -66,11 +70,13 @@ public function getHierarchyDelimiter(): string return is_string($delimiter) && $delimiter !== '' ? $delimiter : '/'; } + #[\Override] public function createFolder(string $folder): bool { return $this->imap->createFolder($folder); } + #[\Override] public function selectFolder(string $folder): int { $status = $this->imap->status($folder, ['MESSAGES']); @@ -81,6 +87,7 @@ public function selectFolder(string $folder): int return (int) ($status['MESSAGES'] ?? 0); } + #[\Override] public function getFolderSize(string $folder): int { if ($this->supportsStatusSize()) { @@ -108,6 +115,7 @@ public function getFolderSize(string $folder): int return $totalSize; } + #[\Override] public function getQuota(string $folder): ?array { $quota = $this->imap->getQuota($folder); @@ -121,6 +129,7 @@ public function getQuota(string $folder): ?array ]; } + #[\Override] public function supportsStatusSize(): bool { if ($this->statusSizeSupported === null) { @@ -130,6 +139,7 @@ public function supportsStatusSize(): bool return $this->statusSizeSupported; } + #[\Override] public function fetchMessageIdentities(string $folder): array { $totalMessages = $this->selectFolder($folder); @@ -160,6 +170,7 @@ public function fetchMessageIdentities(string $folder): array return $identities; } + #[\Override] public function fetchMessageRaw(string $folder, int $uid): ?array { $messages = $this->imap->fetch($folder, (string) $uid, true, ['UID', 'RFC822', 'FLAGS', 'INTERNALDATE']); @@ -179,6 +190,7 @@ public function fetchMessageRaw(string $folder, int $uid): ?array ]; } + #[\Override] public function appendMessage(string $folder, string $rawMessage, array $flags, ?string $internalDate): bool { $message = $rawMessage; @@ -193,7 +205,11 @@ public function appendMessage(string $folder, string $rawMessage, array $flags, throw new RoundcubeImapSyncException($errorMessage); } - return $result; + // rcube_imap_generic::append returns mixed: false on failure, true on + // plain success, or the appended UID (string) when the server supports + // UIDPLUS. Our interface only contracts a bool, so collapse the + // success cases to true. + return (bool) $result; } /** diff --git a/psalm-baseline.xml b/psalm-baseline.xml deleted file mode 100644 index d988917..0000000 --- a/psalm-baseline.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - folders[$folder])) + 1]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/psalm.xml b/psalm.xml index d9d1cc7..aae5ea7 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,7 +1,6 @@ folders[$folder] = $messages; } + #[\Override] public function connect(string $host, int $port, string $user, string $password, array $options): void { if ($this->connectShouldFail) { @@ -30,20 +31,24 @@ public function connect(string $host, int $port, string $user, string $password, } } + #[\Override] public function close(): void { } + #[\Override] public function listFolders(): array { return array_keys($this->folders); } + #[\Override] public function getHierarchyDelimiter(): string { return $this->delimiter; } + #[\Override] public function createFolder(string $folder): bool { if (isset($this->createFolderFailures[$folder])) { @@ -59,6 +64,7 @@ public function createFolder(string $folder): bool return false; } + #[\Override] public function selectFolder(string $folder): int { if (!isset($this->folders[$folder])) { @@ -68,6 +74,7 @@ public function selectFolder(string $folder): int return count($this->folders[$folder]); } + #[\Override] public function getFolderSize(string $folder): int { if ($this->getFolderSizeShouldFail) { @@ -90,16 +97,19 @@ public function getFolderSize(string $folder): int return $total; } + #[\Override] public function getQuota(string $folder): ?array { return $this->quotaResult; } + #[\Override] public function supportsStatusSize(): bool { return $this->statusSizeSupported; } + #[\Override] public function fetchMessageIdentities(string $folder): array { if (!isset($this->folders[$folder])) { @@ -114,6 +124,7 @@ public function fetchMessageIdentities(string $folder): array return $identities; } + #[\Override] public function fetchMessageRaw(string $folder, int $uid): ?array { if (!isset($this->folders[$folder][$uid])) { @@ -127,6 +138,7 @@ public function fetchMessageRaw(string $folder, int $uid): ?array ]; } + #[\Override] public function appendMessage(string $folder, string $rawMessage, array $flags, ?string $internalDate): bool { if (isset($this->appendQuotaFailures[$folder])) { @@ -158,7 +170,10 @@ private function nextUid(string $folder): int return 1; } - return max(array_keys($this->folders[$folder])) + 1; + /** @var non-empty-array $messages */ + $messages = $this->folders[$folder]; + + return max(array_keys($messages)) + 1; } private function identityFromRaw(string $rawMessage): array diff --git a/tests/Integration/PreflightAndQuotaIntegrationTest.php b/tests/Integration/PreflightAndQuotaIntegrationTest.php index b8f9a17..1be9be0 100644 --- a/tests/Integration/PreflightAndQuotaIntegrationTest.php +++ b/tests/Integration/PreflightAndQuotaIntegrationTest.php @@ -15,6 +15,7 @@ final class PreflightAndQuotaIntegrationTest extends TestCase private static ?DovecotContainer $sourceContainer = null; private static ?DovecotContainer $destinationContainer = null; + #[\Override] public static function setUpBeforeClass(): void { if (getenv('DOVECOT_INTEGRATION_SKIP') === '1') { @@ -38,6 +39,7 @@ public static function setUpBeforeClass(): void self::seedSourceContainer(); } + #[\Override] public static function tearDownAfterClass(): void { try { diff --git a/tests/Integration/SyncEngineIntegrationTest.php b/tests/Integration/SyncEngineIntegrationTest.php index 46335ff..08d0b50 100644 --- a/tests/Integration/SyncEngineIntegrationTest.php +++ b/tests/Integration/SyncEngineIntegrationTest.php @@ -17,6 +17,7 @@ final class SyncEngineIntegrationTest extends TestCase private static ?DovecotContainer $destinationContainer = null; private static string $sourceSubfolder = ''; + #[\Override] public static function setUpBeforeClass(): void { if (getenv('DOVECOT_INTEGRATION_SKIP') === '1') { @@ -38,6 +39,7 @@ public static function setUpBeforeClass(): void self::seedSourceContainer(); } + #[\Override] public static function tearDownAfterClass(): void { try { From 9e8809549de612290dda09babbb0916a71d99829 Mon Sep 17 00:00:00 2001 From: Dennis Hermsmeier Date: Tue, 26 May 2026 16:48:24 +0200 Subject: [PATCH 5/5] docs(agents): drop psalm-baseline.xml from the layout block Followup to the baseline removal. The layout block still listed it as a tracked file. Updated and made the no-baseline policy visible. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bfed407..62540c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,8 +57,7 @@ future worker mode — must use an out-of-session store (file, DB, Roundcube cac ├── LICENSE # MIT ├── README.md # user-facing documentation ├── composer.json # plugin manifest (Roundcube plugin-installer type) -├── psalm.xml # Psalm static-analysis configuration -├── psalm-baseline.xml # current Psalm baseline for brownfield issues +├── psalm.xml # Psalm static-analysis configuration (no baseline — every error must be fixed) ├── imapsync.php # plugin entry class (extends rcube_plugin) ├── imapsync.js # client-side: form handling, result rendering ├── config.inc.php.dist # default plugin config, copied by user to config.inc.php