Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 2 additions & 1 deletion .horde.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ dependencies:
horde/exception: ^3
horde/mail: ^3
horde/mime: ^3
horde/socket_client: ^3
horde/sasl: ^1
horde/socket_client: ^3.1
horde/stream: ^2
horde/secret: ^3
horde/stream_filter: ^3
Expand Down
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Horde_Imap_Client

An IMAP4rev1 / IMAP4rev2 (RFC 3501 / RFC 9051) and POP3 (RFC 1939) client
library for PHP.

The package ships two codebases side by side:

- **`src/`** (namespace `Horde\Imap\Client`): the modern, PSR-4, PHP 8.2+
rewrite. Small final classes and immutable value objects, built on
`Horde\Socket\Client` and `Horde\Sasl`. This is the recommended API for
new code.
- **`lib/`** (`Horde_Imap_Client_*`): the legacy PSR-0 engine, retained for
backward compatibility. Functional but not actively developed.

## Modern usage

```php
use Horde\Imap\Client\ConnectionConfig;
use Horde\Imap\Client\ImapClient;
use Horde\Imap\Client\ImapFetchQuery;
use Horde\Imap\Client\OpenMode;
use Horde\Imap\Client\SecureMode;
use Horde\Sasl\Credentials\PasswordCredentials;
use Horde\Sasl\Credentials\PlainSecret;

$client = new ImapClient(
new ConnectionConfig(hostspec: 'imap.example.com', port: 993, secure: SecureMode::Ssl),
new PasswordCredentials('alice', new PlainSecret('secret')),
);

$client->login();
$client->openMailbox('INBOX', OpenMode::Readonly);

$query = (new ImapFetchQuery())->envelope()->flags();
foreach ($client->fetch('INBOX', $client->getIdsOb('1:*'), $query) as $uid => $msg) {
echo $uid . ': ' . $msg->getEnvelope()->subject . "\n";
}

$client->logout();
```

`ImapClient` implements `ImapProtocol` (and `MailboxProtocol`), plus the
optional `ImapAclAware`, `ImapQuotaAware` and `ImapMetadataAware`
extension interfaces. `Pop3Client` implements `MailboxProtocol`.

Message caching is opt-in: pass an `ImapCacheStore` (backed by any PSR-16
cache) as the fifth `ImapClient` constructor argument and it is used
transparently across fetch, store and expunge.

## Supported extensions

STARTTLS, SASL (via `Horde\Sasl`), CAPABILITY/ENABLE, IMAP4rev2,
UTF8=ACCEPT, UIDPLUS, MOVE, CONDSTORE/QRESYNC, ESEARCH, SORT/ESORT,
THREAD, LIST-EXTENDED, LIST-STATUS, NAMESPACE, ACL, QUOTA, METADATA,
MULTIAPPEND, CATENATE, BINARY and SEARCH=FUZZY.

## Documentation

- `doc/UPGRADING.md`: migrating from the legacy `lib/` API to the modern
`src/` clients, with a class-mapping table and before/after examples.
- `doc/examples/imapclient.php`: a runnable, read-only IMAP demonstration
(capabilities, namespaces, mailbox list, status, fetch, search).
- `doc/examples/pop3client.php`: the equivalent POP3 demonstration.
- `doc/POP3CAPABILITIES.md`: what the POP3 client implements, what it
deliberately does not and a server-compatibility matrix.

## License

