/ src / library / mail / ImapEnvelopeParser.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;

/**
 * Tokenizes the IMAP ENVELOPE structure defined in RFC 3501
 * section 7.4.2 and surfaces the few fields the Mail activity
 * displays in its inbox listing: date, subject, and the first
 * from-address.
 *
 * The envelope is a parenthesized list of ten positional fields:
 * date, subject, from, sender, reply-to, to, cc, bcc, in-reply-to,
 * and message-id. Every field is one of three token forms: a
 * double-quoted string (with backslash escaping), the bare atom
 * NIL, or a nested parenthesized list. The address fields (from,
 * sender, reply-to, to, cc, bcc) are either NIL or a list of
 * address structures, each itself a four-element list of
 * personal-name, source-route, mailbox, and host.
 *
 * An earlier implementation matched the envelope with a single
 * regular expression that assumed date and subject were always
 * quoted and that the from-address always had an exact shape.
 * Real servers routinely send NIL for a missing Date header, quote
 * embedded characters, or vary spacing, so that approach silently
 * produced empty subjects and from-addresses for a large share of
 * messages. This tokenizer walks the grammar instead.
 *
 * Literal payloads ({N} length markers followed by N bytes inline,
 * as ImapClient::send() stitches them) are handled within a single
 * envelope string. The subject and the from personal-name are
 * RFC 2047 decoded, so a non-ASCII subject sent as an encoded-word
 * is surfaced as readable text rather than its raw encoded form.
 *
 * @author Chris Pollett
 */
class ImapEnvelopeParser
{
    /**
     * Parses an IMAP ENVELOPE structure into the display fields the
     * inbox listing needs. The input is a FETCH response line in
     * which an "ENVELOPE (...)" item appears; the envelope list is
     * located within it and walked. A line with no ENVELOPE item
     * yields all-empty fields.
     *
     * @param string $envelope_text a FETCH line containing an
     *      ENVELOPE item
     * @return array associative array with DATE, SUBJECT, and FROM
     *      keys; any field the envelope leaves as NIL or that
     *      cannot be parsed comes back as the empty string
     */
    public static function parse($envelope_text)
    {
        $result = ["DATE" => "", "SUBJECT" => "", "FROM" => ""];
        $start = self::findEnvelopeStart($envelope_text);
        if ($start < 0) {
            return $result;
        }
        $pos = $start;
        $fields = self::parseList($envelope_text, $pos);
        if ($fields === null) {
            return $result;
        }
        if (isset($fields[0]) && is_string($fields[0])) {
            $result["DATE"] = $fields[0];
        }
        if (isset($fields[1]) && is_string($fields[1])) {
            $result["SUBJECT"] = self::decodeWord($fields[1]);
        }
        if (isset($fields[2]) && is_array($fields[2])) {
            $result["FROM"] = self::formatFirstAddress($fields[2]);
        }
        return $result;
    }

    /**
     * Locates the opening parenthesis of the envelope list within a
     * FETCH response line. Returns -1 when the line carries no
     * ENVELOPE item at all; the caller then yields empty fields
     * rather than misreading some other parenthesized FETCH data
     * (FLAGS, the UID list) as if it were the envelope.
     *
     * @param string $text the text to scan
     * @return int the index of the envelope's opening "(", or -1
     *      when no envelope list is present
     */
    private static function findEnvelopeStart($text)
    {
        $marker = strpos($text, "ENVELOPE (");
        if ($marker !== false) {
            return $marker + strlen("ENVELOPE ");
        }
        return -1;
    }

    /**
     * Parses a parenthesized list beginning at $text[$pos], which
     * must be "(". Advances $pos past the closing ")". List
     * elements are quoted strings, the NIL atom (returned as the
     * empty string), nested lists (returned as arrays), or literal
     * payloads. Returns null when the text does not start a
     * well-formed list.
     *
     * @param string $text the full text being parsed
     * @param int &$pos cursor into $text; advanced past the list
     * @return array|null the parsed elements, or null on malformed
     *      input
     */
    private static function parseList($text, &$pos)
    {
        $length = strlen($text);
        if ($pos >= $length || $text[$pos] !== "(") {
            return null;
        }
        $pos++;
        $elements = [];
        while ($pos < $length) {
            if ($text[$pos] === " ") {
                $pos++;
                continue;
            }
            if ($text[$pos] === ")") {
                $pos++;
                return $elements;
            }
            if ($text[$pos] === "(") {
                $nested = self::parseList($text, $pos);
                if ($nested === null) {
                    return null;
                }
                $elements[] = $nested;
                continue;
            }
            $elements[] = self::parseAtom($text, $pos);
        }
        return null;
    }

