Fix: Add iconv fallback when mbstring extension is missing + tests

This commit is contained in:
Prabhat Mishra 2025-08-25 18:23:31 +05:30
parent d43654d445
commit 571be1b375
2 changed files with 194 additions and 22 deletions

View File

@ -3693,9 +3693,14 @@ class PHPMailer
if (function_exists('mb_strlen')) {
return strlen($str) > mb_strlen($str, $this->CharSet);
}
// Fallback to iconv if available
if (function_exists('iconv_strlen')) {
return strlen($str) > iconv_strlen($str, $this->CharSet);
}
//Assume no multibytes (we can't handle without mbstring functions anyway)
return false;
// If neither mbstring nor iconv is available, make a basic check for non-ASCII characters
return preg_match('/[^\x00-\x7F]/', $str) === 1;
}
/**
@ -3713,7 +3718,7 @@ class PHPMailer
/**
* Encode and wrap long multibyte strings for mail headers
* without breaking lines within a character.
* Adapted from a function by paravoid.
* Uses mbstring if available, with fallback to iconv or native functions.
*
* @see https://www.php.net/manual/en/function.mb-encode-mimeheader.php#60283
*
@ -3731,28 +3736,54 @@ class PHPMailer
$linebreak = static::$LE;
}
$mb_length = mb_strlen($str, $this->CharSet);
//Each line must have length <= 75, including $start and $end
$length = 75 - strlen($start) - strlen($end);
//Average multi-byte ratio
$ratio = $mb_length / strlen($str);
//Base64 has a 4:3 ratio
$avgLength = floor($length * $ratio * .75);
// Use mbstring if available
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
$mb_length = mb_strlen($str, $this->CharSet);
// Each line must have length <= 75, including $start and $end
$length = 75 - strlen($start) - strlen($end);
// Average multi-byte ratio
$ratio = $mb_length / strlen($str);
// Base64 has a 4:3 ratio
$avgLength = floor($length * $ratio * .75);
$offset = 0;
for ($i = 0; $i < $mb_length; $i += $offset) {
$lookBack = 0;
do {
$offset = $avgLength - $lookBack;
$chunk = mb_substr($str, $i, $offset, $this->CharSet);
$chunk = base64_encode($chunk);
++$lookBack;
} while (strlen($chunk) > $length);
$encoded .= $chunk . $linebreak;
$offset = 0;
for ($i = 0; $i < $mb_length; $i += $offset) {
$lookBack = 0;
do {
$offset = $avgLength - $lookBack;
$chunk = mb_substr($str, $i, $offset, $this->CharSet);
$chunk = base64_encode($chunk);
++$lookBack;
} while (strlen($chunk) > $length);
$encoded .= $chunk . $linebreak;
}
}
// Fallback to iconv if mbstring is not available
elseif (function_exists('iconv_strlen') && function_exists('iconv_substr')) {
$iconv_length = iconv_strlen($str, $this->CharSet);
$length = 75 - strlen($start) - strlen($end);
$ratio = $iconv_length / strlen($str);
$avgLength = floor($length * $ratio * .75);
$offset = 0;
for ($i = 0; $i < $iconv_length; $i += $offset) {
$lookBack = 0;
do {
$offset = $avgLength - $lookBack;
$chunk = iconv_substr($str, $i, $offset, $this->CharSet);
$chunk = base64_encode($chunk);
++$lookBack;
} while (strlen($chunk) > $length);
$encoded .= $chunk . $linebreak;
}
}
// Fallback to basic implementation if neither mbstring nor iconv is available
else {
$encoded = chunk_split(base64_encode($str), 76, $linebreak);
$encoded = trim($encoded, $linebreak);
}
//Chomp the last linefeed
return substr($encoded, 0, -strlen($linebreak));
return $encoded;
}
/**

View File

@ -0,0 +1,141 @@
<?php
namespace PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\PHPMailer;
use PHPUnit\Framework\TestCase;
/**
* Test fallback behavior when mbstring extension is not available
*/
class NoMbstringTest extends TestCase
{
/**
* @var PHPMailer
*/
protected $mail;
/**
* Backup of mbstring functions
*/
protected static $mbstringFunctions = [
'mb_strlen' => null,
'mb_substr' => null,
];
/**
* Set up before each test
*/
protected function setUp(): void
{
$this->mail = new PHPMailer();
$this->mail->CharSet = PHPMailer::CHARSET_UTF8;
// Backup mbstring functions if they exist
foreach (array_keys(self::$mbstringFunctions) as $function) {
if (function_exists($function)) {
self::$mbstringFunctions[$function] = $function;
$this->disableFunction($function);
}
}
}
/**
* Restore mbstring functions after each test
*/
protected function tearDown(): void
{
// Restore mbstring functions
foreach (self::$mbstringFunctions as $function => $original) {
if ($original !== null) {
$this->restoreFunction($function);
}
}
}
/**
* Test that hasMultiBytes works without mbstring
*/
public function testHasMultiBytesWithoutMbstring()
{
// Test with ASCII string (should return false)
$this->assertFalse($this->mail->hasMultiBytes('ASCII string'));
// Test with multibyte string (should return true)
$this->assertTrue($this->mail->hasMultiBytes('Multibyte string: ñáéíóú'));
}
/**
* Test that base64EncodeWrapMB works without mbstring
*/
public function testBase64EncodeWrapMBWithoutMbstring()
{
$testString = 'This is a test string with multibyte characters: ñáéíóú';
$encoded = $this->mail->base64EncodeWrapMB($testString);
// The encoded string should not be empty
$this->assertNotEmpty($encoded, 'Encoded string is empty');
// When decoded, it should match the original string
$normalized = preg_replace('/\s+/', '', $encoded);
$decoded = base64_decode($normalized, true);
$this->assertNotFalse($decoded, 'Failed to decode base64 string');
$this->assertEquals($testString, $decoded, 'Decoded string does not match original');
}
/**
* Test that encodeString works without mbstring
*/
public function testEncodeStringWithoutMbstring()
{
$testString = 'This is a test string with multibyte characters: ñáéíóú';
$encoded = $this->mail->encodeString($testString, PHPMailer::ENCODING_BASE64);
// The encoded string should not be empty
$this->assertNotEmpty($encoded, 'Encoded string is empty');
// When decoded, it should match the original string
$normalized = preg_replace('/\s+/', '', $encoded);
$decoded = base64_decode($normalized, true);
$this->assertNotFalse($decoded, 'Failed to decode base64 string');
$this->assertEquals($testString, $decoded, 'Decoded string does not match original');
}
/**
* Disable a function for testing purposes
*
* @param string $functionName Name of the function to disable
*/
private function disableFunction($functionName)
{
$namespace = __NAMESPACE__;
$code = <<<EOT
namespace {$namespace};
if (!function_exists('{$functionName}')) {
function {$functionName}() {
throw new \RuntimeException('{$functionName} should not be called in this test');
}
}
EOT;
eval($code);
}
/**
* Restore a function that was previously disabled
*
* @param string $functionName Name of the function to restore
*/
private function restoreFunction($functionName)
{
$namespace = __NAMESPACE__;
$code = <<<EOT
namespace {$namespace};
if (function_exists('{$functionName}_backup')) {
function {$functionName}() {
return call_user_func_array('{$functionName}_backup', func_get_args());
}
}
EOT;
eval($code);
}
}