pop-mail splits into two halves that share nothing but a namespace. A Message is composed and handed to a
Mailer, which hands it to a transport — SMTP, a hosted API, or sendmail. A client, separately, reads an
existing mailbox. Composing is the common case, and the transport is the only part that changes between a
laptop and production.
composer require popphp/pop-mail
Sending a Message#
Three objects. Pop\Mail\Message is what you are sending, a transport is how it leaves, and Pop\Mail\Mailer
puts the two together.
use Pop\Mail\Mailer;
use Pop\Mail\Message;
use Pop\Mail\Transport\Sendmail;
$message = new Message('My Message Subject');
$message->setTo('you@domain.com');
$message->setFrom('me@domain.com');
$message->setBody('Hello World! This is a text body!');
$mailer = new Mailer(new Sendmail());
$mailer->send($message);
The subject goes in the constructor. Everything else is a setter, and every address field takes three shapes: a
plain string, an email => name array for a display name, or an array of several addresses.
use Pop\Mail\Message;
$message = new Message('My Message Subject');
$message->setTo(['you@domain.com' => 'Recipient Name']);
$message->setCc('cc@domain.com');
$message->setBcc(['bcc1@domain.com', 'bcc2@domain.com']);
$message->setFrom(['me@domain.com' => 'My Name']);
$message->setReplyTo('replies@domain.com');
$message->setSender('sender@domain.com');
$message->setReturnPath('bounces@domain.com');
$message->setBody('Hello World! This is a text body!');
To, CC, BCC, From, Reply-To, Sender and Return-Path are all there. A name given as an array key
renders as Recipient Name <you@domain.com>; a bare string renders as the address alone.
A Mailer takes a default From as its second argument, applied to any message that does not set one of its
own — which keeps a no-reply address in one place instead of on every message:
use Pop\Mail\Mailer;
use Pop\Mail\Transport\Sendmail;
$mailer = new Mailer(new Sendmail(), 'noreply@domain.com');
Before anything is sent, render() returns the complete MIME message as a string. It's the fastest way to see
what a recipient will actually receive, and it's worth reading once:
use Pop\Mail\Message;
$message = new Message('My Message Subject');
$message->setTo('you@domain.com');
$message->setFrom('me@domain.com');
$message->setBody('Hello World! This is a text body!');
echo $message->render();
Subject: My Message Subject
To: you@domain.com
From: me@domain.com
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=f911ed0f0afe4d85dce07f0a364d147c573ee572
This is a multi-part message in MIME format.
--f911ed0f0afe4d85dce07f0a364d147c573ee572
Content-Type: text/plain
Hello World! This is a text body!
--f911ed0f0afe4d85dce07f0a364d147c573ee572--
Even a plain text message comes out multipart/mixed with a single part. The boundary is generated once per
message object and stays put across repeated render() calls, so the only string that differs between two
otherwise identical messages is that one.
Attachments and HTML Bodies#
setBody() makes a plain-text message. For a message that carries both a text and an HTML version, use
addText() and addHtml() instead — mail clients pick whichever they prefer, and the text part is what a
screen reader or a plain-text client falls back to.
use Pop\Mail\Message;
$message = new Message('My Message Subject');
$message->setTo('you@domain.com');
$message->setFrom('me@domain.com');
$message->addText('Hello World! This is a text body!');
$message->addHtml('<html><body><h1>Hello World!</h1><p>This is an HTML body!</p></body></html>');
Adding both switches the rendered Content-Type from multipart/mixed to multipart/alternative, which is
what tells a client the two parts are versions of the same thing rather than two separate things. Send only the
HTML part and some clients show nothing at all — write both.
attachFile() attaches a file from disk:
use Pop\Mail\Message;
$message = new Message('Monthly report');
$message->setTo('you@domain.com');
$message->setFrom('me@domain.com');
$message->setBody('See attached.');
$message->attachFile(__DIR__ . '/report.csv');
The filename in the message is the file's own basename, and the content type is worked out from its extension —
report.csv arrives as text/csv, an attached PDF as application/pdf — with Content-Transfer-Encoding: base64 set for you. Nothing about the encoding is yours to arrange.
When the bytes are already in memory — generated in the request, pulled from storage — attachFileFromStream()
takes the contents and the name to give them:
use Pop\Mail\Message;
$message = new Message('Your invoice');
$message->setTo('you@domain.com');
$message->setFrom('me@domain.com');
$message->setBody('Your invoice is attached.');
$message->attachFileFromStream($pdfContents, 'invoice.pdf');
The extension on that second argument is what picks the content type, exactly as the real filename does for
attachFile(). Name it invoice with no extension and the part goes out as application/octet-stream, which
most clients render as an unnamed blob rather than a PDF.
Attachments and an HTML body coexist — the message renders as multipart/mixed with the alternative parts and
the attachments alongside each other. Mind the size: an attachment is base64-encoded, which adds about a third
to its length, and it's the transport's limit rather than pop-mail's that you will hit. Link to a download
for anything large.
Transports#
Seven transports ship, all implementing Pop\Mail\Transport\TransportInterface, so the Mailer and every
Message above it are unchanged when you swap one for another. Only construction differs.
| Transport | Class | Needs |
|---|---|---|
| SMTP | Transport\Smtp |
host, port, username, password, optional encryption |
| Mailgun | Transport\Mailgun |
api_url, api_key |
| SendGrid | Transport\Sendgrid |
api_url, api_key |
| AWS SES | Transport\Ses |
key, secret |
| Office 365 | Transport\Office365 |
client_id, client_secret, scope, tenant_id, account_id |
Transport\Google |
a service-account JSON file, plus the user's address | |
| Sendmail | Transport\Sendmail |
the local sendmail binary |
The four API transports take their options as an array to the constructor:
use Pop\Mail\Mailer;
use Pop\Mail\Transport\Mailgun;
$mailer = new Mailer(new Mailgun([
'api_url' => 'https://api.mailgun.net/v3/YOUR_MAIL_DOMAIN/messages',
'api_key' => 'MAILGUN_API_KEY',
]));
Sendgrid and Ses take the same shape with their own keys. Missing options are caught at construction, not at
the first send: an incomplete Mailgun or Sendgrid throws Pop\Mail\Transport\Exception ("Error: The
required client options were not provided.") and Ses throws the same class with "Error: The required
credentials to create the client object are missing." Bad credentials are a different matter — those are only
discovered when the API rejects a request.
SMTP is the exception, and it's worth reading closely.
Smtp's constructor takes either an options array or the positional new Smtp($host, $port, $security).
The static Smtp::create() takes the array.
use Pop\Mail\Mailer;
use Pop\Mail\Transport\Smtp;
$transport = Smtp::create([
'host' => 'smtp.example.com',
'port' => 587,
'username' => 'SMTP_USERNAME',
'password' => 'SMTP_PASSWORD',
'encryption' => 'tls',
]);
$mailer = new Mailer($transport);
create() requires all four of host, port, username and password, and throws
Pop\Mail\Transport\Exception ("Error: The required credentials were not provided.") without them. encryption
is optional.
Office365 and Google are OAuth-based and construct empty, then take a createClient() call and a token
round trip. Both expose requestToken(), getToken() and getTokenExpires(), so an application stores the
token and hands it back with setToken() and setTokenExpires() on the next run; an expired token is
refreshed for you, and the fresh one is worth storing in turn.
use Pop\Mail\Mailer;
use Pop\Mail\Transport\Office365;
$transport = new Office365();
$transport->createClient([
'client_id' => 'O365_CLIENT_ID',
'client_secret' => 'O365_CLIENT_SECRET',
'scope' => 'https://graph.microsoft.com/.default',
'tenant_id' => 'O365_TENANT_ID',
'account_id' => 'O365_ACCOUNT_ID',
]);
$transport->requestToken();
$mailer = new Mailer($transport);
Google::createClient() takes the service-account JSON file path and the mailbox address instead:
$transport->createClient('my-google-app-config.json', 'me@domain.com').
Sendmail hands the message to PHP's mail() and needs no credentials, which is why this page's samples
use it. It depends on a correctly configured local MTA. A string passed to its constructor is forwarded to
mail() as additional parameters, which is how you set an envelope sender: new Sendmail('-f bounces@domain.com').
Mail Queues#
A Pop\Mail\Queue sends one message to many recipients, with per-recipient values substituted into the subject
and body. The placeholder syntax is [{key}], and the keys are whatever you put in each recipient's array.
use Pop\Mail\Mailer;
use Pop\Mail\Message;
use Pop\Mail\Queue;
use Pop\Mail\Transport\Sendmail;
$queue = new Queue();
$queue->addRecipient([
'email' => 'me@domain.com',
'name' => 'My Name',
'company' => 'My Company',
]);
$queue->addRecipient([
'email' => 'another@domain.com',
'name' => 'Another Name',
'company' => 'Another Company',
]);
$message = new Message('Hello [{name}]!');
$message->setFrom('noreply@domain.com');
$message->setBody('How are you doing? Your [{company}] is great!');
$queue->addMessage($message);
$mailer = new Mailer(new Sendmail());
$mailer->sendFromQueue($queue); // 2
email is the one required key — it becomes the To address, and name, where present, becomes the display
name. Everything else is substitution data. A recipient array without email throws Pop\Mail\Exception
("Error: The recipient's array must contain at least an 'email' key.") from addRecipient(), so a malformed
row is caught as you add it rather than mid-send.
sendFromQueue() calls prepare(), which expands the queue into one Message per recipient, and returns the
number sent. Call prepare() yourself when you want to see the expansion before anything leaves — each message
comes back fully substituted, so render() on one of them shows the exact bytes that recipient would receive.
This is a template mechanism, not a background worker. sendFromQueue() sends every message in the loop before
returning, so a queue of a thousand recipients holds the request open for a thousand sends. For work that
belongs off the request, see Queues & Workers — that's a different Queue, in a different
component, and the two are unrelated despite the name.
A placeholder with no matching key is left in the message verbatim, so give every recipient array the same keys.
Saving Mail to Disk#
save() writes a message to a file as its rendered MIME source. That decouples composing from sending: the
request that has the data writes the message, and something later — a cron job, a worker — sends what it finds.
use Pop\Mail\Message;
$message = new Message('Hello World');
$message->setTo('user1@domain.com');
$message->setFrom('me@domain.com');
$message->addText('Hello World! This is a test!');
$message->addHtml('<html><body><h1>Hello World!</h1></body></html>');
$message->save(__DIR__ . '/../mail-queue/message1.msg');
sendFromDir() loads every file in a directory and sends each one, returning the number sent:
use Pop\Mail\Mailer;
use Pop\Mail\Transport\Sendmail;
$mailer = new Mailer(new Sendmail());
$mailer->sendFromDir(__DIR__ . '/../mail-queue');
Keep sendFromDir() pointed at a dedicated directory — every entry apart from ., .. and .empty is
treated as a message, and it does not delete what it sends, so move the files afterward or the next run
sends them again.
Message::load() reads one saved file back into a Message, which is what you want to inspect a message or to
send it yourself rather than sweeping a whole directory:
use Pop\Mail\Message;
$message = Message::load(__DIR__ . '/../mail-queue/message1.msg');
$message->getSubject();
Message::parse() does the same thing from a raw MIME string rather than a path, for a message that arrived
from somewhere other than save().
Keep the directory sendFromDir() reads to saved MIME messages only — it calls Message::load() on every
entry it finds.
A round trip is faithful: the headers, both body parts and any attachments survive save() and load()
intact, display names included. The MIME boundary is the one thing that changes, since the loaded object
generates its own.
Receiving over IMAP#
Everything above sends. Pop\Mail\Client\Imap reads — it opens a mailbox and gives you the messages in it,
which is how an application processes replies, bounces or an inbox used as a work queue. It wraps PHP's imap
extension, so that extension has to be loaded.
use Pop\Mail\Client\Imap;
$imap = new Imap('imap.gmail.com', 993);
$imap->setUsername('me@domain.com')
->setPassword('password');
$imap->setFolder('INBOX');
$imap->open('/ssl');
The string passed to open() is appended to the connection string as IMAP flags — /ssl, /tls, /novalidate-cert
and the rest of the mailbox-name syntax the extension understands. getConnectionString() shows the result, and
returns null until open() has been called, since the string is assembled there.
Imap::connect() does the same in one call, and rejects an incomplete credentials array up front with
Pop\Mail\Client\Exception ("Error: The credentials were incomplete. They must contain 'host', 'port',
'username' and 'password'."):
use Pop\Mail\Client\Imap;
$imap = Imap::connect([
'host' => 'imap.gmail.com',
'port' => 993,
'username' => 'me@domain.com',
'password' => 'password',
'folder' => 'INBOX',
], '/ssl');
A third constructor argument switches the protocol: new Imap($host, 995, 'pop3') speaks POP3 instead.
Once open, the mailbox is queryable. getNumberOfMessages(), getNumberOfUnreadMessages() and
getNumberOfReadMessages() count; listMailboxes() enumerates folders; getMessageIds() and
getMessageIdsBy() select messages, the latter sorted:
use Pop\Mail\Client\Imap;
$imap = Imap::connect([
'host' => 'imap.gmail.com',
'port' => 993,
'username' => 'me@domain.com',
'password' => 'password',
'folder' => 'INBOX',
], '/ssl');
$ids = $imap->getMessageIdsBy(SORTDATE, true); // newest first
$headers = $imap->getMessageHeadersById($ids[0]);
$parts = $imap->getMessageParts($ids[0]);
$imap->close();
getMessageParts() returns the decoded parts, each with its own content — an attachment's bytes are on the
part, and getMessageAttachments() narrows to those alone. markAsRead(), moveMessage(), copyMessage() and
deleteMessage() act on the mailbox, so an inbox-as-queue pattern can file each message once it is handled.
close() when you are done.
Google and Microsoft have disabled basic-authentication IMAP for most tenants, so use their APIs against those providers and keep IMAP and POP3 for servers you run.
The clients carry more surface than this page covers — mailbox management, message flags, the Office 365 and Google clients in full — see the pop-mail README.
See Also#
- Queues & Workers — running a large send off the request, with a different
Queue - HTTP Client — what the API transports use underneath
- Logging — recording what was sent, and to whom
- pop-mail README — the full API surface, transports and clients alike