thingmabobby / imap-email-checker
A PHP 8.4 library to fetch and process email from IMAP mailboxes, with safe incremental sync.
Requires
- php: ^8.4
- ext-mbstring: *
- ext-zip: *
- fain182/ext-imap-polyfill: ^0.3
- psr/log: ^1.1 || ^2.0 || ^3.0
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5
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
- Installation
- Quick start
- API overview
- Options
- Common use cases
- Simple polling walkthrough
- Working with messages
- Sync, search, and mailbox actions
- Logging
- Exceptions
- Upgrading from 1.x / 2.0
- Notes
- License
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
42and 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->messageIdmay 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:
- You finished handling every message in
$result->messages(DB inserts, side effects, etc.), and - 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->checkpointonly 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->uidValidityResetis 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: falseto 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. InvalidConfigurationExceptionis not a subclass ofIMAPEmailCheckerException(it extendsInvalidArgumentException), but both implementExceptionInterface.- Catching
RuntimeException/InvalidArgumentExceptionstill 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 exactlyINBOX.listUidsSince($fromUid)must return UIDs>= $fromUidin 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.