Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.28% covered (success)
95.28%
242 / 254
83.33% covered (success)
83.33%
20 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Mail
95.28% covered (success)
95.28%
242 / 254
83.33% covered (success)
83.33%
20 / 24
114
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 createBoundary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getServerName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setFrom
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setEmailTo
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 addEmailTo
80.00% covered (success)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
7.39
 validateEmail
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 addCc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addTo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 send
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
17
 sendPreparedEnvelope
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 createHeaders
85.37% covered (success)
85.37%
35 / 41
0.00% covered (danger)
0.00%
0 / 1
28.12
 getDate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createBody
97.67% covered (success)
97.67%
42 / 43
0.00% covered (danger)
0.00%
0 / 1
11
 wrapLines
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 fixEOL
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getMUA
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 sendViaSmtpAgent
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 enqueueForDelivery
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
7
 isQueueDeliveryEnabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 createProvider
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 setReplyTo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 safeEmail
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * MUA (Mail User Agent) implementation.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Matteo Scaramuccia <matteo@phpmyfaq.de>
12 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
13 * @copyright 2009-2026 phpMyFAQ Team
14 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
15 * @link      https://www.phpmyfaq.de
16 * @since     2009-09-11
17 */
18
19declare(strict_types=1);
20
21namespace phpMyFAQ;
22
23use phpMyFAQ\Core\Exception;
24use phpMyFAQ\Mail\Builtin;
25use phpMyFAQ\Mail\MailProviderInterface;
26use phpMyFAQ\Mail\Provider\MailgunProvider;
27use phpMyFAQ\Mail\Provider\SendGridProvider;
28use phpMyFAQ\Mail\Provider\SesProvider;
29use phpMyFAQ\Mail\Smtp;
30use phpMyFAQ\Queue\DatabaseMessageBus;
31use phpMyFAQ\Queue\Message\SendMailMessage;
32use Symfony\Component\HttpFoundation\Request;
33use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
34use Throwable;
35
36/**
37 * Class Mail
38 *
39 * @package phpMyFAQ
40 */
41/* @mago-expect lint:too-many-properties - carries the full MIME envelope state */
42/* @mago-expect lint:kan-defect - legacy mail composer; split planned with the mailer rework */
43class Mail
44{
45    /**
46     * Type of the used MUA. Possible values:
47     * - built-in.
48     */
49    public string $agent;
50
51    /**
52     * Attached filed.
53     */
54    /** @var array<int, array<string, string>> */
55    public array $attachments = [];
56
57    /**
58     * Body of the e-mail.
59     */
60    public string $body = '';
61
62    /**
63     * Boundary.
64     */
65    public string $boundary = '----------';
66
67    /**
68     * Charset.
69     */
70    public string $charset = 'utf-8';
71
72    /**
73     * Content disposition.
74     */
75    public string $contentDisposition = 'inline';
76
77    /**
78     * Content type.
79     */
80    public string $contentType = 'text/plain';
81
82    /**
83     * Content transfer encoding.
84     */
85    public string $contentTransferEncoding = '8bit';
86
87    /**
88     * The one and only valid EOL sequence as per RFC 2822:
89     * carriage-return followed by line-feed.
90     */
91    public string $eol = "\r\n";
92
93    /**
94     * Array of headers of the e-mail
95     *
96     * @var array<string|int>
97     */
98    /** @var array<string, int|string|null> */
99    public array $headers = [];
100
101    /**
102     * Message of the e-mail: HTML text allowed.
103     */
104    public string $message = '';
105
106    /**
107     * Alternate message of the e-mail: only plain text allowed.
108     */
109    public string $messageAlt = '';
110
111    /**
112     * Message-ID of the e-mail.
113     */
114    public string $messageId;
115
116    /**
117     * Priorities: 1 (Highest), 2 (High), 3 (Normal), 4 (Low), 5 (Lowest).
118     *
119     * @var array<string>
120     */
121    public array $priorities = [
122        1 => 'Highest',
123        2 => 'High',
124        3 => 'Normal',
125        4 => 'Low',
126        5 => 'Lowest',
127    ];
128
129    /**
130     * Priority of the e-mail: 1 (Highest), 2 (High), 3 (Normal), 4 (Low), 5 (Lowest).
131     *
132     *
133     * @see priorities
134     */
135    public int $priority = 3;
136
137    /**
138     * Subject of the e-mail.
139     */
140    public string $subject = '';
141
142    /**
143     * Recipients of the e-mail as <BCC>.
144     */
145    /** @var array<string, string|null> */
146    private array $bcc = [];
147
148    /**
149     * Recipients of the e-mail as <CC>.
150     */
151    /** @var array<string, string|null> */
152    private array $cc = [];
153
154    /**
155     * Recipients of the e-mail as <From>.
156     */
157    /** @var array<string, string|null> */
158    private array $from = [];
159
160    /**
161     * Mailer string.
162     */
163    private readonly string $mailer;
164
165    /**
166     * Recipient of the optional notification.
167     */
168    /** @var array<string, string|null> */
169    private array $notifyTo = [];
170
171    /**
172     * Recipient of the e-mail as <Reply-To>.
173     */
174    /** @var array<string, string|null> */
175    private array $replyTo = [];
176
177    /**
178     * Recipient of the e-mail as <Return-Path>.
179     */
180    /** @var array<string, string|null> */
181    private array $returnPath = [];
182
183    /**
184     * Recipient of the e-mail as <Sender>.
185     */
186    /** @var array<string, string|null> */
187    private array $sender = [];
188
189    /**
190     * Recipients of the e-mail as <TO:>.
191     */
192    /** @var array<string, string|null> */
193    private array $to = [];
194
195    private readonly Configuration $configuration;
196
197    /**
198     * Default constructor.
199     * Note: any email will be sent from the PMF administrator, use unsetFrom
200     * before using setFrom.
201     */
202    public function __construct(Configuration $configuration)
203    {
204        // Set the default value for public properties
205        $this->agent = $configuration->get(item: 'mail.remoteSMTP') ? 'smtp' : 'built-in';
206        $this->boundary = self::createBoundary();
207        $this->messageId =
208            '<'
209            . (string) Request::createFromGlobals()->server->get(key: 'REQUEST_TIME')
210            . '.'
211            . md5(microtime())
212            . '@'
213            . self::getServerName()
214            . '>';
215
216        // Set the default value for private properties
217        $this->configuration = $configuration;
218
219        // Set phpMyFAQ related data
220        $this->mailer = 'phpMyFAQ/' . $this->configuration->getVersion();
221        try {
222            $this->setFrom($this->configuration->getAdminEmail(), $this->configuration->getTitle());
223        } catch (Exception $exception) {
224            $this->configuration
225                ->getLogger()
226                ->warning('Unable to initialize mail sender defaults: ' . $exception->getMessage());
227        }
228    }
229
230    /**
231     * Create a string to be used as a valid boundary value.
232     *
233     * @static
234     * @return string The boundary value.
235     */
236    public static function createBoundary(): string
237    {
238        return '-----' . md5(microtime());
239    }
240
241    /**
242     * Returns the server name.
243     *
244     * @static
245     * @return string The server name.
246     */
247    public static function getServerName(): string
248    {
249        $request = Request::createFromGlobals();
250        $host = $request->getHost();
251        return $host !== '' ? $host : 'localhost.localdomain';
252    }
253
254    /**
255     * Set the "From" address.
256     *
257     * @param string      $address User e-mail address.
258     * @param string|null $name Username (optional).
259     * @return bool True if successful, false otherwise.
260     * @throws Exception
261     */
262    public function setFrom(string $address, ?string $name = null): bool
263    {
264        return $this->setEmailTo($this->from, targetAlias: 'From', address: $address, name: $name);
265    }
266
267    /**
268     * Set just one e-mail address into an array.
269     *
270     * @param array<string, string|null> $target Target array.
271     * @param string $targetAlias Alias Target alias.
272     * @param string $address User e-mail address.
273     * @param string|null $name Username (optional).
274     * @return bool True if successful, false otherwise.
275     * @throws Exception
276     */
277    private function setEmailTo(array &$target, string $targetAlias, string $address, ?string $name = null): bool
278    {
279        // Check for the permitted number of items into the $target array
280        if (count($target) > 2) {
281            $keys = array_keys($target);
282            throw new Exception(sprintf(
283                "Too many e-mail addresses, %s, have been already added as '%s'!",
284                $keys[0],
285                $targetAlias,
286            ));
287        }
288
289        return $this->addEmailTo($target, $targetAlias, $address, $name);
290    }
291
292    /**
293     * Add an e-mail address to an array.
294     *
295     * @param array<string, string|null> $target Target array.
296     * @param string $targetAlias Alias Target alias.
297     * @param string $address User e-mail address.
298     * @param string|null $name Username (optional).
299     * @return bool True if successful, false otherwise.
300     */
301    private function addEmailTo(array &$target, string $targetAlias, string $address, ?string $name = null): bool
302    {
303        // Check
304        if (!self::validateEmail($address)) {
305            $this->configuration->getLogger()->error('"' . $address . '" is not a valid email address!');
306            return false;
307        }
308
309        // Don't allow duplicated addresses
310        if (array_key_exists($address, $target)) {
311            $this->configuration
312                ->getLogger()
313                ->error('"' . $address . '" has been already added in ' . $targetAlias . '!');
314            return false;
315        }
316
317        if ($name !== null) {
318            // Remove CR and LF characters to prevent header injection
319            $name = str_replace(search: ["\n", "\r"], replace: '', subject: $name);
320
321            // Encode any special characters in the displayed name
322            $encodedName = iconv_mime_encode($targetAlias, $name);
323            $name = $encodedName === false ? $name : $encodedName;
324
325            // Wrap the displayed name in quotes (to fix problems with commas etc.),
326            // and escape any existing quotes
327            $name = '"' . str_replace(search: '"', replace: '\"', subject: $name) . '"';
328        }
329
330        // Add the email address into the target array
331        $target[$address] = $name;
332        // On Windows, when using PHP built-in mail drops any name, just use the e-mail address
333        if ('WIN' !== strtoupper(substr(string: PHP_OS, offset: 0, length: 3))) {
334            return true;
335        }
336
337        if ('built-in' !== $this->agent) {
338            return true;
339        }
340
341        $target[$address] = null;
342
343        return true;
344    }
345
346    /**
347     * Validate an address as an e-mail address.
348     *
349     * @param string $address E-Mail address
350     *
351     * @return bool True if the given address is a valid e-mail address, false otherwise.
352     */
353    public static function validateEmail(string $address): bool
354    {
355        if ($address === '' || $address === '0') {
356            return false;
357        }
358
359        $unsafe = ["\r", "\n"];
360        if ($address !== str_replace(search: $unsafe, replace: '', subject: $address)) {
361            return false;
362        }
363
364        return (bool) filter_var($address, FILTER_VALIDATE_EMAIL);
365    }
366
367    /**
368     * Add a recipient as <CC>.
369     *
370     * @param string      $address User e-mail address.
371     * @param string|null $name Username (optional).
372     * @return bool True if successful, false otherwise.
373     * @throws Exception
374     */
375    public function addCc(string $address, ?string $name = null): bool
376    {
377        return $this->addEmailTo($this->cc, targetAlias: 'Cc', address: $address, name: $name);
378    }
379
380    /**
381     * Add a recipient as <TO>.
382     *
383     * @param string $address User e-mail address.
384     * @param string|null   $name Username (optional).
385     * @return bool True if successful, false otherwise.
386     * @throws Exception
387     */
388    public function addTo(string $address, ?string $name = null): bool
389    {
390        return $this->addEmailTo($this->to, targetAlias: 'To', address: $address, name: $name);
391    }
392
393    /**
394     * Send the email according to the current settings.
395     *
396     * @throws Exception|TransportExceptionInterface
397     */
398    public function send(bool $forceSynchronousDelivery = false): int
399    {
400        // Check
401        if ((count($this->to) + count($this->cc) + count($this->bcc)) < 1) {
402            throw new Exception(message: 'You need at least to set one recipient among TO, CC and BCC!');
403        }
404
405        // Has any alternative message been provided?
406        if ($this->messageAlt !== '' && $this->messageAlt !== '0') {
407            $this->contentType = 'multipart/alternative';
408        }
409
410        // Has any attachment been provided?
411        if (count($this->attachments) > 0) {
412            $this->contentType = 'multipart/mixed';
413        }
414
415        // Has any in-line attachment been provided?
416        $hasInlineAttachments = false;
417        $idx = 0;
418        while (!$hasInlineAttachments && $idx < count($this->attachments)) {
419            $hasInlineAttachments = 'inline' === $this->attachments[$idx]['disposition'];
420            ++$idx;
421        }
422
423        if ($hasInlineAttachments) {
424            $this->contentType = 'multipart/related';
425        }
426
427        // A valid MUA needs to implement the PMF_Mail_IMUA interface
428        // i.e. we must prepare recipients, headers, body for the send() method
429
430        // Prepare the recipients
431        $to = [];
432        foreach ($this->to as $address => $name) {
433            $to[] = ($name !== null && $name !== '' ? $name . ' ' : '') . '<' . $address . '>';
434        }
435
436        $recipients = implode(separator: ',', array: $to);
437        // Check for the need of undisclosed recipients outlook-like <TO:>
438        if (($recipients === '' || $recipients === '0') && 0 === count($this->cc)) {
439            $recipients = '<Undisclosed-Recipient:;>';
440        }
441
442        // Prepare the headers
443        $this->createHeaders();
444
445        // Prepare the body
446        $this->createBody();
447
448        if (
449            !$forceSynchronousDelivery
450            && $this->isQueueDeliveryEnabled()
451            && $this->enqueueForDelivery($recipients, $this->headers, $this->body)
452        ) {
453            return count($this->to) + count($this->cc) + count($this->bcc);
454        }
455
456        return $this->sendPreparedEnvelope($recipients, $this->headers, $this->body);
457    }
458
459    /**
460     * @param array<string, int|string|null> $headers
461     * @throws Exception|TransportExceptionInterface
462     */
463    public function sendPreparedEnvelope(string $recipients, array $headers, string $body): int
464    {
465        $provider = $this->configuration->getMailProvider();
466
467        return match ($provider) {
468            'smtp' => $this->sendViaSmtpAgent($recipients, $headers, $body),
469            'sendgrid' => $this->createProvider('sendgrid')->send($recipients, $headers, $body),
470            'ses' => $this->createProvider('ses')->send($recipients, $headers, $body),
471            'mailgun' => $this->createProvider('mailgun')->send($recipients, $headers, $body),
472            default => $this->sendViaSmtpAgent($recipients, $headers, $body),
473        };
474    }
475
476    /**
477     * Create the headers of the email.
478     */
479    private function createHeaders(): void
480    {
481        // Cleanup headers
482        $this->headers = [];
483
484        // Check if the message consists of just a "plain" single item
485        if (!str_contains($this->contentType, needle: 'multipart')) {
486            // Content-Disposition: inline
487            $this->headers['Content-Disposition'] = $this->contentDisposition;
488            // Content-Type
489            $this->headers['Content-Type'] = $this->contentType . '; format=flowed; charset="' . $this->charset . '"';
490            // Content-Transfer-Encoding: 7bit
491            $this->headers['Content-Transfer-Encoding'] = '7bit';
492        }
493
494        if (str_contains($this->contentType, needle: 'multipart')) {
495            $this->headers['Content-Type'] = $this->contentType . '; boundary="' . $this->boundary . '"';
496        }
497
498        // Date
499        $this->headers['Date'] = self::getDate(self::getTime());
500
501        // Disposition-Notification-To, RFC 3798
502        $notifyTos = [];
503        foreach ($this->notifyTo as $address => $name) {
504            $notifyTos[] = ($name !== null && $name !== '' ? $name . ' ' : '') . '<' . $address . '>';
505        }
506
507        $notifyTo = implode(separator: ',', array: $notifyTos);
508        if ($notifyTo !== '' && $notifyTo !== '0') {
509            $this->headers['Disposition-Notification-To'] = $notifyTo;
510        }
511
512        // From
513        foreach ($this->from as $address => $name) {
514            $this->headers['From'] = ($name !== null && $name !== '' ? $name . ' ' : '') . '<' . $address . '>';
515        }
516
517        // CC
518        foreach ($this->cc as $address => $name) {
519            $this->headers['CC'] = ($name !== null && $name !== '' ? $name . ' ' : '') . '<' . $address . '>';
520        }
521
522        // BCC
523        foreach ($this->bcc as $address => $name) {
524            $this->headers['BCC'] = ($name !== null && $name !== '' ? $name . ' ' : '') . '<' . $address . '>';
525        }
526
527        // Message-Id
528        $this->headers['Message-ID'] = $this->messageId;
529
530        // MIME-Version: 1.0
531        $this->headers['MIME-Version'] = '1.0';
532
533        // Reply-To
534        $this->headers['Reply-To'] = $this->headers['From'] ?? null;
535        foreach ($this->replyTo as $address => $name) {
536            $this->headers['Reply-To'] = ($name !== null && $name !== '' ? $name . ' ' : '') . '<' . $address . '>';
537        }
538
539        // Return-Path
540        foreach ($this->from as $address => $name) {
541            $this->headers['Return-Path'] = '<' . $address . '>';
542        }
543
544        foreach ($this->returnPath as $address => $name) {
545            $this->headers['Return-Path'] = '<' . $address . '>';
546        }
547
548        // Sender
549        $this->headers['Sender'] = $this->headers['From'] ?? null;
550        foreach ($this->sender as $address => $name) {
551            $this->headers['Sender'] = ($name !== null && $name !== '' ? $name . ' ' : '') . '<' . $address . '>';
552        }
553
554        // Subject. Note: it must be RFC 2047 compliant
555        // @todo: wrap mb_encode_mimeheader() to add other content encodings
556        $this->headers['Subject'] = Utils::resolveMarkers(
557            html_entity_decode($this->subject, ENT_COMPAT, encoding: 'UTF-8'),
558            $this->configuration,
559        );
560
561        // X-Mailer
562        $this->headers['X-Mailer'] = $this->mailer;
563
564        // X-MSMail-Priority
565        if (array_key_exists($this->priority, $this->priorities)) {
566            $this->headers['X-MSMail-Priority'] = $this->priorities[$this->priority];
567        }
568
569        // X-Originating-IP
570        $this->headers['X-Originating-IP'] = Request::createFromGlobals()->getClientIp();
571
572        // X-Priority
573        $this->headers['X-Priority'] = $this->priority;
574    }
575
576    /**
577     * Returns the date according to RFC 2822.
578     *
579     * @static
580     *
581     * @param int $date Unix timestamp.
582     *
583     * @return string The RFC 2822 date if successful, false otherwise.
584     */
585    public static function getDate(int $date): string
586    {
587        return date(format: 'r', timestamp: $date);
588    }
589
590    /**
591     * Returns the Unix timestamp with preference to the Page Request time.
592     *
593     * @static
594     *
595     * @return int Unix timestamp.
596     */
597    public static function getTime(): int
598    {
599        return (int) (Request::createFromGlobals()->server->get(key: 'REQUEST_TIME') ?? time());
600    }
601
602    /**
603     * Create the body of the email.
604     */
605    private function createBody(): void
606    {
607        $lines = [];
608        $mainBoundary = $this->boundary;
609
610        // Cleanup body
611        $this->body = '';
612
613        // Add lines
614        if (str_contains($this->contentType, needle: 'multipart')) {
615            $lines[] = 'This is a multi-part message in MIME format.';
616            $lines[] = '';
617        }
618
619        if (in_array($this->contentType, ['multipart/mixed', 'multipart/related'], strict: true)) {
620            $lines[] = '--' . $mainBoundary;
621            $this->boundary = '--=alternative=' . self::createBoundary();
622            $lines[] = 'Content-Type: multipart/alternative; boundary="' . $this->boundary . '"';
623            $lines[] = '';
624        }
625
626        if (str_contains($this->contentType, needle: 'multipart')) {
627            // At least we have messageAlt and message
628            if ($this->messageAlt !== '' && $this->messageAlt !== '0') {
629                // 1/2. messageAlt, supposed as plain text
630                $lines[] = '--' . $this->boundary;
631                $lines[] = 'Content-Type: text/plain; charset="' . $this->charset . '"';
632                $lines[] = 'Content-Transfer-Encoding: ' . $this->contentTransferEncoding;
633                $lines[] = '';
634                $lines[] = self::wrapLines(Utils::resolveMarkers($this->messageAlt, $this->configuration));
635                $lines[] = '';
636            }
637
638            // 2/2. message, supposed as, potentially, HTML
639            $lines[] = '--' . $this->boundary;
640            $lines[] = 'Content-Type: text/html; charset="' . $this->charset . '"';
641            $lines[] = 'Content-Transfer-Encoding: ' . $this->contentTransferEncoding;
642            $lines[] = '';
643            $lines[] = self::wrapLines($this->message);
644            // Close the boundary delimiter
645            $lines[] = '--' . $this->boundary . '--';
646        }
647
648        if (!str_contains($this->contentType, needle: 'multipart')) {
649            $lines[] = self::wrapLines($this->message);
650        }
651
652        if (in_array($this->contentType, ['multipart/mixed', 'multipart/related'], strict: true)) {
653            // Back to the main boundary
654            $this->boundary = $mainBoundary;
655            // Add the attachments
656            foreach ($this->attachments as $attachment) {
657                $lines[] = '--' . $this->boundary;
658                $lines[] = 'Content-Type: ' . $attachment['mimetype'] . '; name="' . $attachment['name'] . '"';
659                $lines[] = 'Content-Transfer-Encoding: base64';
660                if ('inline' === $attachment['disposition']) {
661                    $lines[] = 'Content-ID: <' . $attachment['cid'] . '>';
662                }
663
664                $lines[] =
665                    'Content-Disposition: ' . $attachment['disposition'] . '; filename="' . $attachment['name'] . '"';
666                $lines[] = '';
667                $lines[] = chunk_split(base64_encode((string) file_get_contents($attachment['path'])));
668            }
669
670            // Close the boundary delimiter
671            $lines[] = '--' . $this->boundary . '--';
672        }
673
674        // Create the final body
675        $this->body = '';
676        foreach ($lines as $line) {
677            $this->body .= $line . $this->eol;
678        }
679    }
680
681    /**
682     * Wraps the lines contained into the given message.
683     *
684     * @param string $message Message.
685     * @param int    $width Column width. Defaults to 72.
686     * @return string The given message, wrapped as requested.
687     */
688    public function wrapLines(string $message, int $width = 72): string
689    {
690        $message = $this->fixEOL($message);
691
692        $lines = explode($this->eol, $message);
693        $wrapped = '';
694        foreach ($lines as $line) {
695            $wrapped .= $wrapped === '' || $wrapped === '0' ? '' : $this->eol;
696            $wrapped .= wordwrap($line, $width, $this->eol);
697        }
698
699        return $wrapped;
700    }
701
702    /**
703     * Returns the given text being sure that any CR or LF has been fixed
704     * according to RFC 2822 EOL setting.
705     *
706     * @param string $text Text with a mixed usage of CR, LF, CRLF.
707     * @return string The fixed text.
708     * @see eol
709     */
710    public function fixEOL(string $text): string
711    {
712        // Assure that anything among CRLF, CR will be replaced with just LF
713        $text = str_replace(
714            search: [
715                "\r\n",
716                // CRLF
717                "\r",
718                // CR
719                "\n",
720            ],
721            replace: "\n", // LF
722            subject: $text,
723        );
724        // Set any LF to the RFC 2822 EOL
725        return str_replace(search: "\n", replace: $this->eol, subject: $text);
726    }
727
728    /**
729     * Get the instance of the class implementing the MUA for the given type.
730     *
731     * @static
732     * @param string $mua Type of the MUA.
733     */
734    public static function getMUA(string $mua): Builtin|Smtp
735    {
736        return match ($mua) {
737            'smtp' => new Smtp(),
738            'built-in', 'builtin' => new Builtin(),
739            default => throw new \InvalidArgumentException(sprintf('Unknown mail user agent "%s".', $mua)),
740        };
741    }
742
743    /**
744     * @param array<string, int|string|null> $headers
745     * @throws Exception|TransportExceptionInterface
746     */
747    private function sendViaSmtpAgent(string $recipients, array $headers, string $body): int
748    {
749        $mua = self::getMUA($this->agent);
750
751        if (method_exists($mua, method: 'setAuthConfig')) {
752            $mua->setAuthConfig(
753                (string) $this->configuration->get(item: 'mail.remoteSMTPServer'),
754                (string) $this->configuration->get(item: 'mail.remoteSMTPUsername'),
755                (string) $this->configuration->get(item: 'mail.remoteSMTPPassword'),
756                (int) $this->configuration->get(item: 'mail.remoteSMTPPort'),
757                true === $this->configuration->get(item: 'mail.remoteSMTPDisableTLSPeerVerification'),
758            );
759        }
760
761        return match ($this->agent) {
762            'smtp', 'built-in' => $mua->send($recipients, $headers, $body),
763            default => throw new Exception('<strong>Mail Class</strong>: ' . $this->agent . ' has no implementation!'),
764        };
765    }
766
767    /**
768     * @param array<string, int|string|null> $headers
769     */
770    private function enqueueForDelivery(string $recipients, array $headers, string $body): bool
771    {
772        try {
773            $container = $this->configuration->getServiceContainer();
774            if (!$container instanceof \Psr\Container\ContainerInterface) {
775                return false;
776            }
777
778            if (!$container->has('phpmyfaq.queue.message-bus')) {
779                return false;
780            }
781
782            $messageBus = $container->get('phpmyfaq.queue.message-bus');
783            if (!$messageBus instanceof DatabaseMessageBus) {
784                return false;
785            }
786
787            $firstRecipient = array_key_first($this->to);
788            if ($firstRecipient === null) {
789                return false;
790            }
791
792            $message = new SendMailMessage(
793                recipient: $firstRecipient,
794                subject: (string) ($headers['Subject'] ?? $this->subject),
795                body: $this->message !== '' ? $this->message : $body,
796                metadata: [
797                    'envelope' => [
798                        'recipients' => $recipients,
799                        'headers' => $headers,
800                        'body' => $body,
801                    ],
802                ],
803            );
804
805            $messageBus->dispatch($message, 'mail');
806
807            return true;
808        } catch (Throwable $throwable) {
809            $this->configuration->getLogger()->error('Queueing mail failed, falling back to synchronous delivery.', [
810                'message' => $throwable->getMessage(),
811                'trace' => $throwable->getTraceAsString(),
812            ]);
813
814            return false;
815        }
816    }
817
818    private function isQueueDeliveryEnabled(): bool
819    {
820        $useQueue = $this->configuration->get('mail.useQueue');
821        if ($useQueue === null) {
822            return false;
823        }
824
825        return (bool) $useQueue;
826    }
827
828    /**
829     * @throws Exception
830     */
831    private function createProvider(string $provider): MailProviderInterface
832    {
833        return match ($provider) {
834            'sendgrid' => new SendGridProvider($this->configuration),
835            'ses' => new SesProvider($this->configuration),
836            'mailgun' => new MailgunProvider($this->configuration),
837            default => throw new Exception('Unsupported mail provider: ' . $provider),
838        };
839    }
840
841    /**
842     * Set the "Reply-to" address.
843     *
844     * @param string      $address User e-mail address.
845     * @param string|null $name Username (optional).
846     * @return bool True if successful, false otherwise.
847     * @throws Exception
848     */
849    public function setReplyTo(string $address, ?string $name = null): bool
850    {
851        return $this->setEmailTo($this->replyTo, targetAlias: 'Reply-To', address: $address, name: $name);
852    }
853
854    /**
855     * If the email spam protection has been activated from the general
856     * phpMyFAQ configuration, this method converts an email address e.g.,
857     * from "user@example.org" to "user_AT_example_DOT_org". Otherwise,
858     * it will return the plain email address.
859     *
860     * @param string $email E-mail address
861     * @static
862     */
863    public function safeEmail(string $email): string
864    {
865        if ($this->configuration->get(item: 'spam.enableSafeEmail')) {
866            return str_replace(['@', '.'], ['_AT_', '_DOT_'], $email);
867        }
868
869        return $email;
870    }
871}