Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
91 commits
Select commit Hold shift + click to select a range
149d1b0
Improve append and expunge operations
stevebauman Sep 1, 2026
0ad3ca6
Link AppendResult to APPENDUID specification
stevebauman Sep 1, 2026
00d74f4
Merge pull request #181 from DirectoryTree/feature/v2-append-result
stevebauman Sep 1, 2026
8df62d6
Refactor message fetch item selection
stevebauman Sep 1, 2026
1bdb7bb
Model message data as composable fetch items
stevebauman Sep 1, 2026
d78b398
Merge pull request #182 from DirectoryTree/feature/v2-message-fetch-i…
stevebauman Sep 1, 2026
d615c73
Simplify message sorting API
stevebauman Sep 1, 2026
66911e9
Separate UID ordering from server sorting
stevebauman Sep 1, 2026
a59a97e
Model message ordering as a single strategy
stevebauman Sep 1, 2026
56fd8b4
Document ordering constructors
stevebauman Sep 1, 2026
0a794d6
Preserve message sorting semantics
stevebauman Sep 1, 2026
de8379c
Add mailbox capability checks
stevebauman Sep 1, 2026
08e73a0
Update MessageQuery.php
stevebauman Sep 2, 2026
5d2f7da
Simplify message ordering normalization
stevebauman Sep 2, 2026
1549510
Update MessageQuery.php
stevebauman Sep 2, 2026
d8cb50f
Merge pull request #183 from DirectoryTree/feature/v2-message-sorting
stevebauman Sep 2, 2026
f0e97e0
Add special-use folder resolution
stevebauman Sep 2, 2026
aa92d54
Remove special-use folder name fallbacks
stevebauman Sep 2, 2026
d28305a
Add folder data builder
stevebauman Sep 2, 2026
b0f8ec0
Fix code style
stevebauman Sep 2, 2026
d28e218
Keep special-use resolution on folder collections
stevebauman Sep 2, 2026
891f54a
Rename folder flags to attributes
stevebauman Sep 2, 2026
c22065c
Merge pull request #184 from DirectoryTree/feature/v2-special-use-fol…
stevebauman Sep 2, 2026
4364fad
Add incremental mailbox synchronization and extensible fetched data
stevebauman Sep 2, 2026
61c0b43
Support incremental synchronization through fetch modifiers
stevebauman Sep 2, 2026
95a67d8
Remove redundant connection FETCH shortcuts
stevebauman Sep 2, 2026
e5a2034
Unify IMAP operation identifiers and STORE modifiers
stevebauman Sep 2, 2026
b9f3499
Align connection parameters with IMAP command syntax
stevebauman Sep 2, 2026
67b5640
Send SORT charsets unquoted for server compatibility
stevebauman Sep 2, 2026
b9a7101
Adjust formatting
stevebauman Sep 2, 2026
4fd92fb
Spacing
stevebauman Sep 2, 2026
ba8ce90
Address incremental synchronization review feedback
stevebauman Sep 3, 2026
a4c13d0
Adjust param order
stevebauman Sep 3, 2026
ab63e17
Rename sequence set parser to fromSequenceSet
stevebauman Sep 3, 2026
0844b7b
Harden synchronization results and mailbox reconnects
stevebauman Sep 3, 2026
b5dda15
Extract SASL authentication exchange
stevebauman Sep 5, 2026
68e6ab6
Add fake logger assertions
stevebauman Sep 5, 2026
007487b
Refine capability and selection APIs
stevebauman Sep 5, 2026
cea1083
Fix code style
stevebauman Sep 5, 2026
cba52f6
Make mailbox capabilities immutable snapshots
stevebauman Sep 20, 2026
9494048
Handle selection capability and state edge cases
stevebauman Sep 20, 2026
fdafa28
Improve tokenizer method and enablement interface naming
stevebauman Sep 20, 2026
81822a9
Handle authentication replies within the exchange
stevebauman Sep 20, 2026
5889554
Sort cases
stevebauman Sep 20, 2026
8dcaf34
Adjust comments and spacing
stevebauman Sep 20, 2026
7fc93bf
Construct fake mailboxes through a typed factory
stevebauman Sep 20, 2026
58a3e0e
Validate IMAP arguments and centralize mailbox reset
stevebauman Sep 20, 2026
d396273
Refine FETCH and STORE response handling
stevebauman Sep 20, 2026
3fc9e70
Fix code style
stevebauman Sep 20, 2026
3c3e379
Send atom-safe IMAP charsets without quotes
stevebauman Sep 20, 2026
2636658
Merge branch 'feature/v2-incremental-sync' of https://github.com/Dire…
stevebauman Sep 20, 2026
d5bbe4a
Sort match asc
stevebauman Sep 21, 2026
c4e730d
Potential fix for pull request finding 'Require 64-bit PHP for 63-bit…
stevebauman Sep 21, 2026
2e34fd6
Potential fix for pull request finding 'Normalize case-insensitive VA…
stevebauman Sep 21, 2026
cf94985
Simplify fetched response handling
stevebauman Sep 21, 2026
1c03e60
Fix code style
stevebauman Sep 21, 2026
35ea32d
Adjust bool prop name
stevebauman Sep 21, 2026
d8cf4b4
Validate IMAP command tokens
stevebauman Sep 21, 2026
91d49c3
Potential fix for pull request finding 'Validate LIST selection optio…
stevebauman Sep 22, 2026
25b493b
Mirror selection capability checks in fake mailbox
stevebauman Sep 22, 2026
9702316
Track examined folders in fake mailbox
stevebauman Sep 22, 2026
089f1dd
Preserve fetched message identifiers
stevebauman Sep 22, 2026
95f8759
Harden incremental synchronization behavior
stevebauman Sep 27, 2026
f83582a
Fix code style
stevebauman Sep 27, 2026
6f39234
Simplify closure
stevebauman Sep 27, 2026
cad4abd
Merge branch 'feature/v2-incremental-sync' of https://github.com/Dire…
stevebauman Sep 27, 2026
0d027e9
Allow QRESYNC sequence matching without known UIDs
stevebauman Sep 27, 2026
75d66c1
Validate synchronization checkpoint ranges
stevebauman Sep 27, 2026
473e6ea
Validate fake synchronization checkpoints
stevebauman Sep 27, 2026
edc1566
Optimize message set range matching
stevebauman Sep 27, 2026
af9f411
Align fake mailbox connection state
stevebauman Sep 27, 2026
e6ba8b9
Merge pull request #185 from DirectoryTree/feature/v2-incremental-sync
stevebauman Sep 27, 2026
66d168d
Add typed IDLE events and dedicated session handling
stevebauman Sep 28, 2026
55ade1e
Potential fix for pull request finding 'Fake idle fails to invoke non…
stevebauman Sep 28, 2026
d7b82a6
Honor configured connection timeout when finishing IDLE
stevebauman Sep 28, 2026
ea225e9
Pass the mailbox timeout when finishing IDLE
stevebauman Sep 28, 2026
ff8731a
Tweak comments
stevebauman Sep 28, 2026
17bc4dd
Align fake folder IDLE event setter with existing conventions
stevebauman Sep 28, 2026
8e3fdaf
Separate mailbox events from IDLE message delivery
stevebauman Sep 28, 2026
7496d74
Align polling and IDLE message delivery behavior
stevebauman Sep 28, 2026
833b7d0
Merge pull request #187 from DirectoryTree/feature/v2-idle-events
stevebauman Sep 28, 2026
755b384
Fix international search encoding
stevebauman Sep 28, 2026
f80844d
Merge pull request #188 from DirectoryTree/feature/v2-international-s…
stevebauman Sep 28, 2026
ed95f3e
Add partial body fetching and chunked attachment streams
stevebauman Sep 28, 2026
b31bed2
Merge pull request #189 from DirectoryTree/feature/v2-partial-body-fe…
stevebauman Sep 28, 2026
1134e3f
Add Dovecot integration testing
stevebauman Sep 28, 2026
2c44105
Fix code style
stevebauman Sep 28, 2026
3c62743
Add end-to-end synchronization and IDLE coverage
stevebauman Sep 29, 2026
e5710d3
Refine v2 result collections and address review regressions
stevebauman Sep 30, 2026
7cfcf77
Fix review regressions and streamline protocol test fixtures
stevebauman Sep 30, 2026
f9d441b
Potential fix for pull request finding 'Filter unsolicited responses …
stevebauman Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions .github/workflows/run-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,38 @@ jobs:
run-tests:
runs-on: ${{ matrix.os }}

