laikait / laika-mailman
Mail package for the Laika PHP MVC Framework — send via PHPMailer (SMTP/sendmail/mail/qmail), read via built-in IMAP and POP3 clients.
Requires
- php: ^8.1
- ext-mbstring: *
- ext-openssl: *
- phpmailer/phpmailer: ^7.1
Suggests
- league/oauth2-client: Required only if you use PHPMailer's bundled OAuth class for XOAUTH2 — implementing OAuthTokenProvider yourself needs nothing
README
Mail package for the Laika PHP MVC Framework. Sending is a fluent wrapper over PHPMailer (SMTP, sendmail, qmail, mail()); reading is a pair of IMAP and POP3 clients written for this package, because PHPMailer cannot read mail at all.
Standalone: it depends on PHPMailer and nothing else. No framework core, no ORM, no ext-imap.
Install
composer require laikait/laika-mailman
Sending
use Laika\Mailman\Mailer; $mailer = new Mailer([ 'driver' => 'smtp', 'host' => 'smtp.gmail.com', 'port' => 587, 'username' => 'you@gmail.com', 'password' => 'app-password', 'encryption' => 'tls', 'from' => 'you@gmail.com', 'from_name' => 'Your App', ]); $mailer->to('someone@example.com', 'Someone') ->subject('Monthly report') ->body('<h1>Report</h1><p>Attached.</p>', 'Report — attached.') ->attach('/path/to/report.pdf') ->send();
Every build method returns $this. body() takes the HTML first and a plaintext alternative second — supplying a real plaintext version rather than letting it be auto-generated from the HTML scores measurably better with spam filters.
| Method | Notes |
|---|---|
from() to() cc() bcc() replyTo() |
Second argument is the display name |
subject() |
|
body($html, $plain = '') / html() |
AltBody is auto-generated when $plain is omitted |
text() |
Plaintext-only message |
attach($path, $name = '') |
From disk |
attachData($bytes, $filename, $mime = '') |
Bytes you already hold, no temp file |
embed($path, $cid) |
Reference it as <img src="cid:$cid"> |
priority() header() |
|
xmailer() |
Set, replace or remove X-Mailer — see below |
send() |
Returns bool, throws on failure |
reset() |
Clears recipients/attachments/body so the instance can send another |
lastError() |
PHPMailer's ErrorInfo |
phpMailer() |
Escape hatch to the underlying PHPMailer instance |
Bulk sending
sendMany() reuses one authenticated SMTP connection across messages, which is the difference that matters at volume — reconnecting per message is slow, and several providers rate-limit connections rather than messages.
$results = $mailer->sendMany([ fn (Mailer $m) => $m->to('a@example.com')->subject('Hi')->text('One.'), fn (Mailer $m) => $m->to('b@example.com')->subject('Hi')->text('Two.'), ]); // [0 => true, 1 => true] — one failure doesn't abort the rest of the batch
DSN
$mailer = Mailer::fromDsn('smtp://user:pass@smtp.example.com:587?encryption=tls');
This builds the PHPMailer itself rather than using PHPMailer's bundled DSNConfigurator, which has a genuine bug: for the smtps:// scheme it sets SMTPSecure to STARTTLS ('tls') while defaulting the port to 465 (DSNConfigurator.php:140-144). 465 is the implicit-TLS port and needs 'ssl' — that pairing fails against most servers. Mailer::fromDsn() maps smtps:// to 'ssl' and port 465, as intended.
Percent-encode credentials containing @, : or /.
The X-Mailer header
Outbound mail carries X-Mailer: Laika Mailman by default — the sending software, with no version number. Left to PHPMailer, every message would instead advertise X-Mailer: PHPMailer 7.1.1 (https://github.com/PHPMailer/PHPMailer), telling each recipient exactly which release to look up in a vulnerability list. That is disclosure with no upside, so it is off unless you ask for it.
$mailer->xmailer('My App 2.0'); // X-Mailer: My App 2.0 $mailer->xmailer(Mailer::XMAILER_NONE); // no X-Mailer header at all $mailer->xmailer(Mailer::XMAILER_PHPMAILER); // PHPMailer's default, version and all
Also settable as the xmailer config key, or as a DSN query parameter: smtp://…?xmailer=My+App.
Use the constants rather than a bare null or ''. PHPMailer's underlying property is three-way and the states are the opposite of the obvious reading — '' means "use the default", and null or whitespace means "no header" (PHPMailer.php:2918-2927). Setting XMailer = '' to suppress the header gets you the loudest possible value instead. Mailer::normalizeXMailer() wraps that so call sites say what they mean; CR/LF are stripped there too, so a value carrying a newline cannot inject a second header.
UTF-8
CharSet is set to UTF-8 explicitly. PHPMailer 7 still defaults to ISO-8859-1 (PHPMailer.php:78), so without this every non-ASCII subject and body arrives as mojibake. Override with the charset config key if you actually want something else.
Reading
PHPMailer contributes nothing to this half. It is a send-only library: its POP3.php is a POP-before-SMTP authentication helper that sends only USER/PASS/QUIT, disconnects the instant login succeeds, and reads responses one 128-byte line at a time — so it cannot handle the multi-line responses that RETR, LIST and UIDL produce, and there is no IMAP client anywhere in the package. ImapReader and Pop3Reader are therefore real protocol clients written here, over raw sockets.
They also avoid ext-imap deliberately: it was deprecated in PHP 8.4 and moved to PECL, and it is absent on a lot of shared hosting.
IMAP
use Laika\Mailman\Reader\ImapReader; $reader = new ImapReader([ 'host' => 'imap.gmail.com', 'port' => 993, 'encryption' => 'ssl', 'username' => 'you@gmail.com', 'password' => 'app-password', 'folder' => 'INBOX', ]); $reader->connect(); foreach ($reader->mailboxes() as $mailbox) { echo $mailbox->name, "\n"; } $uids = $reader->search([ 'unseen' => true, 'since' => new DateTimeImmutable('-7 days'), 'from' => 'billing@vendor.com', ]); foreach ($uids as $uid) { $message = $reader->fetch($uid); echo $message->subject, ' — ', $message->fromAddress(), "\n"; foreach ($message->files() as $attachment) { $attachment->saveTo('/var/mail-attachments'); } } $reader->disconnect();
fetch() uses BODY.PEEK[], not BODY[] — the latter sets \Seen as a side effect, and marking mail read merely by looking at it surprises callers. Use markSeen() when you mean it.
Search criteria: unseen seen flagged unflagged answered deleted draft recent all (bool); from to cc bcc subject body text keyword (string); since before on sentsince sentbefore senton (DateTimeInterface or anything strtotime() accepts). Searching happens on the server.
IMAP-only methods beyond the shared interface: select(), mailboxes(), search(), markSeen(), markUnseen(), flag(), unflag(), move(), copy(), capabilities(), hasCapability(), mailbox().
move() uses UID MOVE where the server advertises the capability and falls back to UID COPY + \Deleted where it doesn't.
POP3
use Laika\Mailman\Reader\Pop3Reader; $reader = new Pop3Reader([ 'host' => 'pop.gmail.com', 'port' => 995, 'encryption' => 'ssl', 'username' => 'you@gmail.com', 'password' => 'app-password', ]); $reader->connect(); echo $reader->count(), " messages\n"; // TOP n 0 — headers only, far cheaper than RETR for building a listing foreach (array_keys($reader->listing()) as $number) { echo $reader->headers($number)->subject, "\n"; } $message = $reader->fetch(1); // full RETR $reader->disconnect();
POP3-only methods: listing(), uidl(), headers(), size(), reset().
Message numbers are only stable within a session — they are reassigned after any expunge. Persist uidl() values instead if an id has to survive a reconnect.
Choosing a reader
ImapReader |
Pop3Reader |
|
|---|---|---|
| Folders | Yes | No — one implicit mailbox |
| Server-side search | Yes | No |
Flags (\Seen, \Flagged) |
Yes | No |
| Headers without the body | Via BODY.PEEK[HEADER] |
Via TOP n 0 |
| Stable ids across sessions | UIDs | uidl() only |
| Delete semantics | \Deleted then expunge(), stays connected |
DELE then expunge(), which ends the session |
Prefer IMAP unless the mailbox genuinely only speaks POP3. MailReaderInterface covers only what both can honestly do — connect(), disconnect(), count(), fetch(), all(), delete(), expunge(). Folders, search and flags are concrete methods on ImapReader rather than interface members, because a POP3 "search" that downloads the whole mailbox and filters locally would make the two look interchangeable when they are not.
Deleting
Both protocols are two-phase, but they differ in a way worth knowing:
- IMAP —
delete($uid)sets\Deleted;expunge()commits and you stay connected.unflag($uid, '\Deleted')undoes it. - POP3 —
delete($n)sendsDELE; deletions are only applied when the session enters the UPDATE state, which happens onQUIT. Soexpunge()commits and ends the session (the next call reconnects).disconnect()deliberately closes withoutQUIT, which discards pending deletions, andreset()sendsRSETto drop them without disconnecting.
Messages
fetch() returns a Message with everything already decoded to UTF-8 — RFC 2047 headers unwrapped, bodies base64/quoted-printable decoded and charset-converted.
$message->subject; // string, decoded $message->from; // ['email' => ..., 'name' => ...] $message->to; // list of the same $message->date; // ?DateTimeImmutable $message->textBody; // text/plain part $message->htmlBody; // text/html part $message->body(); // htmlBody if present, else textBody $message->attachments; // Attachment[], including inline cid: parts $message->files(); // Attachment[], excluding inline parts $message->flags; // IMAP only $message->header('x-spam-score');
$message->body() is not sanitized. Inbound HTML is attacker-controlled — run it through your own sanitizer before putting it in a page.
Attachment::saveTo() accepts a file path or a directory. Given a directory it appends safeFilename(), which strips path traversal and NUL bytes from the sender-supplied name — that name is untrusted input.
Scanning incoming mail
Pipeline scans inbound mail for identifiers — ticket numbers, invoice numbers, order references — and routes each message to your handlers. The job it exists for: a support inbox where a reply carrying [TICKET-1234] must append to ticket 1234 rather than open a new one.
use Laika\Mailman\Pipeline\Pipeline; use Laika\Mailman\Pipeline\ScanResult; use Laika\Mailman\Pipeline\Source\ImapSource; $pipeline = (new Pipeline()) ->onIdentifier('ticket', fn (ScanResult $r) => $tickets->appendReply($r->first('ticket'), $r->message)) ->onIdentifier('invoice', fn (ScanResult $r) => $billing->attach($r->first('invoice'), $r->message)) ->otherwise(fn (ScanResult $r) => $tickets->open($r->message)) ->afterProcessing(Pipeline::MOVE, 'Processed'); $stats = $pipeline->run(new ImapSource($reader, ['unseen' => true])); // ['scanned' => 12, 'handled' => 10, 'unmatched' => 2, 'failed' => 0]
Where messages come from
| Source | Use |
|---|---|
ImapSource |
Poll a mailbox from cron. Supports every post-action. |
Pop3Source |
Same, for servers that only speak POP3. DELETE/LEAVE only. |
StdinSource |
The MTA pipes the raw message to your script at delivery. |
RawSource |
Raw strings or .eml files — the testing seam. |
Polling needs no server access; piping is lower latency but needs an MTA you control. Both feed identical rules and handlers, so you can develop against IMAP and deploy behind a pipe without changing anything but the source.
For a mail pipe, register examples/pipeline-stdin.php in /etc/aliases (support: "|/usr/bin/php /path/to/pipeline-stdin.php") or cPanel's "Pipe to a Program". Your script must exit 0 even when a handler fails — the MTA reads the exit code, and a non-zero exit bounces the mail back to the sender.
What gets extracted
Built-in rules, all overridable, all active by default:
| Name | Matches |
|---|---|
ticket |
[#1234], [TICKET-1234], TKT-1234, Ticket #1234, CASE 1234 |
invoice |
INV-2024-0087, Invoice #1234, INV#1234 |
order |
Order #1234, ORD-1234, PO-9001 |
reference |
Ref: ABC-123, Reference: 12345 |
Register your own, or replace a preset by re-using its name. withoutPresets() drops them entirely.
$pipeline->extract('customer', '/\bCUST-(\d{4,})/i') ->extract('ticket', '/\bSUP-(\d+)/i', [Extractor::SUBJECT]); // replaces the preset
The third argument restricts where a rule may match — Extractor::SUBJECT, BODY, HEADERS, RECIPIENTS. Scoping is usually more effective than tightening the pattern: an invoice regex loose enough to catch real invoice numbers will also match a reference in a signature block, and confining it to the subject line fixes that without making the pattern brittle. An invalid regex throws at registration rather than silently never matching.
Confidence
The same identifier can appear in several places, and they are not equally trustworthy. Results are ordered by tier, so first() returns the best one:
| Tier | Source | Why |
|---|---|---|
header |
X-Ticket-ID: 5150 |
You set it on the way out. Taken verbatim, not regex-matched. |
recipient |
support+ticket-1234@example.com |
Generated by your own Reply-To; a client cannot strip it without breaking delivery. |
subject |
[TICKET-1234] … |
Conventional, but a human can edit it. |
body |
anywhere in the text | Least reliable — prose and signatures live here. |
$r->first('ticket'); // '5150' — best available $r->all('ticket'); // ['5150', '991', '1234'] — every hit, best first $r->confidenceOf('ticket'); // 'header' $r->isTrusted('ticket'); // true for header/recipient only
isTrusted() is the hook for treating a body-only match differently — queue it for review rather than auto-filing it.
Replies without an identifier
A user who strips the subject tag still sends In-Reply-To. $r->threadIds collects the Message-ID, In-Reply-To and References values so the thread can be recovered when no regex matched:
->otherwise(function (ScanResult $r) use ($tickets) { if ($r->isReply() && $t = $tickets->findByMessageIds($r->threadIds)) { return $t->appendReply($r->message); } $tickets->open($r->message); })
Quoted history is not scanned
Body extraction ignores quoted reply history by default — > lines, On … wrote: attributions, -----Original Message-----, and the -- signature delimiter. Without this, every reply re-matches every identifier ever mentioned in the thread: a conversation that started as ticket 1111 and now mentions ticket 2222 would yield both, with no principled way to choose, and would file against the wrong one roughly half the time.
scanQuotedText(true) opts back in. stripQuoted() is protected and overridable for mail systems with their own quoting conventions.
Retries and idempotency
The post-action is applied after handlers succeed, never on sight:
| Action | Effect |
|---|---|
Pipeline::MARK_SEEN |
Mark \Seen (default). Pairs with an ['unseen' => true] search so the mailbox itself is the state — no storage. |
Pipeline::MOVE |
Mark \Seen, then move to a folder. |
Pipeline::DELETE |
Mark deleted; committed when the run finishes. |
Pipeline::LEAVE |
Nothing — you are tracking processed mail yourself. |
A handler that throws leaves its message untouched, so the next run picks it up again. onError() receives the exception and the message, the run continues to the next message, and failed is incremented. That is the entire retry story — no queue, no state file.
Pop3Source rejects MARK_SEEN and MOVE rather than accepting them and doing nothing: POP3 has no flags or folders, and silently ignoring the action would reprocess the same mail forever. Use DELETE, or LEAVE with your own bookkeeping keyed on Pop3Reader::uidl().
Testing your rules
scan() is public and side-effect free — no source, no mailbox, no handlers:
$result = $pipeline->scan(MimeParser::parseMessage($rawEmail)); assert($result->first('ticket') === '1234');
RawSource runs the whole pipeline over in-memory messages and records what complete() would have done, so post-actions can be asserted without a live server.
Configuration
Both readers accept a config array or a DSN.
| Key | Mailer |
ImapReader |
Pop3Reader |
|---|---|---|---|
host |
localhost |
localhost |
localhost |
port |
587 |
993 |
995 |
encryption |
tls |
ssl |
ssl |
username / password |
'' |
'' |
'' |
timeout |
30 |
30 |
30 |
validate_cert |
true |
true |
true |
driver |
smtp |
— | — |
from / from_name |
'' |
— | — |
charset |
UTF-8 |
— | — |
xmailer |
Laika Mailman |
— | — |
debug / keepalive / auto_tls |
0 / false / true |
— | — |
folder |
— | INBOX |
— |
encryption values:
'ssl'— implicit TLS from the first byte (SMTP 465, IMAP 993, POP3 995)'tls'— connect in the clear, then upgrade (SMTP STARTTLS, IMAP STARTTLS, POP3 STLS; ports 587, 143, 110)''— no encryption
DSN forms: smtp://, smtps://, imap://, imaps://, pop3://, pop3s://, each accepting ?encryption=, ?timeout= and ?validate_cert=.
Setting validate_cert to false accepts any certificate, including an attacker's. Only do it against a dev server you control.
OAuth (XOAUTH2)
$mailer->oauth($yourTokenProvider);
$yourTokenProvider implements PHPMailer\PHPMailer\OAuthTokenProvider, which is a single method — getOauth64(), returning base64("user=<email>\1auth=Bearer <token>\1\1"). PHPMailer's own bundled OAuth class needs league/oauth2-client, but you do not have to use it: implementing the one-method interface yourself keeps the dependency out entirely.
Exceptions
Everything throws a Laika\Mailman\Exceptions\MailmanException or one of three subclasses, so there is one hierarchy to catch — PHPMailer's own exception is translated rather than leaking through.
| Exception | Means |
|---|---|
TransportException |
Connect refused, TLS handshake failed, read timeout, peer hung up |
AuthenticationException |
Credentials rejected (IMAP NO on LOGIN, POP3 -ERR on PASS, SMTP AUTH refused) |
ProtocolException |
Connection fine, server refused the command (IMAP NO/BAD, POP3 -ERR) — carries the server's own text |
MailmanException |
Base; anything else |
Credentials are redacted from exception messages, so a LOGIN/PASS failure will not put a password into a log or a stack trace.
Security notes
- Everything interpolated into an IMAP command is quoted, and CR/LF/NUL are stripped first. A folder name or search term carrying a newline would otherwise terminate the command and inject a second one — this is command injection, not a formatting concern. POP3 has no quoting at all, so credentials are stripped the same way.
- TLS peer verification is on by default in both the readers and the mailer.
STARTTLS/STLSupgrades happen before any credential is sent, and IMAP capabilities are re-read after the upgrade because pre-upgrade capabilities can be tampered with.- Attachment filenames from the sender are untrusted; use
safeFilename()(whichsaveTo()does for you when given a directory) rather than the rawfilename.
Known Gaps
- The IMAP client is not a complete RFC 3501 implementation. No
IDLE(so no push; poll instead), no server-sideSORT/THREAD, noCOMPRESS, noNTLM/GSSAPIauthentication.LOGINandXOAUTH2are what's supported. - MIME parsing covers what real mail actually contains, but nested
message/rfc822parts are surfaced as raw attachments rather than recursively parsed into their ownMessage. - POP3
all()andlisting()fetch per message; there is no server-side paging in the protocol, so a large mailbox is genuinely slow. The$limit/$offsetslice is applied to the message-number list before anyRETR, so at least it doesn't download more than you asked for. - No connection pooling and no automatic retry. A dropped connection is reopened on the next call, but an in-flight command fails.
- Pipeline extraction is heuristic. The presets match the conventional formats, but a
body-tier hit is a guess about prose — checkisTrusted()before auto-filing anything consequential. Custom headers and plus-addressing are the signals worth relying on, because your own system generates them; a subject tag is a convention a user can break, and a body match is pattern-matching on English. Pipelineprocesses messages one at a time in a single process. There is no concurrency and no locking, so two pipeline runs against the same mailbox can both pick up the same message before either marks it. Run one at a time per mailbox, or make your handlers idempotent.- There is no test suite in the repository. The parser and both protocol clients were verified against fixture messages and scripted fake servers during development; that harness is not committed.
Requirements
- PHP 8.1+
phpmailer/phpmailer^7.1 — sending onlyext-openssl— TLS for SMTP, IMAP and POP3ext-mbstring— charset conversion in the MIME parser
ext-imap is deliberately not required.
License
MIT