From 2af2294679e79c4fa5d6623a4ebb235aaf5333c8 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 29 Jun 2026 00:22:58 +0200 Subject: [PATCH 1/2] refactor: optimize prepQuotedPrintable() with O(1) lookup, int-length tracking, and sprintf encoding --- system/Email/Email.php | 144 ++++++------------------------- tests/system/Email/EmailTest.php | 107 +++++++++++++++++++++++ 2 files changed, 131 insertions(+), 120 deletions(-) diff --git a/system/Email/Email.php b/system/Email/Email.php index b886d07fdc72..5d70b1305922 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -197,7 +197,7 @@ class Email * * @see http://www.ietf.org/rfc/rfc822.txt * - * @var "\r\n"|"n" + * @var "\r\n"|"\n" */ public $CRLF = "\r\n"; @@ -1335,156 +1335,60 @@ protected function appendAttachments(&$body, $boundary, $multipart = null) */ protected function prepQuotedPrintable($str) { - // ASCII code numbers for "safe" characters that can always be - // used literally, without encoding, as described in RFC 2049. - // http://www.ietf.org/rfc/rfc2049.txt - static $asciiSafeChars = [ - // ' ( ) + , - . / : = ? - 39, - 40, - 41, - 43, - 44, - 45, - 46, - 47, - 58, - 61, - 63, - // numbers - 48, - 49, - 50, - 51, - 52, - 53, - 54, - 55, - 56, - 57, - // upper-case letters - 65, - 66, - 67, - 68, - 69, - 70, - 71, - 72, - 73, - 74, - 75, - 76, - 77, - 78, - 79, - 80, - 81, - 82, - 83, - 84, - 85, - 86, - 87, - 88, - 89, - 90, - // lower-case letters - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 106, - 107, - 108, - 109, - 110, - 111, - 112, - 113, - 114, - 115, - 116, - 117, - 118, - 119, - 120, - 121, - 122, - ]; - - // We are intentionally wrapping so mail servers will encode characters - // properly and MUAs will behave, so {unwrap} must go! $str = str_replace(['{unwrap}', '{/unwrap}'], '', $str); - // RFC 2045 specifies CRLF as "\r\n". - // However, many developers choose to override that and violate - // the RFC rules due to (apparently) a bug in MS Exchange, - // which only works with "\n". if ($this->CRLF === "\r\n") { return quoted_printable_encode($str); } - // Reduce multiple spaces & remove nulls $str = preg_replace(['| +|', '/\x00+/'], [' ', ''], $str); - // Standardize newlines if (str_contains($str, "\r")) { $str = str_replace(["\r\n", "\r"], "\n", $str); } - $escape = '='; + static $asciiSafeChars; + if ($asciiSafeChars === null) { + $safeChars = [39, 40, 41, 43, 44, 45, 46, 47, 58, 61, 63]; + $safeChars = array_merge($safeChars, range(48, 57), range(65, 90), range(97, 122)); + $asciiSafeChars = array_fill_keys($safeChars, true); + } + $output = ''; foreach (explode("\n", $str) as $line) { - $length = static::strlen($line); - $temp = ''; + $length = static::strlen($line); + $temp = ''; + $tempLen = 0; - // Loop through each character in the line to add soft-wrap - // characters at the end of a line " =\r\n" and add the newly - // processed line(s) to the output (see comment on $crlf class property) for ($i = 0; $i < $length; $i++) { - // Grab the next character - $char = $line[$i]; - $ascii = ord($char); + $char = $line[$i]; + $ascii = ord($char); + $charLen = 1; - // Convert spaces and tabs but only if it's the end of the line if ($ascii === 32 || $ascii === 9) { if ($i === ($length - 1)) { - $char = $escape . sprintf('%02s', dechex($ascii)); + $char = sprintf('=%02X', $ascii); + $charLen = 3; } - } - // DO NOT move this below the $ascii_safe_chars line! - // - // = (equals) signs are allowed by RFC2049, but must be encoded - // as they are the encoding delimiter! - elseif ($ascii === 61) { - $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); // =3D - } elseif (! in_array($ascii, $asciiSafeChars, true)) { - $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); + } elseif ($ascii === 61 || ! isset($asciiSafeChars[$ascii])) { + $char = sprintf('=%02X', $ascii); + $charLen = 3; } - // If we're at the character limit, add the line to the output, - // reset our temp variable, and keep on chuggin' - if ((static::strlen($temp) + static::strlen($char)) >= 76) { - $output .= $temp . $escape . $this->CRLF; - $temp = ''; + if (($tempLen + $charLen) >= 76) { + $output .= $temp . '=' . $this->CRLF; + $temp = ''; + $tempLen = 0; } - // Add the character to our temporary line $temp .= $char; + $tempLen += $charLen; } - // Add our completed line to the output $output .= $temp . $this->CRLF; } - // get rid of extra CRLF tacked onto the end return static::substr($output, 0, static::strlen($this->CRLF) * -1); } diff --git a/tests/system/Email/EmailTest.php b/tests/system/Email/EmailTest.php index ca42613505e4..9ca5c5a51a4a 100644 --- a/tests/system/Email/EmailTest.php +++ b/tests/system/Email/EmailTest.php @@ -302,4 +302,111 @@ public function testGetHostnameFallsBackToGethostnameFunction(): void $this->assertSame(gethostname(), $getHostname()); } + + #[DataProvider('providePrepQuotedPrintableWithLfCrlf')] + public function testPrepQuotedPrintableWithLfCrlf(string $input, string $expected): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + $this->assertSame($expected, $prepQP($input)); + } + + /** + * @return iterable + */ + public static function providePrepQuotedPrintableWithLfCrlf(): iterable + { + return [ + 'empty string' => ['', ''], + 'safe ascii only' => ['hello world', 'hello world'], + 'safe chars only' => ['abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789(),-./:?', 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789(),-./:?'], + 'unsafe char encoded' => ["a\x01b", 'a=01b'], + 'trailing space encoded' => ["hello \nworld", "hello=20\nworld"], + 'trailing tab encoded' => ["hello\t\nworld", "hello=09\nworld"], + 'equals sign encoded as =3D' => ['a=b', 'a=3Db'], + 'multiple spaces reduced' => ['a b', 'a b'], + 'null bytes removed' => ["a\x00b", 'ab'], + 'unwrap tags removed' => ['{unwrap}secret{/unwrap}', 'secret'], + 'single line' => ['test', 'test'], + 'two lines' => ["line1\nline2", "line1\nline2"], + 'three lines trailing empty' => ["line1\nline2\n", "line1\nline2\n"], + ]; + } + + public function testPrepQuotedPrintableWithCrlfNative(): void + { + $email = new Email(); + $email->CRLF = "\r\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + $result = $prepQP('test'); + + $this->assertSame(quoted_printable_encode('test'), $result); + } + + public function testPrepQuotedPrintableSoftLineBreak(): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + // 76 'a' chars fit in one line; add 2 more 'b' chars and they soft-wrap + // After reduction: no trailing spaces, just safe chars + $input = str_repeat('a', 76) . 'bb'; + $result = $prepQP($input); + + $this->assertStringContainsString("=\n", $result, 'Soft line break must be present'); + $this->assertStringNotContainsString("\r\n", $result, 'Custom CRLF must not contain \\r'); + } + + public function testPrepQuotedPrintableSoftBreakAfterEncodedChar(): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + // 74 safe chars + 1 encoded (=3D = 3 bytes) = 77 → must break before encoded + $input = str_repeat('a', 74) . '='; + $result = $prepQP($input); + + $this->assertSame(str_repeat('a', 74) . "=\n=3D", $result); + } + + public function testPrepQuotedPrintableHardLineBreakNoInternalSpaceReduction(): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + // Spaces not at end of line must be left as-is + $this->assertSame('a b', $prepQP('a b')); + } + + public function testPrepQuotedPrintableMixedContent(): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + $input = "Hello, World!\nline ends with tab\t\n=special chars: \x01\x02"; + $result = $prepQP($input); + + $this->assertStringContainsString('Hello, World=21', $result); + $this->assertStringContainsString('=09', $result); + $this->assertStringContainsString('=3D', $result); + $this->assertStringContainsString('=01', $result); + $this->assertStringContainsString('=02', $result); + } + + public function testPrepQuotedPrintableUnwrapRemovesTagsOnly(): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + $this->assertSame('keep =7Bbraces=7D', $prepQP('keep {braces}')); + $this->assertSame('keep (parentheses)', $prepQP('keep (parentheses)')); + } } From ce158a23575ca08aac54f30003ccf6f1a221c8cd Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 29 Jun 2026 00:36:08 +0200 Subject: [PATCH 2/2] cs-fix --- system/Email/Email.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/Email/Email.php b/system/Email/Email.php index 5d70b1305922..c643190b58c3 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -197,7 +197,7 @@ class Email * * @see http://www.ietf.org/rfc/rfc822.txt * - * @var "\r\n"|"\n" + * @var "\n"|"\r\n" */ public $CRLF = "\r\n";