services:
greenmail:
image: greenmail/standalone:latest
env:
GREENMAIL_OPTS: -Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled -Dgreenmail.verbose
ports:
- 3143:3143
env:
MAILBOX_HOST: 127.0.0.1
MAILBOX_PORT: ${{ matrix.port }}
MAILBOX_SERVER: ${{ matrix.server }}
MAILBOX_USERNAME: user@localhost.com
MAILBOX_PASSWORD: password
MAILBOX_ENCRYPTION: ""

strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
php: [ 8.5, 8.4, 8.3, 8.2 ]
dependency-version: [ prefer-stable ]
server: [ greenmail ]
port: [ 3143 ]
include:
- os: ubuntu-latest
php: 8.4
dependency-version: prefer-stable
server: dovecot
port: 4143

name: ${{ matrix.os }} - P${{ matrix.php }} - ${{ matrix.dependency-version }}
name: ${{ matrix.server }} - P${{ matrix.php }} - ${{ matrix.dependency-version }}

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Start IMAP server
run: docker compose up -d ${{ matrix.server }}

- name: Cache dependencies
uses: actions/cache@v4
with:
Expand All @@ -48,5 +59,16 @@ jobs:
run: |
composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction

- name: Wait for IMAP server
run: php tests/wait-for-mailbox.php

