1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Mime;
13
14use Symfony\Component\Mime\Exception\LogicException;
15use Symfony\Component\Mime\Part\AbstractPart;
16use Symfony\Component\Mime\Part\DataPart;
17use Symfony\Component\Mime\Part\Multipart\AlternativePart;
18use Symfony\Component\Mime\Part\Multipart\MixedPart;
19use Symfony\Component\Mime\Part\Multipart\RelatedPart;
20use Symfony\Component\Mime\Part\TextPart;
21
22/**
23 * @author Fabien Potencier <fabien@symfony.com>
24 */
25class Email extends Message
26{
27 const PRIORITY_HIGHEST = 1;
28 const PRIORITY_HIGH = 2;
29 const PRIORITY_NORMAL = 3;
30 const PRIORITY_LOW = 4;
31 const PRIORITY_LOWEST = 5;
32
33 private const PRIORITY_MAP = [
34 self::PRIORITY_HIGHEST => 'Highest',
35 self::PRIORITY_HIGH => 'High',
36 self::PRIORITY_NORMAL => 'Normal',
37 self::PRIORITY_LOW => 'Low',
38 self::PRIORITY_LOWEST => 'Lowest',
39 ];
40
41 private $text;
42 private $textCharset;
43 private $html;
44 private $htmlCharset;
45 private $attachments = [];
46
47 /**
48 * @return $this
49 */
50 public function subject(string $subject)
51 {
52 return $this->setHeaderBody('Text', 'Subject', $subject);
53 }
54
55 public function getSubject(): ?string
56 {
57 return $this->getHeaders()->getHeaderBody('Subject');
58 }
59
60 /**
61 * @return $this
62 */
63 public function date(\DateTimeInterface $dateTime)
64 {
65 return $this->setHeaderBody('Date', 'Date', $dateTime);
66 }
67
68 public function getDate(): ?\DateTimeImmutable
69 {
70 return $this->getHeaders()->getHeaderBody('Date');
71 }
72
73 /**
74 * @param Address|string $address
75 *
76 * @return $this
77 */
78 public function returnPath($address)
79 {
80 return $this->setHeaderBody('Path', 'Return-Path', Address::create($address));
81 }
82
83 public function getReturnPath(): ?Address
84 {
85 return $this->getHeaders()->getHeaderBody('Return-Path');
86 }
87
88 /**
89 * @param Address|string $address
90 *
91 * @return $this
92 */
93 public function sender($address)
94 {
95 return $this->setHeaderBody('Mailbox', 'Sender', Address::create($address));
96 }
97
98 public function getSender(): ?Address
99 {
100 return $this->getHeaders()->getHeaderBody('Sender');
101 }
102
103 /**
104 * @param Address|string ...$addresses
105 *
106 * @return $this
107 */
108 public function addFrom(...$addresses)
109 {
110 return $this->addListAddressHeaderBody('From', $addresses);
111 }
112
113 /**
114 * @param Address|string ...$addresses
115 *
116 * @return $this
117 */
118 public function from(...$addresses)
119 {
120 return $this->setListAddressHeaderBody('From', $addresses);
121 }
122
123 /**
124 * @return Address[]
125 */
126 public function getFrom(): array
127 {
128 return $this->getHeaders()->getHeaderBody('From') ?: [];
129 }
130
131 /**
132 * @param Address|string ...$addresses
133 *
134 * @return $this
135 */
136 public function addReplyTo(...$addresses)
137 {
138 return $this->addListAddressHeaderBody('Reply-To', $addresses);
139 }
140
141 /**
142 * @param Address|string ...$addresses
143 *
144 * @return $this
145 */
146 public function replyTo(...$addresses)
147 {
148 return $this->setListAddressHeaderBody('Reply-To', $addresses);
149 }
150
151 /**
152 * @return Address[]
153 */
154 public function getReplyTo(): array
155 {
156 return $this->getHeaders()->getHeaderBody('Reply-To') ?: [];
157 }
158
159 /**
160 * @param Address|string ...$addresses
161 *
162 * @return $this
163 */
164 public function addTo(...$addresses)
165 {
166 return $this->addListAddressHeaderBody('To', $addresses);
167 }
168
169 /**
170 * @param Address|string ...$addresses
171 *
172 * @return $this
173 */
174 public function to(...$addresses)
175 {
176 return $this->setListAddressHeaderBody('To', $addresses);
177 }
178
179 /**
180 * @return Address[]
181 */
182 public function getTo(): array
183 {
184 return $this->getHeaders()->getHeaderBody('To') ?: [];
185 }
186
187 /**
188 * @param Address|string ...$addresses
189 *
190 * @return $this
191 */
192 public function addCc(...$addresses)
193 {
194 return $this->addListAddressHeaderBody('Cc', $addresses);
195 }
196
197 /**
198 * @param Address|string ...$addresses
199 *
200 * @return $this
201 */
202 public function cc(...$addresses)
203 {
204 return $this->setListAddressHeaderBody('Cc', $addresses);
205 }
206
207 /**
208 * @return Address[]
209 */
210 public function getCc(): array
211 {
212 return $this->getHeaders()->getHeaderBody('Cc') ?: [];
213 }
214
215 /**
216 * @param Address|string ...$addresses
217 *
218 * @return $this
219 */
220 public function addBcc(...$addresses)
221 {
222 return $this->addListAddressHeaderBody('Bcc', $addresses);
223 }
224
225 /**
226 * @param Address|string ...$addresses
227 *
228 * @return $this
229 */
230 public function bcc(...$addresses)
231 {
232 return $this->setListAddressHeaderBody('Bcc', $addresses);
233 }
234
235 /**
236 * @return Address[]
237 */
238 public function getBcc(): array
239 {
240 return $this->getHeaders()->getHeaderBody('Bcc') ?: [];
241 }
242
243 /**
244 * Sets the priority of this message.
245 *
246 * The value is an integer where 1 is the highest priority and 5 is the lowest.
247 *
248 * @return $this
249 */
250 public function priority(int $priority)
251 {
252 if ($priority > 5) {
253 $priority = 5;
254 } elseif ($priority < 1) {
255 $priority = 1;
256 }
257
258 return $this->setHeaderBody('Text', 'X-Priority', sprintf('%d (%s)', $priority, self::PRIORITY_MAP[$priority]));
259 }
260
261 /**
262 * Get the priority of this message.
263 *
264 * The returned value is an integer where 1 is the highest priority and 5
265 * is the lowest.
266 */
267 public function getPriority(): int
268 {
269 list($priority) = sscanf($this->getHeaders()->getHeaderBody('X-Priority'), '%[1-5]');
270
271 return $priority ?? 3;
272 }
273
274 /**
275 * @param resource|string $body
276 *
277 * @return $this
278 */
279 public function text($body, string $charset = 'utf-8')
280 {
281 $this->text = $body;
282 $this->textCharset = $charset;
283
284 return $this;
285 }
286
287 /**
288 * @return resource|string|null
289 */
290 public function getTextBody()
291 {
292 return $this->text;
293 }
294
295 public function getTextCharset(): ?string
296 {
297 return $this->textCharset;
298 }
299
300 /**
301 * @param resource|string|null $body
302 *
303 * @return $this
304 */
305 public function html($body, string $charset = 'utf-8')
306 {
307 $this->html = $body;
308 $this->htmlCharset = $charset;
309
310 return $this;
311 }
312
313 /**
314 * @return resource|string|null
315 */
316 public function getHtmlBody()
317 {
318 return $this->html;
319 }
320
321 public function getHtmlCharset(): ?string
322 {
323 return $this->htmlCharset;
324 }
325
326 /**
327 * @param resource|string $body
328 *
329 * @return $this
330 */
331 public function attach($body, string $name = null, string $contentType = null)
332 {
333 $this->attachments[] = ['body' => $body, 'name' => $name, 'content-type' => $contentType, 'inline' => false];
334
335 return $this;
336 }
337
338 /**
339 * @return $this
340 */
341 public function attachFromPath(string $path, string $name = null, string $contentType = null)
342 {
343 $this->attachments[] = ['path' => $path, 'name' => $name, 'content-type' => $contentType, 'inline' => false];
344
345 return $this;
346 }
347
348 /**
349 * @param resource|string $body
350 *
351 * @return $this
352 */
353 public function embed($body, string $name = null, string $contentType = null)
354 {
355 $this->attachments[] = ['body' => $body, 'name' => $name, 'content-type' => $contentType, 'inline' => true];
356
357 return $this;
358 }
359
360 /**
361 * @return $this
362 */
363 public function embedFromPath(string $path, string $name = null, string $contentType = null)
364 {
365 $this->attachments[] = ['path' => $path, 'name' => $name, 'content-type' => $contentType, 'inline' => true];
366
367 return $this;
368 }
369
370 /**
371 * @return $this
372 */
373 public function attachPart(DataPart $part)
374 {
375 $this->attachments[] = ['part' => $part];
376
377 return $this;
378 }
379
380 /**
381 * @return DataPart[]
382 */
383 public function getAttachments(): array
384 {
385 $parts = [];
386 foreach ($this->attachments as $attachment) {
387 $parts[] = $this->createDataPart($attachment);
388 }
389
390 return $parts;
391 }
392
393 public function getBody(): AbstractPart
394 {
395 if (null !== $body = parent::getBody()) {
396 return $body;
397 }
398
399 return $this->generateBody();
400 }
401
402 public function ensureValidity()
403 {
404 if (null === $this->text && null === $this->html && !$this->attachments) {
405 throw new LogicException('A message must have a text or an HTML part or attachments.');
406 }
407
408 parent::ensureValidity();
409 }
410
411 /**
412 * Generates an AbstractPart based on the raw body of a message.
413 *
414 * The most "complex" part generated by this method is when there is text and HTML bodies
415 * with related images for the HTML part and some attachments:
416 *
417 * multipart/mixed
418 * |
419 * |------------> multipart/related
420 * | |
421 * | |------------> multipart/alternative
422 * | | |
423 * | | ------------> text/plain (with content)
424 * | | |
425 * | | ------------> text/html (with content)
426 * | |
427 * | ------------> image/png (with content)
428 * |
429 * ------------> application/pdf (with content)
430 */
431 private function generateBody(): AbstractPart
432 {
433 $this->ensureValidity();
434
435 [$htmlPart, $attachmentParts, $inlineParts] = $this->prepareParts();
436
437 $part = null === $this->text ? null : new TextPart($this->text, $this->textCharset);
438 if (null !== $htmlPart) {
439 if (null !== $part) {
440 $part = new AlternativePart($part, $htmlPart);
441 } else {
442 $part = $htmlPart;
443 }
444 }
445
446 if ($inlineParts) {
447 $part = new RelatedPart($part, ...$inlineParts);
448 }
449
450 if ($attachmentParts) {
451 if ($part) {
452 $part = new MixedPart($part, ...$attachmentParts);
453 } else {
454 $part = new MixedPart(...$attachmentParts);
455 }
456 }
457
458 return $part;
459 }
460
461 private function prepareParts(): ?array
462 {
463 $names = [];
464 $htmlPart = null;
465 $html = $this->html;
466 if (null !== $this->html) {
467 $htmlPart = new TextPart($html, $this->htmlCharset, 'html');
468 $html = $htmlPart->getBody();
469 preg_match_all('(<img\s+[^>]*src\s*=\s*(?:([\'"])cid:([^"]+)\\1|cid:([^>\s]+)))i', $html, $names);
470 $names = array_filter(array_unique(array_merge($names[2], $names[3])));
471 }
472
473 $attachmentParts = $inlineParts = [];
474 foreach ($this->attachments as $attachment) {
475 foreach ($names as $name) {
476 if (isset($attachment['part'])) {
477 continue;
478 }
479 if ($name !== $attachment['name']) {
480 continue;
481 }
482 if (isset($inlineParts[$name])) {
483 continue 2;
484 }
485 $attachment['inline'] = true;
486 $inlineParts[$name] = $part = $this->createDataPart($attachment);
487 $html = str_replace('cid:'.$name, 'cid:'.$part->getContentId(), $html);
488 continue 2;
489 }
490 $attachmentParts[] = $this->createDataPart($attachment);
491 }
492 if (null !== $htmlPart) {
493 $htmlPart = new TextPart($html, $this->htmlCharset, 'html');
494 }
495
496 return [$htmlPart, $attachmentParts, array_values($inlineParts)];
497 }
498
499 private function createDataPart(array $attachment): DataPart
500 {
501 if (isset($attachment['part'])) {
502 return $attachment['part'];
503 }
504
505 if (isset($attachment['body'])) {
506 $part = new DataPart($attachment['body'], $attachment['name'] ?? null, $attachment['content-type'] ?? null);
507 } else {
508 $part = DataPart::fromPath($attachment['path'] ?? '', $attachment['name'] ?? null, $attachment['content-type'] ?? null);
509 }
510 if ($attachment['inline']) {
511 $part->asInline();
512 }
513
514 return $part;
515 }
516
517 /**
518 * @return $this
519 */
520 private function setHeaderBody(string $type, string $name, $body): object
521 {
522 $this->getHeaders()->setHeaderBody($type, $name, $body);
523
524 return $this;
525 }
526
527 private function addListAddressHeaderBody(string $name, array $addresses)
528 {
529 if (!$header = $this->getHeaders()->get($name)) {
530 return $this->setListAddressHeaderBody($name, $addresses);
531 }
532 $header->addAddresses(Address::createArray($addresses));
533
534 return $this;
535 }
536
537 private function setListAddressHeaderBody(string $name, array $addresses)
538 {
539 $addresses = Address::createArray($addresses);
540 $headers = $this->getHeaders();
541 if ($header = $headers->get($name)) {
542 $header->setAddresses($addresses);
543 } else {
544 $headers->addMailboxListHeader($name, $addresses);
545 }
546
547 return $this;
548 }
549
550 /**
551 * @internal
552 */
553 public function __serialize(): array
554 {
555 if (\is_resource($this->text)) {
556 $this->text = (new TextPart($this->text))->getBody();
557 }
558
559 if (\is_resource($this->html)) {
560 $this->html = (new TextPart($this->html))->getBody();
561 }
562
563 foreach ($this->attachments as $i => $attachment) {
564 if (isset($attachment['body']) && \is_resource($attachment['body'])) {
565 $this->attachments[$i]['body'] = (new TextPart($attachment['body']))->getBody();
566 }
567 }
568
569 return [$this->text, $this->textCharset, $this->html, $this->htmlCharset, $this->attachments, parent::__serialize()];
570 }
571
572 /**
573 * @internal
574 */
575 public function __unserialize(array $data): void
576 {
577 [$this->text, $this->textCharset, $this->html, $this->htmlCharset, $this->attachments, $parentData] = $data;
578
579 parent::__unserialize($parentData);
580 }
581}
582