Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.65% covered (success)
93.65%
59 / 63
75.00% covered (success)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
EncodedWord
93.65% covered (success)
93.65%
59 / 63
75.00% covered (success)
75.00%
6 / 8
28.20
0.00% covered (danger)
0.00%
0 / 1
 decode
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 findRanges
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 encode
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 decodeRun
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 decodeWord
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 convertToUtf8
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 needsEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 splitIntoChunks
87.50% covered (success)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
9.16
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <dev@noladev.com>
8 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Mime\Part\Header;
16
17/**
18 * RFC 2047 encoded-word encoder/decoder - has no dependency on ext-imap.
19 * Decoding handles both B (base64) and Q (quoted-printable-style)
20 * encoded-words, since real-world mail uses both; encoding only ever
21 * produces B, which is simpler and unconditionally correct for any
22 * content shape.
23 *
24 * @category   Pop
25 * @package    Pop\Mime
26 * @author     Nick Sagona, III <dev@noladev.com>
27 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
28 * @license    https://www.popphp.org/license     New BSD License
29 * @version    3.0.0
30 */
31final class EncodedWord
32{
33
34    /**
35     * Decode all RFC 2047 encoded-words found in a string. Never throws -
36     * malformed or unrecognized encoded-word-shaped text is left as literal
37     * output.
38     *
39     * @param  string $text
40     * @return string
41     */
42    public static function decode(string $text): string
43    {
44        if (!str_contains($text, '=?')) {
45            return $text;
46        }
47
48        $pattern = '/=\?[^?\s]+\?[BbQq]\?[^?]*\?=(?:\s+=\?[^?\s]+\?[BbQq]\?[^?]*\?=)*/';
49
50        $result = preg_replace_callback($pattern, function ($matches) {
51            return self::decodeRun($matches[0]);
52        }, $text);
53
54        return $result ?? $text;
55    }
56
57    /**
58     * Find the byte ranges of every individual RFC 2047 encoded-word in a
59     * string - used by Header::fold() to treat an encoded-word as an
60     * atomic, unbreakable unit when choosing fold points. Folding between
61     * two adjacent encoded-words, at their separating whitespace, is still
62     * fine - only folding INSIDE one is not.
63     *
64     * @param  string $text
65     * @return array
66     */
67    public static function findRanges(string $text): array
68    {
69        $ranges = [];
70        if (preg_match_all('/=\?[^?\s]+\?[BbQq]\?[^?]*\?=/', $text, $matches, PREG_OFFSET_CAPTURE)) {
71            foreach ($matches[0] as $match) {
72                $ranges[] = ['start' => $match[1], 'end' => $match[1] + strlen($match[0])];
73            }
74        }
75        return $ranges;
76    }
77
78    /**
79     * Encode a string into one or more RFC 2047 encoded-words, if it
80     * contains anything outside printable US-ASCII. Returns the input
81     * unchanged otherwise.
82     *
83     * @param  string $text
84     * @param  string $charset
85     * @return string
86     */
87    public static function encode(string $text, string $charset = 'UTF-8'): string
88    {
89        if (!self::needsEncoding($text)) {
90            return $text;
91        }
92
93        $prefix           = '=?' . $charset . '?B?';
94        $suffix           = '?=';
95        $maxEncodedLength = 75 - strlen($prefix) - strlen($suffix);
96
97        $words = [];
98        foreach (self::splitIntoChunks($text, $maxEncodedLength) as $chunk) {
99            $words[] = $prefix . base64_encode($chunk) . $suffix;
100        }
101
102        return implode(' ', $words);
103    }
104
105    /**
106     * Decode a run of one or more whitespace-separated encoded-words,
107     * dropping the whitespace between them per RFC 2047 Â§6.2
108     *
109     * @param  string $run
110     * @return string
111     */
112    protected static function decodeRun(string $run): string
113    {
114        preg_match_all('/=\?([^?\s]+)\?([BbQq])\?([^?]*)\?=/', $run, $words, PREG_SET_ORDER);
115
116        $decoded = '';
117        foreach ($words as $word) {
118            $decoded .= self::decodeWord($word[1], $word[2], $word[3]);
119        }
120
121        return $decoded;
122    }
123
124    /**
125     * Decode a single encoded-word's charset/encoding/text into UTF-8
126     *
127     * @param  string $charset
128     * @param  string $encoding
129     * @param  string $encodedText
130     * @return string
131     */
132    protected static function decodeWord(string $charset, string $encoding, string $encodedText): string
133    {
134        if (strcasecmp($encoding, 'B') === 0) {
135            $bytes = base64_decode($encodedText, true);
136            $bytes = ($bytes === false) ? '' : $bytes;
137        } else {
138            $bytes = quoted_printable_decode(str_replace('_', ' ', $encodedText));
139        }
140
141        return self::convertToUtf8($bytes, $charset);
142    }
143
144    /**
145     * Convert decoded bytes to UTF-8, gracefully degrading if the charset
146     * can't be converted (no mbstring, or an unrecognized charset name)
147     *
148     * @param  string $bytes
149     * @param  string $charset
150     * @return string
151     */
152    protected static function convertToUtf8(string $bytes, string $charset): string
153    {
154        if ((strcasecmp($charset, 'UTF-8') === 0) || (strcasecmp($charset, 'US-ASCII') === 0)) {
155            return $bytes;
156        }
157
158        if (function_exists('mb_convert_encoding')) {
159            try {
160                return mb_convert_encoding($bytes, 'UTF-8', $charset);
161            } catch (\Throwable) {
162                return $bytes;
163            }
164        }
165
166        return $bytes;
167    }
168
169    /**
170     * Determine if a string contains anything outside printable US-ASCII
171     * and therefore needs RFC 2047 encoding
172     *
173     * @param  string $text
174     * @return bool
175     */
176    protected static function needsEncoding(string $text): bool
177    {
178        return (preg_match('/[^\x20-\x7E]/', $text) === 1);
179    }
180
181    /**
182     * Split a UTF-8 string into byte-boundary-safe chunks, each short
183     * enough that base64-encoding it stays within $maxEncodedLength chars.
184     * Scans UTF-8 lead-byte patterns manually - no mbstring dependency
185     * needed for this direction.
186     *
187     * @param  string $text
188     * @param  int    $maxEncodedLength
189     * @return array
190     */
191    protected static function splitIntoChunks(string $text, int $maxEncodedLength): array
192    {
193        $chunks  = [];
194        $current = '';
195        $length  = strlen($text);
196        $pos     = 0;
197
198        while ($pos < $length) {
199            $byte = ord($text[$pos]);
200            if (($byte & 0x80) === 0x00) {
201                $charLen = 1;
202            } else if (($byte & 0xE0) === 0xC0) {
203                $charLen = 2;
204            } else if (($byte & 0xF0) === 0xE0) {
205                $charLen = 3;
206            } else if (($byte & 0xF8) === 0xF0) {
207                $charLen = 4;
208            } else {
209                $charLen = 1;
210            }
211
212            $char = substr($text, $pos, $charLen);
213
214            if (($current !== '') && (strlen(base64_encode($current . $char)) > $maxEncodedLength)) {
215                $chunks[] = $current;
216                $current  = '';
217            }
218
219            $current .= $char;
220            $pos     += $charLen;
221        }
222
223        if ($current !== '') {
224            $chunks[] = $current;
225        }
226
227        return $chunks;
228    }
229
230}