Skip to content

Commit d0999d1

Browse files
authored
Merge pull request #2 from mittwald/test/dovecot-quota-integration
Integration tests: real-Dovecot OVERQUOTA + preflight quota
2 parents c9b1de1 + 4676ccc commit d0999d1

5 files changed

Lines changed: 441 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ future worker mode — must use an out-of-session store (file, DB, Roundcube cac
8383
│ │ └── FakeImapClient.php # in-memory RoundcubeImapSyncClient for unit tests
8484
│ └── Integration/
8585
│ ├── bootstrap.php
86-
│ ├── DovecotContainer.php # testcontainers-php wrapper for Dovecot
86+
│ ├── DovecotContainer.php # testcontainers-php wrapper for Dovecot (optional quota plugin)
87+
│ ├── PreflightAndQuotaIntegrationTest.php # real-Dovecot OVERQUOTA + preflight quota coverage
8788
│ └── SyncEngineIntegrationTest.php # real-IMAP sync against two Dovecot containers
8889
├── phpunit.xml.dist # unit suite (excludes tests/Integration)
8990
├── phpunit.integration.xml.dist # integration suite (Docker required)

lib/RoundcubeImapSyncClient.php

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,8 @@ public function appendMessage(string $folder, string $rawMessage, array $flags,
185185
$result = $this->imap->append($folder, $message, $flags, $internalDate, false);
186186
if (!$result && $this->imap->error) {
187187
$errorMessage = $this->getErrorMessage("Could not append message to {$folder}.");
188-
if (stripos($errorMessage, '[OVERQUOTA]') !== false) {
188+
$resultCode = is_string($this->imap->resultcode ?? null) ? $this->imap->resultcode : null;
189+
if (self::isQuotaErrorResponse($resultCode, $this->imap->error)) {
189190
throw new RoundcubeImapSyncQuotaExceededException($errorMessage);
190191
}
191192

@@ -195,6 +196,47 @@ public function appendMessage(string $folder, string $rawMessage, array $flags,
195196
return $result;
196197
}
197198

199+
/**
200+
* Decide whether an IMAP NO/BAD response signals "destination mailbox is over quota".
201+
*
202+
* Three signals, any one of which is sufficient:
203+
*
204+
* 1. RFC 5530 / RFC 9208 IMAP response code OVERQUOTA — what stock Dovecot
205+
* >= 2.2.30 returns. rcube_imap_generic extracts response codes into
206+
* `resultcode` and removes them from the human error text, which is
207+
* why we cannot just substring-match the error.
208+
* 2. RFC 3463 enhanced status code "5.2.2" ("Mailbox full") in the error
209+
* text. Operators with a custom `quota_exceeded_message` (e.g. the
210+
* Mittwald default) replace the standard response code with a sentence
211+
* keyed on 5.2.2 — we want to catch those too. Bounded with whitespace
212+
* so we don't false-positive on IP addresses or unrelated dotted-number
213+
* fragments.
214+
* 3. Substring match on "OVERQUOTA" in the error text — defensive catch
215+
* for setups where the tag survives into the human message but somehow
216+
* not as a response code.
217+
*/
218+
public static function isQuotaErrorResponse(?string $resultCode, ?string $errorText): bool
219+
{
220+
if ($resultCode !== null && strcasecmp($resultCode, 'OVERQUOTA') === 0) {
221+
return true;
222+
}
223+
224+
$error = (string) $errorText;
225+
if ($error === '') {
226+
return false;
227+
}
228+
229+
if (preg_match('/(?:^|\s)5\.2\.2(?:\s|$)/', $error) === 1) {
230+
return true;
231+
}
232+
233+
if (stripos($error, 'OVERQUOTA') !== false) {
234+
return true;
235+
}
236+
237+
return false;
238+
}
239+
198240
private function normalizeMessageId(?string $messageId): ?string
199241
{
200242
$messageId = trim((string) $messageId);

tests/ImapSyncClientTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
use PHPUnit\Framework\TestCase;
4+
5+
final class ImapSyncClientTest extends TestCase
6+
{
7+
public function testOverquotaResultCodeIsQuotaError(): void
8+
{
9+
self::assertTrue(
10+
RoundcubeImapSyncGenericClient::isQuotaErrorResponse('OVERQUOTA', 'APPEND: Quota exceeded (mailbox for user is full).'),
11+
);
12+
}
13+
14+
public function testOverquotaResultCodeIsDetectedCaseInsensitively(): void
15+
{
16+
self::assertTrue(
17+
RoundcubeImapSyncGenericClient::isQuotaErrorResponse('overquota', 'whatever'),
18+
);
19+
}
20+
21+
public function testEnhancedStatusCode522IsQuotaError(): void
22+
{
23+
$mittwaldStyleError = '552 5.2.2 No space left in mailbox / Der Speicherplatz des Postfachs ist vollstaendig belegt';
24+
25+
self::assertTrue(
26+
RoundcubeImapSyncGenericClient::isQuotaErrorResponse(null, $mittwaldStyleError),
27+
);
28+
}
29+
30+
public function testOverquotaSubstringIsQuotaError(): void
31+
{
32+
self::assertTrue(
33+
RoundcubeImapSyncGenericClient::isQuotaErrorResponse(null, 'APPEND: [OVERQUOTA] Mailbox is over quota'),
34+
);
35+
}
36+
37+
public function testUnrelatedErrorIsNotQuotaError(): void
38+
{
39+
self::assertFalse(
40+
RoundcubeImapSyncGenericClient::isQuotaErrorResponse(null, 'APPEND: Could not write to disk'),
41+
);
42+
self::assertFalse(
43+
RoundcubeImapSyncGenericClient::isQuotaErrorResponse('TRYCREATE', 'Mailbox does not exist'),
44+
);
45+
}
46+
47+
public function testEmptyErrorWithNoResultCodeIsNotQuotaError(): void
48+
{
49+
self::assertFalse(RoundcubeImapSyncGenericClient::isQuotaErrorResponse(null, null));
50+
self::assertFalse(RoundcubeImapSyncGenericClient::isQuotaErrorResponse(null, ''));
51+
}
52+
53+
public function testDottedNumberSubstringDoesNotFalsePositive(): void
54+
{
55+
// 5.2.2 inside a larger token (e.g. an IP fragment) must not trigger.
56+
self::assertFalse(
57+
RoundcubeImapSyncGenericClient::isQuotaErrorResponse(null, 'APPEND: rejected by host 10.5.2.21'),
58+
);
59+
}
60+
}

tests/Integration/DovecotContainer.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,27 @@ class DovecotContainer
1212

1313
private ?StartedGenericContainer $container = null;
1414
private ?string $configDirectory = null;
15+
private ?int $quotaKilobytes = null;
16+
private ?string $quotaExceededMessage = null;
1517

1618
public function __construct(private readonly string $imageTag = 'dovecot/dovecot:latest-root')
1719
{
1820
}
1921

22+
public function withQuotaKilobytes(int $kilobytes): self
23+
{
24+
$this->quotaKilobytes = $kilobytes;
25+
26+
return $this;
27+
}
28+
29+
public function withQuotaExceededMessage(string $message): self
30+
{
31+
$this->quotaExceededMessage = $message;
32+
33+
return $this;
34+
}
35+
2036
public function start(): self
2137
{
2238
if ($this->container !== null) {
@@ -138,6 +154,52 @@ private function createConfigDirectory(): string
138154
throw new RuntimeException('Could not write Dovecot auth config.');
139155
}
140156

157+
if ($this->quotaKilobytes !== null) {
158+
// Enable the quota plugin and set a storage limit, then let
159+
// Dovecot's defaults handle the IMAP response. Since Dovecot
160+
// 2.2.30, an APPEND that exceeds quota carries an [OVERQUOTA]
161+
// response code (RFC 9208) automatically — we want to test
162+
// against that real-world wording, not against a forced one.
163+
$quotaConfig = strtr(
164+
<<<'DOVECOT'
165+
mail_plugins {
166+
quota = yes
167+
}
168+
169+
protocol imap {
170+
mail_plugins {
171+
quota = yes
172+
imap_quota = yes
173+
}
174+
}
175+
176+
namespace inbox {
177+
inbox = yes
178+
}
179+
180+
quota_storage_size = %KB%K
181+
182+
quota "User quota" {
183+
}
184+
DOVECOT,
185+
['%KB%' => (string) $this->quotaKilobytes],
186+
);
187+
188+
if ($this->quotaExceededMessage !== null) {
189+
// Operators sometimes replace the standard [OVERQUOTA] response
190+
// with a custom 5.2.2-style sentence (e.g. the Mittwald default).
191+
// This option lets a test exercise that variant against real
192+
// Dovecot. Escape embedded double-quotes so the value reaches
193+
// Dovecot intact.
194+
$escaped = str_replace('"', '\\"', $this->quotaExceededMessage);
195+
$quotaConfig .= "\nquota_exceeded_message = \"{$escaped}\"\n";
196+
}
197+
198+
if (file_put_contents($configDirectory . '/quota.conf', $quotaConfig) === false) {
199+
throw new RuntimeException('Could not write Dovecot quota config.');
200+
}
201+
}
202+
141203
return $configDirectory;
142204
}
143205

0 commit comments

Comments
 (0)