diff --git a/.github/scripts/random-tests-config.txt b/.github/scripts/random-tests-config.txt index 413fe10c8d89..1769250eead0 100644 --- a/.github/scripts/random-tests-config.txt +++ b/.github/scripts/random-tests-config.txt @@ -20,7 +20,7 @@ Cookie # DataConverter # Database # Debug -# Email +Email # Encryption # Entity Events diff --git a/tests/system/Email/EmailTest.php b/tests/system/Email/EmailTest.php index 9ca5c5a51a4a..8d5f80a28d2f 100644 --- a/tests/system/Email/EmailTest.php +++ b/tests/system/Email/EmailTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Email; +use Closure; use CodeIgniter\Events\Events; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockEmail; @@ -31,6 +32,11 @@ final class EmailTest extends CIUnitTestCase { use ReflectionHelper; + protected function tearDown(): void + { + parent::tearDown(); + } + public function testEmailValidation(): void { $config = config('Email'); @@ -409,4 +415,486 @@ public function testPrepQuotedPrintableUnwrapRemovesTagsOnly(): void $this->assertSame('keep =7Bbraces=7D', $prepQP('keep {braces}')); $this->assertSame('keep (parentheses)', $prepQP('keep (parentheses)')); } + + public function testBuildMessagePlain(): void + { + $email = new Email(); + $email->setMessage('This is a simple plain text message.'); + $email->setMailType('text'); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $headerStr = $this->getPrivateProperty($email, 'headerStr'); + $finalBody = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('This is a simple plain text message.', (string) $finalBody); + $this->assertStringContainsString('Content-Type: text/plain', (string) $headerStr); + } + + public function testBuildMessageHtmlNoMultipart(): void + { + $email = new Email(); + $email->setMessage('

This is a HTML message.

'); + $email->setMailType('html'); + $this->setPrivateProperty($email, 'sendMultipart', false); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $headerStr = $this->getPrivateProperty($email, 'headerStr'); + $finalBody = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('

This is a HTML message.

', (string) $finalBody); + $this->assertStringContainsString('Content-Type: text/html', (string) $headerStr); + $this->assertStringNotContainsString('multipart/alternative', (string) $headerStr); + } + + public function testBuildMessageHtmlMultipart(): void + { + $email = new Email(); + $email->setMessage('

This is a HTML message.

'); + $email->setMailType('html'); + $this->setPrivateProperty($email, 'sendMultipart', true); + $email->setAltMessage('This is alternative text.'); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $headerStr = $this->getPrivateProperty($email, 'headerStr'); + $finalBody = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('

This is a HTML message.

