<?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\ImapEnvelopeParser;
use seekquarry\yioop\library\UnitTest;
/**
* Unit tests for ImapEnvelopeParser. Each test feeds a FETCH
* response line shaped the way a real IMAP server sends it and
* checks that date, subject, and the first from-address come back
* correctly. The cases deliberately concentrate on the shapes the
* earlier regex-based parser got wrong: a NIL date, a subject with
* embedded escaped quotes, a NIL subject, a from-address with a NIL
* personal name, and a literal-carried subject.
*
* @author Chris Pollett
*/
class ImapEnvelopeParserTest extends UnitTest
{
/**
* No setup state is needed; the parser is pure and static.
*/
public function setUp()
{
}
/**
* No setup state is needed; nothing to tear down.
*/
public function tearDown()
{
}
/**
* A fully-quoted envelope: every field present and quoted, one
* from-address with a personal name. This is the easy shape the
* old parser also handled, kept as a baseline.
*/
public function fullyQuotedEnvelopeTestCase()
{
$line = '* 5 FETCH (UID 100 FLAGS (\\Seen) ENVELOPE (' .
'"Wed, 13 May 2026 20:59:19 +0000" "Hello there" ' .
'(("Jane Roe" NIL "jane" "example.com")) ' .
'(("Jane Roe" NIL "jane" "example.com")) ' .
'(("Jane Roe" NIL "jane" "example.com")) ' .
'(("You" NIL "you" "pollett.org")) NIL NIL NIL ' .
'"<msg-1@example.com>"))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual("Wed, 13 May 2026 20:59:19 +0000",
$envelope["DATE"], "Quoted date is recovered verbatim");
$this->assertEqual("Hello there", $envelope["SUBJECT"],
"Quoted subject is recovered verbatim");
$this->assertEqual("Jane Roe <jane@example.com>",
$envelope["FROM"],
"From-address formats as 'Name <local@host>'");
}
/**
* A message with no Date header: the server sends NIL in the
* date position. The old regex required a quote immediately
* after the envelope's opening parenthesis, so a NIL date made
* it fail to match and the subject came back empty too. Here
* the date must be empty but the subject must still parse.
*/
public function nilDateStillParsesSubjectTestCase()
{
$line = '* 6 FETCH (UID 101 FLAGS () ENVELOPE (' .
'NIL "Subject survives a NIL date" ' .
'(("Sender Name" NIL "sender" "example.org")) ' .
'NIL NIL NIL NIL NIL NIL "<msg-2@example.org>"))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual("", $envelope["DATE"],
"A NIL date comes back as the empty string");
$this->assertEqual("Subject survives a NIL date",
$envelope["SUBJECT"],
"Subject still parses when the date field is NIL");
$this->assertEqual("Sender Name <sender@example.org>",
$envelope["FROM"],
"From-address still parses when the date field is NIL");
}
/**
* A subject containing backslash-escaped quotes. The old
* parser's [^"]* run stopped at the first inner quote and
* truncated or lost the subject. The tokenizer must treat a
* backslash as escaping the next character.
*/
public function escapedQuotesInSubjectTestCase()
{
$line = '* 7 FETCH (UID 102 FLAGS () ENVELOPE (' .
'"Wed, 13 May 2026 10:00:00 +0000" ' .
'"She said \\"hello\\" to everyone" ' .
'(("A" NIL "a" "example.com")) NIL NIL NIL NIL NIL ' .
'NIL "<msg-3@example.com>"))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual('She said "hello" to everyone',
$envelope["SUBJECT"],
"Escaped quotes inside a subject are unescaped, not " .
"treated as string terminators");
}
/**
* A message with no Subject header: NIL in the subject
* position. The subject must come back as the empty string,
* and the surrounding fields must still parse.
*/
public function nilSubjectTestCase()
{
$line = '* 8 FETCH (UID 103 FLAGS () ENVELOPE (' .
'"Wed, 13 May 2026 11:00:00 +0000" NIL ' .
'(("B" NIL "b" "example.com")) NIL NIL NIL NIL NIL ' .
'NIL "<msg-4@example.com>"))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual("", $envelope["SUBJECT"],
"A NIL subject comes back as the empty string");
$this->assertEqual("Wed, 13 May 2026 11:00:00 +0000",
$envelope["DATE"],
"Date still parses when the subject field is NIL");
$this->assertEqual("B <b@example.com>", $envelope["FROM"],
"From-address still parses when the subject is NIL");
}
/**
* A from-address whose personal-name element is NIL: the
* address must format as the bare "local@host" with no angle
* brackets and no leading space.
*/
public function nilPersonalNameAddressTestCase()
{
$line = '* 9 FETCH (UID 104 FLAGS () ENVELOPE (' .
'"Wed, 13 May 2026 12:00:00 +0000" "Plain from" ' .
'((NIL NIL "noreply" "service.example")) ' .
'NIL NIL NIL NIL NIL NIL "<msg-5@service.example>"))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual("noreply@service.example",
$envelope["FROM"],
"An address with a NIL personal name formats as " .
"bare local@host");
}
/**
* A subject delivered as a {N} literal rather than a quoted
* string, the way ImapClient::send() stitches a literal inline
* after its brace marker. The tokenizer must read exactly N
* bytes as the value.
*/
public function literalSubjectTestCase()
{
$subject = "Literal subject of known length";
$line = '* 10 FETCH (UID 105 FLAGS () ENVELOPE (' .
'"Wed, 13 May 2026 13:00:00 +0000" {' .
strlen($subject) . '}' . $subject . ' ' .
'(("C" NIL "c" "example.com")) NIL NIL NIL NIL NIL ' .
'NIL "<msg-6@example.com>"))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual($subject, $envelope["SUBJECT"],
"A literal-carried subject is read by its byte count");
$this->assertEqual("C <c@example.com>", $envelope["FROM"],
"Fields after a literal still parse");
}
/**
* Input with no ENVELOPE item at all: every field must come
* back as the empty string rather than the parser erroring.
*/
public function noEnvelopePresentTestCase()
{
$line = '* 11 FETCH (UID 106 FLAGS (\\Seen))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual("", $envelope["DATE"],
"Missing envelope yields an empty date");
$this->assertEqual("", $envelope["SUBJECT"],
"Missing envelope yields an empty subject");
$this->assertEqual("", $envelope["FROM"],
"Missing envelope yields an empty from-address");
}
/**
* A subject sent as a single RFC 2047 Q-encoded word. The
* encoded form must be decoded to readable text; the "Q"
* encoding uses "_" for space and "=XX" for other bytes.
*/
public function encodedWordSubjectTestCase()
{
$line = '* 12 FETCH (UID 107 FLAGS () ENVELOPE (' .
'"Wed, 13 May 2026 14:00:00 +0000" ' .
'"=?us-ascii?Q?Requisition:_awaiting_your_approval?=" ' .
'(("S" NIL "s" "example.com")) NIL NIL NIL NIL NIL ' .
'NIL "<msg-7@example.com>"))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual("Requisition: awaiting your approval",
$envelope["SUBJECT"],
"A single Q-encoded word subject is decoded to text");
}
/**
* A subject split across two adjacent encoded-words separated
* only by whitespace. RFC 2047 section 6.2 requires that
* separating whitespace be dropped, so the two halves must
* rejoin with no space where the split fell.
*/
public function adjacentEncodedWordsTestCase()
{
$line = '* 13 FETCH (UID 108 FLAGS () ENVELOPE (' .
'"Wed, 13 May 2026 15:00:00 +0000" ' .
'"=?us-ascii?Q?Scheduled_Outage:_CFS_Productio?= ' .
'=?us-ascii?Q?n_and_CFS_Data_Warehouse?=" ' .
'(("F" NIL "f" "example.com")) NIL NIL NIL NIL NIL ' .
'NIL "<msg-8@example.com>"))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual(
"Scheduled Outage: CFS Production and CFS Data Warehouse",
$envelope["SUBJECT"],
"Whitespace between adjacent encoded-words is dropped " .
"so the split word rejoins");
}
/**
* A subject sent as a B-encoded (base64) word, the other
* RFC 2047 encoding. It must decode just as the Q form does.
*/
public function base64EncodedWordSubjectTestCase()
{
$line = '* 14 FETCH (UID 109 FLAGS () ENVELOPE (' .
'"Wed, 13 May 2026 16:00:00 +0000" ' .
'"=?UTF-8?B?SGVsbG8gV29ybGQ=?=" ' .
'(("B" NIL "b" "example.com")) NIL NIL NIL NIL NIL ' .
'NIL "<msg-9@example.com>"))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual("Hello World", $envelope["SUBJECT"],
"A B-encoded (base64) word subject is decoded to text");
}
/**
* A plain ASCII subject with no encoded-word must pass through
* the decode step completely unchanged, including any literal
* "=" characters that are not part of an encoded-word.
*/
public function plainSubjectUnchangedByDecodeTestCase()
{
$line = '* 15 FETCH (UID 110 FLAGS () ENVELOPE (' .
'"Wed, 13 May 2026 17:00:00 +0000" ' .
'"Budget = revenue minus cost" ' .
'(("P" NIL "p" "example.com")) NIL NIL NIL NIL NIL ' .
'NIL "<msg-10@example.com>"))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual("Budget = revenue minus cost",
$envelope["SUBJECT"],
"A plain subject is unchanged by the decode step");
}
/**
* A from-address whose personal name is an RFC 2047 encoded
* word must have that name decoded, while the mailbox and host
* (never encoded-words) are left as-is.
*/
public function encodedWordFromNameTestCase()
{
$line = '* 16 FETCH (UID 111 FLAGS () ENVELOPE (' .
'"Wed, 13 May 2026 18:00:00 +0000" "Plain subject" ' .
'(("=?us-ascii?Q?Finance_Connect?=" NIL ' .
'"noreply" "example.edu")) NIL NIL NIL NIL NIL NIL ' .
'"<msg-11@example.edu>"))';
$envelope = ImapEnvelopeParser::parse($line);
$this->assertEqual(
"Finance Connect <noreply@example.edu>",
$envelope["FROM"],
"An encoded-word personal name is decoded in the " .
"formatted from-address");
}
}