Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
Mailer
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
7 / 7
24
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 via
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 compose
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 queued
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 send
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 address
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 list
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray;
6
7use Celema\Boiler\Engine;
8use Cosray\Exception\RuntimeException;
9use Cosray\Mail\Delivery;
10use Cosray\Mail\Html;
11use Cosray\Mail\Payload;
12use Cosray\View\Boiler\Renderer;
13use Symfony\Component\Mime\Address;
14use Symfony\Component\Mime\Email;
15use Symfony\Component\Mime\Exception\LogicException;
16
17/**
18 * Sends mail through the job queue with Symfony Mailer. `send()` renders
19 * and checks the mail right away and queues it; delivery and its retries
20 * happen later, so a send that returns has not reached anyone yet. Inside
21 * a transaction, the mail is queued only if the transaction commits.
22 *
23 *     $mailer->send('contact', to: $recipients, data: $form, replyTo: $form['email']);
24 *     $mailer->via('newsletter')->send($email);
25 *
26 * Templates are Boiler templates in the `mail.views` directories, then in
27 * Cosray's own: the HTML body, whose `<title>` is the subject, and an
28 * optional `<name>.txt` for the text body, otherwise derived from the HTML.
29 *
30 * @api
31 */
32final class Mailer
33{
34    /** Cosray's own templates, after the application's, which can replace them. */
35    private const string VIEWS = __DIR__ . '/../resources/mail';
36
37    private ?string $transport = null;
38
39    public function __construct(
40        private readonly Config $config,
41        private readonly Queue $queue,
42        private readonly Context $context,
43    ) {}
44
45    /** A mailer that sends through the named transport instead of the default one. */
46    public function via(string $transport): self
47    {
48        if (!array_key_exists($transport, $this->config->mail->transports)) {
49            throw new RuntimeException("There is no mail transport named {$transport}.");
50        }
51
52        $mailer = clone $this;
53        $mailer->transport = $transport;
54
55        return $mailer;
56    }
57
58    /**
59     * Renders a template into a mail without sending it, to add what
60     * `send()` does not cover, like attachments.
61     *
62     * @param non-empty-string $template
63     * @param array<string, mixed> $data
64     * @param ?string $locale one of the site's locales; the current one if null
65     */
66    public function compose(string $template, array $data = [], ?string $locale = null): Email
67    {
68        $render = function () use ($template, $data): Email {
69            // Boiler refuses missing directories, and a site without mails of its own has none.
70            $views = array_values(array_filter([...$this->config->mail->views, self::VIEWS], is_dir(...)));
71            $engine = Engine::create($views, trusted: Renderer::TRUSTED);
72            $html = $engine->render($template, $data);
73            $text = $engine->exists("{$template}.txt")
74                ? $engine->renderUnescaped("{$template}.txt", $data)
75                : Html::text($html);
76
77            return new Email()
78                ->subject(Html::subject($html))
79                ->html($html)
80                ->text($text);
81        };
82
83        if ($locale === null) {
84            return $render();
85        }
86
87        return $this->context->withLocale($this->context->locales()->get($locale), $render);
88    }
89
90    /**
91     * The mails queued and not yet delivered, whatever their transport, in
92     * the order they were sent, as their delivery will hand them over: a mail
93     * for a named transport carries it in `X-Transport`. Tests read what the
94     * code under test sent with it.
95     *
96     * @return list<Email>
97     */
98    public function queued(): array
99    {
100        return array_map(static function (array $payload): Email {
101            [$email, $transport] = Payload::decode($payload);
102
103            if ($transport !== null) {
104                $email->getHeaders()->addTextHeader('X-Transport', $transport);
105            }
106
107            return $email;
108        }, $this->queue->pending(Delivery::class));
109    }
110
111    /**
112     * Queues a mail: a rendered template, or a mail built by hand or with
113     * `compose()`. Addresses add to those the mail has; a mail without a
114     * sender gets `mail.from`.
115     *
116     * @param non-empty-string|Email $mail a template name or a mail
117     * @param Address|string|list<Address|string> $to
118     * @param array<string, mixed> $data for the template
119     * @param Address|string|list<Address|string> $replyTo
120     * @param Address|string|list<Address|string> $cc
121     * @param Address|string|list<Address|string> $bcc
122     * @param ?string $subject replaces the template's
123     * @param ?string $locale for the template, one of the site's locales
124     * @param int $delay seconds before the mail is sent
125     * @return int the id of the delivery job
126     */
127    public function send(
128        string|Email $mail,
129        Address|string|array $to = [],
130        array $data = [],
131        Address|string|array $replyTo = [],
132        Address|string|array $cc = [],
133        Address|string|array $bcc = [],
134        ?string $subject = null,
135        ?string $locale = null,
136        int $delay = 0,
137    ): int {
138        if ($this->config->mail->transports === []) {
139            throw new RuntimeException('No mail transport is configured: set MAILER_DSN or `mail.transports`.');
140        }
141
142        if ($mail instanceof Email && ($data !== [] || $locale !== null)) {
143            throw new RuntimeException('Data and locale are for templates; render one with compose() first.');
144        }
145
146        $email = $mail instanceof Email ? $mail : $this->compose($mail, $data, $locale);
147        $this->address($email, $to, $replyTo, $cc, $bcc);
148
149        if ($subject !== null) {
150            $email->subject($subject);
151        }
152
153        if (trim((string) $email->getSubject()) === '') {
154            throw new RuntimeException('A mail needs a subject: give its template a <title> or pass one.');
155        }
156
157        try {
158            $email->ensureValidity();
159        } catch (LogicException $e) {
160            throw new RuntimeException("The mail cannot be sent: {$e->getMessage()}", previous: $e);
161        }
162
163        return $this->queue->push(Delivery::class, Payload::encode($email, $this->transport), $delay, 'mail');
164    }
165
166    /**
167     * @param Address|string|list<Address|string> $to
168     * @param Address|string|list<Address|string> $replyTo
169     * @param Address|string|list<Address|string> $cc
170     * @param Address|string|list<Address|string> $bcc
171     */
172    private function address(
173        Email $email,
174        Address|string|array $to,
175        Address|string|array $replyTo,
176        Address|string|array $cc,
177        Address|string|array $bcc,
178    ): void {
179        $lists = [
180            [$email->addTo(...), $to],
181            [$email->addReplyTo(...), $replyTo],
182            [$email->addCc(...), $cc],
183            [$email->addBcc(...), $bcc],
184        ];
185
186        foreach ($lists as [$add, $addresses]) {
187            // Adding none would add an empty header, which passes the check for recipients.
188            if ($addresses !== []) {
189                $add(...self::list($addresses));
190            }
191        }
192
193        if ($email->getFrom() === [] && $email->getSender() === null) {
194            $email->from(
195                $this->config->mail->from ?? throw new RuntimeException(
196                    'A mail needs a sender: set MAILER_FROM or `mail.from`, or pass one.',
197                ),
198            );
199        }
200    }
201
202    /**
203     * @param Address|string|list<Address|string> $addresses
204     * @return list<Address|string>
205     */
206    private static function list(Address|string|array $addresses): array
207    {
208        return is_array($addresses) ? $addresses : [$addresses];
209    }
210}