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