thingmabobby/imap-email-checker

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

Maintainers

Package info

github.com/thingmabobby/IMAPEmailChecker

pkg:composer/thingmabobby/imap-email-checker

Transparency log

Statistics

Installs: 17

Dependents: 0

Suggesters: 0

Stars: 7

Open Issues: 0

v3.1.1 2026-08-05 14:21 UTC

This package is auto-updated.

Last update: 2026-08-05 14:26:39 UTC


README

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

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, then try the common use cases and examples/3.0/.

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 (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

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

Method Returns Notes
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<int, EmailMessage>
[uid => EmailMessage, ...]
One-shot helpers; no persistable checkpoint
searchUids / searchSequenceNumbers list<int>
[1, 2, 3, ...]
IMAP search ids only
fetchMessagesByUids / fetchMessagesBySequenceNumbers array<int, EmailMessage>
[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() failures From the latest fetch/sync call

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
Option Default Purpose
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

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

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

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

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

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

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<int, EmailMessage>
    // App helper (not in this library): alert your ops channel / pager
    notifyOps("{$status->unseen} unread (showing " . count($unread) . ')');
}

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

Edit the $hostname / $username / $password placeholders in 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.

Script Shows
checkInboxStatus.php Counts and latest message id
checkImapSinceCheckpoint.php One incremental batch
checkImapSinceDate.php Mail since a date
checkImapAllEmail.php Paged loop until caught up

Older Packagist 2.0 samples (classic API) are kept under examples/2.0/ for reference only - see examples/README.md.

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

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 store Meaning
lastUid How far you got in this "edition" of the mailbox
uidValidity Which "edition" those UIDs belong to

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);
}
Step What to do
Connect Open the mailbox. Optional: pass a PSR-3 logger, or debug: true for simple error_log output.
Load checkpoint Read saved uidValidity + lastUid, or start with MailboxCheckpoint::initial().
Fetch checkSinceCheckpoint() returns the next messages after lastUid.
Handle Process every message in $result->messages before saving. If any of your writes fail, abort and do not save the checkpoint.
Save Persist $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.
Close Always call $checker->close() (a finally block is ideal).

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

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

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)

  • 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

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

EmailMessage

foreach ($result->messages as $uid => $message) {
    echo $message->subject;
    echo $message->preferredFrom?->display;
    echo $message->body;
    $key = $message->idempotencyKey($accountScope);
}
Property Type Description
$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>
[EmailAttachment, ...]
File attachments
$message->inlineAttachments list<EmailAttachment>
[EmailAttachment, ...]
Inline MIME parts
$message->skippedParts list<EmailSkippedPart>
[EmailSkippedPart, ...]
Parts omitted because of size limits

EmailAddress

$from = $message->preferredFrom;
echo $from?->display;  // "Alex <alex@example.com>" or "alex@example.com"
echo $from?->address;  // alex@example.com
echo $from?->name;     // Alex (or null)
Property Type Description
$address->name ?string Display name when present
$address->address string Bare email address
$address->display string "Name <email>" when named, otherwise just the email

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 / method Type Description
$list->bareAddresses list<string>
['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>
[EmailAddress, ...]
All addresses as an array

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
}
Property Type Description
$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 inline

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})");
}
Property Type Description
$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 bytes

EmailBatchResult

Returned by checkSinceCheckpoint.

$result = $checker->checkSinceCheckpoint($checkpoint);
foreach ($result->messages as $message) {
    // handle...
}
if ($result->hasMore) {
    // fetch again with $result->checkpoint
}
Property Type Description
$result->messages array<int, EmailMessage>
[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 limit

EmailFetchSummary

if ($result->summary->uidValidityReset) {
    // handlers should tolerate re-delivery
}
echo $result->summary->returnedCount . ' of ' . $result->summary->candidateCount;
Property Type Description
$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

Returned by checkMailboxStatus.

$status = $checker->checkMailboxStatus();
echo "{$status->mailbox}: {$status->unseen} unseen of {$status->total}";
Property Type Description
$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 known

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 / method Type Description
$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

// 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<int>
$messages = $checker->fetchMessagesByUids($uids); // array<int, EmailMessage>

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<int, EmailMessage>
$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

Criteria strings are standard IMAP search (see PHP imap_search()). 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

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

  • 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

  • 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

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 pass Result
Nothing Silent (NullLogger)
debug: true and no logger Built-in ErrorLogLogger -> PHP error_log()
A PSR-3 logger: That logger is used; debug does not change which logger is chosen

Debug 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

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

Exception Extends When
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

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

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)

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. Use NativeImapClient 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)

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

Backend How candidates are found Why
PECL ext-imap loaded imap_fetch_overview('N:*', FT_UID) UW c-client does not accept UID in imap_search criteria
Polyfill only IMAP SEARCH UID N:* Polyfill overview expands UID ranges against message count (EXISTS), which is wrong for high UIDs

Facade callers do not choose this; it is automatic. Custom ImapClientInterface implementations must provide listUidsSince() (see UPGRADING.md).

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, FT_PEEK is unreliable today (webklex response matching; see webklex/php-imap#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

MIT - see LICENSE.md.