    /**
     * Parses a single non-list token beginning at $text[$pos]: a
     * double-quoted string, a {N} literal, or a bare atom such as
     * NIL. Advances $pos past the token. NIL is normalized to the
     * empty string so callers can treat a missing field and an
     * empty field alike.
     *
     * @param string $text the full text being parsed
     * @param int &$pos cursor into $text; advanced past the token
     * @return string the token's string value
     */
    private static function parseAtom($text, &$pos)
    {
        $length = strlen($text);
        if ($text[$pos] === '"') {
            return self::parseQuoted($text, $pos);
        }
        if ($text[$pos] === "{") {
            return self::parseLiteral($text, $pos);
        }
        $token = "";
        while ($pos < $length && $text[$pos] !== " " &&
            $text[$pos] !== "(" && $text[$pos] !== ")") {
            $token .= $text[$pos];
            $pos++;
        }
        if ($token === "NIL") {
            return "";
        }
        return $token;
    }

    /**
     * Parses a double-quoted string beginning at $text[$pos], which
     * must be the opening quote. A backslash escapes the following
     * character, so an embedded quote or backslash is taken
     * literally. Advances $pos past the closing quote.
     *
     * @param string $text the full text being parsed
     * @param int &$pos cursor into $text; advanced past the string
     * @return string the unescaped string contents
     */
    private static function parseQuoted($text, &$pos)
    {
        $length = strlen($text);
        $pos++;
        $value = "";
        while ($pos < $length) {
            $char = $text[$pos];
            if ($char === "\\" && $pos + 1 < $length) {
                $value .= $text[$pos + 1];
                $pos += 2;
                continue;
            }
            if ($char === '"') {
                $pos++;
                return $value;
            }
            $value .= $char;
            $pos++;
        }
        return $value;
    }

    /**
     * Parses a {N} literal beginning at $text[$pos], which must be
     * the opening brace. ImapClient::send() stitches a literal's
     * payload inline immediately after the brace marker, so the N
     * bytes following the "}" are the value. Advances $pos past the
     * payload.
     *
     * @param string $text the full text being parsed
     * @param int &$pos cursor into $text; advanced past the literal
     * @return string the literal payload, or the empty string when
     *      the marker is malformed
     */
    private static function parseLiteral($text, &$pos)
    {
        $length = strlen($text);
        $close = strpos($text, "}", $pos);
        if ($close === false) {
            $pos = $length;
            return "";
        }
        $count = (int) substr($text, $pos + 1, $close - $pos - 1);
        $payload_start = $close + 1;
        $value = substr($text, $payload_start, $count);
        $pos = $payload_start + $count;
        return $value;
    }

    /**
     * Formats the first address structure from a parsed address
     * list into a display string. Each address structure is a
     * four-element list: personal name, source route, mailbox
     * (local part), and host. The result is "Name <local@host>"
     * when a personal name is present, "local@host" otherwise, and
     * the empty string when the list holds no usable address.
     *
     * @param array $address_list the parsed list of address
     *      structures
     * @return string the formatted first address
     */
    private static function formatFirstAddress($address_list)
    {
        if (empty($address_list) || !is_array($address_list[0])) {
            return "";
        }
        $first = $address_list[0];
        $name = isset($first[0]) && is_string($first[0]) ?
            self::decodeWord($first[0]) : "";
        $local = isset($first[2]) && is_string($first[2]) ?
            $first[2] : "";
        $host = isset($first[3]) && is_string($first[3]) ?
            $first[3] : "";
        if ($local === "" && $host === "") {
            return $name;
        }
        $address = $host === "" ? $local : "$local@$host";
        if ($name !== "") {
            return "$name <$address>";
        }
        return $address;
    }

    /**
     * Decodes a header value that may contain RFC 2047 encoded-words
     * (the "=?charset?encoding?text?=" form used for non-ASCII
     * subjects and display names). A value with no encoded-word is
     * returned unchanged, so this is safe to apply to every field.
     *
     * RFC 2047 section 6.2 requires that linear whitespace
     * separating two adjacent encoded-words be discarded on
     * display; mb_decode_mimeheader does not do this on its own, so
     * the whitespace between an encoded-word terminator and the next
     * encoded-word introducer is collapsed first. Whitespace that
     * separates an encoded-word from ordinary text is left alone.
     *
     * @param string $value the raw header value
     * @return string the value with any encoded-words decoded
     */
    private static function decodeWord($value)
    {
        if (strpos($value, "=?") === false) {
            return $value;
        }
        $stitched = preg_replace('/\?=\s+=\?/', "?==?", $value);
        return mb_decode_mimeheader($stitched);
    }
}
X