Chapter 5 of 8. The confirmation email your double opt-in flow sends, hand-built as multipart MIME so it renders in old Outlook and still reads as plain text. Prerequisite: Chapter 2, where the confirm and unsubscribe URLs this email carries get minted.
Search "multipart mime email php text plain html" and two shapes of answer come back. The first sets Content-Type: text/html and passes an HTML string to mail(). That works until somebody reads mail in a client that prefers text/*, at which point they receive your table markup as literal characters. The second shape is a multipart/alternative snippet, copied down the years, which lists the HTML part before the plain-text part. That one is worse, because it looks right and quietly makes every graphical client render your text part instead of your design.
The confirmation email on captainrandom.co.uk is 129 lines of PHP in cpanel-backend/lib/Email.php. There is no Composer in this project. Email.php imports nothing. It goes out through PHP's built-in mail() on a cPanel shared host, and its plain-text part is byte-for-byte the template that mail-tester scored 9.2 out of 10 before any HTML existed. This chapter is that file, plus the specific paragraphs of RFC 2045, RFC 2046 and RFC 2387 that decide whether your message parses or lands as a wall of boundary markers.
multipart/related wrapping multipart/alternative
Start with the tree, because every mistake below is a mistake about the tree. Email.php documents its own shape in the class docblock, and the docblock is worth reading whole:
/**
* Email — mail() wrapper for the confirmation email.
*
* One method: sendConfirmation(). Builds a multipart MIME message:
*
* multipart/related ← outer; holds the body + inline image
* ├── multipart/alternative ← inner; MUA picks text vs HTML
* │ ├── text/plain (templates/confirm-email.txt)
* │ └── text/html (templates/confirm-email.html)
* └── image/png (templates/captain-random-logo.png)
* Content-ID: <cr-logo>; HTML references it
* via <img src="cid:cr-logo">.
*
* Headers retained from the plain-text version (P8-2 / P8-5):
* - From / Reply-To / Return-Path = FROM_EMAIL (envelope-from via
* `-f` arg to sendmail so SPF aligns with header From)
* - List-Unsubscribe + List-Unsubscribe-Post (RFC 8058) — MUA
* one-click flow routes through unsubscribe.php as a POST
* - Message-ID unique per send
*
* The plain-text part is byte-for-byte the template used by the
* previous plain-only sender (P8-2 mail-tester 9.2/10), so any
* client that picks text/* still receives the scored version. HTML
* is progressive enhancement, not the baseline.
*
* Logo file is 512x512 PNG (~69KB); base64 expansion ~92KB; total
* email ~95KB. Acceptable for a transactional confirmation send.
*/
Two containers, doing two different jobs. multipart/alternative means "here is the same message twice, pick one". multipart/related means "here are several parts that add up to one object, you need all of them". RFC 2387 is explicit that multipart/related exists for compound objects whose parts are interdependent, where "proper display cannot be achieved by individually displaying the constituent body parts". The RFC's own worked example is not even HTML. It is an invented markup language that embeds image/jpeg parts by cid: reference, which tells you the mechanism was never about HTML in the first place.
That relationship is why the logo cannot live inside multipart/alternative. Alternatives are ranked renderings of the same message, and a PNG renders none of it. Drop the image in as a third alternative and a conforming client may display the logo in place of your email, because the last alternative is the one it should prefer.
RFC 2387 also settles the ordering of the outer container. The type parameter is required and names the media type of the root part. The optional start parameter names that root part by Content-ID, and the RFC spells out what happens without it: "If not present the 'root' is the first body part in the Multipart/Related entity." Email.php omits start and puts the alternative block first, so the root resolves correctly:
$headers .= sprintf(
"Content-Type: multipart/related; type=\"multipart/alternative\"; boundary=\"%s\"\r\n",
$bOuter
);
The header block PHP's mail() has to emit
PHP's mail() does not build MIME for you. It takes recipient, subject, body and a headers argument, and hands the lot to the local MTA. Everything structural is yours to write. Here is the real header block, in emitted order:
$headers = sprintf("From: %s <%s>\r\n", self::encodeName($name), $from);
$headers .= sprintf("Reply-To: %s\r\n", $from);
$headers .= sprintf("Return-Path: %s\r\n", $from);
$headers .= sprintf("List-Unsubscribe: <%s>\r\n", $unsubscribeUrl);
$headers .= "List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n";
$headers .= "MIME-Version: 1.0\r\n";
$headers .= sprintf(
"Content-Type: multipart/related; type=\"multipart/alternative\"; boundary=\"%s\"\r\n",
$bOuter
);
$headers .= sprintf("Message-ID: <%s>\r\n", $messageId);
$headers .= "X-Mailer: captain-random-newsletter\r\n";
MIME-Version: 1.0 is the assertion that everything below it is MIME. RFC 2045 requires the header and defines it as a claim that the message was composed in compliance with the MIME specification. Omit it and a strict client is entitled to read your carefully nested body as a single lump of text/plain, boundary markers and all. It is the cheapest header in the message and the one people forget.
The top-level Content-Transfer-Encoding is missing on purpose. RFC 2046 permits only 7bit, 8bit or binary on a multipart body. You may not base64 a container. Individual leaf parts inside it can use any of the five mechanisms RFC 2045 defines, which is how the PNG gets to be base64 while the container stays plain.
The send itself is one line:
return mail($to, $subject, $body, $headers, '-f ' . $from);
That fifth argument is the envelope sender, passed through to the sendmail command line. PHP's manual documents it as the -f parameter. SPF checks that envelope sender. Chapter 4 is where that matters. On the return value the manual is blunt. mail() returns true when the message was "accepted for delivery" by the system. A true here tells you the MTA took the message, and nothing about whether Gmail put it in a folder a human will open.
Two boundaries, random per send, never the same string twice
RFC 2046 makes the boundary parameter mandatory on every multipart entity and caps it at 70 characters. It also states the rule everyone eventually breaks. The delimiter must not appear inside any encapsulated part, "on a line by itself or as the prefix of any line". A line of body text that opens with your delimiter closes the part early, and the rest of your email becomes garbage.
Email.php handles that by minting fresh boundaries on every send:
$bOuter = 'cr_rel_' . bin2hex(random_bytes(10));
$bInner = 'cr_alt_' . bin2hex(random_bytes(10));
Twenty hex characters of CSPRNG output behind a readable prefix. A collision with body content is not a risk you have to reason about, because the string did not exist until the moment of the send.
The other half of that rule is the one tutorials miss. Nested containers need distinct boundaries. Reuse one string for both levels and the first closing delimiter terminates whichever container the parser is currently inside, which is rarely the one you meant. Outer gets cr_rel_, inner gets cr_alt_. They are generated independently.
Order the parts wrong and every graphical client shows your text part
RFC 2046 says that in multipart/alternative, "the alternatives appear in an order of increasing faithfulness to the original content", and that "receiving user agents should pick and display the last format they are capable of displaying". Plainest first. Richest last.
So the message body lists text/plain before text/html:
$body = "--{$bOuter}\r\n";
$body .= "Content-Type: multipart/alternative; boundary=\"{$bInner}\"\r\n\r\n";
$body .= "--{$bInner}\r\n";
$body .= "Content-Type: text/plain; charset=utf-8\r\n";
$body .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$body .= $textBody . "\r\n";
$body .= "--{$bInner}\r\n";
$body .= "Content-Type: text/html; charset=utf-8\r\n";
$body .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$body .= $htmlBody . "\r\n";
$body .= "--{$bInner}--\r\n\r\n";
Invert those two blocks and nothing complains. The message parses. Every syntax checker you own will wave it through. It will render your plain-text version in Gmail, Apple Mail and Outlook, forever, because you told those clients that plain text was the richest thing you had. The closing delimiter is the boundary with two trailing hyphens, exactly as RFC 2046 specifies.
The text part is the one that scored 9.2
Here is the whole plain-text template, all 17 lines of templates/confirm-email.txt:
Thanks for signing up for the Captain Random build log.
To confirm your subscription, click the link below within the next 60
minutes:
{confirm_url}
If you didn't sign up, you can ignore this email — without the click,
nothing happens. The signup attempt drops out of the database within
the hour.
Once confirmed, you can unsubscribe with one click at any time:
{unsubscribe_url}
— Captain Random
{site_url}
That file is byte-for-byte the template that scored 9.2 on mail-tester before the HTML part existed. It was left alone when the design landed. Any client that picks text/* receives the exact message that was measured. The HTML sits on top of a known-good baseline. The docblock says as much in one line: HTML "is progressive enhancement, not the baseline".
Both parts share three placeholders, and they are substituted differently:
$textBody = strtr((string) file_get_contents($txtTemplate), [
'{confirm_url}' => $confirmUrl,
'{unsubscribe_url}' => $unsubscribeUrl,
'{site_url}' => $siteUrl,
]);
$htmlBody = strtr((string) file_get_contents($htmlTemplate), [
'{confirm_url}' => htmlspecialchars($confirmUrl, ENT_QUOTES, 'UTF-8'),
'{unsubscribe_url}' => htmlspecialchars($unsubscribeUrl, ENT_QUOTES, 'UTF-8'),
'{site_url}' => htmlspecialchars($siteUrl, ENT_QUOTES, 'UTF-8'),
]);
Raw URLs in the text part, because it is text. htmlspecialchars with ENT_QUOTES in the HTML part, because those URLs land inside href attributes. The comment in the file is honest about why it bothers. The URLs are server-generated, so the risk is theoretical. It escapes anyway, because defence-in-depth here costs nothing.
The inline logo and its Content-ID
RFC 2045 defines Content-ID with Message-ID syntax and requires the value to be world-unique. RFC 2387 is the half that makes it useful. Parts inside a multipart/related reference each other by Content-ID, addressed as cid: URLs. Wire those two facts together and you get an image that renders inline without a network fetch.
The attachment part:
$logoBase64 = chunk_split(base64_encode((string) file_get_contents($logoFile)), 76, "\r\n");
$body .= "--{$bOuter}\r\n";
$body .= "Content-Type: image/png; name=\"captain-random-logo.png\"\r\n";
$body .= "Content-Transfer-Encoding: base64\r\n";
$body .= "Content-Disposition: inline; filename=\"captain-random-logo.png\"\r\n";
$body .= "Content-ID: <cr-logo>\r\n\r\n";
$body .= $logoBase64 . "\r\n";
$body .= "--{$bOuter}--\r\n";
And the reference, from templates/confirm-email.html:
<img src="cid:cr-logo" width="64" height="64" alt="Captain Random"
style="display:block;border:0;outline:none;text-decoration:none;width:64px;height:64px;">
The angle brackets in Content-ID: <cr-logo> are part of the syntax. The cid: URL drops them. Get that asymmetry wrong and the image silently fails to resolve. On screen, that is indistinguishable from a broken attachment.
chunk_split at 76 with CRLF is there because RFC 2045 caps encoded lines, and MTAs are free to mangle anything longer. Seventy-six characters plus CRLF is the standard base64 line.
The PNG is 69,146 bytes on disk. Base64 expands it to roughly 92KB, and the whole email lands around 95KB. The docblock calls that "acceptable for a transactional confirmation send". The reason it beats a hosted image URL is that most clients block remote images by default on first contact from a sender they have never seen, so a CDN-hosted logo shows as an empty box on the exact send where you most want to look legitimate. CID content is part of the message and renders without the "load images" gate. At broadcast scale, on a weekly send to a warm list, the arithmetic flips and the hosted URL wins.
Building it in order
- Preflight every asset before you compose anything
Email.phpchecks all three template files withis_readable()up front and throwsRuntimeException('confirm-email asset missing: ' . $path)on a miss. A missing logo should fail before a single boundary is minted. The alternative is a message that sends cleanly with an empty base64 part, which you find out about when a subscriber mentions the broken image. - Substitute the placeholders, escaping only the HTML side
Raw URLs into the text template,
htmlspecialcharsinto the HTML template. Twostrtrcalls, three placeholders each. - Encode the binary part
base64_encode, thenchunk_splitat 76 with CRLF. - Mint both boundaries and the Message-ID
bin2hex(random_bytes(10))per boundary. The Message-ID isbin2hex(random_bytes(8))plus the host taken fromFROM_EMAIL, so every send is uniquely identifiable in a mail log. - Emit headers, then body, then send
Headers first,
multipart/relatedat the top level. Body opens the outer boundary, nests the alternative block, closes it, appends the image part, closes the outer boundary. Then onemail()call with-ffor the envelope sender.
What to check before you believe it works
mail() returning true proves the MTA accepted your bytes. It proves nothing about MIME correctness, because a malformed message is still bytes. Send to a real inbox and read it twice. Once normally, and once with the client forced into plain-text view. That second read is the only way you will notice that you ordered the alternatives backwards.
One trap is host-specific. The PHP manual documents a line-ending hazard. Some Unix mail transfer agents, qmail most notably, replace LF with CRLF automatically, which doubles the CR when your body already contains CRLF. This backend writes CRLF throughout. That is a bet on how one cPanel LiteSpeed host's MTA behaves. Send yourself the message and look at the raw source before you assume your host behaves the same way.
The rest of the diagnosis is not in the mail layer at all. When this newsletter's confirmation emails stopped arriving, the answer came from the events table rather than from anything in Email.php, and the failure was upstream of the send entirely. That story is in Three newsletter hotfixes: the events table found all of them, and it is the reason the structured event log exists.
The transferable rule
The relationship between the parts picks the container. Text and HTML are two renderings of one message, which makes them alternatives. HTML and its logo are two pieces of one object, which makes them related. File types are a coincidence.
Match the tree to the relationship and the RFC rules do the rest of the work for you. The last alternative wins, and the first related part is the root.
The corollary is the one that costs people a week. Your message goes out, mail() returns true, the log is clean, and somebody in a text-only client is reading your <table> tags. Read the raw source of your own email at least once before you ship the endpoint that sends it.
Hand-roll a multipart transactional email for your own app
Pick a transactional send your own project makes (a receipt, an invite, a reset link, an alert) and build the message by hand as multipart/related wrapping multipart/alternative: a plain-text part good enough to stand alone, an HTML part as enhancement, and one inline image referenced by cid:.
Expected behaviour
- The text part reads as a complete message on its own and is listed before the HTML part inside multipart/alternative
- Two distinct boundaries, minted per send from a CSPRNG, with the outer Content-Type carrying the required type parameter
- The inline image part carries Content-ID in angle brackets while the HTML references it as a cid: URL without them
- Placeholder substitution escapes URLs on the HTML side only, leaving the text side raw
- Asset preflight: a missing template or image throws before any boundary is minted, rather than sending a broken message
PROVE IT Send it to a real inbox and screenshot it twice: rendered normally with the inline image showing, and again with the client forced into plain-text view showing the standalone text part.
Inside multipart/alternative, why must text/plain be listed before text/html?
At what line length does chunk_split wrap the base64-encoded logo?
Why does the logo live in the multipart/related container rather than as a third part inside multipart/alternative?
Show answer
Alternatives are ranked renderings of the same message, and a PNG renders none of the message, so a conforming client could display the logo in place of the email because the last alternative is the one it should prefer. The logo and the HTML are interdependent pieces of one compound object, which is exactly what multipart/related exists for.
↺ re-read: “multipart/related wrapping multipart/alternative”