- name: Execute tests
run: vendor/bin/pest --testsuite Integration

- name: Show IMAP server logs
if: failure()
run: docker compose logs --no-color ${{ matrix.server }}

- name: Stop IMAP server
if: always()
run: docker compose down --volumes
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
}
],
"require": {
"php": "^8.1",
"php": "^8.2",
"php-64bit": "*",
"symfony/mime": ">=6.0",
"nesbot/carbon": ">=2.0",
"illuminate/collections": ">=9.0",
Expand Down
13 changes: 11 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
services:
greenmail:
image: greenmail/standalone:latest
image: greenmail/standalone:2.1.14
environment:
- GREENMAIL_OPTS=-Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled -Dgreenmail.verbose
ports:
- "3143:3143"
- "127.0.0.1:3143:3143"

dovecot:
image: dovecot/dovecot:2.4.3
environment:
USER_PASSWORD: password
volumes:
- ./tests/docker/dovecot.conf:/etc/dovecot/conf.d/99-test.conf:ro
ports:
- "127.0.0.1:4143:31143"
58 changes: 58 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,61 @@
<p align="center">
<a href="https://imapengine.com">View Documentation</a>
</p>

### Partial body fetching

Fetch a byte range of a message section without downloading the whole section:

```php
$messages = $folder->messages()
->with(MessageData::text()->partial(0, 4096)->peek())
->get();

$preview = $messages->first()->data()->get('BODY[TEXT]<0>');

$chunk = $message->bodyPart('2', offset: 65536, length: 65536);
```

Offsets are zero-based and refer to transfer-encoded bytes. The server may return
fewer bytes at the end of a section, or an empty string beyond its end. A missing
message or section returns `null`. Partial data does not replace the complete body.

Attachments obtained through `attachments(fetch: true)` download in 64 KiB chunks
as their streams are read. Base64 and quoted-printable decoding continues across
chunk boundaries. Decoded content is cached in a temporary stream that spills to
disk beyond 2 MiB, allowing seeks and repeated reads without fetching again.

```php
$attachment = $message->attachments(fetch: true)[0];
$attachment->save('/path/to/attachment.pdf');
```

`save()` copies stream chunks directly to the destination. Calling `contents()` or
casting a stream to a string still allocates the requested contents in memory.
The decoded stream size is unknown (`null`) until the section has been fully read.

### Iterating over synchronization results

`FetchResult::messages()` and `StoreResult::messages()` return collections of
fetched message data. `FetchResult::vanished()` returns a `VanishedCollection`.
Result constructors accept collections rather than arrays.

`uids()`, `vanishedUids()`, and `modified()` return lazy collections. Iterate over
them directly to process identifiers without first building the complete arrays:

```php
foreach ($changes->vanished() as $vanished) {
foreach ($vanished->uids() as $uid) {
// Remove the message from your local mailbox.
}
}

foreach ($store->modified() as $uid) {
// Reconcile a message whose conditional flag update was rejected.
}
```

Call `->all()` when you need an array; this allocates memory for every returned
identifier. `vanishedUids()` combines groups and tracks previously yielded UIDs
to remove duplicates. Iterate over each group’s `uids()` for bounded-memory
processing of large ranges.
60 changes: 60 additions & 0 deletions src/AppendResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Connection\Responses\Data\ResponseCodeData;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;

/**
* @see https://datatracker.ietf.org/doc/html/rfc4315#section-3
*/
class AppendResult
{
/**
* Constructor.
*/
public function __construct(
protected ?int $uidValidity = null,
protected ?int $uid = null,
) {}

/**
* Create an append result from the tagged response.
*/
public static function fromResponse(TaggedResponse $response): static
{
$data = $response->tokenAt(2);

if (! $data instanceof ResponseCodeData) {
return new static;
}

$code = $data->first();

if (! $code instanceof Token || ! $code->is('APPENDUID')) {
return new static;
}

return new static(
uidValidity: (int) $data->tokenAt(1)->value,
uid: (int) $data->tokenAt(2)->value,
);
}

/**
* Get the mailbox UID validity value.
*/
public function uidValidity(): ?int
{
return $this->uidValidity;
}

/**
* Get the appended message UID.
*/
public function uid(): ?int
{
return $this->uid;
}
}
21 changes: 19 additions & 2 deletions src/Attachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Support\LazyBodyPartStream;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
Expand Down Expand Up @@ -63,7 +64,7 @@ public static function lazy(Message $message): array
$part->id(),
$part->contentType(),
$part->disposition()?->type()?->value,
new Support\LazyBodyPartStream($message, $part),
new LazyBodyPartStream($message, $part),
);
}

