<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library\mail;
use seekquarry\yioop\configs as C;
/**
* Turns an email into the exact text a mail server sends, and turns
* received mail text back into its parts. It works in two directions.
*
* Building: given a sender, recipients, subject, body, and any
* attachments, it produces the finished message text ready to hand to
* a mail server or to file into a folder. A message with no
* attachments comes out as plain text; a message with attachments
* comes out as a multi-part message where the body is one part and
* each attached file is another.
*
* Reading: given the raw text of a message that arrived, it pulls out
* the pieces a mail program needs to show it โ the headers (subject,
* sender, and so on), the plain-text version of the body, the HTML
* version of the body, and the list of attachments. It understands
* messages built from nested parts, undoes the common ways body text
* is packed for transport, converts foreign character sets to the
* UTF-8 the rest of Yioop uses, and turns encoded header text like
* "=?UTF-8?B?Tm90ZQ==?=" back into the readable "Note".
*
* Reading aims at the mail people actually send and receive, not at
* every corner of the mail standards. In particular: a message
* attached inside another message is noted by its filename but its
* insides are not taken apart again; digitally signed or encrypted
* multi-part messages are walked the same way as ordinary multi-part
* messages, surfacing whatever readable pieces they hold; and a
* deeply nested or broken structure is treated as a single piece
* rather than causing an error.
*
* Memory note: a message that has been read in keeps the decoded text
* of every body part and every attachment in memory. For ordinary
* mail (a text body and small attachments) that is a few kilobytes to
* a few megabytes. Code that expects very large attachments and only
* wants the body should walk the parts itself instead of holding on
* to the read-in object.
*
* @author Chris Pollett
*/
class MimeMessage
{
/**
* The message's headers, keyed by lowercased name (subject,
* from, to, and so on). Only the first occurrence of a header is
* kept here; when a header legitimately repeats (such as the
* Received trail that records each hop), code that needs every
* copy reads the original message text directly.
* @var array
*/
public $headers = [];
/**
* The plain-text version of the body, as UTF-8 text. Empty when
* the message has no plain-text part.
* @var string
*/
public $body_text = "";
/**
* The HTML version of the body, as UTF-8 text. Empty when the
* message has no HTML part. Anything that displays this MUST
* sanitize it first (a sandboxed frame, an HTML cleaner, or the
* like): HTML from a stranger can carry scripts and tracking
* pixels.
* @var string
*/
public $body_html = "";
/**
* The attachments found in the message. Each entry describes one
* attached file: its filename, its content type, its size, its
* decoded bytes ready to be downloaded, and (when the message
* supplied one) the content id used to reference an inline image.
* @var array
*/
public $attachments = [];
/**
* The renderable body pieces in the order they appear in the
* message, so a viewer can show an image exactly where the sender
* placed it between blocks of text rather than piling every image
* at the end. Each entry has a "kind" of "text", "html", or
* "image": a text or html entry carries its UTF-8 content under
* that same name, and an image entry carries its "mime_type",
* decoded "content" bytes, "filename", and any "cid". Attachments
* proper (files the sender marked for download) stay in
* attachments and are not repeated here.
* @var array
*/
public $body_parts = [];
/**
* Reads the raw text of a whole message and fills in this
* object's headers, plain-text body, HTML body, and attachments.
* The headers are taken from the block before the first blank
* line; the rest is the body, which is taken apart piece by piece
* when the message is built from multiple parts, or read directly
* when it is a single part. An empty input produces an empty
* message with every field at its default.
*
* @param string $rfc822 the full message text, headers and body
* together; line endings may be either CRLF or plain LF
* @return self the read-in message
*/
public static function parse($rfc822)
{
$self = new self();
/* normalise to CRLF so the body split and boundary scan
both operate on a single line-ending convention. */
$normalised = preg_replace('/\r?\n/', "\r\n", $rfc822);
$split_pos = strpos($normalised, "\r\n\r\n");
if ($split_pos === false) {
$header_block = $normalised;
$body_block = "";
} else {
$header_block = substr($normalised, 0, $split_pos);
$body_block = substr($normalised, $split_pos + 4);
}
$headers = self::parseHeaderBlock($header_block);
$self->headers = self::topLevelHeaders($headers);
self::walkPart($self, $headers, $body_block);
return $self;
}
/**
* Turns encoded header text back into readable text. Mail can
* only carry plain ASCII in its headers, so a subject or name
* with accents or non-Latin letters travels in a scrambled form
* that starts with "=?". This unscrambles it back to UTF-8. A
* value with no such marker is returned untouched, so it is safe
* to call on any header. When two scrambled chunks sit side by
* side the standard says the spaces between them are only for
* wrapping and should disappear on display; PHP's own decoder
* does not remove them, so this closes that gap before decoding.
*
* @param string $value the raw header text, possibly containing
* encoded chunks
* @return string the text with any encoded chunks turned back
* into readable UTF-8
*/
public static function decodeHeader($value)
{
if (strpos($value, "=?") === false) {
return $value;
}
$stitched = preg_replace('/\?=\s+=\?/', "?==?", $value);
return mb_decode_mimeheader($stitched);
}
/**
* Unpacks a body that was packed for safe transport back into its
* real bytes. Mail can carry some content directly but has to wrap
* other content (binary files, text with unusual characters) in
* one of a few packing schemes named in the message. This reverses
* the two common schemes โ base64 and quoted-printable โ and
* leaves anything already in plain form untouched. The scheme name
* is matched without regard to capitalization, and an unfamiliar
* scheme is treated as plain and returned as-is. The result is the
* raw bytes in whatever character set the part declared; use
* toUtf8 afterward to make it displayable.
*
* @param string $bytes the packed body bytes
* @param string $encoding the packing scheme named in the part's
* Content-Transfer-Encoding header (an empty value is treated
* as plain)
* @return string the unpacked bytes
*/
public static function decodeBody($bytes, $encoding)
{
$enc = strtolower(trim($encoding));
if ($enc === 'base64') {
return base64_decode($bytes, false);
}
if ($enc === 'quoted-printable') {
return quoted_printable_decode($bytes);
}
/* 7bit, 8bit, binary, empty, and anything we don't recognise
pass through unchanged. */
return $bytes;
}
/**
* Converts text from a foreign character set into UTF-8, the form
* the rest of Yioop works in. Mail can arrive written in any of
* dozens of character sets (Western European, Cyrillic, Japanese,
* Chinese, and more); this rewrites those bytes as the equivalent
* UTF-8. Text that is already UTF-8 or plain ASCII is returned
* untouched. If the named character set is one the converter does
* not know, it logs a short one-line note to mail.log and returns
* the original bytes unchanged, so the screen stays quiet about
* the trouble while an administrator watching the log can still
* see it. A second converter (iconv) is deliberately not used as a
* fallback: it would add a dependency for the tiny fraction of
* mail the built-in converter cannot handle.
*
* @param string $bytes the text to convert
* @param string $from_charset the character set the text is
* currently in
* @return string the same text as UTF-8
*/
public static function toUtf8($bytes, $from_charset)
{
$cs = strtolower(trim($from_charset));
$already_utf8 = ['', 'utf-8', 'utf8', 'us-ascii', 'ascii'];
if (in_array($cs, $already_utf8, true)) {
return $bytes;
}
$captured = "";
set_error_handler(
function ($errno, $errstr) use (&$captured) {
/* keep captured text bounded so a flood of bad
charsets cannot grow the buffer. */
if (strlen($captured) < 512) {
$captured .= $errstr;
}
});
try {
$converted = mb_convert_encoding($bytes, 'UTF-8',
$from_charset);
} finally {
restore_error_handler();
}
if ($captured !== "") {
self::logCharsetWarning($from_charset, $captured);
}
if ($converted !== false && $converted !== null) {
return $converted;
}
return $bytes;
}
/**
* Assembles a finished email from its parts: who it is from, who
* it is to, the subject, the body text, and any attached files.
* The result is the complete message text, ready to hand to a
* mail server for sending or to file into a mail folder. With no
* attachments it comes out as a single plain-text message; with
* attachments it comes out as a multi-part message where the body
* is the first part and each attached file follows as its own
* part. All the housekeeping headers a message needs to be
* accepted and displayed (the date, a unique message id, the
* sender, recipients, subject, and how the body is formatted) are
* filled in here.
*
* Lines end in the carriage-return-plus-newline pair that mail
* servers and mail folders expect. The trailing marker that tells
* a mail server "the message ends here" is not added โ that
* belongs to the sending step and SmtpClient adds it just before
* it transmits.
*
* @param string $from the sender's address (a bare address or a
* "Name <addr>" form; the caller decides how the recipient
* should see it)
* @param mixed $to the recipient. Either a single address as a
* plain string, or a list under keys 'to' (the visible To
* line) and 'cc' (an optional visible Cc line). Blind-copy
* recipients are never listed here; the sender handles those
* by delivering to their addresses without naming them in
* the message.
* @param string $subject the subject line, already free of line
* breaks
* @param string $body the body text, in UTF-8
* @param array $attachments the files to attach, each described
* by its filename (the name the recipient sees), its content
* type, and its raw bytes. An empty list yields a plain-text
* message with no attachments.
* @param array $extra_headers optional further headers to include
* (for example the "in reply to" and "references" headers
* that tie a reply to the message it answers), given as a
* name-to-value list. Control characters are stripped from
* each value so a value cannot smuggle in extra header lines.
* @return string the finished message text, ready to send or to
* file into a folder
*/
public static function build($from, $to, $subject, $body,
$attachments = [], $extra_headers = [])
{
$eol = "\r\n";
$msgid_domain = self::messageIdDomain($from);
$message_id = "<" . bin2hex(random_bytes(8)) . "." .
time() . "@" . $msgid_domain . ">";
if (is_array($to)) {
$to_header_value = (string) ($to['to'] ?? '');
$cc_header_value = (string) ($to['cc'] ?? '');
} else {
$to_header_value = (string) $to;
$cc_header_value = '';
}
$top_headers =
"Date: " . date(DATE_RFC2822) . $eol .
"Subject: " . $subject . $eol .
"From: " . $from . $eol .
"To: " . $to_header_value . $eol;
if ($cc_header_value !== '') {
$top_headers .= "Cc: " . $cc_header_value . $eol;
}
$top_headers .=
"Message-ID: " . $message_id . $eol .
"MIME-Version: 1.0" . $eol;
/* Caller-supplied extra headers (for example the in-reply-to
and references headers on a reply). Each value has control
characters stripped so a value cannot smuggle in extra
header lines. Where these sit relative to the content
headers differs by message shape, below, to keep each
shape's output the same as before message-building was
centralized here. */
$extra = "";
$has_content_type = false;
foreach ($extra_headers as $name => $value) {
if (strcasecmp((string) $name, "Content-Type") === 0) {
$has_content_type = true;
}
$clean_value = preg_replace('/[\x00-\x1F\x7F]/', '',
(string) $value);
$extra .= $name . ": " . $clean_value . $eol;
}
if (empty($attachments)) {
/* single-part message: the content headers come first,
then the extra headers, then a blank line and the body.
When the caller has supplied its own Content-Type (for
example text/html for a formatted mail), use that and do
not also emit the default text/plain, so the message
carries exactly one Content-Type. */
$default_content_type = $has_content_type ? "" :
"Content-Type: text/plain; charset=utf-8" . $eol;
return $top_headers .
$default_content_type .
"Content-Transfer-Encoding: 8bit" . $eol .
$extra .
$eol .
$body;
}
/* message with attachments: the extra headers come before the
multipart Content-Type, which has to sit last in the header
block because it declares the separator the body parts are
built around. */
$boundary = "yioop_b_" . bin2hex(random_bytes(12));
$out = $top_headers .
$extra .
"Content-Type: multipart/mixed; boundary=\"" .
$boundary . "\"" . $eol .
$eol .
"This is a multi-part message in MIME format." . $eol;
/* body part. text/plain, 8bit, charset utf-8 -- same
assumptions the single-part path makes. The trailing
CRLF that precedes the next boundary is part of the
boundary's framing per RFC 2046 ยง5.1.1 and is emitted
with the next delimiter, not appended to the body. */
$out .= $eol . "--" . $boundary . $eol .
"Content-Type: text/plain; charset=utf-8" . $eol .
"Content-Transfer-Encoding: 8bit" . $eol .
$eol .
$body;
foreach ($attachments as $attach) {
$filename = $attach['filename'] ?? 'attachment';
$mime_type = $attach['mime_type'] ?:
'application/octet-stream';
$content = $attach['content'] ?? '';
/* base64-encode and wrap to 76 columns per RFC 2045
ยง6.8 so line length stays within the 998-octet
SMTP/RFC 5322 line limit with safe margin. The
chunk_split tail leaves a trailing CRLF on the
output, which we trim so the per-part trailing
CRLF is owned by the next delimiter, not by the
attachment payload. */
$b64 = rtrim(chunk_split(base64_encode($content), 76,
$eol), "\r\n");
$safe_name = self::quoteParamValue($filename);
$out .= $eol . "--" . $boundary . $eol .
"Content-Type: " . $mime_type .
"; name=" . $safe_name . $eol .
"Content-Disposition: attachment; filename=" .
$safe_name . $eol .
"Content-Transfer-Encoding: base64" . $eol .
$eol .
$b64;
}
$out .= $eol . "--" . $boundary . "--" . $eol;
return $out;
}
/**
* Wraps a plain-text body and an HTML body into a single
* "multipart/alternative" message body. This is the standard way to
* send mail that looks formatted in apps that show HTML while still
* reading cleanly in plain-text-only mail apps: the mail program is
* handed both versions and shows whichever it prefers. Per the MIME
* standard the plain part is placed first and the richer HTML part
* last. The caller puts the returned content-type value (which names
* the boundary between the parts) into the message's Content-Type
* header and sends the returned body.
*
* @param string $text_body the plain-text version of the message
* @param string $html_body the HTML version of the message
* @return array a pair: the multipart Content-Type header value to
* use (including its boundary) and the assembled message body
*/
public static function alternativeBody($text_body, $html_body)
{
$eol = "\r\n";
$boundary = "yioop_alt_" . bin2hex(random_bytes(12));
$content_type = "multipart/alternative; boundary=\"" .
$boundary . "\"";
/* The first line is shown only by mail programs too old to
understand MIME, so they display something rather than the
raw parts. Each part is introduced by a boundary line; the
CRLF that precedes a boundary is part of that boundary's
framing per RFC 2046 5.1.1, so it is emitted with the
delimiter rather than appended to the part before it. */
$body = "This is a multi-part message in MIME format." . $eol;
$body .= $eol . "--" . $boundary . $eol .
"Content-Type: text/plain; charset=utf-8" . $eol .
"Content-Transfer-Encoding: 8bit" . $eol .
$eol .
$text_body;
$body .= $eol . "--" . $boundary . $eol .
"Content-Type: text/html; charset=utf-8" . $eol .
"Content-Transfer-Encoding: 8bit" . $eol .
$eol .
$html_body;
$body .= $eol . "--" . $boundary . "--" . $eol;
return [$content_type, $body];
}
/**
* Makes a filename safe to place inside an attachment's header
* line. A simple name made only of letters, digits, dots,
* dashes, and underscores can go in as-is. Anything else (spaces,
* punctuation) is wrapped in quotes with the tricky characters
* escaped so it cannot break the header. Characters outside plain
* ASCII are dropped, and a name left empty after that falls back
* to a generic "attachment" โ handling non-ASCII filenames fully
* is future work, and for now it is safer to use a plain name
* than to risk writing a malformed header.
*
* @param string $value the raw value, usually a filename
* @return string a version safe to drop into a header line
*/
private static function quoteParamValue($value)
{
$clean = preg_replace('/[\x00-\x1F\x7F-\xFF]/', '',
$value);
if ($clean === '') {
return '"attachment"';
}
if (preg_match('/^[A-Za-z0-9._-]+$/', $clean)) {
return $clean;
}
return '"' . str_replace(['\\', '"'],
['\\\\', '\\"'], $clean) . '"';
}
/**
* Picks the domain to use in a new message's unique id. Every
* outgoing message gets an id of the form "something@domain"; this
* chooses the domain part. It uses the domain from the sender's
* address when there is one. When the sender address has no
* domain, it falls back to the site's configured default sender
* address, then to the site's web address, and finally to a safe
* placeholder โ so the id stays well-formed even when the sender
* is blank, which some system-mail callers leave for this code to
* fill in.
*
* @param string $from the sender address
* @return string a domain to use on the right of the @ in a
* message id
*/
public static function messageIdDomain($from)
{
$candidate = $from;
if (!str_contains($candidate, '@')) {
$candidate = C\p('MAIL_SENDER');
}
$at = strrpos($candidate, '@');
if ($at !== false) {
$domain = trim(substr($candidate, $at + 1), " <>\t");
if ($domain !== '' &&
preg_match('/^[A-Za-z0-9.-]+$/', $domain)) {
return $domain;
}
}
/* fall back to the host part of the site's web address. */
$host = parse_url(C\baseUrl(), PHP_URL_HOST);
if (is_string($host) && $host !== '') {
return $host;
}
return 'localhost';
}
/**
* Records a one-line note in mail.log when a message's character
* set could not be converted. The note holds the date, the
* character-set name the message claimed, and the (shortened)
* warning text from the converter. This is called from the
* conversion fallback in toUtf8. If writing to mail.log fails (no
* log directory, a permission problem), the note still goes to
* PHP's own error log so the trail is not lost.
*
* @param string $charset the character-set name the converter
* rejected
* @param string $warning the warning text captured during the
* failed conversion
*/
private static function logCharsetWarning($charset, $warning)
{
/* squash newlines so the whole record fits on one log
line; truncate the charset name and warning text so the
record is bounded even on adversarial input. */
$charset_clip = substr(trim($charset), 0, 80);
$warning_clip = substr(str_replace(["\r", "\n"], " ",
$warning), 0, 200);
$line = "[" . date(DATE_RFC822) . "] mime-charset: " .
$charset_clip . " -> " . $warning_clip . "\n";
SmtpClient::appendLog($line);
}
/**
* Picks out the headers a mail program usually shows and returns
* them ready to display. That means the subject, sender,
* recipients, date, reply-to, and message id, plus a couple that
* code may want to check (how the body is formatted). Any headers
* that arrived in the scrambled non-ASCII form are turned back
* into readable text.
*
* @param array $headers the full set of headers read from the
* message
* @return array the chosen headers, keyed by lowercased name
*/
protected static function topLevelHeaders($headers)
{
$wanted = ['subject', 'from', 'to', 'cc', 'bcc', 'date',
'reply-to', 'message-id', 'in-reply-to', 'references',
'content-type', 'content-transfer-encoding'];
$result = [];
foreach ($wanted as $name) {
if (isset($headers[$name])) {
$result[$name] = self::decodeHeader($headers[$name]);
}
}
return $result;
}
/**
* Examines one piece of a message and files what it finds. If the
* piece is itself made of several sub-pieces, it splits it apart
* and examines each in turn. A plain-text or HTML body piece is
* added to the ordered body-parts list and also gathered into
* body_text or body_html. An image the sender left inline joins
* the body-parts list in place. An attached file, marked as an
* attachment or carrying a filename, has its bytes unpacked into
* the attachments list. Only the richest form of an alternative
* group is displayed, so the other forms are passed a false
* display flag: they still feed body_text and body_html, for
* replies and search, but do not appear in the shown body.
*
* @param MimeMessage $result the message being filled in as the
* pieces are examined
* @param array $headers this piece's headers, keyed by lowercased
* name
* @param string $body this piece's body bytes
* @param bool $display whether this piece should appear in the
* displayed body; false for the forms of an alternative group
* that are not the chosen one
*/
protected static function walkPart($result, $headers, $body,
$display = true)
{
$content_type = $headers['content-type'] ?? 'text/plain';
$parsed_ct = self::parseContentType($content_type);
$mime_type = strtolower($parsed_ct['type']);
$params = $parsed_ct['params'];
if (str_starts_with($mime_type, 'multipart/')) {
$boundary = $params['boundary'] ?? '';
if ($boundary === '') {
return;
}
$parts = self::splitMultipart($body, $boundary);
$subtype = substr($mime_type, strlen('multipart/'));
if ($subtype === 'alternative') {
/* An alternative group carries the same content in
several forms. Only the richest one is shown, so the
text and html versions are not both drawn; the rest
are still read into body_text and body_html so a
reply or a search has the plain-text rendition even
when the html one is what is displayed. */
$chosen = self::preferredAlternativeIndex($parts);
foreach ($parts as $index => $sub_part) {
list($sub_headers, $sub_body) =
self::splitHeaderBody($sub_part);
self::walkPart($result, $sub_headers, $sub_body,
$display && $index === $chosen);
}
return;
}
foreach ($parts as $sub_part) {
list($sub_headers, $sub_body) =
self::splitHeaderBody($sub_part);
self::walkPart($result, $sub_headers, $sub_body,
$display);
}
return;
}
$encoding = $headers['content-transfer-encoding'] ?? '';
$disposition = strtolower(self::parseContentType(
$headers['content-disposition'] ?? '')['type']);
$filename = self::partFilename($headers, $params);
/* An image the sender did not mark as an attachment is shown
inline where it sits in the message, not filed away below it
with the downloadable attachments. */
$is_inline_image = str_starts_with($mime_type, 'image/') &&
$disposition !== 'attachment';
if ($is_inline_image) {
if ($display) {
$decoded = self::decodeBody($body, $encoding);
$result->body_parts[] = [
'kind' => 'image',
'mime_type' => $mime_type,
'content' => $decoded,
'filename' => $filename,
'cid' => trim($headers['content-id'] ?? '', '<>'),
];
}
return;
}
$is_attachment = ($disposition === 'attachment') ||
($filename !== '' && !str_starts_with($mime_type,
'text/'));
if ($is_attachment) {
$decoded = self::decodeBody($body, $encoding);
$result->attachments[] = [
'filename' => $filename,
'mime_type' => $mime_type,
'size' => strlen($decoded),
'content' => $decoded,
'cid' => trim($headers['content-id'] ?? '', '<>'),
];
return;
}
if ($mime_type === 'text/plain') {
$charset = $params['charset'] ?? 'us-ascii';
$decoded = self::decodeBody($body, $encoding);
$text = self::toUtf8($decoded, $charset);
if ($display) {
$result->body_parts[] = ['kind' => 'text',
'text' => $text];
}
$result->body_text = ($result->body_text === "") ? $text :
$result->body_text . "\r\n\r\n" . $text;
return;
}
if ($mime_type === 'text/html') {
$charset = $params['charset'] ?? 'us-ascii';
$decoded = self::decodeBody($body, $encoding);
$html = self::toUtf8($decoded, $charset);
if ($display) {
$result->body_parts[] = ['kind' => 'html',
'html' => $html];
}
$result->body_html = ($result->body_html === "") ? $html :
$result->body_html . $html;
return;
}
}
/**
* Splits one raw message part into its header list and its body,
* dividing at the blank line that separates them. A part with no
* blank line is treated as all headers and an empty body.
*
* @param string $part the raw text of a single message part
* @return array a two-element list: the parsed headers and the
* body text
*/
protected static function splitHeaderBody($part)
{
$split_pos = strpos($part, "\r\n\r\n");
if ($split_pos === false) {
return [self::parseHeaderBlock($part), ""];
}
return [
self::parseHeaderBlock(substr($part, 0, $split_pos)),
substr($part, $split_pos + 4),
];
}
/**
* Chooses which form of an alternative group to show and returns
* its position in the list. A sender offers the same message as,
* for example, both plain text and html; the standard lists them
* plainest first, so the viewer should take the richest form it
* can display. This prefers html, then a nested multipart, then
* plain text.
*
* @param array $parts the raw text of each alternative part
* @return int the zero-based index of the chosen part, or -1 when
* the group is empty
*/
protected static function preferredAlternativeIndex($parts)
{
$chosen = -1;
$chosen_rank = -1;
foreach ($parts as $index => $part) {
list($headers) = self::splitHeaderBody($part);
$part_ct = self::parseContentType(
$headers['content-type'] ?? 'text/plain');
$part_type = strtolower($part_ct['type']);
$rank = 0;
if ($part_type === 'text/plain') {
$rank = 1;
} else if (str_starts_with($part_type, 'multipart/')) {
$rank = 2;
} else if ($part_type === 'text/html') {
$rank = 3;
}
if ($rank >= $chosen_rank) {
$chosen_rank = $rank;
$chosen = $index;
}
}
return $chosen;
}
/**
* Reads a block of header lines into a name-to-value list keyed
* by lowercased name. A header whose value runs onto following
* lines (each continuation line begins with a space or tab) is
* joined back into one value. When the same header appears more
* than once, only the first is kept here; the uncommon need for
* every copy (the Received trail, assorted breadcrumb headers) is
* met by code that reads the original text instead.
*
* @param string $block the raw header lines, with no trailing
* blank line
* @return array the headers, keyed by lowercased name
*/
protected static function parseHeaderBlock($block)
{
$result = [];
if ($block === "") {
return $result;
}
$lines = explode("\r\n", $block);
$unfolded = [];
foreach ($lines as $line) {
if ($line === "") {
continue;
}
if (preg_match('/^[ \t]/', $line) && !empty($unfolded)) {
$unfolded[count($unfolded) - 1] .= ' ' .
ltrim($line);
continue;
}
$unfolded[] = $line;
}
foreach ($unfolded as $line) {
$colon = strpos($line, ':');
if ($colon === false) {
continue;
}
$name = strtolower(trim(substr($line, 0, $colon)));
$value = trim(substr($line, $colon + 1));
if (!isset($result[$name])) {
$result[$name] = $value;
}
}
return $result;
}
/**
* Takes apart a header that has the shape "value; name=value;
* name=value" โ most often the Content-Type header โ into its
* leading value and its list of named settings. A setting value
* wrapped in double quotes has the quotes removed. Setting names
* are lowercased; setting values keep their original
* capitalization, since some receivers treat filenames and
* character-set names as case-sensitive.
*
* @param string $header_value the raw header value
* @return array{type: string, params: array} the leading value
* under 'type' (empty when the input is empty) and the named
* settings under 'params'
*/
protected static function parseContentType($header_value)
{
$pieces = explode(';', $header_value);
$type = trim(array_shift($pieces) ?? '');
$params = [];
foreach ($pieces as $piece) {
$eq = strpos($piece, '=');
if ($eq === false) {
continue;
}
$name = strtolower(trim(substr($piece, 0, $eq)));
$value = trim(substr($piece, $eq + 1));
if (strlen($value) >= 2 && $value[0] === '"' &&
$value[strlen($value) - 1] === '"') {
$value = substr($value, 1, -1);
}
$params[$name] = $value;
}
return ['type' => $type, 'params' => $params];
}
/**
* Splits a multi-part body into its separate parts. A multi-part
* body keeps its parts apart with a chosen separator line; this
* cuts the body at that separator and returns each part (still
* holding its own headers and body). Any text before the first
* separator and after the closing separator is introductory or
* trailing filler and is dropped. The blank-line spacing that the
* separators add around each part is trimmed off.
*
* @param string $body the multi-part body
* @param string $boundary the separator string named in the
* body's Content-Type (without its leading dashes)
* @return array the separate part texts
*/
protected static function splitMultipart($body, $boundary)
{
$delim = '--' . $boundary;
$close = $delim . '--';
$pieces = explode($delim, $body);
/* the first element is the preamble (before the first
boundary); discard it. */
array_shift($pieces);
$result = [];
foreach ($pieces as $piece) {
if (str_starts_with($piece, '--')) {
/* close-delimiter reached; everything after is the
epilogue. */
break;
}
/* strip the leading CRLF that follows the delimiter
and the trailing CRLF that precedes the next one. */
$piece = preg_replace('/^\r?\n/', '', $piece);
$piece = preg_replace('/\r?\n$/', '', $piece);
$result[] = $piece;
}
return $result;
}
/**
* Finds the filename for an attached file. It first looks at the
* header that exists to name a downloaded file, and if that has no
* name, it falls back to the name carried on the part's
* content-type header. Both can in theory split a long or
* non-ASCII name across several pieces; that fuller form is not
* decoded yet, so only plain names are read. Returns an empty
* string when neither header supplies a name.
*
* @param array $headers the part's headers, keyed by lowercased
* name
* @param array $content_type_params the named settings already
* taken from the part's content-type header
* @return string the filename, or "" when none is given
*/
protected static function partFilename($headers,
$content_type_params)
{
$disposition_raw = $headers['content-disposition'] ?? '';
if ($disposition_raw !== '') {
$disp = self::parseContentType($disposition_raw);
if (!empty($disp['params']['filename'])) {
return self::decodeHeader(
$disp['params']['filename']);
}
}
if (!empty($content_type_params['name'])) {
return self::decodeHeader($content_type_params['name']);
}
return '';
}
}