• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

aplus-framework / email / 30052034246

23 Jul 2026 11:01PM UTC coverage: 86.351% (-13.1%) from 99.405%
30052034246

push

github

natanfelles
Update user guide

601 of 696 relevant lines covered (86.35%)

8.29 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.06
/src/Message.php
1
<?php declare(strict_types=1);
2
/*
3
 * This file is part of Aplus Framework Email Library.
4
 *
5
 * (c) Natan Felles <natanfelles@gmail.com>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
namespace Framework\Email;
11

12
use DateTime;
13
use JetBrains\PhpStorm\ArrayShape;
14
use JetBrains\PhpStorm\Language;
15
use LogicException;
16
use Random\RandomException;
17
use RuntimeException;
18
use Stringable;
19

20
/**
21
 * Class Message.
22
 *
23
 * @package email
24
 */
25
class Message implements Stringable
26
{
27
    /**
28
     * The Mailer instance.
29
     *
30
     * @var Mailer
31
     */
32
    protected Mailer $mailer;
33
    /**
34
     * The message boundary.
35
     *
36
     * @var string
37
     */
38
    protected string $boundary;
39
    /**
40
     * @var array<string,string>
41
     */
42
    protected array $headers = [
43
        'mime-version' => '1.0',
44
    ];
45
    /**
46
     * A list of attachments with Content-Disposition equals `attachment`.
47
     *
48
     * @var array<int,Attachment> The attachments
49
     */
50
    protected array $attachments = [];
51
    /**
52
     * An associative array of attachments with Content-Disposition equals `inline`.
53
     *
54
     * @var array<string,Attachment> The Content-ID's as keys and the Attachments as values
55
     */
56
    protected array $inlineAttachments = [];
57
    /**
58
     * The plain text message.
59
     *
60
     * @var string
61
     */
62
    protected string $plainMessage;
63
    /**
64
     * The HTML message.
65
     *
66
     * @var string
67
     */
68
    protected string $htmlMessage;
69

70
    /**
71
     * Render the Message as string.
72
     *
73
     * @return string
74
     */
75
    public function __toString() : string
76
    {
77
        return $this->renderData();
6✔
78
    }
79

80
    /**
81
     * Set the Mailer instance.
82
     *
83
     * @param Mailer $mailer The Mailer instance
84
     *
85
     * @return static
86
     */
87
    public function setMailer(Mailer $mailer) : static
88
    {
89
        $this->mailer = $mailer;
43✔
90
        return $this;
43✔
91
    }
92

93
    protected function getCrlf() : string
94
    {
95
        if (isset($this->mailer)) {
11✔
96
            return $this->mailer->getConfig('crlf');
11✔
97
        }
98
        return "\r\n";
×
99
    }
100

101
    protected function getCharset() : string
102
    {
103
        if (isset($this->mailer)) {
2✔
104
            return $this->mailer->getConfig('charset');
2✔
105
        }
106
        return 'utf-8';
×
107
    }
108

109
    /**
110
     * Set the boundary.
111
     *
112
     * @param string|null $boundary
113
     *
114
     * @throws RandomException
115
     *
116
     * @return static
117
     */
118
    public function setBoundary(?string $boundary = null) : static
119
    {
120
        $this->boundary = $boundary ?? $this->makeBoundary();
10✔
121
        return $this;
10✔
122
    }
123

124
    protected function makeBoundary() : string
125
    {
126
        return \bin2hex(\random_bytes(16));
10✔
127
    }
128

129
    /**
130
     * Get the boundary.
131
     *
132
     * @throws RandomException
133
     *
134
     * @return string
135
     */
136
    public function getBoundary() : string
137
    {
138
        if (!isset($this->boundary)) {
10✔
139
            $this->setBoundary();
10✔
140
        }
141
        return $this->boundary;
10✔
142
    }
143

144
    /**
145
     * Remove a header.
146
     *
147
     * @param string $name The header name
148
     *
149
     * @return static
150
     */
151
    public function removeHeader(string $name) : static
152
    {
153
        unset($this->headers[\strtolower($name)]);
8✔
154
        return $this;
8✔
155
    }
156

157
    /**
158
     * Set a header.
159
     *
160
     * @param string $name The header name
161
     * @param string $value The header value
162
     *
163
     * @return static
164
     */
165
    public function setHeader(string $name, string $value) : static
166
    {
167
        $this->headers[\strtolower($name)] = $value;
29✔
168
        return $this;
29✔
169
    }
170

171
    /**
172
     * Get a header.
173
     *
174
     * @param string $name The header name
175
     *
176
     * @return string|null The header value or null if not set
177
     */
178
    public function getHeader(string $name) : ?string
179
    {
180
        return $this->headers[\strtolower($name)] ?? null;
31✔
181
    }
182

183
    /**
184
     * Get all headers set.
185
     *
186
     * @return array<string,string> The header names, in lowercase, as keys and
187
     * the values as values
188
     */
189
    public function getHeaders() : array
190
    {
191
        return $this->headers;
8✔
192
    }
193

194
    /**
195
     * Get header lines.
196
     *
197
     * @return array<string,string> The header names (lowercase) as keys and
198
     * lines as values
199
     */
200
    public function getHeaderLines() : array
201
    {
202
        $lines = [];
8✔
203
        foreach ($this->getHeaders() as $name => $value) {
8✔
204
            $value = $this->sanitizeSpaces($value);
8✔
205
            if ($name === 'subject') {
8✔
206
                $value = $this->encodeSubject($value);
7✔
207
            }
208
            $lines[$name] = Header::getName($name) . ': ' . $value;
8✔
209
        }
210
        return $lines;
8✔
211
    }
212

213
    protected function encodeSubject(string $subject) : string
214
    {
215
        return '=?UTF-8?B?' . \base64_encode($subject) . '?=';
7✔
216
    }
217

218
    protected function renderHeaders() : string
219
    {
220
        return \implode($this->getCrlf(), $this->getHeaderLines());
7✔
221
    }
222

223
    protected function prepareHeaders() : void
224
    {
225
        if (!$this->getDate()) {
6✔
226
            $this->setDate();
6✔
227
        }
228
    }
229

230
    protected function renderData() : string
231
    {
232
        if ($this->isHtmlOnly()) {
6✔
233
            return $this->renderHtmlOnly();
×
234
        }
235
        if ($this->isPlainOnly()) {
6✔
236
            return $this->renderPlainOnly();
1✔
237
        }
238
        if ($this->isAlternative()) {
5✔
239
            return $this->renderAlternative();
×
240
        }
241
        if ($this->isMixedAndInline()) {
5✔
242
            return $this->renderMixedAndInline();
5✔
243
        }
244
        if ($this->isMixed()) {
1✔
245
            return $this->renderMixed();
1✔
246
        }
247
        if ($this->isInline()) {
×
248
            return $this->renderInline();
×
249
        }
250
        throw new RuntimeException('No method found to render data');
×
251
    }
252

253
    protected function isHtmlOnly() : bool
254
    {
255
        return $this->getHtmlMessage() !== null
6✔
256
            && $this->getPlainMessage() === null
6✔
257
            && $this->getAttachments() === []
6✔
258
            && $this->getInlineAttachments() === [];
6✔
259
    }
260

261
    protected function renderHtmlOnly() : string
262
    {
263
        $crlf = $this->getCrlf();
×
264
        $this->prepareHeaders();
×
265
        $data = $this->renderHeaders() . $crlf;
×
266
        $data .= 'Content-Type: text/html; charset="utf-8"' . $crlf;
×
267
        $data .= 'Content-Transfer-Encoding: base64' . $crlf;
×
268
        $data .= $crlf;
×
269
        $message = \base64_encode($this->getHtmlMessage());
×
270
        $data .= \chunk_split($message);
×
271
        return $data;
×
272
    }
273

274
    protected function isPlainOnly() : bool
275
    {
276
        return $this->getHtmlMessage() === null
6✔
277
            && $this->getPlainMessage() !== null
6✔
278
            && $this->getAttachments() === []
6✔
279
            && $this->getInlineAttachments() === [];
6✔
280
    }
281

282
    protected function renderPlainOnly() : string
283
    {
284
        $crlf = $this->getCrlf();
1✔
285
        $this->prepareHeaders();
1✔
286
        $data = $this->renderHeaders() . $crlf;
1✔
287
        $data .= 'Content-Type: text/plain; charset="utf-8"' . $crlf;
1✔
288
        $data .= 'Content-Transfer-Encoding: base64' . $crlf;
1✔
289
        $data .= $crlf;
1✔
290
        $message = \base64_encode($this->getPlainMessage());
1✔
291
        $data .= \chunk_split($message);
1✔
292
        return $data;
1✔
293
    }
294

295
    protected function isAlternative() : bool
296
    {
297
        return $this->getHtmlMessage() !== null
5✔
298
            && $this->getPlainMessage() !== null
5✔
299
            && $this->getAttachments() === []
5✔
300
            && $this->getInlineAttachments() === [];
5✔
301
    }
302

303
    protected function renderAlternative() : string
304
    {
305
        $boundary = $this->getBoundary();
×
306
        $crlf = $this->getCrlf();
×
307
        $this->prepareHeaders();
×
308
        $data = $this->renderHeaders() . $crlf;
×
309
        $data .= 'Content-Type: multipart/alternative; boundary="' . $boundary . '"' . $crlf;
×
310
        $data .= $crlf;
×
311
        $data .= '--' . $boundary . $crlf;
×
312
        $data .= 'Content-Type: text/html; charset="utf-8"' . $crlf;
×
313
        $data .= 'Content-Transfer-Encoding: base64' . $crlf;
×
314
        $data .= $crlf;
×
315
        $message = \base64_encode($this->getHtmlMessage());
×
316
        $data .= \chunk_split($message) . $crlf;
×
317
        $data .= '--' . $boundary . $crlf;
×
318
        $data .= 'Content-Type: text/plain; charset="utf-8"' . $crlf;
×
319
        $data .= 'Content-Transfer-Encoding: base64' . $crlf;
×
320
        $data .= $crlf;
×
321
        $message = \base64_encode($this->getPlainMessage());
×
322
        $data .= \chunk_split($message);
×
323
        return $data;
×
324
    }
325

326
    protected function isMixed() : bool
327
    {
328
        return $this->getAttachments() !== []
1✔
329
            && $this->getInlineAttachments() === [];
1✔
330
    }
331

332
    protected function renderMixed() : string
333
    {
334
        $boundary = $this->getBoundary();
1✔
335
        $crlf = $this->getCrlf();
1✔
336
        $this->prepareHeaders();
1✔
337
        $data = $this->renderHeaders() . $crlf;
1✔
338
        $data .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"' . $crlf;
1✔
339
        $data .= $crlf;
1✔
340

341
        $hasAlternative = $this->getHtmlMessage() !== null || $this->getPlainMessage() !== null;
1✔
342
        if ($hasAlternative) {
1✔
343
            $boundary2 = $this->makeBoundary();
1✔
344
            $data .= '--' . $boundary . $crlf;
1✔
345
            $data .= 'Content-Type: multipart/alternative; boundary="' . $boundary2 . '"' . $crlf;
1✔
346
            $data .= $crlf;
1✔
347

348
            $message = $this->getHtmlMessage();
1✔
349
            if ($message !== null) {
1✔
350
                $data .= '--' . $boundary2 . $crlf;
×
351
                $data .= 'Content-Type: text/html; charset="utf-8"' . $crlf;
×
352
                $data .= 'Content-Transfer-Encoding: base64' . $crlf;
×
353
                $data .= $crlf;
×
354
                $message = \base64_encode($message);
×
355
                $data .= \chunk_split($message) . $crlf;
×
356
            }
357

358
            $message = $this->getPlainMessage();
1✔
359
            if ($message !== null) {
1✔
360
                $data .= '--' . $boundary2 . $crlf;
1✔
361
                $data .= 'Content-Type: text/plain; charset="utf-8"' . $crlf;
1✔
362
                $data .= 'Content-Transfer-Encoding: base64' . $crlf;
1✔
363
                $data .= $crlf;
1✔
364
                $message = \base64_encode($message);
1✔
365
                $data .= \chunk_split($message) . $crlf;
1✔
366
            }
367
            $data .= '--' . $boundary2 . '--' . $crlf . $crlf;
1✔
368
        }
369

370
        $part = '';
1✔
371
        foreach ($this->getAttachments() as $attachment) {
1✔
372
            $part .= '--' . $boundary . $crlf;
1✔
373
            $part .= 'Content-Type: ' . $attachment->getMimeType() . '; name="' . $attachment->getName() . '"' . $crlf;
1✔
374
            $part .= 'Content-Disposition: attachment; filename="' . $attachment->getName() . '"' . $crlf;
1✔
375
            $part .= 'Content-Transfer-Encoding: base64' . $crlf;
1✔
376
            $part .= $crlf;
1✔
377
            $part .= $attachment->getBase64SplitContents() . $crlf;
1✔
378
        }
379
        $data .= $part;
1✔
380

381
        $data .= '--' . $boundary . '--';
1✔
382
        return $data;
1✔
383
    }
384

385
    protected function isInline() : bool
386
    {
387
        return  $this->getInlineAttachments() !== [];
×
388
    }
389

390
    protected function renderInline() : string
391
    {
392
        $boundary = $this->getBoundary();
×
393
        $crlf = $this->getCrlf();
×
394
        $this->prepareHeaders();
×
395
        $data = $this->renderHeaders() . $crlf;
×
396
        $data .= 'Content-Type: multipart/related; boundary="' . $boundary . '"' . $crlf;
×
397
        $data .= $crlf;
×
398

399
        $hasAlternative = $this->getHtmlMessage() !== null || $this->getPlainMessage() !== null;
×
400
        if ($hasAlternative) {
×
401
            $boundary2 = $this->makeBoundary();
×
402
            $data .= '--' . $boundary . $crlf;
×
403
            $data .= 'Content-Type: multipart/alternative; boundary="' . $boundary2 . '"' . $crlf;
×
404
            $data .= $crlf;
×
405

406
            $message = $this->getHtmlMessage();
×
407
            if ($message !== null) {
×
408
                $data .= '--' . $boundary2 . $crlf;
×
409
                $data .= 'Content-Type: text/html; charset="utf-8"' . $crlf;
×
410
                $data .= 'Content-Transfer-Encoding: base64' . $crlf;
×
411
                $data .= $crlf;
×
412
                $message = \base64_encode($message);
×
413
                $data .= \chunk_split($message) . $crlf;
×
414
            }
415

416
            $message = $this->getPlainMessage();
×
417
            if ($message !== null) {
×
418
                $data .= '--' . $boundary2 . $crlf;
×
419
                $data .= 'Content-Type: text/plain; charset="utf-8"' . $crlf;
×
420
                $data .= 'Content-Transfer-Encoding: base64' . $crlf;
×
421
                $data .= $crlf;
×
422
                $message = \base64_encode($message);
×
423
                $data .= \chunk_split($message) . $crlf;
×
424
            }
425
            $data .= '--' . $boundary2 . '--' . $crlf . $crlf;
×
426
        }
427

428
        $part = '';
×
429
        foreach ($this->getAttachments() as $attachment) {
×
430
            $part .= '--' . $boundary . $crlf;
×
431
            $part .= 'Content-Type: ' . $attachment->getMimeType() . '; name="' . $attachment->getName() . '"' . $crlf;
×
432
            $part .= 'Content-Disposition: attachment; filename="' . $attachment->getName() . '"' . $crlf;
×
433
            $part .= 'Content-Transfer-Encoding: base64' . $crlf;
×
434
            $part .= $crlf;
×
435
            $part .= $attachment->getBase64SplitContents() . $crlf;
×
436
        }
437
        $data .= $part;
×
438

439
        $part = '';
×
440
        foreach ($this->getInlineAttachments() as $cid => $attachment) {
×
441
            $part .= '--' . $boundary . $crlf;
×
442
            $part .= 'Content-ID: <' . $cid . '>' . $crlf;
×
443
            $part .= 'Content-Type: ' . $attachment->getMimeType() . $crlf;
×
444
            $part .= 'Content-Disposition: inline' . $crlf;
×
445
            $part .= 'Content-Transfer-Encoding: base64' . $crlf;
×
446
            $part .= $crlf;
×
447
            $part .= $attachment->getBase64SplitContents() . $crlf;
×
448
        }
449
        $data .= $part;
×
450

451
        $data .= '--' . $boundary . '--';
×
452
        return $data;
×
453
    }
454

455
    protected function isMixedAndInline() : bool
456
    {
457
        return $this->getAttachments() !== []
5✔
458
            && $this->getInlineAttachments() !== [];
5✔
459
    }
460

461
    protected function renderMixedAndInline() : string
462
    {
463
        $boundary = $this->getBoundary();
5✔
464
        //$boundary = 'mixed_raiz_aaa';
465
        $crlf = $this->getCrlf();
5✔
466
        $this->prepareHeaders();
5✔
467
        $data = $this->renderHeaders() . $crlf;
5✔
468
        $data .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"' . $crlf;
5✔
469
        $data .= $crlf;
5✔
470

471
        $boundary2 = $this->makeBoundary();
5✔
472
        //$boundary2 = 'alternative_nivel2_bbb';
473
        $data .= '--' . $boundary . $crlf;
5✔
474
        $data .= 'Content-Type: multipart/alternative; boundary="' . $boundary2 . '"' . $crlf;
5✔
475
        $data .= $crlf;
5✔
476

477
        $message = $this->getPlainMessage();
5✔
478
        if ($message !== null) {
5✔
479
            $data .= '--' . $boundary2 . $crlf;
5✔
480
            $data .= 'Content-Type: text/plain; charset="utf-8"' . $crlf;
5✔
481
            $data .= 'Content-Transfer-Encoding: base64' . $crlf;
5✔
482
            $data .= $crlf;
5✔
483
            $message = \base64_encode($message);
5✔
484
            $data .= \chunk_split($message) . $crlf;
5✔
485
        }
486

487
        $boundary3 = $this->makeBoundary();
5✔
488
        //$boundary3 = 'related_nivel3_ccc';
489
        $data .= '--' . $boundary2 . $crlf;
5✔
490
        $data .= 'Content-Type: multipart/related; boundary="' . $boundary3 . '"' . $crlf;
5✔
491
        $data .= $crlf;
5✔
492

493
        $message = $this->getHtmlMessage();
5✔
494
        if ($message !== null) {
5✔
495
            $data .= '--' . $boundary3 . $crlf;
5✔
496
            $data .= 'Content-Type: text/html; charset="utf-8"' . $crlf;
5✔
497
            $data .= 'Content-Transfer-Encoding: base64' . $crlf;
5✔
498
            $data .= $crlf;
5✔
499
            $message = \base64_encode($message);
5✔
500
            $data .= \chunk_split($message) . $crlf;
5✔
501
        }
502

503
        $part = '';
5✔
504
        foreach ($this->getInlineAttachments() as $cid => $attachment) {
5✔
505
            $part .= '--' . $boundary3 . $crlf;
5✔
506
            $part .= 'Content-ID: <' . $cid . '>' . $crlf;
5✔
507
            $part .= 'Content-Type: ' . $attachment->getMimeType() . $crlf;
5✔
508
            $part .= 'Content-Disposition: inline' . $crlf;
5✔
509
            $part .= 'Content-Transfer-Encoding: base64' . $crlf;
5✔
510
            $part .= $crlf;
5✔
511
            $part .= $attachment->getBase64SplitContents() . $crlf;
5✔
512
        }
513
        $data .= $part;
5✔
514

515
        $data .= '--' . $boundary3 . '--' . $crlf . $crlf;
5✔
516
        $data .= '--' . $boundary2 . '--' . $crlf . $crlf;
5✔
517

518
        $part = '';
5✔
519
        foreach ($this->getAttachments() as $attachment) {
5✔
520
            $part .= '--' . $boundary . $crlf;
5✔
521
            $part .= 'Content-Type: ' . $attachment->getMimeType() . '; name="' . $attachment->getName() . '"' . $crlf;
5✔
522
            $part .= 'Content-Disposition: attachment; filename="' . $attachment->getName() . '"' . $crlf;
5✔
523
            $part .= 'Content-Transfer-Encoding: base64' . $crlf;
5✔
524
            $part .= $crlf;
5✔
525
            $part .= $attachment->getBase64SplitContents() . $crlf;
5✔
526
        }
527
        $data .= $part;
5✔
528

529
        $data .= '--' . $boundary . '--';
5✔
530
        return $data;
5✔
531
    }
532

533
    /**
534
     * Set the text/plain message.
535
     *
536
     * @param string $message The text/plain message
537
     *
538
     * @return static
539
     */
540
    public function setPlainMessage(string $message) : static
541
    {
542
        $this->plainMessage = $message;
13✔
543
        return $this;
13✔
544
    }
545

546
    /**
547
     * Get the text/plain message.
548
     *
549
     * @return string|null The message or null if not set
550
     */
551
    public function getPlainMessage() : ?string
552
    {
553
        return $this->plainMessage ?? null;
14✔
554
    }
555

556
    protected function renderPlainMessage() : ?string
557
    {
558
        $message = $this->getPlainMessage();
1✔
559
        return $message !== null ? $this->renderMessage($message, 'text/plain') : null;
1✔
560
    }
561

562
    /**
563
     * Alias of {@see Framework\Email\Message::setHtmlMessage()}.
564
     *
565
     * @param string $body The text/html message
566
     *
567
     * @return static
568
     */
569
    public function setBody(#[Language('HTML')] string $body) : static
570
    {
571
        return $this->setHtmlMessage($body);
1✔
572
    }
573

574
    /**
575
     * Alias of {@see Framework\Email\Message::getHtmlMessage()}.
576
     *
577
     * @return string|null The text/html message or null if not set
578
     */
579
    public function getBody() : ?string
580
    {
581
        return $this->getHtmlMessage();
1✔
582
    }
583

584
    /**
585
     * Set the text/html message.
586
     *
587
     * @param string $message The text/html message
588
     *
589
     * @return static
590
     */
591
    public function setHtmlMessage(#[Language('HTML')] string $message) : static
592
    {
593
        $this->htmlMessage = $message;
14✔
594
        return $this;
14✔
595
    }
596

597
    /**
598
     * Get the text/html message.
599
     *
600
     * @return string|null The text/html message or null if not set
601
     */
602
    public function getHtmlMessage() : ?string
603
    {
604
        return $this->htmlMessage ?? null;
10✔
605
    }
606

607
    protected function renderHtmlMessage() : ?string
608
    {
609
        $message = $this->getHtmlMessage();
1✔
610
        return $message !== null ? $this->renderMessage($message) : null;
1✔
611
    }
612

613
    protected function renderMessage(
614
        string $message,
615
        string $contentType = 'text/html'
616
    ) : string {
617
        $message = \base64_encode($message);
2✔
618
        $crlf = $this->getCrlf();
2✔
619
        $part = '--alt-' . $this->getBoundary() . $crlf;
2✔
620
        $part .= 'Content-Type: ' . $contentType . '; charset='
2✔
621
            . $this->getCharset() . $crlf;
2✔
622
        $part .= 'Content-Transfer-Encoding: base64' . $crlf . $crlf;
2✔
623
        $part .= \chunk_split($message) . $crlf;
2✔
624
        return $part;
2✔
625
    }
626

627
    /**
628
     * Get a lis of attachments.
629
     *
630
     * @return array<int,Attachment> Array of Attachments
631
     */
632
    public function getAttachments() : array
633
    {
634
        return $this->attachments;
7✔
635
    }
636

637
    /**
638
     * Add an attachment.
639
     *
640
     * @param string $filename The filename
641
     * @param string|null $name The name
642
     * @param string|null $mimeType The MIME type
643
     *
644
     * @return static
645
     */
646
    public function addAttachment(
647
        string $filename,
648
        ?string $name = null,
649
        ?string $mimeType = null
650
    ) : static {
651
        $this->attachments[] = new Attachment($filename, $name, $mimeType);
13✔
652
        return $this;
12✔
653
    }
654

655
    /**
656
     * Set a filename to be attached inline (image).
657
     *
658
     * @param string $filename The filename
659
     * @param string $cid The Content-ID
660
     * @param string|null $mimeType The MIME type
661
     *
662
     * @return static
663
     */
664
    public function setInlineAttachment(
665
        string $filename,
666
        string $cid,
667
        ?string $mimeType = null
668
    ) : static {
669
        $this->inlineAttachments[$cid] = new Attachment($filename, mimeType: $mimeType);
14✔
670
        return $this;
13✔
671
    }
672

673
    /**
674
     * Get a lis of inline attachments.
675
     *
676
     * @return array<string,Attachment> Content-IDs as keys and Attachments as values
677
     */
678
    public function getInlineAttachments() : array
679
    {
680
        return $this->inlineAttachments;
8✔
681
    }
682

683
    protected function renderAttachments() : string
684
    {
685
        $part = '';
1✔
686
        $crlf = $this->getCrlf();
1✔
687
        foreach ($this->getAttachments() as $attachment) {
1✔
688
            $part .= '--mixed-' . $this->getBoundary() . $crlf;
1✔
689
            $part .= 'Content-Type: ' . $attachment->getMimeType()
1✔
690
                . '; name="' . $attachment->getName() . '"' . $crlf;
1✔
691
            $part .= 'Content-Disposition: attachment; filename="' . $attachment->getName() . '"' . $crlf;
1✔
692
            $part .= 'Content-Transfer-Encoding: base64' . $crlf . $crlf;
1✔
693
            $part .= $attachment->getBase64SplitContents() . $crlf;
1✔
694
        }
695
        return $part;
1✔
696
    }
697

698
    protected function renderInlineAttachments() : string
699
    {
700
        $part = '';
1✔
701
        $crlf = $this->getCrlf();
1✔
702
        foreach ($this->getInlineAttachments() as $cid => $attachment) {
1✔
703
            $part .= '--mixed-' . $this->getBoundary() . $crlf;
1✔
704
            $part .= 'Content-ID: ' . $cid . $crlf;
1✔
705
            $part .= 'Content-Type: ' . $attachment->getMimeType() . $crlf;
1✔
706
            $part .= 'Content-Disposition: inline' . $crlf;
1✔
707
            $part .= 'Content-Transfer-Encoding: base64' . $crlf . $crlf;
1✔
708
            $part .= $attachment->getBase64SplitContents() . $crlf;
1✔
709
        }
710
        return $part;
1✔
711
    }
712

713
    /**
714
     * Set the 'Subject' header.
715
     *
716
     * @param string $subject The header value
717
     *
718
     * @return static
719
     */
720
    public function setSubject(string $subject) : static
721
    {
722
        $this->setHeader(Header::SUBJECT, $subject);
16✔
723
        return $this;
16✔
724
    }
725

726
    /**
727
     * Get the 'Subject' header.
728
     *
729
     * @return string|null The header value or null if not set
730
     */
731
    public function getSubject() : ?string
732
    {
733
        return $this->getHeader(Header::SUBJECT);
16✔
734
    }
735

736
    /**
737
     * Add address and name in the 'To' header.
738
     *
739
     * @param string $address The email address
740
     * @param string|null $name The name or null to don't set
741
     *
742
     * @return static
743
     */
744
    public function addTo(string $address, ?string $name = null) : static
745
    {
746
        $list = $this->getTo();
17✔
747
        $list[$address] = $name;
17✔
748
        $this->setHeader(Header::TO, static::formatAddressList($list));
17✔
749
        return $this;
17✔
750
    }
751

752
    /**
753
     * Get items of the 'To' header.
754
     *
755
     * @return array<string,string|null> Emails as keys and names as values
756
     */
757
    public function getTo() : array
758
    {
759
        return $this->extractEmails($this->getHeader(Header::TO));
18✔
760
    }
761

762
    /**
763
     * Remove all items of the 'To' header.
764
     *
765
     * @return static
766
     */
767
    public function removeTo() : static
768
    {
769
        $this->removeHeader(Header::TO);
2✔
770
        return $this;
2✔
771
    }
772

773
    /**
774
     * Add address and name in the 'Cc' header.
775
     *
776
     * @param string $address The email address
777
     * @param string|null $name The name or null to don't set
778
     *
779
     * @return static
780
     */
781
    public function addCc(string $address, ?string $name = null) : static
782
    {
783
        $list = $this->getCc();
2✔
784
        $list[$address] = $name;
2✔
785
        $this->setHeader(Header::CC, static::formatAddressList($list));
2✔
786
        return $this;
2✔
787
    }
788

789
    /**
790
     * Get items of the 'Cc' header.
791
     *
792
     * @return array<string,string|null> Emails as keys and names as values
793
     */
794
    public function getCc() : array
795
    {
796
        return $this->extractEmails($this->getHeader(Header::CC));
8✔
797
    }
798

799
    /**
800
     * Remove all items of the 'Cc' header.
801
     *
802
     * @return static
803
     */
804
    public function removeCc() : static
805
    {
806
        $this->removeHeader(Header::CC);
1✔
807
        return $this;
1✔
808
    }
809

810
    /**
811
     * @return array<int,string>
812
     */
813
    public function getRecipients() : array
814
    {
815
        $recipients = \array_replace($this->getTo(), $this->getCc(), $this->getBcc());
7✔
816
        return \array_keys($recipients);
7✔
817
    }
818

819
    /**
820
     * Add address and name in the 'Bcc' header.
821
     *
822
     * @param string $address The email address
823
     * @param string|null $name The name or null to don't set
824
     *
825
     * @return static
826
     */
827
    public function addBcc(string $address, ?string $name = null) : static
828
    {
829
        $list = $this->getBcc();
2✔
830
        $list[$address] = $name;
2✔
831
        $this->setHeader(Header::BCC, static::formatAddressList($list));
2✔
832
        return $this;
2✔
833
    }
834

835
    /**
836
     * Get items of the 'Bcc' header.
837
     *
838
     * @return array<string,string|null> Emails as keys and names as values
839
     */
840
    public function getBcc() : array
841
    {
842
        return $this->extractEmails($this->getHeader(Header::BCC));
8✔
843
    }
844

845
    /**
846
     * Remove all items of the 'Bcc' header.
847
     *
848
     * @return static
849
     */
850
    public function removeBcc() : static
851
    {
852
        $this->removeHeader(Header::BCC);
1✔
853
        return $this;
1✔
854
    }
855

856
    /**
857
     * Add address and name in the 'Reply-To' header.
858
     *
859
     * @param string $address The email address
860
     * @param string|null $name The name or null to don't set
861
     *
862
     * @return static
863
     */
864
    public function addReplyTo(string $address, ?string $name = null) : static
865
    {
866
        $list = $this->getReplyTo();
1✔
867
        $list[$address] = $name;
1✔
868
        $this->setHeader(Header::REPLY_TO, static::formatAddressList($list));
1✔
869
        return $this;
1✔
870
    }
871

872
    /**
873
     * Get items of the 'Reply-To' header.
874
     *
875
     * @return array<string,string|null> Emails as keys and names as values
876
     */
877
    public function getReplyTo() : array
878
    {
879
        return $this->extractEmails($this->getHeader(Header::REPLY_TO));
1✔
880
    }
881

882
    /**
883
     * Remove all items of the 'Reply-To' header.
884
     *
885
     * @return static
886
     */
887
    public function removeReplyTo() : static
888
    {
889
        $this->removeHeader(Header::REPLY_TO);
1✔
890
        return $this;
1✔
891
    }
892

893
    /**
894
     * Set the 'From' header.
895
     *
896
     * @param string $address The email address
897
     * @param string|null $name The name or null to don't set
898
     *
899
     * @return static
900
     */
901
    public function setFrom(string $address, ?string $name = null) : static
902
    {
903
        $this->setHeader(Header::FROM, static::formatAddress($address, $name));
18✔
904
        return $this;
18✔
905
    }
906

907
    /**
908
     * Get the 'From' header items.
909
     *
910
     * @return array<string,string|null> Two keys: address and name
911
     */
912
    #[ArrayShape(['address' => 'string', 'name' => 'string|null'])]
913
    public function getFrom() : array
914
    {
915
        $from = $this->extractEmails($this->getHeader(Header::FROM));
18✔
916
        if (empty($from)) {
18✔
917
            return [];
2✔
918
        }
919
        return [
17✔
920
            'address' => \array_key_first($from),
17✔
921
            'name' => \array_first($from),
17✔
922
        ];
17✔
923
    }
924

925
    /**
926
     * Get the email address of the 'From' header.
927
     *
928
     * @return string|null The email or null if not set
929
     */
930
    public function getFromAddress() : ?string
931
    {
932
        return $this->getFrom()['address'] ?? null;
18✔
933
    }
934

935
    /**
936
     * Get the name of the 'From' header.
937
     *
938
     * @return string|null The name or null if not set
939
     */
940
    public function getFromName() : ?string
941
    {
942
        return $this->getFrom()['name'] ?? null;
1✔
943
    }
944

945
    /**
946
     * Remove all items of the 'From' header.
947
     *
948
     * @return static
949
     */
950
    public function removeFrom() : static
951
    {
952
        $this->removeHeader(Header::FROM);
2✔
953
        return $this;
2✔
954
    }
955

956
    /**
957
     * Set the 'Date' header.
958
     *
959
     * @param DateTime|null $datetime A custom DateTime or null to set the
960
     * current datetime
961
     *
962
     * @return static
963
     */
964
    public function setDate(?DateTime $datetime = null) : static
965
    {
966
        $date = $datetime ? $datetime->format('r') : \date('r');
7✔
967
        $this->setHeader(Header::DATE, $date);
7✔
968
        return $this;
7✔
969
    }
970

971
    /**
972
     * Get the 'Date' header.
973
     *
974
     * @return string|null The header value or null if not set
975
     */
976
    public function getDate() : ?string
977
    {
978
        return $this->getHeader(Header::DATE);
7✔
979
    }
980

981
    /**
982
     * Set the 'X-Priority' header.
983
     *
984
     * @param XPriority $priority The {@see XPriority} case
985
     *
986
     * @return static
987
     */
988
    public function setXPriority(XPriority $priority) : static
989
    {
990
        $this->setHeader(Header::X_PRIORITY, (string) $priority->value);
1✔
991
        return $this;
1✔
992
    }
993

994
    /**
995
     * Get the 'X-Priority' header.
996
     *
997
     * @return XPriority|null The {@see XPriority} case or null
998
     */
999
    public function getXPriority() : ?XPriority
1000
    {
1001
        $header = $this->getHeader(Header::X_PRIORITY);
1✔
1002
        if ($header === null) {
1✔
1003
            return null;
1✔
1004
        }
1005
        return XPriority::from((int) $header);
1✔
1006
    }
1007

1008
    /**
1009
     * Set the 'X-Mailer' header.
1010
     *
1011
     * @param string|null $xMailer The X-Mailer header or null to set the default
1012
     *
1013
     * @return static
1014
     */
1015
    public function setXMailer(?string $xMailer = null) : static
1016
    {
1017
        $xMailer ??= 'Aplus Mailer';
1✔
1018
        $this->setHeader(Header::X_MAILER, $xMailer);
1✔
1019
        return $this;
1✔
1020
    }
1021

1022
    /**
1023
     * Get the 'X-Mailer' header.
1024
     *
1025
     * @return string|null The X-Mailer header or null
1026
     */
1027
    public function getXMailer() : ?string
1028
    {
1029
        return $this->getHeader(Header::X_MAILER);
1✔
1030
    }
1031

1032
    public function validate() : void
1033
    {
1034
        $from = $this->getFromAddress();
17✔
1035
        if ($this->isNullOrEmptyString($from)) {
17✔
1036
            throw new LogicException("The message 'From' address is empty");
1✔
1037
        }
1038
        if (!\filter_var($from, \FILTER_VALIDATE_EMAIL)) {
16✔
1039
            throw new LogicException("The message 'From' address '{$from}' is not a valid email");
1✔
1040
        }
1041
        if (empty($this->getTo())) {
15✔
1042
            throw new LogicException("The message 'To' address is empty");
1✔
1043
        }
1044
        if ($this->isNullOrEmptyString($this->getSubject())) {
14✔
1045
            throw new LogicException("The message 'Subject' is empty");
1✔
1046
        }
1047
        if ($this->isNullOrEmptyString($this->getPlainMessage())
13✔
1048
            && $this->isNullOrEmptyString($this->getHtmlMessage())
13✔
1049
        ) {
1050
            throw new LogicException('The message body is empty');
2✔
1051
        }
1052
    }
1053

1054
    protected function isNullOrEmptyString(?string $value) : bool
1055
    {
1056
        if ($value === null) {
17✔
1057
            return true;
4✔
1058
        }
1059
        if ($value === '') {
16✔
1060
            return true;
1✔
1061
        }
1062
        return false;
16✔
1063
    }
1064

1065
    /**
1066
     * Extract emails (addresses and names) from an emails header string.
1067
     *
1068
     * @param string|null $header An header like: `foo@bar, "Baz" <foo@baz>`
1069
     *
1070
     * @return array<string,string|null> Addresses as keys and names
1071
     * (string or null) as values
1072
     */
1073
    protected function extractEmails(?string $header) : array
1074
    {
1075
        if ($header === null) {
25✔
1076
            return [];
24✔
1077
        }
1078
        $exploded = \explode(',', $header);
23✔
1079
        foreach ($exploded as &$part) {
23✔
1080
            $part = \trim($part);
23✔
1081
        }
1082
        unset($part);
23✔
1083
        $emails = [];
23✔
1084
        foreach ($exploded as $part) {
23✔
1085
            if (\str_starts_with($part, '"')) {
23✔
1086
                $extracted = $this->extractAddressAndName($part);
6✔
1087
                $emails[$extracted['address']] = $extracted['name'];
6✔
1088
                continue;
6✔
1089
            }
1090
            $part = $this->sanitizeSpaces($part);
23✔
1091
            $part = $this->removeSpaces($part);
23✔
1092
            $emails[$part] = null;
23✔
1093
        }
1094
        return $emails;
23✔
1095
    }
1096

1097
    /**
1098
     * Replace whitespaces with one space.
1099
     *
1100
     * @param string $string
1101
     *
1102
     * @return string
1103
     */
1104
    protected function sanitizeSpaces(string $string) : string
1105
    {
1106
        $string = \preg_replace('/\s+/', ' ', $string);
25✔
1107
        return \trim($string);
25✔
1108
    }
1109

1110
    /**
1111
     * Remove spaces.
1112
     *
1113
     * @param string $string
1114
     *
1115
     * @return string
1116
     */
1117
    protected function removeSpaces(string $string) : string
1118
    {
1119
        return \strtr($string, [' ' => '']);
23✔
1120
    }
1121

1122
    /**
1123
     * Extract address and name from a header part.
1124
     *
1125
     * @param string $headerPart A header part like: `"Baz" <foo@baz>`
1126
     *
1127
     * @return array<string,string>
1128
     */
1129
    #[ArrayShape(['address' => 'string', 'name' => 'string'])]
1130
    protected function extractAddressAndName(string $headerPart) : array
1131
    {
1132
        $headerPart = $this->sanitizeSpaces($headerPart);
6✔
1133
        $headerPart = \strtr($headerPart, ['"<' => '" <']);
6✔
1134
        \preg_match_all('#\"(.*?)\" <(.*?)>#', $headerPart, $matches);
6✔
1135
        $name = \trim($matches[1][0]);
6✔
1136
        $address = \trim($matches[2][0]);
6✔
1137
        $address = $this->removeSpaces($address);
6✔
1138
        return [
6✔
1139
            'address' => $address,
6✔
1140
            'name' => $name,
6✔
1141
        ];
6✔
1142
    }
1143

1144
    protected static function formatAddress(string $address, ?string $name = null) : string
1145
    {
1146
        return $name !== null
25✔
1147
            ? '"' . $name . '" <' . $address . '>'
7✔
1148
            : $address;
25✔
1149
    }
1150

1151
    /**
1152
     * @param array<string,string|null> $addresses
1153
     *
1154
     * @return string
1155
     */
1156
    protected static function formatAddressList(array $addresses) : string
1157
    {
1158
        $data = [];
21✔
1159
        foreach ($addresses as $address => $name) {
21✔
1160
            $data[] = static::formatAddress($address, $name);
21✔
1161
        }
1162
        return \implode(', ', $data);
21✔
1163
    }
1164
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc