/ src / library / mail / MailHeaderParser.php
<?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;

/**
 * Light-weight RFC 5322 header parser used by Yioop's webmail to
 * extract Subject / From / Date / Delivered-To / Message-ID and
 * similar from a raw header byte block (typically returned by
 * MailSite::messageHeaderBytes). Designed to handle the common-
 * case real-world mail correctly without pulling in a full MIME
 * parser dependency.
 *
 * What's supported:
 *   - Header unfolding (continuation lines starting with WSP)
 *   - Case-insensitive header names (normalized to lower-case
 *     keys on output)
 *   - Both CRLF and bare-LF line terminators
 *   - RFC 2047 encoded-word decoding for Q and B encodings,
 *     UTF-8, ISO-8859-1, and us-ascii
 *   - Adjacent encoded-words concatenation (RFC 2047 Section 6.2)
 *   - Address-list split into [name, email] pairs
 *
 * What's NOT supported:
 *   - Group-list address syntax (group-name: a@b, c@d;)
 *   - Comments inside the header value (RFC 5322 allows them but
 *     they almost never appear in From/To/Cc)
 *   - Encoded-word charsets beyond UTF-8 / ISO-8859-1 / us-ascii
 *     (anything else returns the raw bytes for graceful
 *     degradation)
 *
 * Intentionally not exposed: the parse-all variant that
 * preserves every Received line. The handful of headers Yioop
 * cares about today are not the kind that repeat with semantic
 * significance, so the simpler name=>value shape is enough.
 *
 * @author Chris Pollett
 */
