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

aplus-framework / email / 30052704075

23 Jul 2026 11:16PM UTC coverage: 86.227% (-0.1%) from 86.351%
30052704075

push

github

natanfelles
Add final boundary

0 of 2 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

601 of 697 relevant lines covered (86.23%)

8.28 hits per line

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

76.87
/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());
×
NEW
322
        $data .= \chunk_split($message) . $crlf;
×
NEW
323
        $data .= '--' . $boundary . '--';
×
UNCOV
324
        return $data;
×
325
    }
326

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1152
    /**
1153
     * @param array<string,string|null> $addresses
1154
     *
1155
     * @return string
1156
     */
1157
    protected static function formatAddressList(array $addresses) : string
1158
    {
1159
        $data = [];
21✔
1160
        foreach ($addresses as $address => $name) {
21✔
1161
            $data[] = static::formatAddress($address, $name);
21✔
1162
        }
1163
        return \implode(', ', $data);
21✔
1164
    }
1165
}
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