Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.39% covered (success)
99.39%
163 / 164
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Payload
99.39% covered (success)
99.39%
163 / 164
90.00% covered (success)
90.00%
9 / 10
67
0.00% covered (danger)
0.00%
0 / 1
 encode
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 decode
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
15
 valid
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
14
 attachment
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
8.05
 addresses
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 body
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 headers
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 attachments
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 embedded
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
8
 contentId
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Mail;
6
7use Cosray\Exception\RuntimeException;
8use Symfony\Component\Mime\Address;
9use Symfony\Component\Mime\Email;
10use Symfony\Component\Mime\Header\UnstructuredHeader;
11use Symfony\Component\Mime\Part\DataPart;
12
13/**
14 * Turns a mail into the JSON payload of its delivery job and back. The
15 * payload covers addresses, subject, text and HTML bodies, attachments,
16 * embedded images, priority and plain text headers; a mail that uses
17 * anything else is refused when it is queued, not when it is sent.
18 *
19 * @internal
20 *
21 * @phpstan-type Attachment array{name: ?string, type: string, body: string, inline: bool, cid: ?string}
22 * @phpstan-type Data array{
23 *     transport: ?string,
24 *     from: list<string>,
25 *     sender: ?string,
26 *     returnPath: ?string,
27 *     to: list<string>,
28 *     cc: list<string>,
29 *     bcc: list<string>,
30 *     replyTo: list<string>,
31 *     subject: ?string,
32 *     text: ?string,
33 *     html: ?string,
34 *     priority: ?int,
35 *     headers: list<array{string, string}>,
36 *     attachments: list<Attachment>,
37 * }
38 */
39final class Payload
40{
41    /** Bytes of attachments a queued mail may carry. */
42    public const int MAX_ATTACHMENTS = 10 * 1024 * 1024;
43
44    /** Headers the payload covers by their own keys, or that are set when sending. */
45    private const array OWN_HEADERS = [
46        'from',
47        'sender',
48        'return-path',
49        'to',
50        'cc',
51        'bcc',
52        'reply-to',
53        'subject',
54        'x-priority',
55        'date',
56        'message-id',
57        'mime-version',
58        'x-transport',
59    ];
60
61    /** Symfony's patterns for the `cid:` references it resolves (`Email::prepareParts()`). */
62    private const array REFERENCES = [
63        '/<img\s+[^>]*src\s*=\s*(?:([\'"])cid:(.+?)\1|cid:([^>\s]+))/i',
64        '/<\w+\s+[^>]*background\s*=\s*(?:([\'"])cid:(.+?)\1|cid:([^>\s]+))/i',
65    ];
66
67    /** @return Data */
68    public static function encode(Email $email, ?string $transport = null): array
69    {
70        [$html, $attachments] = self::embedded(
71            self::body($email->getHtmlBody(), $email->getHtmlCharset()),
72            self::attachments($email->getAttachments()),
73        );
74
75        return [
76            'transport' => $transport,
77            'from' => self::addresses($email->getFrom()),
78            'sender' => $email->getSender()?->toString(),
79            'returnPath' => $email->getReturnPath()?->toString(),
80            'to' => self::addresses($email->getTo()),
81            'cc' => self::addresses($email->getCc()),
82            'bcc' => self::addresses($email->getBcc()),
83            'replyTo' => self::addresses($email->getReplyTo()),
84            'subject' => $email->getSubject(),
85            'text' => self::body($email->getTextBody(), $email->getTextCharset()),
86            'html' => $html,
87            'priority' => $email->getHeaders()->has('X-Priority') ? $email->getPriority() : null,
88            'headers' => self::headers($email),
89            'attachments' => $attachments,
90        ];
91    }
92
93    /**
94     * @param array<array-key, mixed> $payload
95     * @return array{Email, ?string} the mail and its transport's name
96     */
97    public static function decode(array $payload): array
98    {
99        if (!self::valid($payload)) {
100            throw new RuntimeException('A queued mail payload lacks keys or has values of the wrong type.');
101        }
102
103        $email = new Email();
104        // Setting an empty list would add an empty header.
105        $lists = [
106            'from' => $email->from(...),
107            'to' => $email->to(...),
108            'cc' => $email->cc(...),
109            'bcc' => $email->bcc(...),
110            'replyTo' => $email->replyTo(...),
111        ];
112
113        foreach ($lists as $key => $set) {
114            if ($payload[$key] !== []) {
115                $set(...$payload[$key]);
116            }
117        }
118
119        if ($payload['priority'] !== null) {
120            $email->priority($payload['priority']);
121        }
122
123        if ($payload['sender'] !== null) {
124            $email->sender($payload['sender']);
125        }
126
127        if ($payload['returnPath'] !== null) {
128            $email->returnPath($payload['returnPath']);
129        }
130
131        if ($payload['subject'] !== null) {
132            $email->subject($payload['subject']);
133        }
134
135        if ($payload['text'] !== null) {
136            $email->text($payload['text']);
137        }
138
139        if ($payload['html'] !== null) {
140            $email->html($payload['html']);
141        }
142
143        foreach ($payload['headers'] as [$name, $value]) {
144            $email->getHeaders()->addTextHeader($name, $value);
145        }
146
147        foreach ($payload['attachments'] as $attachment) {
148            $body = base64_decode($attachment['body'], true);
149
150            if ($body === false) {
151                throw new RuntimeException('A queued attachment is not base64.');
152            }
153
154            $part = new DataPart($body, $attachment['name'], $attachment['type']);
155
156            if ($attachment['inline']) {
157                $part->asInline();
158            }
159
160            if ($attachment['cid'] !== null) {
161                $part->setContentId($attachment['cid']);
162            }
163
164            $email->addPart($part);
165        }
166
167        return [$email, $payload['transport']];
168    }
169
170    /**
171     * @param array<array-key, mixed> $payload
172     * @phpstan-assert-if-true Data $payload
173     */
174    private static function valid(array $payload): bool
175    {
176        $strings = static fn(mixed $list): bool => (
177            is_array($list)
178            && array_is_list($list)
179            && array_all($list, static fn(mixed $item): bool => is_string($item))
180        );
181        $optional = static fn(string $key): bool => (
182            array_key_exists($key, $payload)
183            && ($payload[$key] === null || is_string($payload[$key]))
184        );
185
186        return (
187            array_all(['from', 'to', 'cc', 'bcc', 'replyTo'], static fn(string $key): bool => $strings(
188                $payload[$key] ?? null,
189            ))
190                && array_all(['transport', 'sender', 'returnPath', 'subject', 'text', 'html'], $optional)
191                && array_key_exists('priority', $payload)
192                && ($payload['priority'] === null || is_int($payload['priority']))
193                && is_array($payload['headers'] ?? null)
194                && array_all(
195                    $payload['headers'],
196                    static fn(mixed $header): bool => $strings($header) && count($header) === 2,
197                )
198                && is_array($payload['attachments'] ?? null)
199                && array_all($payload['attachments'], self::attachment(...))
200        );
201    }
202
203    private static function attachment(mixed $part): bool
204    {
205        if (!is_array($part)) {
206            return false;
207        }
208
209        $name = $part['name'] ?? null;
210        $cid = $part['cid'] ?? null;
211
212        return (
213            ($name === null || is_string($name))
214                && ($cid === null || is_string($cid))
215                && is_string($part['type'] ?? null)
216                && is_string($part['body'] ?? null)
217                && is_bool($part['inline'] ?? null)
218        );
219    }
220
221    /**
222     * @param array<Address> $addresses
223     * @return list<string>
224     */
225    private static function addresses(array $addresses): array
226    {
227        return array_values(array_map(static fn(Address $address): string => $address->toString(), $addresses));
228    }
229
230    /** @param resource|string|null $body */
231    private static function body(mixed $body, ?string $charset): ?string
232    {
233        if ($body === null) {
234            return null;
235        }
236
237        if ($charset !== null && strtolower($charset) !== 'utf-8') {
238            throw new RuntimeException("A queued mail has UTF-8 bodies, not {$charset}.");
239        }
240
241        if (is_string($body)) {
242            return $body;
243        }
244
245        // Symfony reads a seekable body from its start, wherever the stream stands.
246        if (stream_get_meta_data($body)['seekable']) {
247            rewind($body);
248        }
249
250        return (string) stream_get_contents($body);
251    }
252
253    /** @return list<array{string, string}> */
254    private static function headers(Email $email): array
255    {
256        $headers = [];
257
258        foreach ($email->getHeaders()->all() as $header) {
259            if (in_array(strtolower($header->getName()), self::OWN_HEADERS, true)) {
260                continue;
261            }
262
263            if (!$header instanceof UnstructuredHeader) {
264                throw new RuntimeException("A queued mail takes text headers only, not {$header->getName()}.");
265            }
266
267            $headers[] = [$header->getName(), $header->getValue()];
268        }
269
270        return $headers;
271    }
272
273    /**
274     * @param array<DataPart> $parts
275     * @return list<Attachment>
276     */
277    private static function attachments(array $parts): array
278    {
279        $attachments = [];
280        $bytes = 0;
281
282        foreach ($parts as $part) {
283            $body = $part->getBody();
284            $bytes += strlen($body);
285
286            if ($bytes > self::MAX_ATTACHMENTS) {
287                throw new RuntimeException(
288                    'A queued mail carries at most ' . ((self::MAX_ATTACHMENTS / 1024) / 1024) . ' MiB of attachments.',
289                );
290            }
291
292            $attachments[] = [
293                'name' => $part->getFilename(),
294                'type' => $part->getContentType(),
295                'body' => base64_encode($body),
296                'inline' => $part->getDisposition() === 'inline',
297                'cid' => $part->hasContentId() ? $part->getContentId() : null,
298            ];
299        }
300
301        return $attachments;
302    }
303
304    /**
305     * Makes every image the HTML references by `cid:` an inline part whose
306     * Content-ID, file name and reference are the same. Symfony connects a
307     * reference to its image only when it renders the whole message, as SMTP
308     * does; API transports send the HTML as written and give the image its
309     * Content-ID or its file name, depending on the transport.
310     *
311     * @param list<Attachment> $attachments
312     * @return array{?string, list<Attachment>}
313     */
314    private static function embedded(?string $html, array $attachments): array
315    {
316        if ($html === null) {
317            return [$html, $attachments];
318        }
319
320        $names = [];
321
322        foreach (self::REFERENCES as $pattern) {
323            preg_match_all($pattern, $html, $matches);
324            $names = [...$names, ...$matches[2], ...$matches[3]];
325        }
326
327        $names = array_unique(array_filter($names, static fn(string $name): bool => $name !== ''));
328        $taken = array_values(array_filter(array_column($attachments, 'cid')));
329        $replacements = [];
330
331        foreach ($attachments as $i => $attachment) {
332            $referenced = array_filter(
333                $names,
334                static fn(string $name): bool => (
335                    !isset($replacements["cid:{$name}"])
336                    && ($name === $attachment['name'] || $name === $attachment['cid'])
337                ),
338            );
339
340            if ($referenced === []) {
341                continue;
342            }
343
344            $cid = $attachment['cid'] ?? self::contentId((string) $attachment['name'], $taken);
345            $taken[] = $cid;
346
347            foreach ($referenced as $name) {
348                $replacements["cid:{$name}"] = "cid:{$cid}";
349            }
350
351            $attachments[$i] = ['name' => $cid, 'inline' => true, 'cid' => $cid] + $attachment;
352        }
353
354        // strtr() replaces the longest match first and never inside a replacement, as Symfony does.
355        return [strtr($html, $replacements), $attachments];
356    }
357
358    /**
359     * A Content-ID made from a file name, `logo.png` becoming `logo@cosray.png`:
360     * it is used as file name too, so the extension stays last.
361     *
362     * @param list<string> $taken
363     */
364    private static function contentId(string $filename, array $taken): string
365    {
366        $extension = pathinfo($filename, PATHINFO_EXTENSION);
367        $stem = $extension === '' ? $filename : substr($filename, 0, -strlen($extension) - 1);
368        $safe = static fn(string $part): string => trim((string) preg_replace('/[^A-Za-z0-9_.-]+/', '-', $part), '.-');
369        $stem = $safe($stem);
370        $stem = $stem === '' ? 'image' : $stem;
371        $extension = $safe($extension);
372        $domain = $extension === '' ? 'cosray' : "cosray.{$extension}";
373        $cid = "{$stem}@{$domain}";
374
375        for ($n = 2; in_array($cid, $taken, true); $n++) {
376            $cid = "{$stem}-{$n}@{$domain}";
377        }
378
379        return $cid;
380    }
381}