class MailHeaderParser
{
    /**
     * Parses the raw RFC 5322 header bytes of a message into a
     * lower-case-keyed associative array. Folded continuation
     * lines are unfolded into a single value joined by a space.
     * Duplicate headers: later occurrence wins (good enough for
     * Subject / From / Date / Delivered-To since these typically
     * appear at most once).
     *
     * @param string $bytes the raw header block bytes; everything
     *      before the first blank line. Both CRLF and bare-LF
     *      line terminators are accepted
     * @return array map of lower-case header name to value
     */
    public static function parse($bytes)
    {
        $bytes = str_replace("\r\n", "\n", (string) $bytes);
        $lines = explode("\n", $bytes);
        $headers = [];
        $current_name = null;
        $current_value = '';
        foreach ($lines as $line) {
            if ($line === '') {
                continue;
            }
            $first = $line[0];
            if ($first === ' ' || $first === "\t") {
                if ($current_name !== null) {
                    $current_value .= ' ' . trim($line);
                }
                continue;
            }
            if ($current_name !== null) {
                $headers[$current_name] = $current_value;
            }
            $colon = strpos($line, ':');
            if ($colon === false) {
                $current_name = null;
                $current_value = '';
                continue;
            }
            $current_name = strtolower(substr($line, 0, $colon));
            $current_value = ltrim(substr($line, $colon + 1));
        }
        if ($current_name !== null) {
            $headers[$current_name] = $current_value;
        }
        return $headers;
    }
    /**
     * Decodes an RFC 2047 encoded-word string in place: replaces
     * each =?charset?encoding?data?= run with its decoded form.
     * Encoded-word runs are case-insensitively matched. Adjacent
     * encoded-words are concatenated without the separating
     * whitespace as required by RFC 2047 Section 6.2.
     *
     * @param string $text header value possibly containing
     *      encoded-words
     * @return string decoded UTF-8 string
     */
    public static function decodeMimeWord($text)
    {
        $text = (string) $text;
        if (strpos($text, '=?') === false) {
            return $text;
        }
        $pattern = '/=\?([^?]+)\?([QqBb])\?([^?]*)\?=' .
            '(\s+(?==\?))?/';
        return preg_replace_callback($pattern,
            function ($match) {
                $charset = strtolower($match[1]);
                $encoding = strtolower($match[2]);
                $payload = $match[3];
                if ($encoding === 'q') {
                    $payload = str_replace('_', ' ', $payload);
                    $decoded = quoted_printable_decode($payload);
                } else {
                    $decoded = base64_decode($payload, true);
                    if ($decoded === false) {
                        return $match[0];
                    }
                }
                if ($charset === 'utf-8' || $charset === 'us-ascii') {
                    return $decoded;
                }
                if ($charset === 'iso-8859-1') {
                    return mb_convert_encoding($decoded, 'UTF-8',
                        'ISO-8859-1');
                }
                return $decoded;
            },
            $text);
    }
    /**
     * Splits a From/To/Cc address-list header value into its
     * component addresses. Each entry comes back as a two-element
     * array [name, email]. Display names are unquoted and
     * decoded from RFC 2047 encoded-words if present. The email
     * portion is whatever is between the angle brackets, or the
     * whole token when no brackets are present.
     *
     * @param string $value the header value bytes
     * @return array list of [name, email] pairs
     */
    public static function splitAddresses($value)
    {
        $value = (string) $value;
        if ($value === '') {
            return [];
        }
        $items = [];
        $buffer = '';
        $depth = 0;
        $in_quote = false;
        $length = strlen($value);
        for ($pos = 0; $pos < $length; $pos++) {
            $character = $value[$pos];
            if ($character === '"' &&
                    ($pos === 0 || $value[$pos - 1] !== '\\')) {
                $in_quote = !$in_quote;
                $buffer .= $character;
                continue;
            }
            if (!$in_quote) {
                if ($character === '<') {
                    $depth++;
                } else if ($character === '>' && $depth > 0) {
                    $depth--;
                } else if ($character === ',' && $depth === 0) {
                    $entry = self::splitOneAddress(trim($buffer));
                    if ($entry !== null) {
                        $items[] = $entry;
                    }
                    $buffer = '';
                    continue;
                }
            }
            $buffer .= $character;
        }
        $entry = self::splitOneAddress(trim($buffer));
        if ($entry !== null) {
            $items[] = $entry;
        }
        return $items;
    }
    /**
     * Helper for splitAddresses: decomposes one address token
     * into [name, email]. Recognizes the common forms
     * "Name <a@b>", "<a@b>", and bare "a@b". Quotation marks
     * around the name are stripped; RFC 2047 encoded-words in
     * the name are decoded.
     *
     * @param string $token one trimmed address-list entry
     * @return array|null [name, email] pair, or null if the
     *      token is empty
     */
    protected static function splitOneAddress($token)
    {
        if ($token === '') {
            return null;
        }
        $name = '';
        $email = '';
        $left = strpos($token, '<');
        $right = strrpos($token, '>');
        if ($left !== false && $right !== false && $right > $left) {
            $email = trim(substr($token, $left + 1,
                $right - $left - 1));
            $name = trim(substr($token, 0, $left));
        } else {
            $email = trim($token);
        }
        if ($name !== '' && $name[0] === '"' &&
                substr($name, -1) === '"') {
            $name = substr($name, 1, -1);
        }
        $name = self::decodeMimeWord($name);
        return [$name, $email];
    }
    /**
     * Splits a comma-separated address header into its individual
     * address specs, keeping each one whole. A comma inside a quoted
     * display name or inside the angle brackets around an address is
     * not a separator, so "Doe, Jane <j@x>" stays one entry. Returns
     * the trimmed specs as written, with empty pieces dropped.
     *
     * @param string $list the raw address header value
     * @return array the individual address specs
     */
    public static function parseAddressList($list)
    {
        $out = [];
        if ($list === '') {
            return $out;
        }
        $parts = [];
        $buffer = '';
        $in_quote = false;
        $in_angle = false;
        $length = strlen($list);
        for ($index = 0; $index < $length; $index++) {
            $character = $list[$index];
            if ($character === '"' && !$in_angle) {
                $in_quote = !$in_quote;
                $buffer .= $character;
                continue;
            }
            if ($character === '<' && !$in_quote) {
                $in_angle = true;
                $buffer .= $character;
                continue;
            }
            if ($character === '>' && !$in_quote) {
                $in_angle = false;
                $buffer .= $character;
                continue;
            }
            if ($character === ',' && !$in_quote && !$in_angle) {
                $parts[] = $buffer;
                $buffer = '';
                continue;
            }
            $buffer .= $character;
        }
        if ($buffer !== '') {
            $parts[] = $buffer;
        }
        foreach ($parts as $part) {
            $trimmed = trim($part);
            if ($trimmed !== '') {
                $out[] = $trimmed;
            }
        }
        return $out;
    }
    /**
     * Pulls the plain email address out of one address spec, dropping
     * any display name. "Jane <j@x>" becomes "j@x"; a spec that is
     * already bare comes back unchanged. The result is trimmed and
     * lower-cased so two writings of the same address compare equal.
     *
     * @param string $spec one address spec
     * @return string the bare lower-cased email address
     */
    public static function extractBareAddress($spec)
    {
        $open = strrpos($spec, '<');
        $close = strrpos($spec, '>');
        if ($open !== false && $close !== false &&
                $close > $open) {
            $bare = substr($spec, $open + 1,
                $close - $open - 1);
        } else {
            $bare = $spec;
        }
        return strtolower(trim($bare));
    }
    /**
     * Says whether a bare email address looks deliverable. Normally
     * this is PHP's email validator; in test mode it relaxes to a
     * simple "something@something" check so test addresses with
     * made-up domains still pass.
     *
     * @param string $bare a bare email address
     * @return bool true when the address looks valid
     */
    public static function isValidAddress($bare)
    {
        if (C\p('MAIL_TEST_MODE')) {
            $at_pos = strrpos($bare, '@');
            if ($at_pos === false || $at_pos === 0 ||
                    $at_pos === strlen($bare) - 1) {
                return false;
            }
            return true;
        }
        return (bool) filter_var($bare, FILTER_VALIDATE_EMAIL);
    }
    /**
     * Makes a user-supplied value safe to place in a single mail
     * header: control characters (which could otherwise smuggle in
     * extra header lines) are stripped, surrounding spaces removed,
     * and the value capped at a maximum length.
     *
     * @param string $value the raw value
     * @param int $max_len the most characters to keep
     * @return string the cleaned, length-capped value
     */
    public static function cleanHeader($value, $max_len)
    {
        $value = preg_replace('/[\x00-\x1F\x7F]/', '',
            (string) $value);
        $value = trim($value);
        if (strlen($value) > $max_len) {
            $value = substr($value, 0, $max_len);
        }
        return $value;
    }
    /**
     * Removes specific recipients from an address header: the one
     * address in $exclude_email, and everyone already present in
     * $exclude_listed. Used when building a reply so the sender and
     * the existing recipients are not added again. Returns a
     * comma-joined header of what remains.
     *
     * @param string $list the address header to filter
     * @param string $exclude_email a bare address to drop
     * @param string $exclude_listed an address header whose members
     *      are also dropped
     * @return string the filtered, comma-joined address header
     */
    public static function filterAddressList($list, $exclude_email,
        $exclude_listed)
    {
        $listed_bare = [];
        foreach (self::parseAddressList($exclude_listed) as $addr) {
            $bare = self::extractBareAddress($addr);
            if ($bare !== '') {
                $listed_bare[$bare] = true;
            }
        }
        $out = [];
        foreach (self::parseAddressList($list) as $addr) {
            $bare = self::extractBareAddress($addr);
            if ($bare === $exclude_email) {
                continue;
            }
            if (isset($listed_bare[$bare])) {
                continue;
            }
            $out[] = $addr;
        }
        return implode(', ', $out);
    }
    /**
     * Merges two address headers into one, dropping duplicates and a
     * given address. Used when forwarding or replying to combine
     * recipient lists without repeating anyone or re-including the
     * sender. Returns a comma-joined header.
     *
     * @param string $list_a the first address header
     * @param string $list_b the second address header
     * @param string $exclude_email a bare address to drop
     * @return string the merged, comma-joined address header
     */
    public static function joinAddressLists($list_a, $list_b,
        $exclude_email)
    {
        $seen = [];
        $out = [];
        foreach ([$list_a, $list_b] as $list) {
            foreach (self::parseAddressList($list) as $addr) {
                $bare = self::extractBareAddress($addr);
                if ($bare === $exclude_email) {
                    continue;
                }
                if (isset($seen[$bare])) {
                    continue;
                }
                $seen[$bare] = true;
                $out[] = $addr;
            }
        }
        return implode(', ', $out);
    }
    /**
     * Reads the envelope sender (the Return-Path address) out of a
     * raw message's headers, or returns an empty string when there
     * is none. Only the header block is scanned, and the address is
     * unwrapped from its angle brackets and trimmed.
     *
     * @param string $bytes the raw message bytes
     * @return string the bare Return-Path address, or empty string
     */
    public static function envelopeSender($bytes)
    {
        $bytes = (string) $bytes;
        $break = strpos($bytes, "\r\n\r\n");
        if ($break === false) {
            $break = strpos($bytes, "\n\n");
        }
        $header_block = ($break === false) ? $bytes :
            substr($bytes, 0, $break);
        if (!preg_match('/^Return-Path:\s*(.*)$/mi', $header_block,
            $match)) {
            return '';
        }
        $value = trim($match[1]);
        $value = trim($value, "<>");
        $value = trim($value);
        return $value;
    }
}
X