', (string) $finalBody); + $this->assertStringContainsString('Content-Type: multipart/alternative', (string) $headerStr); + $this->assertStringContainsString('This is alternative text.', (string) $finalBody); + } + + public function testBuildMessagePlainWithAttachments(): void + { + $email = new Email(); + $email->setMessage('Plain text with attachments.'); + $email->attach(SUPPORTPATH . 'Images/ci-logo.png'); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $headerStr = $this->getPrivateProperty($email, 'headerStr'); + $finalBody = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('Content-Type: multipart/mixed', (string) $headerStr); + $this->assertStringContainsString('Content-Type: image/png', (string) $finalBody); + $this->assertStringContainsString('Content-Disposition: attachment', (string) $finalBody); + } + + public function testWordWrap(): void + { + $email = new Email(); + /** @phpstan-ignore assign.propertyType */ + $email->newline = "\n"; + + $text = 'This is a very long line of text that should be wrapped to a smaller character limit.'; + $wrapped = $email->wordWrap($text, 20); + + // Verify that long text gets wrapped at roughly 20 chars + $lines = explode("\n", trim($wrapped)); + + foreach ($lines as $line) { + $this->assertLessThanOrEqual(20, strlen($line)); + } + + // Verify unwrap behavior + $unwrapText = 'This is {unwrap}a very long word or link that should not be wrapped under any circumstances{/unwrap} even if it exceeds the limit.'; + $wrappedUnwrap = $email->wordWrap($unwrapText, 20); + $this->assertStringContainsString('a very long word or link that should not be wrapped under any circumstances', $wrappedUnwrap); + } + + public function testCleanEmail(): void + { + $email = new Email(); + + // Test with string input + $this->assertSame('john@example.com', $email->cleanEmail('John Doe ')); + $this->assertSame('simple@example.com', $email->cleanEmail('simple@example.com')); + + // Test with array input + $emails = [ + 'John Doe ', + 'simple@example.com', + '', + ]; + $expected = [ + 'john@example.com', + 'simple@example.com', + 'jane@example.com', + ]; + $this->assertSame($expected, $email->cleanEmail($emails)); + } + + public function testValidateEmail(): void + { + $email = new Email(); + + // Must be array + $this->assertFalse($email->validateEmail('string@example.com')); + + // Valid array + $this->assertTrue($email->validateEmail(['test@example.com', 'another@example.com'])); + + // Invalid array + $this->assertFalse($email->validateEmail(['test@example.com', 'invalid-email'])); + } + + public function testIsValidEmail(): void + { + $email = new Email(); + + $this->assertTrue($email->isValidEmail('test@example.com')); + $this->assertFalse($email->isValidEmail('invalid-email')); + $this->assertFalse($email->isValidEmail('test@example')); + + // IDN domain (internationalized) + if (function_exists('idn_to_ascii') && defined('INTL_IDNA_VARIANT_UTS46')) { + $this->assertTrue($email->isValidEmail('user@müller.de')); + } + } + + public function testClear(): void + { + $email = new Email(); + $email->setTo('test@example.com'); + $email->setSubject('My Subject'); + $email->setMessage('Hello'); + $email->attach(SUPPORTPATH . 'Images/ci-logo.png'); + + // Verify state before clear + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertSame('=?UTF-8?Q?My=20Subject?=', $headers['Subject']); + $this->assertNotEmpty($this->getPrivateProperty($email, 'attachments')); + + // Clear without clearing attachments + $email->clear(false); + $headersAfter = $this->getPrivateProperty($email, 'headers'); + $this->assertCount(1, $headersAfter); + $this->assertArrayHasKey('Date', $headersAfter); + $this->assertNotEmpty($this->getPrivateProperty($email, 'attachments')); + + // Clear with clearing attachments + $email->clear(true); + $this->assertEmpty($this->getPrivateProperty($email, 'attachments')); + } + + public function testSetters(): void + { + $email = new Email(); + + $email->setMailType('html'); + $this->assertSame('html', $this->getPrivateProperty($email, 'mailType')); + + $email->setWordWrap(false); + $this->assertFalse($this->getPrivateProperty($email, 'wordWrap')); + + $email->setProtocol('smtp'); + $this->assertSame('smtp', $this->getPrivateProperty($email, 'protocol')); + + $email->setPriority(1); + $this->assertSame(1, $this->getPrivateProperty($email, 'priority')); + + $email->setNewline("\r\n"); + $this->assertSame("\r\n", $this->getPrivateProperty($email, 'newline')); + + $email->setCRLF("\r\n"); + $this->assertSame("\r\n", $this->getPrivateProperty($email, 'CRLF')); + } + + public function testSetFrom(): void + { + $email = new Email(); + $email->setFrom('john@example.com', 'John Doe'); + + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertSame('"John Doe" ', $headers['From']); + $this->assertSame('', $headers['Return-Path']); + + // Testing Q Encoding + $email->setFrom('john@example.com', 'Jöhn Døe'); + $headersQ = $this->getPrivateProperty($email, 'headers'); + $this->assertStringContainsString('=?UTF-8?Q?', (string) $headersQ['From']); + } + + public function testSetReplyTo(): void + { + $email = new Email(); + $email->setReplyTo('support@example.com', 'Support Team'); + + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertSame('"Support Team" ', $headers['Reply-To']); + $this->assertTrue($this->getPrivateProperty($email, 'replyToFlag')); + } + + public function testSetToCCBCC(): void + { + $email = new Email(); + $email->setProtocol('smtp'); + + $email->setTo('one@example.com, two@example.com'); + $this->assertSame(['one@example.com', 'two@example.com'], $this->getPrivateProperty($email, 'recipients')); + + $email->setCC('cc@example.com'); + $this->assertSame(['cc@example.com'], $this->getPrivateProperty($email, 'CCArray')); + + $email->setBCC('bcc@example.com'); + $this->assertSame(['bcc@example.com'], $this->getPrivateProperty($email, 'BCCArray')); + } + + public function testAttach(): void + { + $email = new Email(); + $email->attach(SUPPORTPATH . 'Images/ci-logo.png', 'attachment', 'new-name.png'); + + $attachments = $this->getPrivateProperty($email, 'attachments'); + $this->assertCount(1, $attachments); + $this->assertSame('new-name.png', $attachments[0]['name'][1]); + $this->assertSame('attachment', $attachments[0]['disposition']); + $this->assertSame('image/png', $attachments[0]['type']); + } + + public function testPrintDebugger(): void + { + $email = new Email(); + $this->setPrivateProperty($email, 'headerStr', 'To: recipient@example.com'); + $this->setPrivateProperty($email, 'subject', 'Debug Test'); + $this->setPrivateProperty($email, 'finalBody', 'Testing debugger'); + $this->setPrivateProperty($email, 'debugMessage', ['SMTP success message
']); + + $debug = $email->printDebugger(); + $this->assertStringContainsString('SMTP success message', $debug); + $this->assertStringContainsString('Debug Test', $debug); + $this->assertStringContainsString('recipient@example.com', $debug); + } + + public function testSettersFallback(): void + { + $email = new Email(); + + // Invalid newline falls back to "\n" + $email->setNewline('invalid'); + $this->assertSame("\n", $this->getPrivateProperty($email, 'newline')); + + // Invalid CRLF falls back to "\n" + $email->setCRLF('invalid'); + $this->assertSame("\n", $this->getPrivateProperty($email, 'CRLF')); + } + + public function testGetMessageID(): void + { + $email = new Email(); + $email->setFrom('john@example.com'); + + $getMessageID = self::getPrivateMethodInvoker($email, 'getMessageID'); + $messageID = $getMessageID(); + + $this->assertStringStartsWith('<', $messageID); + $this->assertStringEndsWith('>', $messageID); + $this->assertStringContainsString('@example.com', $messageID); + } + + public function testValidateEmailForShell(): void + { + $email = new Email(); + + $validateForShell = Closure::bind(fn (&$email) => $this->validateEmailForShell($email), $email, Email::class); + + $address = 'test@example.com'; + $this->assertTrue($validateForShell($address)); + + $invalidAddress = 'invalid-email-address'; + $this->assertFalse($validateForShell($invalidAddress)); + + $shellInjection = 'test@example.com; rm -rf /'; + $this->assertFalse($validateForShell($shellInjection)); + } + + public function testSetPriorityFallback(): void + { + $email = new Email(); + + // Priority 6 is invalid, falls back to 3 + $email->setPriority(6); + $this->assertSame(3, $this->getPrivateProperty($email, 'priority')); + + // Priority 0 is invalid, falls back to 3 + $email->setPriority(0); + $this->assertSame(3, $this->getPrivateProperty($email, 'priority')); + } + + public function testSMTPAuthenticateValidation(): void + { + $email = new Email(); + + $smtpAuthenticate = self::getPrivateMethodInvoker($email, 'SMTPAuthenticate'); + + // SMTPAuth false returns true immediately + $this->setPrivateProperty($email, 'SMTPAuth', false); + $this->assertTrue($smtpAuthenticate()); + + // SMTPAuth true but empty credentials returns false + $this->setPrivateProperty($email, 'SMTPAuth', true); + $this->setPrivateProperty($email, 'SMTPUser', ''); + $this->setPrivateProperty($email, 'SMTPPass', ''); + $this->assertFalse($smtpAuthenticate()); + + // Invalid SMTP auth method returns false + $this->setPrivateProperty($email, 'SMTPUser', 'user'); + $this->setPrivateProperty($email, 'SMTPPass', 'pass'); + $this->setPrivateProperty($email, 'SMTPAuthMethod', 'oauth'); + $this->assertFalse($smtpAuthenticate()); + } + + public function testAttachMissingFile(): void + { + $email = new Email(); + $this->assertFalse($email->attach('/path/to/nonexistent/file.txt')); + } + + public function testAttachUnreadableFile(): void + { + $email = new Email(); + // A directory is not readable as a regular file for fopen + $this->assertFalse($email->attach(__DIR__)); + } + + public function testSetAttachmentCID(): void + { + $email = new Email(); + + // Attach from buffer + $email->attach('file_content', 'inline', 'photo.jpg', 'image/jpeg'); + + $cid = $email->setAttachmentCID('photo.jpg'); + $this->assertIsString($cid); + $this->assertStringContainsString('photo.jpg@', $cid); + + // Non-existent file/buffer name + $this->assertFalse($email->setAttachmentCID('nonexistent.jpg')); + } + + public function testSmtpConnectAlreadyConnected(): void + { + $email = new Email(); + $this->setPrivateProperty($email, 'SMTPConnect', fopen('php://memory', 'r+b')); + + $smtpConnect = self::getPrivateMethodInvoker($email, 'SMTPConnect'); + $this->assertTrue($smtpConnect()); + } + + public function testBuildMessageHtmlWithInlineAttachments(): void + { + $email = new Email(); + $email->setMailType('html'); + $email->setFrom('sender@example.com'); + $email->setTo('recipient@example.com'); + $email->setSubject('Inline Test'); + $email->setMessage('

