PHPackages                             thingmabobby/imap-email-checker - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [HTTP &amp; Networking](/categories/http)
4. /
5. thingmabobby/imap-email-checker

ActiveLibrary[HTTP &amp; Networking](/categories/http)

thingmabobby/imap-email-checker
===============================

A PHP 8.4 library to fetch and process email from IMAP mailboxes, with safe incremental sync.

v3.1.1(1w ago)717MITPHPPHP ^8.4CI failing

Since Apr 27Pushed 1w ago1 watchersCompare

[ Source](https://github.com/thingmabobby/IMAPEmailChecker)[ Packagist](https://packagist.org/packages/thingmabobby/imap-email-checker)[ RSS](/packages/thingmabobby-imap-email-checker/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (9)Dependencies (4)Versions (9)Used By (0)

IMAP Email Checker
==================

[](#imap-email-checker)

A PHP library for reading mail from an IMAP mailbox: connect, fetch messages (including attachments), and keep track of what you have already processed so you can poll for new mail safely.

Table of contents
-----------------

[](#table-of-contents)

- [When to use it](#when-to-use-it)
- [Installation](#installation)
- [Quick start](#quick-start)
- [API overview](#api-overview)
- [Options](#options)
- [Common use cases](#common-use-cases)
    - [Email archiving](#email-archiving)
    - [Automated email processing](#automated-email-processing)
    - [Linking emails to system records](#linking-emails-to-system-records)
    - [Email backup](#email-backup)
    - [Mailbox monitoring](#mailbox-monitoring)
    - [Targeted retrieval](#targeted-retrieval)
    - [Runnable demos](#runnable-demos)
- [Simple polling walkthrough](#simple-polling-walkthrough)
    - [Two numbers to remember](#two-numbers-to-remember)
    - [Example loop](#example-loop)
    - [What to store](#what-to-store)
    - [Preventing duplicate processing](#preventing-duplicate-processing)
        - [Multitenant applications](#multitenant-applications)
        - [What the key is (and is not)](#what-the-key-is-and-is-not)
- [Working with messages](#working-with-messages)
    - [EmailMessage](#emailmessage)
    - [EmailAddress](#emailaddress)
    - [EmailAddressList](#emailaddresslist)
    - [EmailAttachment](#emailattachment)
    - [EmailSkippedPart](#emailskippedpart)
    - [EmailBatchResult](#emailbatchresult)
    - [EmailFetchSummary](#emailfetchsummary)
    - [MailboxStatus](#mailboxstatus)
    - [MailboxCheckpoint](#mailboxcheckpoint)
- [Sync, search, and mailbox actions](#sync-search-and-mailbox-actions)
    - [Search criteria](#search-criteria)
    - [When to save the checkpoint](#when-to-save-the-checkpoint)
    - [Checkpoint rules that matter](#checkpoint-rules-that-matter)
    - [Delete, archive, and expunge](#delete-archive-and-expunge)
- [Logging](#logging)
- [Exceptions](#exceptions)
- [Upgrading from 1.x / 2.0](#upgrading-from-1x-20)
- [Notes](#notes)
    - [Custom IMAP clients (`fromClient`)](#custom-imap-clients-fromclient)
    - [Native ext-imap vs polyfill (UID discovery)](#native-ext-imap-vs-polyfill-uid-discovery)
    - [Composer IMAP polyfill and Seen flags](#composer-imap-polyfill-and-seen-flags)
- [License](#license)

---

When to use it
==============

[](#when-to-use-it)

This library helps you fetch and process IMAP mail in PHP for applications such as:

- **Email archiving** - Store messages (and attachments) in a database or other storage for record-keeping
- **Automated email processing** - Analyze incoming mail, trigger actions from content, or push data into other systems
- **Linking emails to system records** - Pull ticket numbers, order IDs, or custom tags from subjects with a configurable regex (CRM, helpdesk, order management)
- **Email backup** - Download mailbox contents for backup or export
- **Mailbox monitoring** - Check for new or unread mail and mailbox totals without fetching every body
- **Targeted retrieval** - Search by IMAP criteria and fetch only the matching messages

New to incremental IMAP sync? Start with [Simple polling walkthrough](#simple-polling-walkthrough), then try the [common use cases](#common-use-cases) and [`examples/3.0/`](examples/3.0/).

---

Installation
============

[](#installation)

```
composer require thingmabobby/imap-email-checker
```

**Requirements:** PHP **8.4+**, `ext-mbstring`, `ext-zip`, and `psr/log` (`^1.1 || ^2.0 || ^3.0`). IMAP access is provided by Composer via [fain182/ext-imap-polyfill](https://github.com/fain182/ext-imap-polyfill) (no separate PECL `ext-imap` install required). If PECL `ext-imap` is already enabled, that is used instead. Incremental sync (`checkSinceCheckpoint`) works on both backends as of **3.0.1** (native and polyfill use different UID-discovery strategies).

---

Quick start
===========

[](#quick-start)

```
use DateTimeInterface;
use IMAPEmailChecker\IMAPEmailChecker;
use IMAPEmailChecker\Configuration\CheckerOptions;
use IMAPEmailChecker\Exception\ExceptionInterface;
use IMAPEmailChecker\Imap\MailboxCheckpoint;

// Your app's id for this mailbox account (database id, UUID, etc.).
// This is NOT the IMAP login username - you invent it and keep it stable.
$accountId = 'mail-account-1';

try {
    // Open the IMAP mailbox. Host is the server name only (no {braces}).
    // maxMessagesPerFetch keeps each pull small so memory stays reasonable.
    $checker = IMAPEmailChecker::connect(
        hostname: 'imap.example.com',
        username: 'you@example.com',
        password: $password,
        options: new CheckerOptions(maxMessagesPerFetch: 50),
    );

    try {
        // Where did we leave off last time?
        // App helper (not in this library): read saved uidValidity + lastUid from your DB.
        // If this is the first run, start from the beginning.
        $checkpoint = loadCheckpoint($accountId, 'INBOX') ?? MailboxCheckpoint::initial();

        do {
            // Ask the server for the next batch of new messages after the checkpoint.
            $result = $checker->checkSinceCheckpoint($checkpoint);

            foreach ($result->messages as $message) {
                // Build a plain array of the fields most apps store.
                // Use whatever column names fit your schema.
                $record = [
                    // Stable unique key for this message in YOUR database.
                    // Put a UNIQUE index on this column so retries do not double-insert.
                    'imap_idempotency_key' => $message->idempotencyKey($accountId),

                    // IMAP identity (where this message lived on the server for this fetch)
                    'mailbox' => $message->imapIdentity->mailbox,
                    'uid_validity' => $message->imapIdentity->uidValidity,
                    'uid' => $message->uid,

                    // Optional RFC Message-ID (useful for secondary matching; may be missing)
                    'message_id' => $message->messageId,

                    'subject' => $message->subject,

                    // Who sent it (preferredFrom = From, or Sender if From is empty)
                    'from_email' => $message->preferredFrom?->address,
                    'from_name' => $message->preferredFrom?->name,
                    'sender_email' => $message->sender?->address,

                    // Address lists as simple email-string arrays
                    'reply_to' => $message->replyTo->bareAddresses,
                    'to' => $message->to->bareAddresses,
                    'cc' => $message->cc->bareAddresses,
                    'bcc' => $message->bcc->bareAddresses,

                    // Prefer a parsed datetime when available; fall back to the raw header string
                    'date' => $message->datetime?->format(DateTimeInterface::ATOM) ?? $message->date,

                    // Bodies: keep HTML and plain separately if you need both;
                    // $message->body is a convenience (HTML if present, otherwise plain text)
                    'body_html' => $message->htmlBody,
                    'body_text' => $message->textBody,
                    'body' => $message->body,
                ];

                // App helper (not in this library): insert or upsert this row.
                // If the unique key already exists, treat that as "already imported" and continue.
                saveMessageRecord($record);

                // Save each file attachment (bytes are already decoded in memory)
                foreach ($message->attachments as $attachment) {
                    // App helper (not in this library): write to disk, S3, a blob column, etc.
                    saveAttachment(
                        messageKey: $record['imap_idempotency_key'],
                        filename: $attachment->filename,
                        mimeType: $attachment->mimeType,
                        content: $attachment->content,
                        size: $attachment->size,
                    );
                }
            }

            // Only advance "how far we got" after THIS batch was saved successfully.
            // If you save the checkpoint too early, you can skip mail on the next run.
            // App helper (not in this library): write uidValidity + lastUid to your DB.
            saveCheckpoint($accountId, 'INBOX', $result->checkpoint);
            $checkpoint = $result->checkpoint;

            // If hasMore is true, there is still more mail - loop and fetch the next batch.
        } while ($result->hasMore);
    } finally {
        // Always disconnect, even if something above threw.
        $checker->close();
    }
} catch (ExceptionInterface $e) {
    // Do not save a new checkpoint here - the next run should retry from the last good one.
    error_log('IMAP sync failed: ' . $e->getMessage());
}
```

Pass `mailbox`, `port`, and `flags` when you need them (defaults: `INBOX`, `993`, `/ssl`).

```
$checker = IMAPEmailChecker::connect(
    hostname: 'imap.example.com',
    username: $username,
    password: $password,
    mailbox: 'INBOX',
    port: 993,
    flags: '/ssl',
    debug: true, // optional: write library diagnostics to PHP's error_log
);
```

---

API overview
============

[](#api-overview)

MethodReturnsNotes`connect(...)``self`You own the connection - call `close()``fromClient(...)``self`Inject a custom `ImapClientInterface` (tests / existing connection); `close()` will not disconnect it`close()``void`Safe to call more than once`checkMailboxStatus()``MailboxStatus`Totals and latest UID`checkSinceCheckpoint(...)``EmailBatchResult`Preferred for polling; includes `checkpoint``checkAllEmails` / `checkSinceDate` / `checkUnreadEmails``array`
`[uid => EmailMessage, ...]`One-shot helpers; no persistable checkpoint`searchUids` / `searchSequenceNumbers``list`
`[1, 2, 3, ...]`IMAP search ids only`fetchMessagesByUids` / `fetchMessagesBySequenceNumbers``array`
`[uid => EmailMessage, ...]`Full messages keyed by id`setMessageReadStatus(array $uids, bool $markAsRead)``void`Several UIDs at once`deleteEmail` / `archiveEmail``void`One UID; optional deferred expunge`expunge``void`Permanently purge deleted messages in the mailbox`getLastProcessingFailures()`failuresFrom the latest fetch/sync call---

Options
=======

[](#options)

Pass `CheckerOptions` into `connect()` (or `fromClient()`):

```
use IMAPEmailChecker\Configuration\CheckerOptions;

$options = new CheckerOptions(
    maxMessagesPerFetch: 50,
    subjectIdentifierPattern: '/\bb#\s*(\d+)/i', // e.g. "B#1234" -> "1234"
);

$checker = IMAPEmailChecker::connect(
    hostname: $hostname,
    username: $username,
    password: $password,
    options: $options,
);

// $message->subjectIdentifier / $message->hasSubjectIdentifier
```

OptionDefaultPurpose`embedInlineImages``true`Turn `cid:` images in HTML into embedded data URIs`maxAttachmentBytes``20_000_000` (~20 MB)Max size for one attachment (`null` = no limit)`maxTotalAttachmentBytes``25_000_000` (~25 MB)Max total attachment bytes per message (`null` = no limit)`maxInlineImageBytes``5_000_000` (~5 MB)Max size for one inline image when embedding (`null` = no limit). ~5 MB is a practical default for HTML mail; raise or set `null` if you embed larger images.`maxMessagesPerFetch``null`Max messages per sync call (`null` = no limit)`subjectIdentifierPattern``null`Optional regex; the matched text is stored on `$message->subjectIdentifier` (`null` = off)With `embedInlineImages: true` (default), matching inline images are written into the HTML body as data URIs (within size limits). Oversized parts are skipped and listed on `$message->skippedParts`. Set `embedInlineImages: false` if you want to keep `cid:` links and handle inline files yourself.

---

Common use cases
================

[](#common-use-cases)

Snippets below assume you already connected with `IMAPEmailChecker::connect(...)` (see [Quick start](#quick-start)). Always call `$checker->close()` when finished, and catch `ExceptionInterface` around library calls.

Email archiving
---------------

[](#email-archiving)

Poll with a checkpoint, persist each message, then move it out of the inbox:

```
$result = $checker->checkSinceCheckpoint($checkpoint);

foreach ($result->messages as $uid => $message) {
    // App helper (not in this library): persist the message (and attachments) in your storage
    archiveToDatabase($message);
    $checker->archiveEmail(uid: $uid, archiveFolder: 'Archive', expunge: false);
}

$checker->expunge(); // one purge after deferred moves
// App helper (not in this library): write uidValidity + lastUid after the batch succeeded
saveCheckpoint($accountId, 'INBOX', $result->checkpoint);
```

Automated email processing
--------------------------

[](#automated-email-processing)

Use the preferred body (`$message->body`) or separate HTML / plain fields to drive your own logic:

```
foreach ($result->messages as $message) {
    if (str_contains(mb_strtolower($message->subject), 'out of office')) {
        continue;
    }

    $text = $message->textBody ?? strip_tags($message->body);
    // App helper (not in this library): run your inbound workflow / queue job
    dispatchInboundMail($message->preferredFrom?->address, $text, $message->attachments);
}
```

Linking emails to system records
--------------------------------

[](#linking-emails-to-system-records)

Configure a subject regex once; the first capturing group is exposed as `$message->subjectIdentifier`:

```
use IMAPEmailChecker\Configuration\CheckerOptions;

$checker = IMAPEmailChecker::connect(
    hostname: $hostname,
    username: $username,
    password: $password,
    options: new CheckerOptions(
        subjectIdentifierPattern: '/\bticket\s*#\s*(\d+)/i', // "Ticket #4521" -> "4521"
    ),
);

foreach ($result->messages as $message) {
    if ($message->hasSubjectIdentifier) {
        // App helper (not in this library): link this mail to the ticket/record id from the subject
        attachEmailToTicket((int) $message->subjectIdentifier, $message);
    }
}
```

Email backup
------------

[](#email-backup)

Download a batch (including attachment bytes) and write them somewhere durable. Prefer checkpointed batches over `checkAllEmails()` on large mailboxes:

```
$result = $checker->checkSinceCheckpoint($checkpoint);

foreach ($result->messages as $message) {
    $dir = $backupRoot . '/' . $message->imapIdentity->uid;
    mkdir($dir, 0775, true);
    file_put_contents($dir . '/headers.txt', "Subject: {$message->subject}\nDate: {$message->date}\n");
    file_put_contents($dir . '/body.html', $message->htmlBody ?? $message->body);

    foreach ($message->attachments as $attachment) {
        file_put_contents($dir . '/' . $attachment->filename, $attachment->content);
    }
}

// App helper (not in this library): persist the returned checkpoint after a successful batch
saveCheckpoint($accountId, 'INBOX', $result->checkpoint);
```

Mailbox monitoring
------------------

[](#mailbox-monitoring)

Check totals without fetching bodies, or pull a limited unread set:

```
$status = $checker->checkMailboxStatus();
// $status->total, $status->unseen, $status->latestUid

if ($status->unseen > 0) {
    $unread = $checker->checkUnreadEmails(limit: 20); // array
    // App helper (not in this library): alert your ops channel / pager
    notifyOps("{$status->unseen} unread (showing " . count($unread) . ')');
}
```

Targeted retrieval
------------------

[](#targeted-retrieval)

Search with standard IMAP criteria, then fetch only those UIDs:

```
$uids = $checker->searchUids('SINCE "1-Jan-2026" SUBJECT "invoice" TEXT ".pdf"');
$messages = $checker->fetchMessagesByUids($uids);

foreach ($messages as $uid => $message) {
    // App helper (not in this library): extract/handle invoice data from this message
    processInvoiceMail($message);
}
```

Runnable demos
--------------

[](#runnable-demos)

Edit the `$hostname` / `$username` / `$password` placeholders in [`examples/3.0/`](examples/3.0/), run `composer install`, then open a script in the browser or CLI. Do not commit real passwords - use env vars or a secrets manager in real apps.

ScriptShows[`checkInboxStatus.php`](examples/3.0/checkInboxStatus.php)Counts and latest message id[`checkImapSinceCheckpoint.php`](examples/3.0/checkImapSinceCheckpoint.php)One incremental batch[`checkImapSinceDate.php`](examples/3.0/checkImapSinceDate.php)Mail since a date[`checkImapAllEmail.php`](examples/3.0/checkImapAllEmail.php)Paged loop until caught upOlder Packagist **2.0** samples (classic API) are kept under [`examples/2.0/`](examples/2.0/) for reference only - see [`examples/README.md`](examples/README.md).

---

Simple polling walkthrough
==========================

[](#simple-polling-walkthrough)

The usual production pattern: connect, load where you left off, fetch a batch, save progress, repeat until done, then disconnect.

Two numbers to remember
-----------------------

[](#two-numbers-to-remember)

**UID** - each message in a mailbox has a unique id (for example `1042`). Newer mail usually gets a higher UID. Incremental sync means: "give me messages with UID greater than the last one I finished."

**UIDVALIDITY** - a number for the *mailbox* as a whole. It rarely changes. If the server rebuilds the mailbox, UIDVALIDITY changes and old UIDs are no longer safe to reuse. Store it next to your last UID.

You storeMeaning`lastUid`How far you got in this "edition" of the mailbox`uidValidity`Which "edition" those UIDs belong toExample loop
------------

[](#example-loop)

```
use DateTimeInterface;
use IMAPEmailChecker\IMAPEmailChecker;
use IMAPEmailChecker\Configuration\CheckerOptions;
use IMAPEmailChecker\Exception\ExceptionInterface;
use IMAPEmailChecker\Imap\MailboxCheckpoint;

try {
    $checker = IMAPEmailChecker::connect(
        hostname: 'imap.example.com',
        username: 'you@example.com',
        password: $password,
        options: new CheckerOptions(maxMessagesPerFetch: 50),
    );

    try {
        // App helper (not in this library): load saved uidValidity + lastUid
        $checkpoint = loadCheckpoint($accountId, 'INBOX') ?? MailboxCheckpoint::initial();

        do {
            $result = $checker->checkSinceCheckpoint($checkpoint);

            if ($result->summary->uidValidityReset) {
                // Mailbox was reset - your handlers should tolerate seeing mail again
                // (for example skip rows that already have the same Message-ID).
            }

            foreach ($result->messages as $message) {
                // Typical fields you might persist (shape is up to your app):
                $record = [
                    'imap_idempotency_key' => $message->idempotencyKey($accountId),
                    'mailbox' => $message->imapIdentity->mailbox,
                    'uid_validity' => $message->imapIdentity->uidValidity,
                    'uid' => $message->uid,
                    'message_id' => $message->messageId,
                    'subject' => $message->subject,
                    'from_email' => $message->preferredFrom?->address,
                    'from_name' => $message->preferredFrom?->name,
                    'to_emails' => $message->to->bareAddresses,
                    'date' => $message->datetime?->format(DateTimeInterface::ATOM) ?? $message->date,
                    'body_html' => $message->htmlBody,
                    'body_text' => $message->textBody,
                    'body' => $message->body,
                    'attachment_names' => array_map(
                        static fn($a) => $a->filename,
                        $message->attachments,
                    ),
                ];

                // App helper (not in this library): insert/upsert $record (and attachment bytes if needed)
                saveMessageRecord($record, $message->attachments);
            }

            // "Successful batch" means YOUR handling of every message above succeeded.
            // The library does not know if your insert failed - only save when you are sure.
            // App helper (not in this library): persist checkpoint only after the full batch succeeded
            saveCheckpoint($accountId, 'INBOX', $result->checkpoint);
            $checkpoint = $result->checkpoint;
        } while ($result->hasMore);
    } finally {
        $checker->close();
    }
} catch (ExceptionInterface $e) {
    // Leave the last saved checkpoint alone so the next run retries this batch
    error_log('IMAP sync failed for account ' . $accountId . ': ' . $e->getMessage());
    // App helper (not in this library): optional alert / queue retry
    // notifyOps($e);
}
```

StepWhat to doConnectOpen the mailbox. Optional: pass a PSR-3 `logger`, or `debug: true` for simple `error_log` output.Load checkpointRead saved `uidValidity` + `lastUid`, or start with `MailboxCheckpoint::initial()`.Fetch`checkSinceCheckpoint()` returns the next messages after `lastUid`.HandleProcess **every** message in `$result->messages` before saving. If any of *your* writes fail, abort and **do not** save the checkpoint.SavePersist `$result->checkpoint` only when the whole batch was handled successfully. Saving mid-batch can skip mail; never saving can repeat a batch.More?If `$result->hasMore` is true, loop with the new checkpoint.CloseAlways call `$checker->close()` (a `finally` block is ideal).What to store
-------------

[](#what-to-store)

**Required for sync** - one checkpoint per account + mailbox:

```
account_id | mailbox | uid_validity (nullable int) | last_uid (int, default 0)

```

```
$checkpoint = new MailboxCheckpoint(
    uidValidity: $row['uid_validity'] !== null ? (int) $row['uid_validity'] : null,
    lastUid: (int) $row['last_uid'],
);

// After a successful batch:
$row['uid_validity'] = $result->checkpoint->uidValidity;
$row['last_uid'] = $result->checkpoint->lastUid;
```

Only stored a UID before? Use `new MailboxCheckpoint(null, $oldUid)` once, then save the full checkpoint after the first successful batch.

Preventing duplicate processing
-------------------------------

[](#preventing-duplicate-processing)

Generating an idempotency key is optional. When using this feature, pass a stable application-owned scope identifying the configured IMAP account:

```
$key = $message->idempotencyKey($accountScope);
```

The scope may be a globally unique mail-account ID, mailbox configuration UUID, or tenant-scoped account identifier. It is not inferred from the IMAP username.

The generated key identifies:

```
account scope + mailbox + UIDVALIDITY + UID

```

Store it with a unique database constraint to make repeated processing of the same IMAP message idempotent.

```
foreach ($result->messages as $message) {
    $key = $message->idempotencyKey((string) $mailAccountId);

    // Prefer an atomic insert/upsert that includes the unique key.
    // Treat a duplicate-key violation as "already processed".
    // App helper (not in this library): insert/upsert the message keyed by $key
    storeMessageIdempotently($key, $message);
}

// App helper (not in this library): persist checkpoint after successful handling
saveCheckpoint($accountId, 'INBOX', $result->checkpoint);
```

### Multitenant applications

[](#multitenant-applications)

The account scope must be unique within the database or storage system where the generated key is indexed. If mail-account IDs are only unique within a tenant, include both tenant and mail-account identity or use a globally unique mailbox-configuration ID:

```
$accountScope = sprintf(
    'tenant:%s:mail-account:%s',
    $tenantId,
    $mailAccountId,
);

foreach ($result->messages as $message) {
    $key = $message->idempotencyKey($accountScope);
    // App helper (not in this library): insert/upsert under a unique imap_idempotency_key
    storeMessageIdempotently(key: $key, message: $message);
}

// App helper (not in this library): persist checkpoint after successful handling
saveCheckpoint($result->checkpoint);
```

Whether tenants share one imported-message store (and therefore intentionally share account scopes) is an application decision. The library does not infer it.

### What the key is (and is not)

[](#what-the-key-is-and-is-not)

- `idempotencyKey()` returns a deterministic opaque string (`imap:v1:` + 64 hex chars; 72 characters total). Treat it as opaque - do not reimplement or parse the hash.
- Integer `42` and string `"42"` produce the same key; `"042"` produces a different key. Use the same account-scope representation consistently.
- Changing your account-scope format later produces different keys.
- The library does not save or remember generated keys. Persist the returned key with the imported record (for example `VARCHAR(128)`).
- Prefer claiming the key and storing related data in one transaction. Avoid check-then-insert races.
- The checkpoint determines where the next mailbox scan starts; the stored key protects against processing the same IMAP identity twice.
- Inspect mailbox / UIDVALIDITY / UID via `$message->imapIdentity` (and `$message->uid`, which derives from the identity). IMAP UID and UIDVALIDITY use an unsigned 32-bit range; full-range support assumes a normal 64-bit PHP runtime.
- After a UIDVALIDITY reset, the same logical email may receive a different key. `$message->messageId` may help with secondary reconciliation, but Message-ID is optional, not guaranteed unique, and is not part of the primary key.
- This supports retry-safe idempotent processing. It does not guarantee exactly-once processing or cross-mailbox / cross-UIDVALIDITY logical-email deduplication.

Example unique index:

```
CREATE UNIQUE INDEX unique_imap_message_key
    ON imported_messages (imap_idempotency_key);
```

**Tip:** Prefer `checkSinceCheckpoint()` with a batch size over `checkAllEmails()` on large mailboxes.

---

Working with messages
=====================

[](#working-with-messages)

`$result->messages` is an array of `EmailMessage` objects keyed by UID.

EmailMessage
------------

[](#emailmessage)

```
foreach ($result->messages as $uid => $message) {
    echo $message->subject;
    echo $message->preferredFrom?->display;
    echo $message->body;
    $key = $message->idempotencyKey($accountScope);
}
```

PropertyTypeDescription`$message->imapIdentity``ImapMessageIdentity`Selected mailbox, UIDVALIDITY, and UID for this fetch`$message->imapIdentity->mailbox``string`Logical mailbox name (e.g. `INBOX`)`$message->imapIdentity->uidValidity``int`Mailbox UIDVALIDITY observed for this fetch`$message->imapIdentity->uid``int`IMAP UID`$message->uid``int`Same as `$message->imapIdentity->uid``$message->idempotencyKey($accountScope)``string`Opaque `imap:v1:...` key for unique storage (method; pass your account scope)`$message->messageNumber``int`IMAP sequence number in the current mailbox (not stable like UID)`$message->messageId``?string`RFC Message-ID header when present (not the IMAP UID)`$message->subject``string`Decoded subject`$message->subjectIdentifier``?string`First capture from your configured subject pattern, if any`$message->hasSubjectIdentifier``bool`Whether `subjectIdentifier` is set`$message->body``string`HTML if present, else plain text, else `''``$message->bodyType``?string``'html'`, `'text'`, or `null``$message->htmlBody``?string`HTML body when present`$message->textBody``?string`Plain-text body when present`$message->date``?string`Raw date string from the server, if any`$message->datetime``?DateTimeImmutable`Parsed date when available`$message->from``?EmailAddress`From address`$message->sender``?EmailAddress`Sender address`$message->preferredFrom``?EmailAddress`From if set, otherwise Sender`$message->to``EmailAddressList`
`[EmailAddress, ...]`To recipients`$message->cc``EmailAddressList`
`[EmailAddress, ...]`Cc recipients`$message->bcc``EmailAddressList`
`[EmailAddress, ...]`Bcc recipients`$message->replyTo``EmailAddressList`
`[EmailAddress, ...]`Reply-To addresses`$message->unseen``bool`Unseen (`\Seen` not set)`$message->recent``bool`Recent flag`$message->flagged``bool`Flagged`$message->answered``bool`Answered`$message->deleted``bool`Deleted`$message->draft``bool`Draft`$message->size``?int`Message size in bytes when known`$message->inReplyTo``?string`In-Reply-To header`$message->references``?string`References header`$message->returnPath``?string`Return-Path header`$message->rawHeaders``?string`Raw header block when fetched`$message->attachments``list`
`[EmailAttachment, ...]`File attachments`$message->inlineAttachments``list`
`[EmailAttachment, ...]`Inline MIME parts`$message->skippedParts``list`
`[EmailSkippedPart, ...]`Parts omitted because of size limitsEmailAddress
------------

[](#emailaddress)

```
$from = $message->preferredFrom;
echo $from?->display;  // "Alex " or "alex@example.com"
echo $from?->address;  // alex@example.com
echo $from?->name;     // Alex (or null)
```

PropertyTypeDescription`$address->name``?string`Display name when present`$address->address``string`Bare email address`$address->display``string``"Name "` when named, otherwise just the emailEmailAddressList
----------------

[](#emailaddresslist)

Used by `$message->to`, `cc`, `bcc`, and `replyTo`.

```
foreach ($message->to as $recipient) {
    echo $recipient->display;
}
$emailsOnly = $message->cc->bareAddresses;
$first = $message->replyTo->first();
```

Property / methodTypeDescription`$list->bareAddresses``list`
`['a@example.com', ...]`Email addresses only`$list->count()``int`Number of addresses`$list->first()``?EmailAddress`First address, if any`$list->isEmpty()``bool`Whether the list has no addresses`$list->all()``list`
`[EmailAddress, ...]`All addresses as an arrayEmailAttachment
---------------

[](#emailattachment)

File attachments and inline parts.

```
foreach ($message->attachments as $attachment) {
    file_put_contents($dir . '/' . $attachment->filename, $attachment->content);
    // $attachment->mimeType, $attachment->size, $attachment->isInline
}
```

PropertyTypeDescription`$attachment->filename``string`Filename`$attachment->mimeType``string`Full MIME type (e.g. `application/pdf`)`$attachment->type``string`MIME subtype (e.g. `pdf`)`$attachment->content``string`Decoded bytes (in memory)`$attachment->size``int`Decoded content size in bytes`$attachment->disposition``?string`Content-Disposition when present`$attachment->contentId``?string`Content-ID for inline parts when present`$attachment->partNumber``string`MIME part number`$attachment->isInline``bool`Whether the part is inlineEmailSkippedPart
----------------

[](#emailskippedpart)

Part omitted because of a size limit.

```
foreach ($message->skippedParts as $skip) {
    error_log("Skipped {$skip->kind} part {$skip->partNumber} on UID {$skip->uid} ({$skip->observedSize} > {$skip->limit})");
}
```

PropertyTypeDescription`$skip->uid``int`Message UID the part belonged to`$skip->partNumber``string`MIME part number`$skip->filename``?string`Filename when known`$skip->kind``string``'attachment'` or `'inline'``$skip->limit``int`Size limit that caused the skip`$skip->observedSize``int`Observed part size in bytesEmailBatchResult
----------------

[](#emailbatchresult)

Returned by `checkSinceCheckpoint`.

```
$result = $checker->checkSinceCheckpoint($checkpoint);
foreach ($result->messages as $message) {
    // handle...
}
if ($result->hasMore) {
    // fetch again with $result->checkpoint
}
```

PropertyTypeDescription`$result->messages``array`
`[uid => EmailMessage, ...]`Messages keyed by UID`$result->checkpoint``MailboxCheckpoint`Watermark to persist after a successful batch`$result->summary``EmailFetchSummary`Fetch counts and flags for this batch`$result->hasMore``bool`More mail exists beyond this batch limitEmailFetchSummary
-----------------

[](#emailfetchsummary)

```
if ($result->summary->uidValidityReset) {
    // handlers should tolerate re-delivery
}
echo $result->summary->returnedCount . ' of ' . $result->summary->candidateCount;
```

PropertyTypeDescription`$result->summary->candidateCount``int`Candidates found before applying the limit`$result->summary->returnedCount``int`Messages returned in this batch`$result->summary->truncated``bool`Candidate set larger than the applied limit`$result->summary->highestReturnedUid``?int`Highest UID returned, if any`$result->summary->uidValidityReset``bool`Incoming UIDVALIDITY did not match; sync restarted from UID 0`$result->summary->hasMore``bool`Same idea as `truncated`MailboxStatus
-------------

[](#mailboxstatus)

Returned by `checkMailboxStatus`.

```
$status = $checker->checkMailboxStatus();
echo "{$status->mailbox}: {$status->unseen} unseen of {$status->total}";
```

PropertyTypeDescription`$status->mailbox``string`Mailbox path/name reported by the server`$status->total``int`Total messages`$status->unseen``int`Unseen message count`$status->latestUid``?int`Latest UID when knownMailboxCheckpoint
-----------------

[](#mailboxcheckpoint)

```
// App helper (not in this library): load saved uidValidity + lastUid
$checkpoint = loadCheckpoint($accountId, 'INBOX') ?? MailboxCheckpoint::initial();
$result = $checker->checkSinceCheckpoint($checkpoint);
// After a successful batch:
// App helper (not in this library): persist uidValidity + lastUid
saveCheckpoint($accountId, 'INBOX', $result->checkpoint);
```

Property / methodTypeDescription`$checkpoint->uidValidity``?int`Mailbox UIDVALIDITY (`null` if unknown / first sync)`$checkpoint->lastUid``int`Last safely processed UID (`0` to start)`MailboxCheckpoint::initial()``MailboxCheckpoint`Convenience for `uidValidity: null`, `lastUid: 0`---

Sync, search, and mailbox actions
=================================

[](#sync-search-and-mailbox-actions)

```
// Status
$status = $checker->checkMailboxStatus();
// $status->total, $status->unseen, $status->latestUid, ...

// Search returns UIDs; fetch returns EmailMessage objects keyed by UID
$uids = $checker->searchUids('UNSEEN FROM "billing@example.com"'); // list
$messages = $checker->fetchMessagesByUids($uids); // array

foreach ($messages as $uid => $message) {
    echo $message->subject;
}

// Other convenience fetches (no checkpoint returned - fine for one-off jobs)
$unread = $checker->checkUnreadEmails(limit: 20); // array
$since = $checker->checkSinceDate(new DateTimeImmutable('2026-01-01'), limit: 50);

// Read/unread accepts multiple UIDs at once
$checker->setMessageReadStatus([$uid], markAsRead: true);
$checker->setMessageReadStatus([101, 102, 103], markAsRead: false);

// Delete / archive are one UID at a time
$checker->deleteEmail(uid: $uid, expunge: false);
$checker->archiveEmail(uid: $uid, archiveFolder: 'Archive', expunge: false);

// Expunge permanently removes messages already marked deleted in this mailbox
// (including ones you deferred with expunge: false above).
$checker->expunge();
$checker->close();
```

Search criteria
---------------

[](#search-criteria)

Criteria strings are standard IMAP search (see PHP [`imap_search()`](https://www.php.net/manual/en/function.imap-search.php)). Examples:

```
$checker->searchUids('UNSEEN');
$checker->searchUids('SINCE "1-Jan-2026" SUBJECT "invoice"');
$checker->searchUids('OR FLAGGED LARGER 1048576');
```

No matches returns an empty list. Prefer UIDs over sequence numbers when you store ids.

When to save the checkpoint
---------------------------

[](#when-to-save-the-checkpoint)

`checkSinceCheckpoint()` almost always returns a result object - it does not know whether *your* app finished its work.

**Save `$result->checkpoint` only when:**

1. You finished handling every message in `$result->messages` (DB inserts, side effects, etc.), and
2. That work succeeded (no exception / failed transaction you care about).

**Do not save when:** your loop throws, a DB transaction rolls back, or you bail out mid-batch. The next run will start from the previous checkpoint and may re-deliver some messages - keep handlers idempotent.

The library itself may leave `lastUid` unchanged if it hit a hard failure building a message in the batch (see `getLastProcessingFailures()`). Soft issues (for example an oversize attachment) still return the message and usually still advance the library watermark for that UID - your app save decision is separate.

Checkpoint rules that matter
----------------------------

[](#checkpoint-rules-that-matter)

- One checkpoint **per account + mailbox** - do not share across inboxes.
- Persist `$result->checkpoint` only after the **entire** batch is handled by your code.
- If the server's UIDVALIDITY no longer matches what you stored, sync restarts from the beginning and `$result->summary->uidValidityReset` is true - make your imports idempotent (for example with `$message->idempotencyKey($accountScope)` and/or Message-ID).

Delete, archive, and expunge
----------------------------

[](#delete-archive-and-expunge)

- `deleteEmail($uid)` marks that message deleted on the server. By default it also **expunges** immediately (`expunge: true`).
- `archiveEmail($uid, $folder)` moves the message, then expunges by default.
- Pass `expunge: false` to mark/move now and call `$checker->expunge()` once later (one expunge can cover several deferred deletes in the same mailbox).
- **Expunge** permanently removes messages that are marked deleted. It affects the whole selected mailbox, not only the last UID you touched.

---

Logging
=======

[](#logging)

By default the library is silent. Pass either `debug: true` or a PSR-3 `logger` into `connect()` / `fromClient()`:

```
// Quick: write library messages to PHP's error_log()
$checker = IMAPEmailChecker::connect(
    hostname: 'imap.example.com',
    username: $username,
    password: $password,
    debug: true,
);

// Production: hand in your app logger (Monolog, etc.)
$checker = IMAPEmailChecker::connect(
    hostname: 'imap.example.com',
    username: $username,
    password: $password,
    logger: $psr3Logger, // Psr\Log\LoggerInterface
);
```

What you passResultNothingSilent (`NullLogger`)`debug: true` and no `logger`Built-in `ErrorLogLogger` -&gt; PHP `error_log()`A PSR-3 `logger:`That logger is used; `debug` does not change which logger is chosenDebug mode is for local troubleshooting (warnings about UIDVALIDITY resets, failed message processing, MIME issues, and similar). With a logger or `debug: true`, each successfully built message also emits a debug record `Fetched IMAP message.` with `mailbox`, `uid_validity`, and `uid` (not account scope or the generated idempotency key). Prefer a real PSR-3 logger in apps. Sensitive context keys such as passwords and raw bodies are stripped from `error_log` output.

---

Exceptions
==========

[](#exceptions)

All package exceptions implement `IMAPEmailChecker\Exception\ExceptionInterface`. Catch that when you want every library failure in one place (as in [Quick start](#quick-start)).

ExceptionExtendsWhen`ExceptionInterface`(marker)Any package-specific failure`IMAPEmailCheckerException``RuntimeException`Base for operational failures`ConnectionException``IMAPEmailCheckerException`Connect / closed connection problems`MissingExtensionException``IMAPEmailCheckerException`Required PHP APIs or extensions missing`ImapOperationException``IMAPEmailCheckerException`An `imap_*` call failed (`$operation`, `$imapErrors`, `$context`)`MessageProcessingException``IMAPEmailCheckerException`Building a message from IMAP data failed hard`MimeDecodingException``IMAPEmailCheckerException`MIME / transfer decoding failure`InvalidConfigurationException``InvalidArgumentException`Bad options, empty criteria, invalid UIDs, etc.```
use IMAPEmailChecker\Exception\ExceptionInterface;
use IMAPEmailChecker\Exception\IMAPEmailCheckerException;
use IMAPEmailChecker\Exception\InvalidConfigurationException;
use IMAPEmailChecker\Exception\ImapOperationException;

try {
    // connect / fetch / search / ...
} catch (InvalidConfigurationException $e) {
    // Fix caller input (options, empty search string, bad UID list, ...)
    error_log('Bad IMAPEmailChecker usage: ' . $e->getMessage());
} catch (ImapOperationException $e) {
    // Server or protocol failure - $e->operation, $e->imapErrors
    error_log("IMAP {$e->operation} failed: " . $e->getMessage());
} catch (IMAPEmailCheckerException $e) {
    // Other operational failures (connection, MIME, processing, missing extension)
    error_log('IMAP sync failed: ' . $e->getMessage());
}

// Or one catch-all for anything from this library:
// } catch (ExceptionInterface $e) { ... }
```

Notes:

- Soft per-message problems during a batch are often logged and skipped rather than thrown; inspect `getLastProcessingFailures()` after a fetch.
- `InvalidConfigurationException` is not a subclass of `IMAPEmailCheckerException` (it extends `InvalidArgumentException`), but both implement `ExceptionInterface`.
- Catching `RuntimeException` / `InvalidArgumentException` still works for legacy-style handlers, but prefer the package types above.

---

Upgrading from 1.x / 2.0
========================

[](#upgrading-from-1x--20)

See [UPGRADING.md](UPGRADING.md) and [CHANGELOG.md](CHANGELOG.md). Require `^3.1` for this API (includes idempotency keys and `ImapMessageIdentity`).

---

Notes
=====

[](#notes)

- Sanitize HTML (`body` / `htmlBody`) before showing it in a browser - this library does not sanitize.
- Message bodies and attachments stay in memory on the objects you keep. Use `maxMessagesPerFetch`, process a batch, save the checkpoint, then drop the result before fetching more.

Custom IMAP clients (`fromClient`)
----------------------------------

[](#custom-imap-clients-fromclient)

Most apps should use `IMAPEmailChecker::connect(...)`, which opens a normal IMAP connection for you.

Use a custom `ImapClientInterface` when you need to:

- **Unit-test** without a real mail server (inject a fake client)
- **Reuse an existing connection** your app already opened
- **Swap the transport** (for example wrap another IMAP library) while still using this package’s parsing, checkpoints, and message DTOs

```
use IMAPEmailChecker\IMAPEmailChecker;
use IMAPEmailChecker\Imap\ImapClientInterface;

/** @var ImapClientInterface $client */
$checker = IMAPEmailChecker::fromClient($client);

try {
    $result = $checker->checkSinceCheckpoint($checkpoint);
    // ...
} finally {
    $checker->close(); // does NOT close an injected client - you still own $client
}
```

Implement every method on [`ImapClientInterface`](src/Imap/ImapClientInterface.php). Use [`NativeImapClient`](src/Imap/NativeImapClient.php) as the reference implementation.

Important requirements for custom adapters:

- `selectedMailbox()` must return the **logical** mailbox name (e.g. `INBOX`, `Archive/2026`), not a `{host:port/flags}...` connection string. Normalize any case of inbox to exactly `INBOX`.
- `listUidsSince($fromUid)` must return UIDs `>= $fromUid` in ascending order using a strategy that works on your backend (see below).
- `getUidValidity()` must return the mailbox’s current UIDVALIDITY (used for checkpoints and `$message->imapIdentity`).

Native ext-imap vs polyfill (UID discovery)
-------------------------------------------

[](#native-ext-imap-vs-polyfill-uid-discovery)

`checkSinceCheckpoint()` lists new UIDs via `ImapClientInterface::listUidsSince()`:

BackendHow candidates are foundWhyPECL `ext-imap` loaded`imap_fetch_overview('N:*', FT_UID)`UW c-client does not accept `UID` in `imap_search` criteriaPolyfill onlyIMAP `SEARCH UID N:*`Polyfill overview expands UID ranges against message count (EXISTS), which is wrong for high UIDsFacade callers do not choose this; it is automatic. Custom `ImapClientInterface` implementations must provide `listUidsSince()` (see [UPGRADING.md](UPGRADING.md#upgrading-from-300-to-301)).

Composer IMAP polyfill and Seen flags
-------------------------------------

[](#composer-imap-polyfill-and-seen-flags)

Native `ext-imap` fetches body parts with `FT_PEEK`, so reading a message does not mark it read.

Under [fain182/ext-imap-polyfill](https://github.com/fain182/ext-imap-polyfill), `FT_PEEK` is unreliable today (webklex response matching; see [webklex/php-imap#625](https://github.com/Webklex/php-imap/pull/625)). This library approximates peek by fetching without `FT_PEEK`, then clearing the Seen flag again if the message was unseen beforehand.

Side effects while a body is loading: the message may briefly appear read; if the restore fails, it can stay read (a warning is logged). This applies only when the polyfill is active (not when PECL `ext-imap` is loaded), and only for calls that download MIME body parts or attachments:

- `checkSinceCheckpoint()`
- `checkAllEmails()` / `checkSinceDate()` / `checkUnreadEmails()`
- `fetchMessagesByUids()` / `fetchMessagesBySequenceNumbers()`

Not affected: `checkMailboxStatus()`, `searchUids()` / `searchSequenceNumbers()`, `setMessageReadStatus()`, `deleteEmail()`, `archiveEmail()`, `expunge()`.

---

License
=======

[](#license)

MIT - see [LICENSE.md](LICENSE.md).

###  Health Score

48

—

FairBetter than 94% of packages

Maintenance98

Actively maintained with recent releases

Popularity13

Limited adoption so far

Community7

Small or concentrated contributor base

Maturity61

Established project with proven stability

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~66 days

Recently: every ~104 days

Total

8

Last Release

13d ago

Major Versions

v1.2.1 → v2.0.02025-06-14

v2.0.0 → v3.0.02026-08-03

PHP version history (2 changes)v1.1PHP ^8.0

v3.0.0PHP ^8.4

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/166399989?v=4)[thingmabobby](/maintainers/thingmabobby)[@thingmabobby](https://github.com/thingmabobby)

---

Top Contributors

[![thingmabobby](https://avatars.githubusercontent.com/u/166399989?v=4)](https://github.com/thingmabobby "thingmabobby (56 commits)")

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/thingmabobby-imap-email-checker/health.svg)

```
[![Health](https://phpackages.com/badges/thingmabobby-imap-email-checker/health.svg)](https://phpackages.com/packages/thingmabobby-imap-email-checker)
```

###  Alternatives

[symfony/http-kernel

Provides a structured process for converting a Request into a Response

8.1k886.6M9.7k](/packages/symfony-http-kernel)[symfony/http-client

Provides powerful methods to fetch HTTP resources synchronously or asynchronously

2.0k347.8M5.6k](/packages/symfony-http-client)[zircote/swagger-php

Generate interactive documentation for your RESTful API using PHP attributes (preferred) or PHPDoc annotations

5.3k148.6M671](/packages/zircote-swagger-php)[matomo/matomo

Matomo is the leading Free/Libre open analytics platform

21.7k39.6k](/packages/matomo-matomo)[api-platform/metadata

API Resource-oriented metadata attributes and factories

275.5M254](/packages/api-platform-metadata)[tempest/framework

The PHP framework that gets out of your way.

2.3k37.6k21](/packages/tempest-framework)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
