/ tests / MailHeaderParserTest.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\tests;

use seekquarry\yioop\library\mail\MailHeaderParser;
use seekquarry\yioop\library\UnitTest;

/**
 * Tests for MailHeaderParser: the parse() header-block parser,
 * decodeMimeWord() RFC 2047 decoder, and splitAddresses()
 * address-list splitter. All three methods are static pure
 * functions of their string arguments, so each test case is
 * self-contained; no setUp / tearDown is required.
 *
 * @author Chris Pollett
 */
class MailHeaderParserTest extends UnitTest
{
    /**
     * No setup state needed; the parser methods are static
     * pure functions of their string arguments.
     */
    public function setUp()
    {
    }
    /**
     * No teardown either; setUp does nothing.
     */
    public function tearDown()
    {
    }
    /**
     * A minimal header block with three simple headers and CRLF
     * terminators round-trips into a lowercase-keyed array with
     * leading whitespace stripped from each value.
     */
    public function parseBasicCrlfTestCase()
    {
        $bytes = "From: alice@example.com\r\n" .
            "To: bob@example.com\r\n" .
            "Subject: Hello world\r\n";
        $headers = MailHeaderParser::parse($bytes);
        $this->assertEqual('alice@example.com', $headers['from'],
            'From header round-trips with lowercase key');
        $this->assertEqual('bob@example.com', $headers['to'],
            'To header round-trips with lowercase key');
        $this->assertEqual('Hello world', $headers['subject'],
            'Subject value has leading whitespace stripped');
    }
    /**
     * Bare-LF line terminators are accepted as a fallback for
     * sources that did not produce strict CRLF (some mailbox
     * formats, some test setups).
     */
    public function parseBareLfTestCase()
    {
        $bytes = "From: a@b\n" .
            "Subject: bare-lf source\n";
        $headers = MailHeaderParser::parse($bytes);
        $this->assertEqual('a@b', $headers['from'],
            'Bare-LF From header parses');
        $this->assertEqual('bare-lf source', $headers['subject'],
            'Bare-LF Subject header parses');
    }
    /**
     * A header value continued on the next line (RFC 5322
     * folding: continuation starts with whitespace) is unfolded
     * into a single value with the continuation joined by a
     * space.
     */
    public function parseFoldedContinuationTestCase()
    {
        $bytes = "Subject: This is a very long subject\r\n" .
            " that wraps onto a second line\r\n" .
            "From: sender@example.com\r\n";
        $headers = MailHeaderParser::parse($bytes);
        $this->assertEqual(
            'This is a very long subject ' .
            'that wraps onto a second line',
            $headers['subject'],
            'Folded subject is unfolded with single-space join');
        $this->assertEqual('sender@example.com', $headers['from'],
            'Header after a fold still parses');
    }
    /**
     * Tab-led continuation lines fold the same way as space-led
     * ones (RFC 5322 LWS allows either).
     */
    public function parseFoldedTabTestCase()
    {
        $bytes = "References: <id1@example>\r\n" .
            "\t<id2@example>\r\n" .
            "\t<id3@example>\r\n";
        $headers = MailHeaderParser::parse($bytes);
        $this->assertEqual(
            '<id1@example> <id2@example> <id3@example>',
            $headers['references'],
            'Tab-led continuation lines fold like space-led');
    }
    /**
     * When the same header appears twice, the parser keeps the
     * later occurrence. This matches the common convention for
     * Subject/From/Date that appear at most once per RFC 5322 but
     * may be duplicated by buggy intermediaries.
     */
    public function parseDuplicateHeaderTestCase()
    {
        $bytes = "Subject: first\r\n" .
            "Subject: second\r\n";
        $headers = MailHeaderParser::parse($bytes);
        $this->assertEqual('second', $headers['subject'],
            'Duplicate Subject: later occurrence wins');
    }
    /**
     * A line with no colon is skipped without breaking the
     * surrounding parse (defensive against truncated /
     * malformed input).
     */
    public function parseMissingColonTestCase()
    {
        $bytes = "From: a@b\r\n" .
            "garbage-line-no-colon\r\n" .
            "Subject: still parses\r\n";
        $headers = MailHeaderParser::parse($bytes);
        $this->assertEqual('a@b', $headers['from'],
            'Header before garbage is preserved');
        $this->assertEqual('still parses', $headers['subject'],
            'Header after garbage is preserved');
        $this->assertTrue(
            !isset($headers['garbage-line-no-colon']),
            'Garbage line did not synthesise a header');
    }
    /**
     * An empty header block produces an empty array, not a
     * warning or null.
     */
    public function parseEmptyTestCase()
    {
        $headers = MailHeaderParser::parse('');
        $this->assertEqual([], $headers,
            'Empty input yields empty header array');
    }
    /**
     * Plain ASCII text passes through decodeMimeWord unchanged.
     */
    public function decodeMimePlainAsciiTestCase()
    {
        $decoded = MailHeaderParser::decodeMimeWord(
            'Hello, world!');
        $this->assertEqual('Hello, world!', $decoded,
            'Plain ASCII passes through unchanged');
    }
    /**
     * A single Q-encoded word with UTF-8 charset decodes to the
     * expected glyph.
     */
    public function decodeMimeQEncodedTestCase()
    {
        $decoded = MailHeaderParser::decodeMimeWord(
            '=?UTF-8?Q?caf=C3=A9?=');
        $this->assertEqual('café', $decoded,
            'Q-encoded UTF-8 round-trips to the right glyph');
    }
    /**
     * A single B-encoded (base64) word with UTF-8 charset
     * decodes to the expected text.
     */
    public function decodeMimeBEncodedTestCase()
    {
        /* base64("hello") = aGVsbG8= */
        $decoded = MailHeaderParser::decodeMimeWord(
            '=?UTF-8?B?aGVsbG8=?=');
        $this->assertEqual('hello', $decoded,
            'B-encoded UTF-8 round-trips');
    }
    /**
     * Two adjacent encoded-words are concatenated WITHOUT the
     * intervening whitespace per RFC 2047 Section 6.2; the
     * whitespace between them is presentation, not content.
     */
    public function decodeMimeAdjacentTestCase()
    {
        $decoded = MailHeaderParser::decodeMimeWord(
            '=?UTF-8?Q?hello?= =?UTF-8?Q?_world?=');
        /* the underscore in Q-encoding decodes to a space, so
           the result is "hello world" with the inter-word
           whitespace discarded and the encoded space honoured. */
        $this->assertEqual('hello world', $decoded,
            'Adjacent encoded-words drop the inter-word space');
    }
    /**
     * Mixed content (literal text + encoded-word + literal
     * text) preserves the literal pieces and decodes the
     * middle.
     */
    public function decodeMimeMixedTestCase()
    {
        $decoded = MailHeaderParser::decodeMimeWord(
            'A note from =?UTF-8?Q?Andr=C3=A9?= to you');
        $this->assertEqual('A note from André to you', $decoded,
            'Mixed literal/encoded text preserves both');
    }
    /**
     * A single bare address splits into a one-element list, the
     * pair shape is [name='', email=bare-token].
     */
    public function splitAddressesSingleTestCase()
    {
        $list = MailHeaderParser::splitAddresses(
            'alice@example.com');
        $this->assertEqual(1, count($list),
            'Single bare address yields one entry');
        $this->assertEqual('', $list[0][0],
            'Bare address has no name component');
        $this->assertEqual('alice@example.com', $list[0][1],
            'Bare address surfaces as the email portion');
    }
    /**
     * Two comma-separated bare addresses split into two entries
     * with leading whitespace stripped from each.
     */
    public function splitAddressesCommaTestCase()
    {
        $list = MailHeaderParser::splitAddresses(
            'a@x.com, b@y.com');
        $this->assertEqual(2, count($list),
            'Two comma-separated addresses yield two entries');
        $this->assertEqual('a@x.com', $list[0][1],
            'First entry email matches');
        $this->assertEqual('b@y.com', $list[1][1],
            'Second entry email matches');
    }
    /**
     * Display-name plus angle-bracketed address: name decoded
     * and unquoted, email taken from inside the angles. The
     * display-name commas inside double quotes must NOT split
     * the address list since they belong to the name and not
     * the recipient boundary.
     */
    public function splitAddressesQuotedNameCommaTestCase()
    {
        $list = MailHeaderParser::splitAddresses(
            '"Doe, Jane" <jane@x.com>, ' .
            '"Smith, Bob" <bob@y.com>');
        $this->assertEqual(2, count($list),
            'Two recipients despite commas inside quoted names');
        $this->assertEqual('Doe, Jane', $list[0][0],
            'First recipient name (unquoted) recovered');
        $this->assertEqual('jane@x.com', $list[0][1],
            'First recipient email recovered');
        $this->assertEqual('Smith, Bob', $list[1][0],
            'Second recipient name (unquoted) recovered');
        $this->assertEqual('bob@y.com', $list[1][1],
            'Second recipient email recovered');
    }
    /**
     * An empty input splits to an empty list, not a one-element
     * list containing an empty pair.
     */
    public function splitAddressesEmptyTestCase()
    {
        $list = MailHeaderParser::splitAddresses('');
        $this->assertEqual([], $list,
            'Empty input yields empty list');
    }
    /**
     * Surrounding whitespace on each address is stripped so
     * downstream consumers do not need to trim either part.
     */
    public function splitAddressesWhitespaceTestCase()
    {
        $list = MailHeaderParser::splitAddresses(
            '   a@x.com   ,    b@y.com   ');
        $this->assertEqual(2, count($list),
            'Whitespace-padded addresses still yield two entries');
        $this->assertEqual('a@x.com', $list[0][1],
            'First email is trimmed');
        $this->assertEqual('b@y.com', $list[1][1],
            'Second email is trimmed');
    }
    /**
     * parseAddressList splits on top-level commas only, keeping
     * quoted-name and angle-bracketed entries whole.
     */
    public function parseAddressListTestCase()
    {
        $list = MailHeaderParser::parseAddressList(
            'Jane <j@x>, "Doe, J" <d@y>');
        $this->assertEqual(2, count($list),
            'a quoted comma does not split the entry');
        $this->assertEqual('"Doe, J" <d@y>', $list[1],
            'the second spec is kept whole');
        $this->assertEqual([],
            MailHeaderParser::parseAddressList(''),
            'an empty header yields no entries');
    }
    /**
     * extractBareAddress unwraps angle brackets, trims, lower-cases.
     */
    public function extractBareAddressTestCase()
    {
        $this->assertEqual('j@x',
            MailHeaderParser::extractBareAddress('Jane <J@X>'),
            'the address is unwrapped and lower-cased');
        $this->assertEqual('plain@x',
            MailHeaderParser::extractBareAddress('  plain@x  '),
            'a bare address is trimmed');
    }
    /**
     * isValidAddress accepts a normal address and rejects ones with
     * no @ or nothing after it (true in both normal and test mode).
     */
    public function isValidAddressTestCase()
    {
        $this->assertTrue(
            MailHeaderParser::isValidAddress('user@example.com'),
            'a normal address is valid');
        $this->assertFalse(
            MailHeaderParser::isValidAddress('nope'),
            'an address with no @ is invalid');
        $this->assertFalse(
            MailHeaderParser::isValidAddress('a@'),
            'an address with nothing after @ is invalid');
    }
    /**
     * cleanHeader strips control characters, trims, and caps length.
     */
    public function cleanHeaderTestCase()
    {
        $this->assertEqual('abcd',
            MailHeaderParser::cleanHeader("a\r\nb\tcd", 100),
            'control characters are stripped');
        $this->assertEqual('hi',
            MailHeaderParser::cleanHeader('  hi  ', 100),
            'surrounding spaces are trimmed');
        $this->assertEqual('abc',
            MailHeaderParser::cleanHeader('abcdef', 3),
            'the value is capped at the maximum length');
    }
    /**
     * filterAddressList drops the excluded address and any already
     * present in the second list.
     */
    public function filterAddressListTestCase()
    {
        $result = MailHeaderParser::filterAddressList(
            'a@x, b@y, c@z', 'a@x', 'b@y');
        $this->assertEqual('c@z', $result,
            'the excluded and already-listed addresses are removed');
    }
    /**
     * joinAddressLists merges two lists, dropping duplicates and the
     * excluded address.
     */
    public function joinAddressListsTestCase()
    {
        $result = MailHeaderParser::joinAddressLists(
            'a@x, b@y', 'b@y, c@z', 'a@x');
        $this->assertEqual('b@y, c@z', $result,
            'duplicates and the excluded address are dropped');
    }
    /**
     * envelopeSender reads the Return-Path address, or empty when
     * the header is absent.
     */
    public function envelopeSenderTestCase()
    {
        $bytes = "Return-Path: <bounce@x>\r\nFrom: a@y\r\n" .
            "\r\nbody here";
        $this->assertEqual('bounce@x',
            MailHeaderParser::envelopeSender($bytes),
            'the Return-Path address is unwrapped');
        $this->assertEqual('',
            MailHeaderParser::envelopeSender("From: a@y\r\n\r\nb"),
            'no Return-Path yields an empty string');
    }
}
X