<?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);
}
}