Repository: DirectoryTree/ImapEngine (php)

Issue:
Using Since in imap filter turn into error with Seznam.cz provider
**Environment**
- **PHP version:8.3.4**
- **ImapEngine version:1.10.1**
- **IMAP server:Seznam.cz**

**IMAP logs**
DirectoryTree\ImapEngine\Exceptions\ImapCommandException
IMAP command "TAG4 UID SEARCH SINCE "04-Apr-2025"" failed. Response: "TAG4 NO SEARCH: mtd: internal error: Bad token (expecting date specification) ."

Same problem was already mentioned in different project https://github.com/freescout-help-desk/freescout/issues/4182

solution seems to be disable quotes around the date value

Pull request description:
Bug 56 - Using Since in imap filter turn into error with Seznam.cz provider
Closes #56 

Patch:
diff --git a/src/Connection/ImapQueryBuilder.php b/src/Connection/ImapQueryBuilder.php
index 173be9b..fc25762 100644
--- a/src/Connection/ImapQueryBuilder.php
+++ b/src/Connection/ImapQueryBuilder.php
@@ -4,6 +4,7 @@ namespace DirectoryTree\ImapEngine\Connection;
 
 use BackedEnum;
 use Carbon\Carbon;
+use Carbon\CarbonInterface;
 use DateTimeInterface;
 use DirectoryTree\ImapEngine\Enums\ImapSearchKey;
 use DirectoryTree\ImapEngine\Support\Str;
@@ -177,7 +178,9 @@ class ImapQueryBuilder
      */
     public function on(mixed $date): static
     {
-        return $this->where(ImapSearchKey::On, $this->parseDate($date));
+        return $this->where(ImapSearchKey::On, new RawQueryValue(
+            $this->parseDate($date)->format($this->dateFormat)
+        ));
     }
 
     /**
@@ -185,7 +188,9 @@ class ImapQueryBuilder
      */
     public function since(mixed $date): static
     {
-        return $this->where(ImapSearchKey::Since, $this->parseDate($date));
+        return $this->where(ImapSearchKey::Since, new RawQueryValue(
+            $this->parseDate($date)->format($this->dateFormat)
+        ));
     }
 
     /**
@@ -193,7 +198,9 @@ class ImapQueryBuilder
      */
     public function before(mixed $value): static
     {
-        return $this->where(ImapSearchKey::Before, $this->parseDate($value));
+        return $this->where(ImapSearchKey::Before, new RawQueryValue(
+            $this->parseDate($value)->format($this->dateFormat)
+        ));
     }
 
     /**
@@ -308,6 +315,34 @@ class ImapQueryBuilder
         ];
     }
 
+    /**
+     * Prepare the where value, escaping it as needed.
+     */
+    protected function prepareWhereValue(mixed $value): RawQueryValue|string|null
+    {
+        if (is_null($value)) {
+            return null;
+        }
+
+        if ($value instanceof RawQueryValue) {
+            return $value;
+        }
+
+        if ($value instanceof BackedEnum) {
+            $value = $value->value;
+        }
+
+        if ($value instanceof DateTimeInterface) {
+            $value = Carbon::instance($value);
+        }
+
+        if ($value instanceof CarbonInterface) {
+            $value = $value->format($this->dateFormat);
+        }
+
+        return Str::escape($value);
+    }
+
     /**
      * Add a nested condition group to the query.
      */
@@ -325,30 +360,15 @@ class ImapQueryBuilder
     }
 
     /**
-     * Recursively compile the wheres array into an IMAP-compatible string.
+     * Attempt to parse a date string into a Carbon instance.
      */