LGPL 2.1. See the enclosed `LICENSE` file.
13 changes: 11 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,24 @@
"mailbox"
],
"time": "2026-07-21",
"repositories": [],
"repositories": [
{
"type": "path",
"url": "../Sasl",
"options": {
"symlink": true
}
}
],
"require": {
"horde/horde-installer-plugin": "dev-FRAMEWORK_6_0 || ^3 || ^2",
"php": "^8.1",
"horde/eventdispatcher": "^1 || dev-FRAMEWORK_6_0",
"horde/exception": "^3 || dev-FRAMEWORK_6_0",
"horde/mail": "^3 || dev-FRAMEWORK_6_0",
"horde/mime": "^3 || dev-FRAMEWORK_6_0",
"horde/socket_client": "^3 || dev-FRAMEWORK_6_0",
"horde/sasl": "^1 || dev-FRAMEWORK_6_0",
"horde/socket_client": "^3.1 || dev-FRAMEWORK_6_0",
"horde/stream": "^2 || dev-FRAMEWORK_6_0",
"horde/secret": "^3 || dev-FRAMEWORK_6_0",
"horde/stream_filter": "^3 || dev-FRAMEWORK_6_0",
Expand Down
4 changes: 2 additions & 2 deletions doc/Horde/Imap/Client/UPGRADING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Upgrading to 3.0.0
Memcache 1.0 with poor multi-get; modern Redis with MGET/pipelining
handles the sliced strategy of Backend_Cache equally well. Use
Backend_Cache with Horde_Cache configured to wrap a HashTable storage
instead — single code path, consistent TTL/age handling.
instead. Single code path, consistent TTL/age handling.

Existing IMP configurations setting ``cache.driver = 'hashtable'`` for
the IMAP cache continue to work: the IMP wrapper transparently falls
Expand Down Expand Up @@ -325,7 +325,7 @@ Upgrading to 2.9.0

- Horde_Imap_Client_Base

The 'cacheob', 'lifetime', and 'slicesize' parameters to the 'cache'
The 'cacheob', 'lifetime' and 'slicesize' parameters to the 'cache'
constructor option have been deprecated. Use the 'backend' parameter
instead.

Expand Down
130 changes: 130 additions & 0 deletions doc/POP3CAPABILITIES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# POP3 Capabilities

POP3 is a mostly frozen protocol. Few features were added after 2010.
Server support for those later features is thin. The protocol is in
maintenance mode with barely any active development.

This document covers two things:

1. What `Pop3Client` (the modern `src/` client) implements and what it
deliberately does not.
2. Which server products are likely to work with each capability.

## RFC history

- **RFC 1939** (May 1996, Standard). The core protocol: `USER`, `PASS`,
`APOP`, `STAT`, `LIST`, `RETR`, `DELE`, `NOOP`, `RSET`, `TOP`, `UIDL`,
`QUIT`.
- **RFC 2449** (November 1998, Proposed Standard). Adds `CAPA`, the
capability-discovery command.
- **RFC 2595** (June 1999, Proposed Standard). Adds `STLS`, the
STARTTLS-equivalent upgrade command.
- **RFC 5034** (July 2007, Proposed Standard). Adds the `AUTH` command
and SASL framing for POP3.
- **RFC 5721** (February 2010, Experimental). An early UTF-8 proposal.
It was later obsoleted by RFC 6856.
- **RFC 6186** (March 2011, Proposed Standard). DNS SRV records for
service discovery. This is a client/DNS concern, not a server
protocol feature. It updates RFC 1939 only in name.
- **RFC 6856** (March 2013, Proposed Standard). Supersedes RFC 5721.
Adds the `UTF8` command and a `UTF8` variant of the `USER` capability.

## Practices that never became RFCs

- **OAuth 2.0 / SASL XOAUTH2.** Not an RFC mechanism. Google introduced
it first; Microsoft added it to Exchange Online in 2020. Since
October 2022, Exchange Online requires it: basic auth no longer
works there. The client-credentials OAuth grant does not apply to
POP3; only delegated, user-context tokens do.
- **Microsoft NTLM over POP3** (`MS-POP3`/`MS-OXPOP3`). A proprietary
Microsoft mechanism, still usable on-premises but deprecated even
there.
- **Implicit TLS on port 995.** RFC 2595 expects `STLS` on port 110.
In practice, Google, Microsoft and Apple all standardized on
implicit TLS on 995 instead, with no plaintext fallback.
- **App-specific passwords.** A provisioning workaround from Google,
Apple and Yahoo for clients without OAuth2 support. It is not a
protocol change. It is just an ordinary password, so it needs no
special library support.
- **POP3 decline.** Gmail dropped "Check mail from other accounts" in
early 2026. Microsoft requires modern auth in Exchange Online.
Tutanota and ProtonMail never implemented POP3 natively (ProtonMail
offers a local bridge instead).
- **LIST+ draft.** An expired 2011 IETF draft
(`draft-lehmann-morg-pop3listplus`) that proposed richer `LIST`
metadata. It never became an RFC and saw no real adoption.

## What this library implements

| Capability | Source | Implemented | Notes |
|---|---|---|---|
| `USER`/`PASS` login | RFC 1939 | Yes | Fallback when no SASL mechanism fits. |
| `APOP` login | RFC 1939 | Yes | Used when the server greeting carries a timestamp. |
| `STAT`, `LIST`, `RETR`, `DELE`, `RSET`, `NOOP`, `QUIT` | RFC 1939 | Yes | Core mailbox operations. |
| `UIDL` | RFC 1939 | Yes | Drives UID-based fetch/store and `getIdsOb()`. |
| `TOP` | RFC 1939 | Yes | Used to fetch headers without a full `RETR`, with a `RETR` fallback. |
| `CAPA` | RFC 2449 | Yes | Queried once per connection, cached, cleared after STARTTLS. |
| `STLS` (STARTTLS) | RFC 2595 | Yes | Used when `SecureMode::Tls` is configured. |
| Implicit TLS on connect | Practice | Yes | Used when `SecureMode::Ssl` is configured (the default port is 995). |
| `AUTH` + SASL framing | RFC 5034 | Yes | Every mechanism below rides on this. |
| SASL PLAIN | RFC 4616 | Yes | |
| SASL LOGIN | De facto | Yes | Legacy fallback, still common. |
| SASL CRAM-MD5 | RFC 2195 | Yes | |
| SASL DIGEST-MD5 | RFC 2831 | Yes | Obsolete, kept for old servers. |
| SASL SCRAM-SHA-1/256/512 (+ `-PLUS`) | RFC 5802, RFC 7677, RFC 5056 | Yes | `-PLUS` variants use TLS channel binding. |
| SASL XOAUTH2 | Google practice | Yes | |
| SASL OAUTHBEARER | RFC 7628 | Yes | |
| SASL EXTERNAL | RFC 4422 §5.7 | Yes | TLS client-certificate auth. |
| SASL ANONYMOUS | RFC 4505 | Yes | |

## What this library does not implement

| Capability | Source | Why not |
|---|---|---|
| `UTF8` command, `UTF8 USER` | RFC 6856 | Almost no server advertises it. Add it if a target server needs it. |
| SRV-based autodiscovery | RFC 6186 | A DNS/client concern, not a wire-protocol capability. The caller resolves the host before constructing `ConnectionConfig`. |
| `LIST+` | Expired draft | Never standardized, no real server support. |
| NTLM (`MS-POP3`) | Microsoft proprietary | Proprietary and Microsoft-only. Deprecated even inside Exchange. |

App-specific passwords need no library support. They are ordinary
passwords from the client's point of view. `PasswordCredentials`
already covers them.

## Server compatibility matrix

Columns show whether each server product is likely to accept the
capability. "Plugin" means the mechanism needs extra, non-default
server software. "No evidence" means public documentation does not
confirm support either way.

| Capability | Dovecot | Cyrus IMAP | Exchange Online | Gmail | Stalwart | Courier-IMAP |
|---|---|---|---|---|---|---|
| `USER`/`PASS` | Yes | Yes | No (disabled Oct 2022) | No (disabled) | Yes | Yes |
| `APOP` | Yes | Yes | No | No | Yes | Yes |
| `CAPA` | Yes | Yes | Yes | Yes | Yes | Yes |
| `STLS` on port 110 | Yes | Yes | No (995 only) | No (995 only) | Yes | Yes |
| Implicit TLS on 995 | Yes | Yes | Yes | Yes | Yes | Yes |
| `TOP` | Yes | Yes | Yes | Yes | Yes | Yes |
| `UIDL` | Yes | Yes | Yes | Yes | Yes | Yes |
| SASL PLAIN | Yes | Yes | Yes | Yes | Yes | Yes |
| SASL LOGIN | Yes | Yes | Yes | Yes | Yes | Yes |
| SASL CRAM-MD5 | Yes | Yes | No | No | Yes | Yes |
| SASL DIGEST-MD5 | Yes | Yes | No | No | No evidence | Yes |
| SASL SCRAM-SHA-\* | Yes (2.3+) | Yes (recent) | No | No | Yes | No |
| SASL XOAUTH2 | Plugin | Plugin | Yes | Yes | Yes | No |
| SASL OAUTHBEARER | Plugin | Plugin | No evidence | No evidence | Yes | No |
| SASL EXTERNAL | Yes | Yes | No | No | Yes | No evidence |
| SASL ANONYMOUS | If configured | If configured | No | No | If configured | No |
| `UTF8` (RFC 6856) | No | No | No | No | Yes | No |

## Bottom line

Only one post-2010 feature has broad, real-world server support:
OAuth2/XOAUTH2. Even there, Dovecot and Cyrus need a third-party SASL
plugin; only Exchange Online, Gmail and Stalwart support it natively.
RFC 6856 (UTF-8) has barely spread past Stalwart.

Everything this library implements maps onto RFC 1939, RFC 2449,
RFC 2595, RFC 5034, and the mechanisms in `horde/sasl`. That already
covers every server in the matrix above, at least for one working
authentication path each.
115 changes: 115 additions & 0 deletions doc/SEARCH_CACHE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Caching search results

The modern `Horde\Imap\Client\ImapClient` deliberately does **not** cache
`SEARCH` results. This document explains how an application can
build and correctly invalidate its own search cache using the signals the
client exposes.

## Why the library does not cache searches

`ImapClient` caches per-message data and per-mailbox metadata through the
optional `ImapCacheStore` for envelope, flags, structure, size and similar
immutable-per-UID fields. Those are protocol facts the client can
invalidate deterministically. When a UID is expunged it is dropped, when
`UIDVALIDITY` changes the mailbox cache is dropped.

A `SEARCH` result set is different. It is a *derived query answer*.
Whether a cached answer is still acceptable is a policy decision that
depends on the consumer, not on the IMAP protocol. A message-list view, a
background indexer, and a filter rule each tolerate different staleness. A
library should not hardcode one invalidation policy on behalf of the consumer.

Instead the client reports *what changed* and leaves the caching policy to the application.

`ImapClient::search()` therefore always issues the command and returns a fresh `ImapSearchResult`.

## What the client provides

### Authoritative reconciliation: `sync()`

`getSyncToken(string $mailbox): string` captures the mailbox state
(`UIDVALIDITY` + `HIGHESTMODSEQ` + `UIDNEXT`) as an opaque token.

`sync(string $mailbox, string $token, array $criteria = []): ImapSyncResult`
diffs the current state against a stored token and returns:

- `newMsgs`: UIDs that appeared since the token,
- `flagChanges`: UIDs whose flags changed,
- `vanished`: UIDs that were removed.

A changed `UIDVALIDITY` (or a malformed token) raises `SyncException`,
meaning the UID space was reassigned and every cached result for that
mailbox is stale.

This is the *authoritative* mechanism. Store the sync token beside each
cached search result. Before serving a cached result, call `sync()` and
decide, per your policy, whether the reported `vanished` / `flagChanges`
sets invalidate it. `sync()` also recovers from gaps (for example a
reconnect during which live events were missed).

### Reactive signals: PSR-14 events

If you pass a PSR-14 `EventDispatcherInterface` to the client constructor,
it dispatches typed domain events you can listen for to invalidate
immediately, without polling:

- `Event\MailboxSelected`: A mailbox was opened. Fields: `mailbox`,
`uidvalidity`, `uidnext`, `highestmodseq`. A new `uidvalidity` for a
mailbox you have cached means drop everything for it.
- `Event\MailboxExpunged`: A messages were removed (a plain `EXPUNGE`, a
`UID EXPUNGE`, or the source side of a `MOVE`). Fields: `mailbox`,
`vanished` (an `ImapIdSet`), `uidvalidity`.

Inspect `$event->vanished->isSequence()`:
- `false`: The set is UIDs (server reported `VANISHED` or you issued a
`UID EXPUNGE`). Intersect it with your cached result sets and drop only
the affected entries.
- `true`: The set is sequence numbers (a plain `EXPUNGE`). Sequence
numbers are not stable keys, so invalidate the mailbox's cached
searches conservatively.

These events are plain `ImapEvent` subclasses (not `DiagnosticEvent`), so
the default `FilteredEventDispatcher` passes them through to your listeners.

## Recommended pattern

Combine both: Events for low-latency in-process invalidation and `sync()` as
the authoritative check that also covers missed events.

```php
use Horde\Imap\Client\Event\MailboxExpunged;
use Horde\Imap\Client\Event\MailboxSelected;

// 1. React to in-process mutations.
$listener = function (object $event) use ($searchCache): void {
if ($event instanceof MailboxExpunged) {
if ($event->vanished->isSequence()) {
$searchCache->invalidateMailbox($event->mailbox);
} else {
$searchCache->invalidateUids($event->mailbox, $event->vanished->toArray());
}
} elseif ($event instanceof MailboxSelected) {
$searchCache->onUidValidity($event->mailbox, $event->uidvalidity);
}
};

// 2. Cache a search result together with a sync token.
$token = $client->getSyncToken('INBOX');
$result = $client->search('INBOX', $query);
$searchCache->store('INBOX', $queryKey, $result->match->toArray(), $token);

// 3. Before serving a cached result, reconcile against the server.
[$cachedUids, $token] = $searchCache->load('INBOX', $queryKey);
try {
$delta = $client->sync('INBOX', $token);
if ($delta->vanished->count() > 0 /* or your flag-change policy */) {
$searchCache->invalidate('INBOX', $queryKey);
}
} catch (\Horde\Imap\Client\Exception\SyncException $e) {
$searchCache->invalidateMailbox('INBOX'); // UIDVALIDITY changed.
}
```

The library owns none of `$searchCache`'s policy. It only tells you what
changed. You as the integrator decide on how aggressively you invalidate, what you key on and where you
store it.
Loading
Loading