/ tests / ImapEnvelopeParserTest.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\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");
    }
}
X