Expand Down Expand Up @@ -137,7 +138,23 @@ public function contentStream(): StreamInterface
*/
public function save(string $path): false|int
{
return file_put_contents($path, $this->contents());
if ($this->contentStream->isSeekable()) {
$this->contentStream->rewind();
}

if (! $resource = fopen($path, 'wb')) {
return false;
}

$destination = Utils::streamFor($resource);

try {
Utils::copyToStream($this->contentStream, $destination);

return $destination->tell();
} finally {
$destination->close();
}
}

/**
Expand Down
54 changes: 54 additions & 0 deletions src/Authentication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use Throwable;

class Authentication
{
/**
* Constructor.
*/
public function __construct(
protected ConnectionInterface $connection,
protected AuthenticatorInterface $authenticator,
) {}

/**
* Authenticate the connection.
*/
public function authenticate(bool $initial = false): TaggedResponse
{
$response = $this->authenticator->initial();

$sent = $initial && $response !== null;

$exchange = $this->connection->authenticate(
$this->authenticator->mechanism(),
$sent ? $response : null,
);

while ($exchange->valid()) {
$challenge = $exchange->current();

try {
if (! $sent && $response !== null) {
$answer = $response;
$sent = true;
} else {
$answer = $this->authenticator->respond($challenge);
}
} catch (Throwable $e) {
$this->connection->disconnect();

throw $e;
}

$exchange->send($answer);
}

return $exchange->getReturn();
}
}
40 changes: 40 additions & 0 deletions src/Authentication/XOAuth2.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace DirectoryTree\ImapEngine\Authentication;

use DirectoryTree\ImapEngine\AuthenticatorInterface;

class XOAuth2 implements AuthenticatorInterface
{
/**
* Constructor.
*/
public function __construct(
protected string $user,
protected string $token,
) {}

/**
* {@inheritDoc}
*/
public function mechanism(): string
{
return 'XOAUTH2';
}

/**
* {@inheritDoc}
*/
public function initial(): string
{
return "user=$this->user\1auth=Bearer $this->token\1\1";
}

/**
* {@inheritDoc}
*/
public function respond(string $challenge): string
{
return '';
}
}
21 changes: 21 additions & 0 deletions src/AuthenticatorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace DirectoryTree\ImapEngine;

interface AuthenticatorInterface
{
/**
* Get the SASL mechanism name.
*/
public function mechanism(): string;

/**
* Get the unencoded initial data, or null to await a challenge.
*/
public function initial(): ?string;

/**
* Respond to a decoded challenge. Return null to cancel authentication.
*/
public function respond(string $challenge): ?string;
}
Loading
Loading