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