Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.69% covered (success)
98.69%
151 / 153
87.50% covered (success)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Message
98.69% covered (success)
98.69%
151 / 153
87.50% covered (success)
87.50%
7 / 8
65
0.00% covered (danger)
0.00%
0 / 1
 setMessageId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseMessage
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 parseForm
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 createForm
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
16
 parseHeaders
89.47% covered (success)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
7.06
 parseBody
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 parsePart
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
9
 buildLeafBody
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
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 <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Mime;
16
17use Pop\Mime\Part\Header;
18use Pop\Mime\Part\Body;
19
20/**
21 * MIME message class
22 *
23 * @category   Pop
24 * @package    Pop\Mime
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    3.0.0
29 */
30class Message extends Part
31{
32
33    /**
34     * Set the Message-ID header
35     *
36     * @param  ?string $id
37     * @param  ?string $domain
38     * @return Message
39     */
40    public function setMessageId(?string $id = null, ?string $domain = null): Message
41    {
42        $this->addHeader('Message-ID', $id ?? $this->generateId($domain));
43        return $this;
44    }
45
46    /**
47     * Parse message
48     *
49     * @param  string $messageString
50     * @return Message
51     */
52    public static function parseMessage(string $messageString): Message
53    {
54        $splitPos = strpos($messageString, "\r\n\r\n");
55
56        // Without a header/body delimiter, there are no headers to parse
57        // and the entire string is taken as the body
58        if ($splitPos !== false) {
59            $headerString = substr($messageString, 0, $splitPos);
60            $bodyString   = substr($messageString, $splitPos + 4);
61        } else {
62            $headerString = '';
63            $bodyString   = $messageString;
64        }
65
66        $headers  = self::parseHeaders($headerString);
67        $boundary = null;
68
69        foreach ($headers as $header) {
70            foreach ($header->getValues() as $headerValue) {
71                if ($headerValue->hasParameter('boundary')) {
72                    $boundary = $headerValue->getParameter('boundary');
73                    break;
74                }
75            }
76        }
77
78        $message = new self();
79
80        if (!empty($headers)) {
81            $message->addHeaders($headers);
82        }
83
84        // With a boundary, the body is a multipart container of nested parts.
85        // Without one, this is a single, non-multipart message and the body
86        // belongs directly on the message itself - mirrors parsePart()'s
87        // leaf/container branch below.
88        if ($boundary !== null) {
89            $parts = [];
90            foreach (self::parseBody($bodyString, $boundary) as $partString) {
91                $parts[] = self::parsePart($partString);
92            }
93            if (!empty($parts)) {
94                $message->addParts($parts);
95            }
96        } else if (trim($bodyString) !== '') {
97            self::buildLeafBody($message, trim($bodyString));
98        }
99
100        return $message;
101    }
102
103    /**
104     * Parse form data
105     *
106     * @param  string $formString
107     * @return array
108     */
109    public static function parseForm(string $formString): array
110    {
111        $form     = self::parseMessage($formString);
112        $formData = [];
113
114        foreach ($form->getParts() as $part) {
115            if (($part->hasHeader('Content-Disposition')) && (count($part->getHeader('Content-Disposition')->getValues()) == 1)) {
116                $disposition = $part->getHeader('Content-Disposition');
117                if (($disposition->hasValue('form-data')) && ($disposition->getValue(0)->hasParameter('name'))) {
118                    $name     = $disposition->getValue(0)->getParameter('name');
119                    $contents = $part->getContents();
120                    $filename = ($disposition->getValue(0)->hasParameter('filename')) ? $disposition->getValue(0)->getParameter('filename') : null;
121
122                    if (str_ends_with($name, '[]')) {
123                        $name = substr($name, 0, -2);
124                        if (!isset($formData[$name])) {
125                            $formData[$name] = [];
126                        }
127                        $formData[$name][] = $contents;
128                    } else {
129                        if ($filename !== null) {
130                            $formData[$name] = [
131                                'filename' => $filename,
132                                'contents' => $contents
133                            ];
134                        } else {
135                            $formData[$name] = $contents;
136                        }
137                    }
138                }
139            }
140        }
141
142        return $formData;
143    }
144
145    /**
146     * Create multipart form object
147     *
148     * @param  array $fields
149     * @return Message
150     */
151    public static function createForm(array $fields = []): Message
152    {
153        $message = new self();
154        $header  = new Header('Content-Type', new Header\Value('multipart/form-data', null, ['boundary' => $message->generateBoundary()]));
155        $message->addHeader($header);
156
157        if (!empty($fields)) {
158            foreach ($fields as $name => $value) {
159                if (is_array($value)) {
160                    // Is file
161                    if (isset($value['filename'])) {
162                        $parameters   = ['name' => $name];
163                        $contentType  = null;
164                        $fileContents = null;
165
166                        foreach ($value as $key => $val) {
167                            $key = strtolower($key);
168                            if ($key == 'filename') {
169                                $parameters['filename'] = basename($val);
170                            }
171                            if (($key == 'content-type') || ($key == 'contenttype') ||
172                                ($key == 'mime-type') || ($key == 'mimetype') || ($key == 'mime')) {
173                                $contentType = $val;
174                            }
175                        }
176
177                        if (isset($value['contents'])) {
178                            $fileContents = $value['contents'];
179                        } else if (file_exists($value['filename'])) {
180                            $fileContents = file_get_contents($value['filename']);
181                        }
182
183                        $fieldPart = new Part(new Header('Content-Disposition', new Header\Value('form-data', null, $parameters)));
184                        if ($contentType !== null) {
185                            $fieldPart->addHeader('Content-Type', $contentType);
186                        }
187                        $fieldPart->setBody(new Body($fileContents));
188                        $message->addPart($fieldPart);
189                    } else {
190                        foreach ($value as $val) {
191                            $fieldPart = new Part(
192                                new Header('Content-Disposition', new Header\Value('form-data', null, ['name' => $name . '[]']))
193                            );
194                            $fieldPart->setBody(new Body($val, Body\Encoding::RAW_URL));
195                            $message->addPart($fieldPart);
196                        }
197                    }
198                } else {
199                    $fieldPart = new Part(new Header('Content-Disposition', new Header\Value('form-data', null, ['name' => $name])));
200                    $fieldPart->setBody(new Body($value, Body\Encoding::RAW_URL));
201                    $message->addPart($fieldPart);
202                }
203            }
204        }
205
206        return $message;
207    }
208
209    /**
210     * Parse message header string
211     *
212     * @param  string $headerString
213     * @return array
214     */
215    public static function parseHeaders(string $headerString): array
216    {
217        $headers  = [];
218        $unfolded = Header\Lexer::unfold($headerString);
219        $lines    = explode("\r\n", $unfolded);
220
221        $currentName  = null;
222        $currentLines = [];
223
224        foreach ($lines as $line) {
225            if ($line === '') {
226                continue;
227            }
228
229            $colonToken = Header\Lexer::findTopLevelDelimiter($line, ':');
230
231            if ($colonToken !== null) {
232                if ($currentName !== null) {
233                    $headers[] = Header::parse(implode("\r\n", $currentLines));
234                }
235                $currentName  = trim(substr($line, 0, $colonToken->start));
236                $currentLines = [$line];
237            } else if ($currentName !== null) {
238                $currentLines[] = $line;
239            }
240        }
241
242        if ($currentName !== null) {
243            $headers[] = Header::parse(implode("\r\n", $currentLines));
244        }
245
246        return $headers;
247    }
248
249    /**
250     * Parse message body string
251     *
252     * @param  string  $bodyString
253     * @param  ?string $boundary
254     * @return array
255     */
256    public static function parseBody(string $bodyString, ?string $boundary = null): array
257    {
258        $needle   = '--' . $boundary;
259        $splitPos = strpos($bodyString, $needle);
260
261        if ($splitPos !== false) {
262            $parts = explode($needle, $bodyString);
263            if ($splitPos > 0) {
264                unset($parts[0]);
265            }
266        } else {
267            $parts = [$bodyString];
268        }
269
270        return array_values(array_filter(array_map('trim', $parts), function ($value) {
271            return (!empty($value) && ($value != '--'));
272        }));
273    }
274
275    /**
276     * Parse message part string
277     *
278     * @param  string $partString
279     * @return Part|array
280     */
281    public static function parsePart(string $partString): Part|array
282    {
283        $headers = [];
284
285        $splitPos = strpos($partString, "\r\n\r\n");
286
287        if ($splitPos !== false) {
288            $headerString = substr($partString, 0, $splitPos);
289            $bodyString   = trim(substr($partString, $splitPos + 4));
290            $headers      = self::parseHeaders($headerString);
291        } else {
292            $bodyString   = trim($partString);
293        }
294
295        $part = new Part();
296
297        if (!empty($headers)) {
298            $part->addHeaders($headers);
299        }
300
301        $boundary = null;
302        foreach ($part->getHeaders() as $header) {
303            foreach ($header->getValues() as $headerValue) {
304                if ($headerValue->hasParameter('boundary')) {
305                    $boundary = $headerValue->getParameter('boundary');
306                    break;
307                }
308            }
309        }
310
311        if (!empty($bodyString)) {
312            if ($boundary !== null) {
313                $subPartStrings = self::parseBody($bodyString, $boundary);
314                $subParts       = [];
315
316                foreach ($subPartStrings as $subPartString) {
317                    $subParts[] = self::parsePart($subPartString);
318                }
319                return $subParts;
320            } else {
321                self::buildLeafBody($part, $bodyString);
322            }
323        }
324
325        return $part;
326    }
327
328    /**
329     * Build and set a leaf part's body from decoded content, inferring the
330     * encoding from Content-Transfer-Encoding / form-data Content-Disposition
331     * headers already set on the part
332     *
333     * @param  Part   $part
334     * @param  string $bodyString
335     * @return void
336     */
337    protected static function buildLeafBody(Part $part, string $bodyString): void
338    {
339        $encoding = null;
340        $isFile   = (($part->hasHeader('Content-Disposition')) &&
341            ($part->getHeader('Content-Disposition')->isAttachment()));
342        $isForm   = (($part->hasHeader('Content-Disposition')) &&
343            ($part->getHeader('Content-Disposition')->hasValue('form-data')));
344        if ($part->hasHeader('Content-Transfer-Encoding') && (count($part->getHeader('Content-Transfer-Encoding')->getValues()) == 1)) {
345            $encoding = Body\Encoding::fromHeaderValue((string)$part->getHeader('Content-Transfer-Encoding')->getValue(0));
346        } else if ($isForm) {
347            $encoding = Body\Encoding::RAW_URL;
348        }
349        $body = new Body($bodyString, $encoding);
350        if ($encoding !== null) {
351            $body->setAsEncoded(true);
352        }
353        if ($isFile) {
354            $body->setAsFile(true);
355        }
356        $part->setBody($body);
357    }
358
359}