Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 24 additions & 120 deletions system/Email/Email.php
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
}

Expand Down
107 changes: 107 additions & 0 deletions tests/system/Email/EmailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{string, string}>
*/
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)'));
}
}
Loading