/ tests / MimeMessageTest.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\MimeMessage;
use seekquarry\yioop\library\UnitTest;

/**
 * Unit tests for MimeMessage. Each case feeds a synthetic RFC822
 * message and checks the parsed object's headers, body fields, and
 * attachment list.
 *
 * @author Chris Pollett
 */
class MimeMessageTest 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()
    {
    }
    /**
     * The simplest case: a plain-text message with a few standard
     * headers and a single-line body. Headers should appear in the
     * dictionary; the body should be the one returned by body_text;
     * no HTML body and no attachments.
     */
    public function plainTextMessageTestCase()
    {
        $msg = "From: alice@example.com\r\n" .
            "To: bob@example.com\r\n" .
            "Subject: Hello\r\n" .
            "Date: Wed, 14 May 2026 12:00:00 +0000\r\n" .
            "Content-Type: text/plain; charset=utf-8\r\n" .
            "\r\n" .
            "Hi there.";
        $mime = MimeMessage::parse($msg);
        $this->assertEqual("Hello", $mime->headers['subject'],
            "Subject extracted");
        $this->assertEqual("alice@example.com",
            $mime->headers['from'], "From extracted");
        $this->assertEqual("Hi there.", $mime->body_text,
            "Body extracted as text");
        $this->assertEqual("", $mime->body_html,
            "No HTML body");
        $this->assertEqual(0, count($mime->attachments),
            "No attachments");
    }
    /**
     * A multipart/alternative with text/plain and text/html
     * branches. Both should land in their respective slots.
     */
    public function multipartAlternativeTestCase()
    {
        $msg = "From: alice@example.com\r\n" .
            "Subject: Both flavors\r\n" .
            "Content-Type: multipart/alternative; " .
            "boundary=\"BOUNDARY1\"\r\n" .
            "\r\n" .
            "--BOUNDARY1\r\n" .
            "Content-Type: text/plain; charset=utf-8\r\n" .
            "\r\n" .
            "Plain version\r\n" .
            "--BOUNDARY1\r\n" .
            "Content-Type: text/html; charset=utf-8\r\n" .
            "\r\n" .
            "<p>HTML version</p>\r\n" .
            "--BOUNDARY1--\r\n";
        $mime = MimeMessage::parse($msg);
        $this->assertEqual("Plain version", $mime->body_text,
            "Plain branch decoded");
        $this->assertEqual("<p>HTML version</p>", $mime->body_html,
            "HTML branch decoded");
    }
    /**
     * A multipart/mixed with a text body and two attachments. Body
     * goes to body_text; both attachments appear with the right
     * filename, size, and decoded content.
     */
    public function multipartMixedWithAttachmentsTestCase()
    {
        $pdf_bytes = "%PDF-1.4 fake content";
        $png_bytes = "fake-png-bytes";
        $msg = "From: alice@example.com\r\n" .
            "Subject: With files\r\n" .
            "Content-Type: multipart/mixed; " .
            "boundary=\"MIX1\"\r\n" .
            "\r\n" .
            "--MIX1\r\n" .
            "Content-Type: text/plain; charset=utf-8\r\n" .
            "\r\n" .
            "See attached.\r\n" .
            "--MIX1\r\n" .
            "Content-Type: application/pdf; name=\"report.pdf\"\r\n" .
            "Content-Disposition: attachment; " .
            "filename=\"report.pdf\"\r\n" .
            "Content-Transfer-Encoding: base64\r\n" .
            "\r\n" .
            base64_encode($pdf_bytes) . "\r\n" .
            "--MIX1\r\n" .
            "Content-Type: image/png; name=\"chart.png\"\r\n" .
            "Content-Disposition: attachment; " .
            "filename=\"chart.png\"\r\n" .
            "Content-Transfer-Encoding: base64\r\n" .
            "\r\n" .
            base64_encode($png_bytes) . "\r\n" .
            "--MIX1--\r\n";
        $mime = MimeMessage::parse($msg);
        $this->assertEqual("See attached.", $mime->body_text,
            "Body part extracted");
        $this->assertEqual(2, count($mime->attachments),
            "Two attachments found");
        $this->assertEqual("report.pdf",
            $mime->attachments[0]['filename'],
            "First attachment filename");
        $this->assertEqual("application/pdf",
            $mime->attachments[0]['mime_type'],
            "First attachment mime type");
        $this->assertEqual($pdf_bytes,
            $mime->attachments[0]['content'],
            "First attachment content decoded");
        $this->assertEqual(strlen($pdf_bytes),
            $mime->attachments[0]['size'],
            "First attachment size matches decoded length");
        $this->assertEqual("chart.png",
            $mime->attachments[1]['filename'],
            "Second attachment filename");
        $this->assertEqual($png_bytes,
            $mime->attachments[1]['content'],
            "Second attachment content decoded");
    }
    /**
     * A single-part message with Content-Transfer-Encoding: base64.
     * decodeBody must apply when extracting the text body.
     */
    public function base64BodyTestCase()
    {
        $raw = "Decoded body content\r\nWith two lines.";
        $msg = "From: a@x.org\r\n" .
            "Subject: B64\r\n" .
            "Content-Type: text/plain; charset=utf-8\r\n" .
            "Content-Transfer-Encoding: base64\r\n" .
            "\r\n" .
            base64_encode($raw);
        $mime = MimeMessage::parse($msg);
        $this->assertEqual($raw, $mime->body_text,
            "Base64 body decoded");
    }
    /**
     * Content-Transfer-Encoding: quoted-printable. The text body
     * should come back with =3D unescaped to = and so on.
     */
    public function quotedPrintableBodyTestCase()
    {
        $msg = "Subject: QP\r\n" .
            "Content-Type: text/plain; charset=utf-8\r\n" .
            "Content-Transfer-Encoding: quoted-printable\r\n" .
            "\r\n" .
            "=3D=3D Snowman: =E2=98=83 =3D=3D";
        $mime = MimeMessage::parse($msg);
        $this->assertEqual("== Snowman: \u{2603} ==",
            $mime->body_text,
            "Quoted-printable body decoded with snowman intact");
    }
    /**
     * A subject in RFC 2047 Q-encoded form. decodeHeader (called
     * from topLevelHeaders) should surface the decoded value.
     */
    public function encodedSubjectQTestCase()
    {
        $msg = "Subject: =?UTF-8?Q?Hello_=E2=98=83?=\r\n" .
            "Content-Type: text/plain\r\n" .
            "\r\n" .
            "body";
        $mime = MimeMessage::parse($msg);
        $this->assertEqual("Hello \u{2603}",
            $mime->headers['subject'],
            "Q-encoded subject decoded");
    }
    /**
     * A subject in RFC 2047 B-encoded form.
     */
    public function encodedSubjectBTestCase()
    {
        $b64 = base64_encode("Hello \u{2603}");
        $msg = "Subject: =?UTF-8?B?$b64?=\r\n" .
            "Content-Type: text/plain\r\n" .
            "\r\n" .
            "body";
        $mime = MimeMessage::parse($msg);
        $this->assertEqual("Hello \u{2603}",
            $mime->headers['subject'],
            "B-encoded subject decoded");
    }
    /**
     * A body in a non-UTF-8 charset (ISO-8859-1) must be converted
     * to UTF-8 when stored in body_text.
     */
    public function nonUtf8CharsetTestCase()
    {
        /* "café" with é encoded as 0xE9 in Latin-1. */
        $latin1 = "caf\xE9";
        $msg = "Subject: Latin1\r\n" .
            "Content-Type: text/plain; charset=iso-8859-1\r\n" .
            "\r\n" .
            $latin1;
        $mime = MimeMessage::parse($msg);
        $this->assertEqual("caf\u{00E9}", $mime->body_text,
            "Latin-1 body converted to UTF-8");
    }
    /**
     * A folded header (continuation line starting with whitespace)
     * must unfold into a single value.
     */
    public function foldedHeaderTestCase()
    {
        $msg = "Subject: This is a long subject line that\r\n" .
            " continues on the next line\r\n" .
            "Content-Type: text/plain\r\n" .
            "\r\n" .
            "body";
        $mime = MimeMessage::parse($msg);
        $this->assertEqual(
            "This is a long subject line that " .
            "continues on the next line",
            $mime->headers['subject'],
            "Folded subject unfolded");
    }
    /**
     * A message with LF-only line endings (not CRLF) must still
     * parse: real-world IMAP servers and PHP test setups both
     * sometimes hand us LF.
     */
    public function lfOnlyLineEndingsTestCase()
    {
        $msg = "Subject: LF only\n" .
            "Content-Type: text/plain\n" .
            "\n" .
            "body line one\nbody line two";
        $mime = MimeMessage::parse($msg);
        $this->assertEqual("LF only", $mime->headers['subject'],
            "Subject parsed despite LF line endings");
        $this->assertEqual("body line one\r\nbody line two",
            $mime->body_text,
            "Body parsed despite LF line endings (normalised " .
            "to CRLF on the way through)");
    }
    /**
     * build with no attachments produces a single-part
     * text/plain message that re-parses back to the same body.
     */
    public function buildPlainNoAttachmentsTestCase()
    {
        $bytes = MimeMessage::build("alice@example.com",
            "bob@example.com", "Hi", "Hello world.", []);
        $this->assertTrue(strpos($bytes, "From: alice@example.com")
            !== false, "From header present");
        $this->assertTrue(strpos($bytes, "To: bob@example.com")
            !== false, "To header present");
        $this->assertTrue(strpos($bytes, "Subject: Hi") !== false,
            "Subject header present");
        $this->assertTrue(strpos($bytes, "Message-ID: <") !== false,
            "Message-ID generated");
        $this->assertTrue(strpos($bytes,
            "Content-Type: text/plain; charset=utf-8") !== false,
            "single-part Content-Type");
        $this->assertTrue(strpos($bytes, "multipart") === false,
            "no multipart header in single-part case");
        $reparsed = MimeMessage::parse($bytes);
        $this->assertEqual("Hello world.", $reparsed->body_text,
            "round-trip body");
        $this->assertEqual(0, count($reparsed->attachments),
            "round-trip attachment count");
    }
    /**
     * build with one attachment produces a multipart/mixed
     * message with a text/plain first part and a base64
     * attachment, which re-parses back to the same body and
     * attachment.
     */
    public function buildOneAttachmentTestCase()
    {
        $pdf = "%PDF-1.4 test bytes\nwith two lines";
        $bytes = MimeMessage::build("a@x.org", "b@y.org",
            "Report", "See attached.", [[
                'filename' => 'report.pdf',
                'mime_type' => 'application/pdf',
                'content' => $pdf,
            ]]);
        $this->assertTrue(
            strpos($bytes, "multipart/mixed") !== false,
            "multipart/mixed Content-Type");
        $this->assertTrue(strpos($bytes, "boundary=") !== false,
            "boundary parameter present");
        $reparsed = MimeMessage::parse($bytes);
        $this->assertEqual("See attached.", $reparsed->body_text,
            "round-trip body");
        $this->assertEqual(1, count($reparsed->attachments),
            "one attachment found");
        $this->assertEqual("report.pdf",
            $reparsed->attachments[0]['filename'],
            "round-trip filename");
        $this->assertEqual($pdf,
            $reparsed->attachments[0]['content'],
            "round-trip attachment content");
    }
    /**
     * build with several attachments preserves them in order.
     */
    public function buildMultipleAttachmentsTestCase()
    {
        $bytes = MimeMessage::build("a@x.org", "b@y.org", "S",
            "B", [
            ['filename' => 'a.txt', 'mime_type' => 'text/plain',
                'content' => 'aaa'],
            ['filename' => 'b.bin',
                'mime_type' => 'application/octet-stream',
                'content' => "\x00\x01\x02"],
            ['filename' => 'c.json',
                'mime_type' => 'application/json',
                'content' => '{"k":1}'],
        ]);
        $reparsed = MimeMessage::parse($bytes);
        $this->assertEqual(3, count($reparsed->attachments),
            "three attachments");
        $this->assertEqual("a.txt",
            $reparsed->attachments[0]['filename'],
            "first preserved");
        $this->assertEqual("b.bin",
            $reparsed->attachments[1]['filename'],
            "second preserved");
        $this->assertEqual("c.json",
            $reparsed->attachments[2]['filename'],
            "third preserved");
        $this->assertEqual("\x00\x01\x02",
            $reparsed->attachments[1]['content'],
            "binary content round-trips");
    }
    /**
     * Filenames with spaces and special characters get quoted
     * in the Content-Type and Content-Disposition parameters so
     * the receiver sees the original name even when it includes
     * characters that would otherwise need separator escaping.
     */
    public function buildFilenameWithSpacesTestCase()
    {
        $bytes = MimeMessage::build("a@x.org", "b@y.org", "S",
            "B", [[
                'filename' => 'My Report Q4.pdf',
                'mime_type' => 'application/pdf',
                'content' => 'X',
            ]]);
        $this->assertTrue(strpos($bytes,
            'filename="My Report Q4.pdf"') !== false,
            "filename quoted with spaces preserved");
        $reparsed = MimeMessage::parse($bytes);
        $this->assertEqual("My Report Q4.pdf",
            $reparsed->attachments[0]['filename'],
            "round-trip preserves spaces");
    }
}
X