-    protected function compileWheres(array $wheres): string
+    protected function parseDate(mixed $date): CarbonInterface
     {
-        if (empty($wheres)) {
-            return '';
-        }
-
-        // Convert each "where" into a node for later merging.
-        $exprNodes = array_map(fn ($where) => (
-            $this->makeExpressionNode($where)
-        ), $wheres);
-
-        // Start with the first expression.
-        $combined = array_shift($exprNodes)['expr'];
-
-        // Merge the rest of the expressions.
-        foreach ($exprNodes as $node) {
-            $combined = $this->mergeExpressions(
-                $combined, $node['expr'], $node['boolean']
-            );
+        if ($date instanceof CarbonInterface) {
+            return $date;
         }
 
-        return trim($combined);
+        return Carbon::parse($date);
     }
 
     /**
@@ -384,39 +404,30 @@ class ImapQueryBuilder
     }
 
     /**
-     * Prepare the where value, escaping it as needed.
+     * Recursively compile the wheres array into an IMAP-compatible string.
      */
-    protected function prepareWhereValue(mixed $value): ?string
+    protected function compileWheres(array $wheres): string
     {
-        if (is_null($value)) {
-            return null;
-        }
-
-        if ($value instanceof DateTimeInterface) {
-            $value = Carbon::instance($value);
-        }
-
-        if ($value instanceof BackedEnum) {
-            $value = $value->value;
+        if (empty($wheres)) {
+            return '';
         }
 
-        if ($value instanceof Carbon) {
-            $value = $value->format($this->dateFormat);
-        }
+        // Convert each "where" into a node for later merging.
+        $exprNodes = array_map(fn ($where) => (
+            $this->makeExpressionNode($where)
+        ), $wheres);
 
-        return Str::escape($value);
-    }
+        // Start with the first expression.
+        $combined = array_shift($exprNodes)['expr'];
 
-    /**
-     * Attempt to parse a date string into a Carbon instance.
-     */
-    protected function parseDate(mixed $date): Carbon
-    {
-        if ($date instanceof Carbon) {
-            return $date;
+        // Merge the rest of the expressions.
+        foreach ($exprNodes as $node) {
+            $combined = $this->mergeExpressions(
+                $combined, $node['expr'], $node['boolean']
+            );
         }
 
-        return Carbon::parse($date);
+        return trim($combined);
     }
 
     /**
@@ -426,7 +437,9 @@ class ImapQueryBuilder
     {
         $part = strtoupper($where['key']);
 
-        if ($where['value']) {
+        if ($where['value'] instanceof RawQueryValue) {
+            $part .= ' '.$where['value']->value;
+        } elseif ($where['value']) {
             $part .= ' "'.$where['value'].'"';
         }
 
diff --git a/src/Connection/RawQueryValue.php b/src/Connection/RawQueryValue.php
new file mode 100644
index 0000000..c97e8bd
--- /dev/null
+++ b/src/Connection/RawQueryValue.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace DirectoryTree\ImapEngine\Connection;
+
+use Stringable;
+
+class RawQueryValue
+{
+    /**
+     * Constructor.
+     */
+    public function __construct(
+        public readonly Stringable|string $value
+    ) {}
+}
diff --git a/src/HasParsedMessage.php b/src/HasParsedMessage.php
index fe78ede..bed28f1 100644
--- a/src/HasParsedMessage.php
+++ b/src/HasParsedMessage.php
@@ -3,6 +3,7 @@
 namespace DirectoryTree\ImapEngine;
 
 use Carbon\Carbon;
+use Carbon\CarbonInterface;
 use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
 use GuzzleHttp\Psr7\Utils;
 use ZBateson\MailMimeParser\Header\HeaderConsts;
@@ -24,7 +25,7 @@ trait HasParsedMessage
     /**
      * Get the message date and time.
      */
-    public function date(): ?Carbon
+    public function date(): ?CarbonInterface
     {
         if ($date = $this->header(HeaderConsts::DATE)?->getDateTime()) {
             return Carbon::instance($date);
diff --git a/src/Idle.php b/src/Idle.php
index 60593a3..1f545ab 100644
--- a/src/Idle.php
+++ b/src/Idle.php
@@ -3,6 +3,7 @@
 namespace DirectoryTree\ImapEngine;
 
 use Carbon\Carbon;
+use Carbon\CarbonInterface;
 use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
 use DirectoryTree\ImapEngine\Exceptions\Exception;
 use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
@@ -152,7 +153,7 @@ class Idle
     /**
      * Get the next timeout as a Carbon instance.
      */
-    protected function getNextTimeout(): Carbon
+    protected function getNextTimeout(): CarbonInterface
     {
         return Carbon::now()->addSeconds($this->timeout);
     }
diff --git a/src/MessageInterface.php b/src/MessageInterface.php
index b9fbba5..9fb1db0 100644
--- a/src/MessageInterface.php
+++ b/src/MessageInterface.php
@@ -2,7 +2,7 @@
 
 namespace DirectoryTree\ImapEngine;
 
-use Carbon\Carbon;
+use Carbon\CarbonInterface;
 use Stringable;
 use ZBateson\MailMimeParser\Header\IHeader;
 use ZBateson\MailMimeParser\Message as MailMimeMessage;
@@ -17,7 +17,7 @@ interface MessageInterface extends Stringable
     /**
      * Get the message date and time.
      */
-    public function date(): ?Carbon;
+    public function date(): ?CarbonInterface;
 
     /**
      * Get the message's subject.


Test patch:
diff --git a/tests/Unit/Connection/ImapQueryBuilderTest.php b/tests/Unit/Connection/ImapQueryBuilderTest.php
index 862baa2..8035f30 100644
--- a/tests/Unit/Connection/ImapQueryBuilderTest.php
+++ b/tests/Unit/Connection/ImapQueryBuilderTest.php
@@ -1,6 +1,8 @@
 <?php
 
+use Carbon\Carbon;
 use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
+use DirectoryTree\ImapEngine\Connection\RawQueryValue;
 use DirectoryTree\ImapEngine\Enums\ImapSearchKey;
 
 test('returns an empty string if no conditions are provided', function () {
@@ -194,3 +196,27 @@ test('escapes multiple special characters', function () {
 
     expect($builder->toImap())->toBe('SUBJECT "Foo \\"Bar\\"Baz\\\\Zot"');
 });
+
+test('compiles a SINCE condition with unquoted date', function () {
+    $builder = new ImapQueryBuilder;
+
+    $builder->since(Carbon::create(2024, 4, 4));
+
+    expect($builder->toImap())->toBe('SINCE 04-Apr-2024');
+});
+
+test('compiles a SINCE condition with quoted date', function () {
+    $builder = new ImapQueryBuilder;
+
+    $builder->where('since', Carbon::create(2024, 4, 4));
+
+    expect($builder->toImap())->toBe('SINCE "04-Apr-2024"');
+});
+
+test('compiles raw value', function () {
+    $builder = new ImapQueryBuilder;
+
+    $builder->where('foo', new RawQueryValue('bar'));
+
+    expect($builder->toImap())->toBe('FOO bar');
+});