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