phore / mail-client
Immutable email values and a safe IMAP client with reviewable drafts.
Requires
- php: >=8.5
- ext-dom: *
- ext-fileinfo: *
- ext-iconv: *
- ext-json: *
- ext-mbstring: *
- ext-openssl: *
- ext-zip: *
- phore/markdown: dev-main
- webklex/php-imap: ~6.2.0
Requires (Dev)
- phpunit/phpunit: ^13.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-11 16:19:38 UTC
README
PHP 8.5+ IMAP library with immutable Email content and explicit server operations.
Install with composer require phore/mail-client. Requires phore/markdown and
Webklex 6.2.0's pure PHP transport; ext-imap is not used.
Mailbox configuration from a file
The complete JSON reference tree documents every field, its type, required status, default (where defined), example and meaning. It also documents the credential exclusivity rule. This is documentation, not a loadable mailbox file: actual mailbox files use the flat structure below and contain exactly one credential option.
Maintain the JSON reference manually whenever mailbox fields, types, defaults, validation or credential resolution change, as required by AGENTS.md. Update it in the same change as the implementation.
Store one mailbox in a local mailbox.json. JSON works with the existing PHP
requirements, without an additional parser dependency:
{
"host": "imap.example.org",
"username": "support@example.org",
"passwordFromSecretName": "SUPPORT_MAIL_PASSWORD",
"from": "Support <support@example.org>",
"mode": "manual"
}
use Phore\MailClient\MailboxConfig; $config = MailboxConfig::fromFile('/etc/my-app/mailbox.json'); // offline validation $client = $config->connect(); // resolve password and connect with verified TLS
Provide SUPPORT_MAIL_PASSWORD in the process environment or mount a file named
/var/run/secrets/SUPPORT_MAIL_PASSWORD. With passwordFromSecretName, the configuration stores only the name.
The environment takes precedence; only an unset variable falls back to the
file. An empty variable or file raises an exception. One trailing LF or CRLF is
removed from file values; all other whitespace and environment values are preserved.
Missing/unreadable secrets fail before any connection. Named secrets are resolved anew
on every connect() and are never cached in the config object.
Alternatively, supply the literal password directly:
{
"host": "imap.example.org",
"username": "support@example.org",
"password": "example-literal-password",
"mode": "manual"
}
password is used exactly as supplied, without trimming or environment lookup.
Literal passwords are held privately in PHP's SensitiveParameterValue, which
redacts ordinary object dumps and prevents serialization of that credential.
This does not encrypt the original config file: it contains the supplied plaintext.
| Setting | Required / default |
|---|---|
host |
Required, IMAP hostname without a URL scheme |
username |
Required, nonempty login name |
password |
Literal nonempty password string; mutually exclusive with passwordFromSecretName |
passwordFromSecretName |
Alternative to password; name beginning with a letter or underscore, followed by letters, digits, _, - or . |
port |
Integer, 1–65535; defaults to 993 |
draftsFolder / trashFolder |
Exact nonempty names; default Drafts / Trash |
from |
Optional address string, default null |
mode |
automatic (default, same as MailClient::connect) or manual |
Exactly one of password or passwordFromSecretName must be present. Both, neither,
null/empty credentials, unknown fields, invalid types and path-like secret names
are rejected. The previous draft field passwordSecret is no longer accepted. Config files cannot disable TLS or certificate verification.
The example explicitly uses manual to avoid automatic flag changes.
For other secret mount locations, use
$config->connect(secretsDirectory: '/run/secrets'). This is an application option,
not a path supplied by the config file. Mounted secret-file symlinks are supported.
For existing YAML-based applications, pass your parser's associative array to
MailboxConfig::fromArray($settings); fromFile() itself accepts JSON only.
The config's properties are read-only. Existing template/signature objects can be
passed through $config->connect(messageDefaults: $defaults); they are not stored
in the file. Direct MailClient::connect(...) remains available.
The connection example supports
MAIL_CONFIG_FILE=/etc/my-app/mailbox.json; when set, the file supplies all mailbox
settings instead of the example's individual MAIL_IMAP_*, MAIL_FROM_* and
MAIL_MODE variables.
Automatic and manual flags
MailClient::connect(...) defaults to mode: 'automatic'. Use mode: 'manual'
for explicit flag management. Mode changes affect future operations only:
$client->setAutomaticMode(false); // all automatic flags off $client->setAutomaticMode(true); // all on; resets overrides $client->setAutomaticMode(false, 'seen'); // only automatic Seen off $client->setAutomaticMode(false, 'answered'); $client->setAutomaticMode(true, 'forwarded'); // can also enable one in manual mode $enabled = $client->isAutomatic('seen');
| Action | Automatic behavior |
|---|---|
get() / listNew() |
Mark successfully read messages \Seen |
| Save a derived reply/reply-all draft | Mark its server-side source \Answered after successful storage |
| Save a derived forward draft | Mark its server-side source $Forwarded after successful storage |
| Local composition, internal reads, attachment access | No automatic flags |
Explicit markRead, markUnread, markAnswered, markForwarded, addFlag,
removeFlag remain available in both modes. Automatic Answered/Forwarded means
a draft has been saved, not that mail was sent. Disable these actions if your
workflow reserves the flags for actual sending. Saving and setting the source flag
are separate IMAP operations: if the latter fails, the exception names the saved
draft ID; retry the same Email to finish without duplicating the draft.
Examples
- Connection and mode
- Address values
- Compose a draft
- Templates and signatures
- Incremental reading
- Reply/reply-all
- Forward
- Flags and trash
Set the MAIL_IMAP_* environment variables shown in the connection example.
MAIL_MODE=manual makes the examples read-only until an explicit write is requested.
No sending API is exposed. Trash requires native MOVE plus UIDPLUS, with no delete
or EXPUNGE fallback. Inbox is INBOX; configure the exact Drafts and Trash names.
TLS is always implicit and certificate-verified (default port 993).
Email values retain their Message-ID through local edits. Server IDs and cursors
encode account, folder, UIDVALIDITY and UID; stale/foreign references fail. Store
nextCursor only after processing a whole batch. New means after cursor, not unread.
Attachment metadata is fetched without binary content; access is explicit and bounded.
Source HTML stays untrusted. Markdown rendering does not fetch remote content.
Repeated sequential saveDraft() calls with identical resolved content reuse the
same draft; changed content under that Message-ID is a conflict. The library verifies
stored content, not a claimed hash header. IMAP has no atomic unique-Message-ID
constraint: serialize concurrent writes of the same Email in your application.
Missing/ambiguous APPEND results fail explicitly; retry searches before appending.
Attachment/body limits are 25 MB, headers 256 KB, and MIME nesting 20 levels/200 parts.
Group/obsolete address syntax is rejected. Quoted signatures are retained as content;
complex incoming HTML is quoted through its plain-text view, without active markup.
Tests
composer test runs local unit tests. PHORE_TEST_IMAP=1 composer test additionally
runs integration tests against the disposable local Dovecot created by
bash test/Integration/dovecot.sh (Linux, sudo). CI checks PHP syntax, the offline
address example, MIME round trips, retries/conflicts, cursors, automatic/manual flags,
trash and TLS hostname rejection. No personal mailbox is used by these tests.
The separate WEB.DE read-only integration workflow uses GitHub secrets EMAIL
and EMAIL_PASSWD. It verifies TLS/authentication, opens INBOX with EXAMINE, searches
UIDs and reads at most the three newest messages through get() and cursor pagination.
It checks manual mode, per-action Seen suppression and unchanged persistent flags.
Attachment descriptors are checked without downloading attachment content. A test
transport guard rejects all write operations. No mail content, addresses, identifiers,
server responses or exception traces are logged or uploaded as artifacts. An empty
INBOX passes connection/cursor checks and explicitly skips message-dependent checks.
Concurrent deletion or flag changes by another client can fail the live assertions.
The same read-only probe is exercised against seeded Dovecot in normal CI, which also
covers attachment byte limits and failed APPEND without automatic source flags.
The provider workflow runs on relevant source/dependency/test changes on main and the
test/expand-imap-provider-coverage branch, and supports manual dispatch. It is separate
from ordinary PR tests and never exposes secrets to fork pull requests.
WEB.DE must have IMAP access enabled; two-factor accounts may need an app password.
See WEB.DE's server settings.
Provider rendering in Thunderbird/Outlook and arbitrary provider interoperability are not claimed by the local Dovecot tests.