Test HTML

'); + $email->attach('image_data', 'inline', 'image.jpg', 'image/jpeg'); + $email->setAttachmentCID('image.jpg'); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $body = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('Content-Disposition: inline', (string) $body); + $this->assertStringContainsString('Content-ID:', (string) $body); + } + + public function testMimeTypesUnknown(): void + { + $email = new Email(); + $mimeTypes = self::getPrivateMethodInvoker($email, 'mimeTypes'); + $this->assertSame('application/x-unknown-content-type', $mimeTypes('invalid_ext')); + } + + public function testAttachRealFileDetectMime(): void + { + $email = new Email(); + $this->assertNotFalse($email->attach(__FILE__)); + + $attachments = $this->getPrivateProperty($email, 'attachments'); + $this->assertCount(1, $attachments); + $this->assertSame('application/x-php', $attachments[0]['type']); + } + + public function testValidateEmailIdn(): void + { + $email = new Email(); + $validateEmail = self::getPrivateMethodInvoker($email, 'validateEmail'); + $address = ['test@przykłady.pl']; + $this->assertTrue($validateEmail($address)); + } + + public function testSetSubjectPreventsHeaderInjection(): void + { + $email = new Email(); + $email->setSubject("Test Subject\r\nBcc: spy@example.com"); + + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertStringNotContainsString('Bcc: spy@example.com', (string) $headers['Subject']); + } + + public function testSetSubjectExtremelyLongUtf8(): void + { + $email = new Email(); + $subject = str_repeat('Zażółć gęślą jaźń ', 10); + $email->setSubject($subject); + + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertStringStartsWith('=?UTF-8?Q?', $headers['Subject']); + } + + public function testAttachWithUtf8Filename(): void + { + $email = new Email(); + $email->attach(SUPPORTPATH . 'Images/ci-logo.png', 'attachment', 'żółć-logo.png'); + + $attachments = $this->getPrivateProperty($email, 'attachments'); + $this->assertSame('żółć-logo.png', $attachments[0]['name'][1]); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $finalBody = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('name="żółć-logo.png"', (string) $finalBody); + } + + public function testGetProtocolFallback(): void + { + $email = new Email(); + $email->setProtocol('invalid'); + $getProtocol = self::getPrivateMethodInvoker($email, 'getProtocol'); + $this->assertSame('mail', $getProtocol()); + } + + public function testSetBccBatchMode(): void + { + $email = new Email(); + $email->setBCC('bcc@example.com', '10'); + $this->assertTrue($this->getPrivateProperty($email, 'BCCBatchMode')); + $this->assertSame('10', $this->getPrivateProperty($email, 'BCCBatchSize')); + } + + public function testSettersWithArray(): void + { + $email = new Email(); + $email->setTo(['one@example.com', 'two@example.com']); + $this->assertSame(['one@example.com', 'two@example.com'], $this->getPrivateProperty($email, 'recipients')); + } + + public function testSetFromAndReplyToWithValidation(): void + { + $config = config('Email'); + $config->validate = true; + $email = new Email($config); + + $email->setFrom('john@example.com', 'Jöhn Døe'); + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertStringContainsString('=?UTF-8?Q?', (string) $headers['From']); + + $email->setReplyTo('support@example.com', 'Jöhn Døe'); + $this->assertTrue($this->getPrivateProperty($email, 'replyToFlag')); + } }