Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
26.43% |
231 / 874 |
|
45.83% |
22 / 48 |
CRAP | |
0.00% |
0 / 1 |
|
26.43% |
231 / 874 |
|
45.83% |
22 / 48 |
35898.26 | |
0.00% |
0 / 1 |
|
setFrom | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
setHtml | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
getContentType | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isHtml | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addTo | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
addCC | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
addBCC | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
addReplyTo | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
parseAddresses | |
85.71% |
30 / 35 |
|
0.00% |
0 / 1 |
13.49 | |||
preSend | |
12.50% |
4 / 32 |
|
0.00% |
0 / 1 |
126.22 | |||
createHeader | |
0.00% |
0 / 41 |
|
0.00% |
0 / 1 |
342 | |||
addrAppend | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
addrFormat | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getMailMime | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
156 | |||
punyencodeAddress | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
56 | |||
generateId | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
createBody | |
0.00% |
0 / 151 |
|
0.00% |
0 / 1 |
870 | |||
getBoundary | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
30 | |||
attachAll | |
0.00% |
0 / 53 |
|
0.00% |
0 / 1 |
240 | |||
quotedString | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
encodeFile | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
encodeString | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
72 | |||
setMessageType | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
hasInlineImage | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
hasAttachment | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
createAddressList | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
setWordWrap | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
56 | |||
wrapText | |
0.00% |
0 / 56 |
|
0.00% |
0 / 1 |
462 | |||
encodeHeader | |
18.60% |
8 / 43 |
|
0.00% |
0 / 1 |
172.85 | |||
encodeQ | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
72 | |||
base64EncodeWrapMB | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
6 | |||
addAttachment | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
4 | |||
getAttachments | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addStringAttachment | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
addEmbeddedImage | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
4 | |||
addStringEmbeddedImage | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
cidExists | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
addCustomHeader | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getCustomHeaders | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
msgHTML | |
91.67% |
44 / 48 |
|
0.00% |
0 / 1 |
21.26 | |||
normalizeBreaks | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
html2text | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
sign | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
dkimQP | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
7 | |||
dkimSign | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
dkimHeaderC | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
3.01 | |||
dkimBodyC | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
dkimAdd | |
0.00% |
0 / 95 |
|
0.00% |
0 / 1 |
420 |
1 | <?php |
2 | /** |
3 | * Jingga |
4 | * |
5 | * PHP Version 8.1 |
6 | * |
7 | * @package phpOMS\Message\Mail |
8 | * @copyright Dennis Eichhorn |
9 | * @license OMS License 2.0 |
10 | * @version 1.0.0 |
11 | * @link https://jingga.app |
12 | * |
13 | * Extended based on: |
14 | * GLGPL 2.1 License |
15 | * (c) 2012 - 2015 Marcus Bointon, 2010 - 2012 Jim Jagielski, 2004 - 2009 Andy Prevost |
16 | * (c) PHPMailer |
17 | */ |
18 | declare(strict_types=1); |
19 | |
20 | namespace phpOMS\Message\Mail; |
21 | |
22 | use phpOMS\System\CharsetType; |
23 | use phpOMS\System\File\FileUtils; |
24 | use phpOMS\System\MimeType; |
25 | use phpOMS\System\SystemUtils; |
26 | use phpOMS\Utils\MbStringUtils; |
27 | use phpOMS\Validation\Network\Email as EmailValidator; |
28 | |
29 | /** |
30 | * Mail class. |
31 | * |
32 | * @package phpOMS\Message\Mail |
33 | * @license OMS License 2.0 |
34 | * @link https://jingga.app |
35 | * @since 1.0.0 |
36 | */ |
37 | class Email implements MessageInterface |
38 | { |
39 | /** |
40 | * Mailer name. |
41 | * |
42 | * @var string |
43 | * @since 1.0.0 |
44 | */ |
45 | public const XMAILER = 'phpOMS'; |
46 | |
47 | /** |
48 | * The maximum line length supported by mail(). |
49 | * |
50 | * @var int |
51 | * @since 1.0.0 |
52 | */ |
53 | public const MAIL_MAX_LINE_LENGTH = 63; |
54 | |
55 | /** |
56 | * The maximum line length allowed by RFC 2822 section 2.1.1. |
57 | * |
58 | * @var int |
59 | * @since 1.0.0 |
60 | */ |
61 | public const MAX_LINE_LENGTH = 998; |
62 | |
63 | /** |
64 | * The lower maximum line length allowed by RFC 2822 section 2.1.1. |
65 | * |
66 | * @var int |
67 | * @since 1.0.0 |
68 | */ |
69 | public const STD_LINE_LENGTH = 76; |
70 | |
71 | /** |
72 | * Folding White Space. |
73 | * |
74 | * @var string |
75 | * @since 1.0.0 |
76 | */ |
77 | public const FWS = ' '; |
78 | |
79 | /** |
80 | * SMTP RFC standard line ending |
81 | * |
82 | * @var string |
83 | * @since 1.0.0 |
84 | */ |
85 | protected static string $LE = "\r\n"; |
86 | |
87 | /** |
88 | * Message id. |
89 | * |
90 | * Format <id@domain>. If empty this is automatically generated. |
91 | * |
92 | * @var string |
93 | * @since 1.0.0 |
94 | */ |
95 | public string $messageId = ''; |
96 | |
97 | /** |
98 | * Unique ID used for message ID and boundaries. |
99 | * |
100 | * @var string |
101 | * @since 1.0.0 |
102 | */ |
103 | public string $uniqueid = ''; |
104 | |
105 | /** |
106 | * Hostname coming from the mail handler. |
107 | * |
108 | * @var string |
109 | * @since 1.0.0 |
110 | */ |
111 | public string $hostname = ''; |
112 | |
113 | /** |
114 | * Mailer for sending message |
115 | * |
116 | * @var string |
117 | * @since 1.0.0 |
118 | */ |
119 | public string $mailer = SubmitType::MAIL; |
120 | |
121 | /** |
122 | * Mail from. |
123 | * |
124 | * @var array |
125 | * @since 1.0.0 |
126 | */ |
127 | public array $from = []; |
128 | |
129 | /** |
130 | * Return path/bounce address |
131 | * |
132 | * @var string |
133 | * @since 1.0.0 |
134 | */ |
135 | public string $sender = ''; |
136 | |
137 | /** |
138 | * Confirm address. |
139 | * |
140 | * @var string |
141 | */ |
142 | public string $confirmationAddress = ''; |
143 | |
144 | /** |
145 | * Mail to. |
146 | * |
147 | * @var array |
148 | * @since 1.0.0 |
149 | */ |
150 | public array $to = []; |
151 | |
152 | /** |
153 | * Mail subject. |
154 | * |
155 | * @var string |
156 | * @since 1.0.0 |
157 | */ |
158 | public string $subject = ''; |
159 | |
160 | /** |
161 | * Mail cc. |
162 | * |
163 | * @var array |
164 | * @since 1.0.0 |
165 | */ |
166 | public array $cc = []; |
167 | |
168 | /** |
169 | * Mail bcc. |
170 | * |
171 | * @var array |
172 | * @since 1.0.0 |
173 | */ |
174 | public array $bcc = []; |
175 | |
176 | /** |
177 | * The array of reply-to names and addresses. |
178 | * |
179 | * @var array |
180 | * @since 1.0.0 |
181 | */ |
182 | public array $replyTo = []; |
183 | |
184 | /** |
185 | * Mail attachments. |
186 | * |
187 | * @var array |
188 | * @since 1.0.0 |
189 | */ |
190 | protected array $attachment = []; |
191 | |
192 | /** |
193 | * Mail body. |
194 | * |
195 | * @var string |
196 | * @since 1.0.0 |
197 | */ |
198 | public string $body = ''; |
199 | |
200 | /** |
201 | * Mail alt. |
202 | * |
203 | * @var string |
204 | * @since 1.0.0 |
205 | */ |
206 | public string $bodyAlt = ''; |
207 | |
208 | /** |
209 | * Ical body. |
210 | * |
211 | * @var string |
212 | * @since 1.0.0 |
213 | */ |
214 | public string $ical = ''; |
215 | |
216 | /** |
217 | * Mail mime. |
218 | * |
219 | * @var string |
220 | * @since 1.0.0 |
221 | */ |
222 | public string $bodyMime = ''; |
223 | |
224 | /** |
225 | * The array of MIME boundary strings. |
226 | * |
227 | * @var array |
228 | * @since 1.0.0 |
229 | */ |
230 | protected array $boundary = []; |
231 | |
232 | /** |
233 | * Mail header. |
234 | * |
235 | * @var string |
236 | * @since 1.0.0 |
237 | */ |
238 | protected string $header = ''; |
239 | |
240 | /** |
241 | * Mail header. |
242 | * |
243 | * @var string |
244 | * @since 1.0.0 |
245 | */ |
246 | public string $headerMime = ''; |
247 | |
248 | /** |
249 | * The array of custom headers. |
250 | * |
251 | * @var array |
252 | * @since 1.0.0 |
253 | */ |
254 | protected array $customHeader = []; |
255 | |
256 | /** |
257 | * Word wrap. |
258 | * |
259 | * @var int |
260 | * @since 1.0.0 |
261 | */ |
262 | protected int $wordWrap = 72; |
263 | |
264 | /** |
265 | * Encoding. |
266 | * |
267 | * @var string |
268 | * @since 1.0.0 |
269 | */ |
270 | protected string $encoding = EncodingType::E_8BIT; |
271 | |
272 | /** |
273 | * Mail content type. |
274 | * |
275 | * @var string |
276 | * @since 1.0.0 |
277 | */ |
278 | protected string $contentType = MimeType::M_TXT; |
279 | |
280 | /** |
281 | * Character set |
282 | * |
283 | * @var string |
284 | * @since 1.0.0 |
285 | */ |
286 | public string $charset = CharsetType::ISO_8859_1; |
287 | |
288 | /** |
289 | * Mail message type. |
290 | * |
291 | * @var string |
292 | * @since 1.0.0 |
293 | */ |
294 | protected string $messageType = ''; |
295 | |
296 | /** |
297 | * Mail from. |
298 | * |
299 | * @var null|\DateTime |
300 | * @since 1.0.0 |
301 | */ |
302 | public ?\DateTimeImmutable $messageDate = null; |
303 | |
304 | /** |
305 | * Priority |
306 | * |
307 | * @var int |
308 | * @since 1.0.0 |
309 | */ |
310 | public int $priority = 0; |
311 | |
312 | /** |
313 | * The S/MIME certificate file path. |
314 | * |
315 | * @var string |
316 | * @since 1.0.0 |
317 | */ |
318 | protected string $signCertFile = ''; |
319 | |
320 | /** |
321 | * The S/MIME key file path. |
322 | * |
323 | * @var string |
324 | * @since 1.0.0 |
325 | */ |
326 | protected string $signKeyFile = ''; |
327 | |
328 | /** |
329 | * The optional S/MIME extra certificates ("CA Chain") file path. |
330 | * |
331 | * @var string |
332 | * @since 1.0.0 |
333 | */ |
334 | protected string $signExtracertFiles = ''; |
335 | |
336 | /** |
337 | * The S/MIME password for the key. |
338 | * Used only if the key is encrypted. |
339 | * |
340 | * @var string |
341 | * @since 1.0.0 |
342 | */ |
343 | protected string $signKeyPass = ''; |
344 | |
345 | /** |
346 | * DKIM selector. |
347 | * |
348 | * @var string |
349 | * @since 1.0.0 |
350 | */ |
351 | public string $dkimSelector = ''; |
352 | |
353 | /** |
354 | * DKIM Identity. |
355 | * Usually the email address used as the source of the email. |
356 | * |
357 | * @var string |
358 | * @since 1.0.0 |
359 | */ |
360 | public string $dkimIdentity = ''; |
361 | |
362 | /** |
363 | * DKIM passphrase. |
364 | * Used if your key is encrypted. |
365 | * |
366 | * @var string |
367 | * @since 1.0.0 |
368 | */ |
369 | public string $dkimPass = ''; |
370 | |
371 | /** |
372 | * DKIM signing domain name. |
373 | * |
374 | * @var string |
375 | * @since 1.0.0 |
376 | */ |
377 | public string $dkimDomain = ''; |
378 | |
379 | /** |
380 | * DKIM Copy header field values for diagnostic use. |
381 | * |
382 | * @var bool |
383 | * @since 1.0.0 |
384 | */ |
385 | public bool $dkimCopyHeader = true; |
386 | |
387 | /** |
388 | * DKIM Extra signing headers. |
389 | * |
390 | * @example ['List-Unsubscribe', 'List-Help'] |
391 | * |
392 | * @var array |
393 | * @since 1.0.0 |
394 | */ |
395 | public array $dkimHeaders = []; |
396 | |
397 | /** |
398 | * DKIM private key file path. |
399 | * |
400 | * @var string |
401 | * @since 1.0.0 |
402 | */ |
403 | public string $dkimPrivatePath = ''; |
404 | |
405 | /** |
406 | * DKIM private key string. |
407 | * |
408 | * If set, takes precedence over `$dkimPrivatePath`. |
409 | * |
410 | * @var string |
411 | * @since 1.0.0 |
412 | */ |
413 | public string $dkimPrivateKey = ''; |
414 | |
415 | /** |
416 | * Set the From and FromName. |
417 | * |
418 | * @param string $address Email address |
419 | * @param string $name Name |
420 | * |
421 | * @return bool |
422 | * |
423 | * @since 1.0.0 |
424 | */ |
425 | public function setFrom(string $address, string $name = '') : bool |
426 | { |
427 | $address = \trim($address); |
428 | $name = \trim(\preg_replace('/[\r\n]+/', '', $name)); |
429 | |
430 | if (!EmailValidator::isValid($address)) { |
431 | return false; |
432 | } |
433 | |
434 | $this->from = [$address, $name]; |
435 | |
436 | if (empty($this->sender)) { |
437 | $this->sender = $address; |
438 | } |
439 | |
440 | return true; |
441 | } |
442 | |
443 | /** |
444 | * Sets message type to html or plain. |
445 | * |
446 | * @param bool $isHtml Html mode |
447 | * |
448 | * @return void |
449 | * |
450 | * @since 1.0.0 |
451 | */ |
452 | public function setHtml(bool $isHtml = true) : void |
453 | { |
454 | $this->contentType = $isHtml ? MimeType::M_HTML : MimeType::M_TEXT; |
455 | } |
456 | |
457 | public function getContentType() : string |
458 | { |
459 | return $this->contentType; |
460 | } |
461 | |
462 | public function isHtml() : bool |
463 | { |
464 | return $this->contentType === MimeType::M_HTML; |
465 | } |
466 | |
467 | /** |
468 | * Add a "To" address. |
469 | * |
470 | * @param string $address Email address |
471 | * @param string $name Name |
472 | * |
473 | * @return bool |
474 | * |
475 | * @since 1.0.0 |
476 | */ |
477 | public function addTo(string $address, string $name = '') : bool |
478 | { |
479 | if (!EmailValidator::isValid($address)) { |
480 | return false; |
481 | } |
482 | |
483 | $this->to[$address] = [$address, $name]; |
484 | |
485 | return true; |
486 | } |
487 | |
488 | /** |
489 | * Add a "CC" address. |
490 | * |
491 | * @param string $address Email address |
492 | * @param string $name Name |
493 | * |
494 | * @return bool |
495 | * |
496 | * @since 1.0.0 |
497 | */ |
498 | public function addCC(string $address, string $name = '') : bool |
499 | { |
500 | if (!EmailValidator::isValid($address)) { |
501 | return false; |
502 | } |
503 | |
504 | $this->cc[$address] = [$address, $name]; |
505 | |
506 | return true; |
507 | } |
508 | |
509 | /** |
510 | * Add a "BCC" address. |
511 | * |
512 | * @param string $address Email address |
513 | * @param string $name Name |
514 | * |
515 | * @return bool |
516 | * |
517 | * @since 1.0.0 |
518 | */ |
519 | public function addBCC(string $address, string $name = '') : bool |
520 | { |
521 | if (!EmailValidator::isValid($address)) { |
522 | return false; |
523 | } |
524 | |
525 | $this->bcc[$address] = [$address, $name]; |
526 | |
527 | return true; |
528 | } |
529 | |
530 | /** |
531 | * Add a "Reply-To" address. |
532 | * |
533 | * @param string $address Email address |
534 | * @param string $name Name |
535 | * |
536 | * @return bool |
537 | * |
538 | * @since 1.0.0 |
539 | */ |
540 | public function addReplyTo(string $address, string $name = '') : bool |
541 | { |
542 | if (!EmailValidator::isValid($address)) { |
543 | return false; |
544 | } |
545 | |
546 | $this->replyTo[$address] = [$address, $name]; |
547 | |
548 | return true; |
549 | } |
550 | |
551 | /** |
552 | * Parse and validate a string containing one or more RFC822-style comma-separated email addresses |
553 | * of the form "display name <address>" into an array of name/address pairs. |
554 | * |
555 | * @param string $addrstr Address line |
556 | * @param bool $useImap Use imap for parsing |
557 | * @param string $charset Charset for email |
558 | * |
559 | * @return array |
560 | * |
561 | * @since 1.0.0 |
562 | */ |
563 | public static function parseAddresses(string $addrstr, bool $useimap = true, string $charset = CharsetType::ISO_8859_1) : array |
564 | { |
565 | $addresses = []; |
566 | if ($useimap && \function_exists('imap_rfc822_parse_adrlist')) { |
567 | $list = \imap_rfc822_parse_adrlist($addrstr, ''); |
568 | foreach ($list as $address) { |
569 | if (($address->host !== '.SYNTAX-ERROR.') |
570 | && EmailValidator::isValid($address->mailbox . '@' . $address->host) |
571 | ) { |
572 | if (\property_exists($address, 'personal') |
573 | && \preg_match('/^=\?.*\?=$/s', $address->personal) |
574 | ) { |
575 | $origCharset = \mb_internal_encoding(); |
576 | \mb_internal_encoding($charset); |
577 | $address->personal = \str_replace('_', '=20', $address->personal); |
578 | $address->personal = \mb_decode_mimeheader($address->personal); |
579 | \mb_internal_encoding($origCharset); |
580 | } |
581 | |
582 | $addresses[] = [ |
583 | 'name' => (\property_exists($address, 'personal') ? $address->personal : ''), |
584 | 'address' => $address->mailbox . '@' . $address->host, |
585 | ]; |
586 | } |
587 | } |
588 | |
589 | return $addresses; |
590 | } |
591 | |
592 | $list = \explode(',', $addrstr); |
593 | foreach ($list as $address) { |
594 | $address = \trim($address); |
595 | if (\strpos($address, '<') === false) { |
596 | if (EmailValidator::isValid($address)) { |
597 | $addresses[] = [ |
598 | 'name' => '', |
599 | 'address' => $address, |
600 | ]; |
601 | } |
602 | } else { |
603 | $addr = \explode('<', $address); |
604 | $email = \trim(\str_replace('>', '', $addr[1])); |
605 | |
606 | if (EmailValidator::isValid($email)) { |
607 | $addresses[] = [ |
608 | 'name' => \trim($addr[0], '\'" '), |
609 | 'address' => $email, |
610 | ]; |
611 | } |
612 | } |
613 | } |
614 | |
615 | return $addresses; |
616 | } |
617 | |
618 | /** |
619 | * Pre-send preparations |
620 | * |
621 | * @param string $mailer Mailer tool |
622 | * |
623 | * @return bool |
624 | * |
625 | * @since 1.0.0 |
626 | */ |
627 | public function preSend(string $mailer) : bool |
628 | { |
629 | $this->header = ''; |
630 | $this->mailer = $mailer; |
631 | |
632 | if (empty($this->to) && empty($this->cc) && empty($this->bcc)) { |
633 | return false; |
634 | } |
635 | |
636 | if (!empty($this->bodyAlt)) { |
637 | $this->contentType = MimeType::M_ALT; |
638 | } |
639 | |
640 | $this->setMessageType(); |
641 | |
642 | $this->headerMime = ''; |
643 | $this->bodyMime = $this->createBody(); |
644 | |
645 | $tempheaders = $this->headerMime; |
646 | $this->headerMime = $this->createHeader(); |
647 | $this->headerMime .= $tempheaders; |
648 | |
649 | if ($this->mailer === SubmitType::MAIL) { |
650 | $this->header .= empty($this->to) |
651 | ? 'Subject: undisclosed-recipients:;' . self::$LE |
652 | : $this->createAddressList('To', $this->to); |
653 | |
654 | $this->header .= 'Subject: ' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))) . self::$LE; |
655 | } |
656 | |
657 | // Sign with DKIM if enabled |
658 | if (!empty($this->dkimDomain) |
659 | && !empty($this->dkimSelector) |
660 | && (!empty($this->dkimPrivateKey) |
661 | || (!empty($this->dkimPrivatePath) |
662 | && FileUtils::isPermittedPath($this->dkimPrivatePath) |
663 | && \is_file($this->dkimPrivatePath) |
664 | ) |
665 | ) |
666 | ) { |
667 | $headerDkim = $this->dkimAdd( |
668 | $this->headerMime . $this->header, |
669 | $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))), |
670 | $this->bodyMime |
671 | ); |
672 | |
673 | $this->headerMime = \rtrim($this->headerMime, " \r\n\t") . self::$LE . |
674 | self::normalizeBreaks($headerDkim, self::$LE) . self::$LE; |
675 | } |
676 | |
677 | return true; |
678 | } |
679 | |
680 | /** |
681 | * Assemble message headers. |
682 | * |
683 | * @return string The assembled headers |
684 | * |
685 | * @since 1.0.0 |
686 | */ |
687 | private function createHeader() : string |
688 | { |
689 | $result = 'Date : ' . ($this->messageDate === null |
690 | ? (new \DateTime('now'))->format('D, j M Y H:i:s O') |
691 | : $this->messageDate->format('D, j M Y H:i:s O')) |
692 | . self::$LE; |
693 | |
694 | if ($this->mailer !== SubmitType::MAIL) { |
695 | $result .= empty($this->to) |
696 | ? 'To: undisclosed-recipients:;' . self::$LE |
697 | : $this->addrAppend('To', $this->to); |
698 | } |
699 | |
700 | $result .= $this->addrAppend('From', [$this->from]); |
701 | |
702 | // sendmail and mail() extract Cc from the header before sending |
703 | if (!empty($this->cc)) { |
704 | $result .= $this->addrAppend('Cc', $this->cc); |
705 | } |
706 | |
707 | // sendmail and mail() extract Bcc from the header before sending |
708 | if (($this->mailer === SubmitType::MAIL |
709 | || $this->mailer === SubmitType::SENDMAIL |
710 | || $this->mailer === SubmitType::QMAIL) |
711 | && !empty($this->bcc) |
712 | ) { |
713 | $result .= $this->addrAppend('Bcc', $this->bcc); |
714 | } |
715 | |
716 | if (!empty($this->replyTo)) { |
717 | $result .= $this->addrAppend('Reply-To', $this->replyTo); |
718 | } |
719 | |
720 | // mail() sets the subject itself |
721 | if ($this->mailer !== SubmitType::MAIL) { |
722 | $result .= 'Subject: ' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))) . self::$LE; |
723 | } |
724 | |
725 | $this->hostname = empty($this->hostname) ? SystemUtils::getHostname() : $this->hostname; |
726 | |
727 | // Only allow a custom message Id if it conforms to RFC 5322 section 3.6.4 |
728 | // https://tools.ietf.org/html/rfc5322#section-3.6.4 |
729 | $this->messageId = $this->messageId !== '' |
730 | && \preg_match('/^<((([a-z\d!#$%&\'*+\/=?^_`{|}~-]+(\.[a-z\d!#$%&\'*+\/=?^_`{|}~-]+)*)' . |
731 | '|("(([\x01-\x08\x0B\x0C\x0E-\x1F\x7F]|[\x21\x23-\x5B\x5D-\x7E])' . |
732 | '|(\\[\x01-\x09\x0B\x0C\x0E-\x7F]))*"))@(([a-z\d!#$%&\'*+\/=?^_`{|}~-]+' . |
733 | '(\.[a-z\d!#$%&\'*+\/=?^_`{|}~-]+)*)|(\[(([\x01-\x08\x0B\x0C\x0E-\x1F\x7F]' . |
734 | '|[\x21-\x5A\x5E-\x7E])|(\\[\x01-\x09\x0B\x0C\x0E-\x7F]))*\])))>$/Di', $this->messageId) |
735 | ? $this->messageId |
736 | : \sprintf('<%s@%s>', $this->uniqueid, $this->hostname); |
737 | |
738 | $result .= 'Message-ID: ' . $this->messageId . self::$LE; |
739 | |
740 | if ($this->priority > 0) { |
741 | $result .= 'X-Priority: ' . $this->priority . self::$LE; |
742 | } |
743 | |
744 | $result .= 'X-Mailer: ' . self::XMAILER . self::$LE; |
745 | |
746 | if ($this->confirmationAddress !== '') { |
747 | $result .= 'Disposition-Notification-To: ' . '<' . $this->confirmationAddress . '>' . self::$LE; |
748 | } |
749 | |
750 | // Add custom headers |
751 | foreach ($this->customHeader as $header) { |
752 | $result .= \trim($header[0]) . ': ' . $this->encodeHeader(\trim($header[1])) . self::$LE; |
753 | } |
754 | |
755 | if (empty($this->signKeyFile)) { |
756 | $result .= 'MIME-Version: 1.0' . self::$LE; |
757 | $result .= $this->getMailMime(); |
758 | } |
759 | |
760 | return $result; |
761 | } |
762 | |
763 | /** |
764 | * Create recipient headers. |
765 | * |
766 | * @param string $type Address type |
767 | * @param array $addr Address 0 = address, 1 = name ['joe@example.com', 'Joe User'] |
768 | * |
769 | * @return string |
770 | * |
771 | * @since 1.0.0 |
772 | */ |
773 | private function addrAppend(string $type, array $addr) : string |
774 | { |
775 | $addresses = []; |
776 | foreach ($addr as $address) { |
777 | $addresses[] = $this->addrFormat($address); |
778 | } |
779 | |
780 | return $type . ': ' . \implode(', ', $addresses) . self::$LE; |
781 | } |
782 | |
783 | /** |
784 | * Format an address for use in a message header. |
785 | * |
786 | * @param array $addr Address 0 = address, 1 = name ['joe@example.com', 'Joe User'] |
787 | * |
788 | * @return string |
789 | * |
790 | * @since 1.0.0 |
791 | */ |
792 | public function addrFormat(array $addr) : string |
793 | { |
794 | if (empty($addr[1])) { |
795 | return \trim(\str_replace(["\r", "\n"], '', $addr[0])); |
796 | } |
797 | |
798 | return $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $addr[1])), 'phrase') . |
799 | ' <' . \trim(\str_replace(["\r", "\n"], '', $addr[0])) . '>'; |
800 | } |
801 | |
802 | /** |
803 | * Get the message MIME type headers. |
804 | * |
805 | * @return string |
806 | * |
807 | * @since 1.0.0 |
808 | */ |
809 | private function getMailMime() : string |
810 | { |
811 | $result = ''; |
812 | $isMultipart = true; |
813 | |
814 | switch ($this->messageType) { |
815 | case 'inline': |
816 | $result .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE; |
817 | $result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE; |
818 | break; |
819 | case 'attach': |
820 | case 'inline_attach': |
821 | case 'alt_attach': |
822 | case 'alt_inline_attach': |
823 | $result .= 'Content-Type: ' . MimeType::M_MIXED . ';' . self::$LE; |
824 | $result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE; |
825 | break; |
826 | case 'alt': |
827 | case 'alt_inline': |
828 | $result .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE; |
829 | $result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE; |
830 | break; |
831 | default: |
832 | // Catches case 'plain': and case '': |
833 | $result .= 'Content-Type: ' . $this->contentType . '; charset=' . $this->charset . self::$LE; |
834 | $isMultipart = false; |
835 | break; |
836 | } |
837 | |
838 | // RFC1341 part 5 says 7bit is assumed if not specified |
839 | if ($this->encoding === EncodingType::E_7BIT) { |
840 | return $result; |
841 | } |
842 | |
843 | // RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit or binary CTE |
844 | if ($isMultipart) { |
845 | if ($this->encoding === EncodingType::E_8BIT) { |
846 | $result .= 'Content-Transfer-Encoding: ' . EncodingType::E_8BIT . self::$LE; |
847 | } |
848 | |
849 | // quoted-printable and base64 are 7bit compatible |
850 | } else { |
851 | $result .= 'Content-Transfer-Encoding: ' . $this->encoding . self::$LE; |
852 | } |
853 | |
854 | return $result; |
855 | } |
856 | |
857 | /** |
858 | * Converts IDN in given email address to its ASCII form |
859 | * |
860 | * @param string $charset Charset |
861 | * @param string $address Email address |
862 | * |
863 | * @return string The encoded address in ASCII form |
864 | * |
865 | * @since 1.0.0 |
866 | */ |
867 | private function punyencodeAddress(string $charset, string $address) : string |
868 | { |
869 | if (empty($charset) || !EmailValidator::isValid($address)) { |
870 | return $address; |
871 | } |
872 | |
873 | $pos = \strrpos($address, '@'); |
874 | $domain = \substr($address, ++$pos); |
875 | |
876 | if (!((bool) \preg_match('/[\x80-\xFF]/', $domain)) || !\mb_check_encoding($domain, $charset)) { |
877 | return $address; |
878 | } |
879 | |
880 | $domain = \mb_convert_encoding($domain, 'UTF-8', $charset); |
881 | |
882 | $errorcode = 0; |
883 | if (\defined('INTL_IDNA_VARIANT_UTS46')) { |
884 | $punycode = \idn_to_ascii( |
885 | $domain, |
886 | \IDNA_DEFAULT | \IDNA_USE_STD3_RULES | \IDNA_CHECK_BIDI | \IDNA_CHECK_CONTEXTJ | \IDNA_NONTRANSITIONAL_TO_ASCII, |
887 | \INTL_IDNA_VARIANT_UTS46); |
888 | } else { |
889 | $punycode = \idn_to_ascii($domain, $errorcode); |
890 | } |
891 | |
892 | if ($punycode !== false) { |
893 | return \substr($address, 0, $pos) . $punycode; |
894 | } |
895 | |
896 | return $address; |
897 | } |
898 | |
899 | /** |
900 | * Create a unique ID to use for boundaries. |
901 | * |
902 | * @return string |
903 | * |
904 | * @since 1.0.0 |
905 | */ |
906 | protected function generateId() : string |
907 | { |
908 | $len = 32; //32 bytes = 256 bits |
909 | $bytes = ''; |
910 | $bytes = \random_bytes($len); |
911 | |
912 | if ($bytes === '') { |
913 | $bytes = \hash('sha256', \uniqid((string) \mt_rand(), true), true); // @codeCoverageIgnore |
914 | } |
915 | |
916 | return \str_replace(['=', '+', '/'], '', \base64_encode(\hash('sha256', $bytes, true))); |
917 | } |
918 | |
919 | /** |
920 | * Assemble the message body. |
921 | * |
922 | * @return string Empty on failure |
923 | * |
924 | * @since 1.0.0 |
925 | */ |
926 | public function createBody() : string |
927 | { |
928 | $body = ''; |
929 | $this->uniqueid = $this->generateId(); |
930 | |
931 | $this->boundary[1] = 'b1=_' . $this->uniqueid; |
932 | $this->boundary[2] = 'b2=_' . $this->uniqueid; |
933 | $this->boundary[3] = 'b3=_' . $this->uniqueid; |
934 | |
935 | if (!empty($this->signKeyFile)) { |
936 | $body .= $this->getMailMime() . self::$LE; |
937 | } |
938 | |
939 | $this->setWordWrap(); |
940 | |
941 | $bodyEncoding = $this->encoding; |
942 | $bodyCharSet = $this->charset; |
943 | |
944 | // Can we do a 7-bit downgrade? |
945 | if ($bodyEncoding === EncodingType::E_8BIT && !((bool) \preg_match('/[\x80-\xFF]/', $this->body))) { |
946 | $bodyEncoding = EncodingType::E_7BIT; |
947 | |
948 | //All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit |
949 | $bodyCharSet = CharsetType::ASCII; |
950 | } |
951 | |
952 | // If lines are too long, and we're not already using an encoding that will shorten them, |
953 | // change to quoted-printable transfer encoding for the body part only |
954 | if ($this->encoding !== EncodingType::E_BASE64 |
955 | && ((bool) \preg_match('/^(.{' . (self::MAX_LINE_LENGTH + \strlen(self::$LE)) . ',})/m', $this->body)) |
956 | ) { |
957 | $bodyEncoding = EncodingType::E_QUOTED; |
958 | } |
959 | |
960 | $altBodyEncoding = $this->encoding; |
961 | $altBodyCharSet = $this->charset; |
962 | |
963 | //Can we do a 7-bit downgrade? |
964 | if ($altBodyEncoding === EncodingType::E_8BIT && !((bool) \preg_match('/[\x80-\xFF]/', $this->bodyAlt))) { |
965 | $altBodyEncoding = EncodingType::E_7BIT; |
966 | |
967 | //All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit |
968 | $altBodyCharSet = CharsetType::ASCII; |
969 | } |
970 | |
971 | //If lines are too long, and we're not already using an encoding that will shorten them, |
972 | //change to quoted-printable transfer encoding for the alt body part only |
973 | if ($altBodyEncoding !== EncodingType::E_BASE64 |
974 | && ((bool) \preg_match('/^(.{' . (self::MAX_LINE_LENGTH + \strlen(self::$LE)) . ',})/m', $this->bodyAlt)) |
975 | ) { |
976 | $altBodyEncoding = EncodingType::E_QUOTED; |
977 | } |
978 | |
979 | //Use this as a preamble in all multipart message types |
980 | $mimePre = ''; |
981 | switch ($this->messageType) { |
982 | case 'inline': |
983 | $body .= $mimePre; |
984 | $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding); |
985 | $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; |
986 | $body .= $this->attachAll('inline', $this->boundary[1]); |
987 | break; |
988 | case 'attach': |
989 | $body .= $mimePre; |
990 | $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding); |
991 | $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; |
992 | $body .= $this->attachAll('attachment', $this->boundary[1]); |
993 | break; |
994 | case 'inline_attach': |
995 | $body .= $mimePre; |
996 | $body .= '--' . $this->boundary[1] . self::$LE; |
997 | $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE; |
998 | $body .= ' boundary="' . $this->boundary[2] . '";' . self::$LE; |
999 | $body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE; |
1000 | $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, '', $bodyEncoding); |
1001 | $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; |
1002 | $body .= $this->attachAll('inline', $this->boundary[2]) . self::$LE; |
1003 | $body .= $this->attachAll('attachment', $this->boundary[1]); |
1004 | break; |
1005 | case 'alt': |
1006 | $body .= $mimePre; |
1007 | $body .= $this->getBoundary($this->boundary[1], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding); |
1008 | $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE; |
1009 | $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, MimeType::M_HTML, $bodyEncoding); |
1010 | $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; |
1011 | |
1012 | if (!empty($this->ical)) { |
1013 | $method = ICALMethodType::REQUEST; |
1014 | $methods = ICALMethodType::getConstants(); |
1015 | |
1016 | foreach ($methods as $imethod) { |
1017 | if (\stripos($this->ical, 'METHOD:' . $imethod) !== false) { |
1018 | $method = $imethod; |
1019 | break; |
1020 | } |
1021 | } |
1022 | |
1023 | $body .= $this->getBoundary($this->boundary[1], '', MimeType::M_ICS . '; method=' . $method, ''); |
1024 | $body .= $this->encodeString($this->ical, $this->encoding) . self::$LE; |
1025 | } |
1026 | |
1027 | $body .= self::$LE . '--' . $this->boundary[1] . '--' . self::$LE; |
1028 | break; |
1029 | case 'alt_inline': |
1030 | $body .= $mimePre; |
1031 | $body .= $this->getBoundary($this->boundary[1], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding); |
1032 | $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE; |
1033 | $body .= '--' . $this->boundary[1] . self::$LE; |
1034 | $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE; |
1035 | $body .= ' boundary="' . $this->boundary[2] . '";' . self::$LE; |
1036 | $body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE; |
1037 | $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, MimeType::M_HTML, $bodyEncoding); |
1038 | $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; |
1039 | $body .= $this->attachAll('inline', $this->boundary[2]) . self::$LE; |
1040 | $body .= self::$LE . '--' . $this->boundary[1] . '--' . self::$LE; |
1041 | break; |
1042 | case 'alt_attach': |
1043 | $body .= $mimePre; |
1044 | $body .= '--' . $this->boundary[1] . self::$LE; |
1045 | $body .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE; |
1046 | $body .= ' boundary="' . $this->boundary[2] . '"' . self::$LE . self::$LE; |
1047 | $body .= $this->getBoundary($this->boundary[2], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding); |
1048 | $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE; |
1049 | $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, MimeType::M_HTML, $bodyEncoding); |
1050 | $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; |
1051 | |
1052 | if (!empty($this->ical)) { |
1053 | $method = ICALMethodType::REQUEST; |
1054 | $methods = ICALMethodType::getConstants(); |
1055 | |
1056 | foreach ($methods as $imethod) { |
1057 | if (\stripos($this->ical, 'METHOD:' . $imethod) !== false) { |
1058 | $method = $imethod; |
1059 | break; |
1060 | } |
1061 | } |
1062 | |
1063 | $body .= $this->getBoundary($this->boundary[2], '', MimeType::M_ICS . '; method=' . $method, ''); |
1064 | $body .= $this->encodeString($this->ical, $this->encoding); |
1065 | } |
1066 | |
1067 | $body .= self::$LE . '--' . $this->boundary[2] . '--' . self::$LE . self::$LE; |
1068 | $body .= $this->attachAll('attachment', $this->boundary[1]); |
1069 | break; |
1070 | case 'alt_inline_attach': |
1071 | $body .= $mimePre; |
1072 | $body .= '--' . $this->boundary[1] . self::$LE; |
1073 | $body .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE; |
1074 | $body .= ' boundary="' . $this->boundary[2] . '"' . self::$LE; |
1075 | $body .= $this->getBoundary($this->boundary[2], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding); |
1076 | $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE; |
1077 | $body .= '--' . $this->boundary[2] . self::$LE; |
1078 | $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE; |
1079 | $body .= ' boundary="' . $this->boundary[3] . '";' . self::$LE; |
1080 | $body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE; |
1081 | $body .= $this->getBoundary($this->boundary[3], $bodyCharSet, MimeType::M_HTML, $bodyEncoding); |
1082 | $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; |
1083 | $body .= $this->attachAll('inline', $this->boundary[3]) . self::$LE; |
1084 | $body .= self::$LE . '--' . $this->boundary[2] . '--' . self::$LE . self::$LE; |
1085 | $body .= $this->attachAll('attachment', $this->boundary[1]); |
1086 | break; |
1087 | default: |
1088 | // Catch case 'plain' and case '', applies to simple `text/plain` and `text/html` body content types |
1089 | $this->encoding = $bodyEncoding; |
1090 | $body .= $this->encodeString($this->body, $this->encoding); |
1091 | break; |
1092 | } |
1093 | |
1094 | if (!empty($this->signKeyFile)) { |
1095 | if (!\defined('PKCS7_TEXT')) { |
1096 | return ''; |
1097 | } |
1098 | |
1099 | $file = \tempnam($tmpDir = \sys_get_temp_dir(), 'srcsign'); |
1100 | $signed = \tempnam($tmpDir, 'mailsign'); |
1101 | \file_put_contents($file, $body); |
1102 | |
1103 | try { |
1104 | // Workaround for PHP bug https://bugs.php.net/bug.php?id=69197 |
1105 | $sign = empty($this->signExtracertFiles) |
1106 | ? \openssl_pkcs7_sign(\realpath($file), $signed, |
1107 | 'file://' . \realpath($this->signCertFile), |
1108 | ['file://' . \realpath($this->signKeyFile), $this->signKeyPass], |
1109 | [], |
1110 | ) |
1111 | : \openssl_pkcs7_sign(\realpath($file), $signed, |
1112 | 'file://' . \realpath($this->signCertFile), |
1113 | ['file://' . \realpath($this->signKeyFile), $this->signKeyPass], |
1114 | [], |
1115 | \PKCS7_DETACHED, |
1116 | $this->signExtracertFiles |
1117 | ); |
1118 | } catch (\Throwable $_) { |
1119 | $sign = false; |
1120 | } |
1121 | |
1122 | \unlink($file); |
1123 | if ($sign === false) { |
1124 | \unlink($signed); |
1125 | return ''; |
1126 | } |
1127 | |
1128 | $body = \file_get_contents($signed); |
1129 | \unlink($signed); |
1130 | |
1131 | //The message returned by openssl contains both headers and body, so need to split them up |
1132 | $parts = \explode("\n\n", $body, 2); |
1133 | $this->headerMime .= $parts[0] . self::$LE . self::$LE; |
1134 | $body = $parts[1]; |
1135 | } |
1136 | |
1137 | return $body; |
1138 | } |
1139 | |
1140 | /** |
1141 | * Return the start of a message boundary. |
1142 | * |
1143 | * @param string $boundary Boundary |
1144 | * @param string $charset Charset |
1145 | * @param string $contentType Content type |
1146 | * @param string $encoding Concoding |
1147 | * |
1148 | * @return string |
1149 | * |
1150 | * @since 1.0.0 |
1151 | */ |
1152 | protected function getBoundary(string $boundary, string $charset, string $contentType, string $encoding) : string |
1153 | { |
1154 | $result = ''; |
1155 | if ($charset === '') { |
1156 | $charset = $this->charset; |
1157 | } |
1158 | |
1159 | if ($contentType === '') { |
1160 | $contentType = $this->contentType; |
1161 | } |
1162 | |
1163 | if ($encoding === '') { |
1164 | $encoding = $this->encoding; |
1165 | } |
1166 | |
1167 | $result .= '--' . $boundary . self::$LE; |
1168 | $result .= \sprintf('Content-Type: %s; charset=%s', $contentType, $charset); |
1169 | $result .= self::$LE; |
1170 | |
1171 | // RFC1341 part 5 says 7bit is assumed if not specified |
1172 | if ($encoding !== EncodingType::E_7BIT) { |
1173 | $result .= 'Content-Transfer-Encoding: ' . $encoding . self::$LE; |
1174 | } |
1175 | |
1176 | return $result . self::$LE; |
1177 | } |
1178 | |
1179 | /** |
1180 | * Attach all file, string, and binary attachments to the message. |
1181 | * |
1182 | * @param string $dispositionType Disposition type |
1183 | * @param string $boundary Boundary string |
1184 | * |
1185 | * @return string |
1186 | * |
1187 | * @since 1.0.0 |
1188 | */ |
1189 | protected function attachAll(string $dispositionType, string $boundary) : string |
1190 | { |
1191 | $mime = []; |
1192 | $cidUniq = []; |
1193 | $incl = []; |
1194 | |
1195 | $attachments = $this->getAttachments(); |
1196 | foreach ($attachments as $attachment) { |
1197 | if ($attachment[6] !== $dispositionType) { |
1198 | continue; |
1199 | } |
1200 | |
1201 | $bString = $attachment[5]; |
1202 | $string = $bString ? $attachment[0] : ''; |
1203 | $path = $bString ? '' : $attachment[0]; |
1204 | |
1205 | $inclHash = \hash('sha256', \serialize($attachment)); |
1206 | if (\in_array($inclHash, $incl, true)) { |
1207 | continue; |
1208 | } |
1209 | |
1210 | $incl[] = $inclHash; |
1211 | $name = $attachment[2]; |
1212 | $encoding = $attachment[3]; |
1213 | $type = $attachment[4]; |
1214 | $disposition = $attachment[6]; |
1215 | $cid = $attachment[7]; |
1216 | |
1217 | if ($disposition === 'inline' && isset($cidUniq[$cid])) { |
1218 | continue; |
1219 | } |
1220 | |
1221 | $cidUniq[$cid] = true; |
1222 | $mime[] = \sprintf('--%s%s', $boundary, self::$LE); |
1223 | |
1224 | //Only include a filename property if we have one |
1225 | $mime[] = empty($name) |
1226 | ? \sprintf('Content-Type: %s%s', |
1227 | $type, |
1228 | self::$LE |
1229 | ) |
1230 | : \sprintf('Content-Type: %s; name=%s%s', |
1231 | $type, |
1232 | self::quotedString($this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $name)))), |
1233 | self::$LE |
1234 | ); |
1235 | |
1236 | // RFC1341 part 5 says 7bit is assumed if not specified |
1237 | if ($encoding !== EncodingType::E_7BIT) { |
1238 | $mime[] = \sprintf('Content-Transfer-Encoding: %s%s', $encoding, self::$LE); |
1239 | } |
1240 | |
1241 | //Only set Content-IDs on inline attachments |
1242 | if ((string) $cid !== '' && $disposition === 'inline') { |
1243 | $mime[] = 'Content-ID: <' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $cid))) . '>' . self::$LE; |
1244 | } |
1245 | |
1246 | // Allow for bypassing the Content-Disposition header |
1247 | if (!empty($disposition)) { |
1248 | $encodedName = $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $name))); |
1249 | $mime[] = empty($encodedName) |
1250 | ? \sprintf('Content-Disposition: %s%s', $disposition, self::$LE . self::$LE) |
1251 | : \sprintf('Content-Disposition: %s; filename=%s%s', |
1252 | $disposition, |
1253 | self::quotedString($encodedName), |
1254 | self::$LE . self::$LE |
1255 | ); |
1256 | } else { |
1257 | $mime[] = self::$LE; |
1258 | } |
1259 | |
1260 | // Encode as string attachment |
1261 | $mime[] = $bString |
1262 | ? $this->encodeString($string, $encoding) |
1263 | : $this->encodeFile($path, $encoding); |
1264 | |
1265 | $mime[] = self::$LE; |
1266 | } |
1267 | |
1268 | $mime[] = \sprintf('--%s--%s', $boundary, self::$LE); |
1269 | |
1270 | return \implode('', $mime); |
1271 | } |
1272 | |
1273 | /** |
1274 | * If a string contains any "special" characters, double-quote the name, |
1275 | * and escape any double quotes with a backslash. |
1276 | * |
1277 | * @param string $str String to quote |
1278 | * |
1279 | * @return string |
1280 | * |
1281 | * @since 1.0.0 |
1282 | */ |
1283 | private static function quotedString(string $str) : string |
1284 | { |
1285 | if (\preg_match('/[ ()<>@,;:"\/\[\]?=]/', $str)) { |
1286 | return '"' . \str_replace('"', '\\"', $str) . '"'; |
1287 | } |
1288 | |
1289 | return $str; |
1290 | } |
1291 | |
1292 | /** |
1293 | * Encode a file attachment in requested format. |
1294 | * |
1295 | * @param string $path Path |
1296 | * @param string $encoding Encoding |
1297 | * |
1298 | * @return string |
1299 | * |
1300 | * @since 1.0.0 |
1301 | */ |
1302 | private function encodeFile(string $path, string $encoding = EncodingType::E_BASE64) : string |
1303 | { |
1304 | if (!FileUtils::isAccessible($path)) { |
1305 | return ''; |
1306 | } |
1307 | |
1308 | $fileBuffer = \file_get_contents($path); |
1309 | |
1310 | return $fileBuffer === false ? '' : $this->encodeString($fileBuffer, $encoding); |
1311 | } |
1312 | |
1313 | /** |
1314 | * Encode a string in requested format. |
1315 | * |
1316 | * @param string $str The text to encode |
1317 | * @param string $encoding Encoding |
1318 | * |
1319 | * @return string |
1320 | * |
1321 | * @since 1.0.0 |
1322 | */ |
1323 | private function encodeString(string $str, string $encoding = EncodingType::E_BASE64) : string |
1324 | { |
1325 | $encoded = ''; |
1326 | switch (\strtolower($encoding)) { |
1327 | case EncodingType::E_BASE64: |
1328 | $encoded = \chunk_split(\base64_encode($str), self::STD_LINE_LENGTH, self::$LE); |
1329 | break; |
1330 | case EncodingType::E_7BIT: |
1331 | case EncodingType::E_8BIT: |
1332 | $encoded = self::normalizeBreaks($str, self::$LE); |
1333 | if (\substr($encoded, -(\strlen(self::$LE))) !== self::$LE) { |
1334 | $encoded .= self::$LE; |
1335 | } |
1336 | |
1337 | break; |
1338 | case EncodingType::E_BINARY: |
1339 | $encoded = $str; |
1340 | break; |
1341 | case EncodingType::E_QUOTED: |
1342 | $encoded = self::normalizeBreaks(\quoted_printable_encode($str), self::$LE); |
1343 | break; |
1344 | default: |
1345 | return ''; |
1346 | } |
1347 | |
1348 | return $encoded; |
1349 | } |
1350 | |
1351 | /** |
1352 | * Set message type based on content |
1353 | * |
1354 | * @return void |
1355 | * |
1356 | * @since 1.0.0 |
1357 | */ |
1358 | protected function setMessageType() : void |
1359 | { |
1360 | $type = []; |
1361 | if (!empty($this->bodyAlt)) { |
1362 | $type[] = 'alt'; |
1363 | } |
1364 | |
1365 | if ($this->hasInlineImage()) { |
1366 | $type[] = 'inline'; |
1367 | } |
1368 | |
1369 | if ($this->hasAttachment()) { |
1370 | $type[] = 'attach'; |
1371 | } |
1372 | |
1373 | $this->messageType = \implode('_', $type); |
1374 | if ($this->messageType === '') { |
1375 | $this->messageType = 'plain'; |
1376 | } |
1377 | } |
1378 | |
1379 | /** |
1380 | * Mail has inline image |
1381 | * |
1382 | * @return bool |
1383 | * |
1384 | * @since 1.0.0 |
1385 | */ |
1386 | public function hasInlineImage() : bool |
1387 | { |
1388 | foreach ($this->attachment as $attachment) { |
1389 | if ($attachment[6] === 'inline') { |
1390 | return true; |
1391 | } |
1392 | } |
1393 | |
1394 | return false; |
1395 | } |
1396 | |
1397 | /** |
1398 | * Mail has attachment |
1399 | * |
1400 | * @return bool |
1401 | * |
1402 | * @since 1.0.0 |
1403 | */ |
1404 | public function hasAttachment() : bool |
1405 | { |
1406 | foreach ($this->attachment as $attachment) { |
1407 | if ($attachment[6] === 'attachment') { |
1408 | return true; |
1409 | } |
1410 | } |
1411 | |
1412 | return false; |
1413 | } |
1414 | |
1415 | /** |
1416 | * Create address list |
1417 | * |
1418 | * @param string $type Address type |
1419 | * @param array $addr Addresses |
1420 | * |
1421 | * @return string |
1422 | * |
1423 | * @since 1.0.0 |
1424 | */ |
1425 | public function createAddressList(string $type, array $addr) : string |
1426 | { |
1427 | $addresses = []; |
1428 | foreach ($addr as $address) { |
1429 | $addresses[] = $this->addrFormat($address); |
1430 | } |
1431 | |
1432 | return $type . ': ' . \implode(', ', $addresses) . static::$LE; |
1433 | } |
1434 | |
1435 | /** |
1436 | * Apply word wrapping |
1437 | * |
1438 | * @return void |
1439 | * |
1440 | * @return 1.0.0 |
1441 | */ |
1442 | public function setWordWrap() : void |
1443 | { |
1444 | if ($this->wordWrap < 1) { |
1445 | return; |
1446 | } |
1447 | |
1448 | switch ($this->messageType) { |
1449 | case 'alt': |
1450 | case 'alt_inline': |
1451 | case 'alt_attach': |
1452 | case 'alt_inline_attach': |
1453 | $this->bodyAlt = $this->wrapText($this->bodyAlt, $this->wordWrap); |
1454 | break; |
1455 | default: |
1456 | $this->body = $this->wrapText($this->body, $this->wordWrap); |
1457 | break; |
1458 | } |
1459 | } |
1460 | |
1461 | /** |
1462 | * Word-wrap message. |
1463 | * Original written by philippe. |
1464 | * |
1465 | * @param string $message The message to wrap |
1466 | * @param int $length The line length to wrap to |
1467 | * @param bool $qpMode Use Quoted-Printable mode |
1468 | * |
1469 | * @return string |
1470 | * |
1471 | * @since 1.0.0 |
1472 | */ |
1473 | private function wrapText(string $message, int $length, bool $qpMode = false) : string |
1474 | { |
1475 | $softBreak = $qpMode ? \sprintf(' =%s', self::$LE) : self::$LE; |
1476 | |
1477 | // Don't split multibyte characters |
1478 | $isUtf8 = \strtolower($this->charset) === CharsetType::UTF_8; |
1479 | $leLen = \strlen(self::$LE); |
1480 | $crlfLen = \strlen(self::$LE); |
1481 | |
1482 | $message = self::normalizeBreaks($message, self::$LE); |
1483 | |
1484 | //Remove a trailing line break |
1485 | if (\substr($message, -$leLen) === self::$LE) { |
1486 | $message = \substr($message, 0, -$leLen); |
1487 | } |
1488 | |
1489 | //Split message into lines |
1490 | $lines = \explode(self::$LE, $message); |
1491 | |
1492 | $message = ''; |
1493 | foreach ($lines as $line) { |
1494 | $words = \explode(' ', $line); |
1495 | $buf = ''; |
1496 | $firstword = true; |
1497 | |
1498 | foreach ($words as $word) { |
1499 | if ($qpMode && \strlen($word) > $length) { |
1500 | $spaceLeft = $length - \strlen($buf) - $crlfLen; |
1501 | |
1502 | if (!$firstword) { |
1503 | if ($spaceLeft > 20) { |
1504 | $len = $spaceLeft; |
1505 | if ($isUtf8) { |
1506 | $len = MbStringUtils::utf8CharBoundary($word, $len); |
1507 | } elseif (\substr($word, $len - 1, 1) === '=') { |
1508 | --$len; |
1509 | } elseif (\substr($word, $len - 2, 1) === '=') { |
1510 | $len -= 2; |
1511 | } |
1512 | |
1513 | $part = \substr($word, 0, $len); |
1514 | $word = \substr($word, $len); |
1515 | $buf .= ' ' . $part; |
1516 | $message .= $buf . \sprintf('=%s', self::$LE); |
1517 | } else { |
1518 | $message .= $buf . $softBreak; |
1519 | } |
1520 | |
1521 | $buf = ''; |
1522 | } |
1523 | |
1524 | while ($word !== '') { |
1525 | if ($length <= 0) { |
1526 | break; |
1527 | } |
1528 | |
1529 | $len = $length; |
1530 | if ($isUtf8) { |
1531 | $len = MbStringUtils::utf8CharBoundary($word, $len); |
1532 | } elseif (\substr($word, $len - 1, 1) === '=') { |
1533 | --$len; |
1534 | } elseif (\substr($word, $len - 2, 1) === '=') { |
1535 | $len -= 2; |
1536 | } |
1537 | |
1538 | $part = \substr($word, 0, $len); |
1539 | $word = (string) \substr($word, $len); |
1540 | |
1541 | if ($word !== '') { |
1542 | $message .= $part . \sprintf('=%s', self::$LE); |
1543 | } else { |
1544 | $buf = $part; |
1545 | } |
1546 | } |
1547 | } else { |
1548 | $bufO = $buf; |
1549 | if (!$firstword) { |
1550 | $buf .= ' '; |
1551 | } |
1552 | |
1553 | $buf .= $word; |
1554 | if ($bufO !== '' && \strlen($buf) > $length) { |
1555 | $message .= $bufO . $softBreak; |
1556 | $buf = $word; |
1557 | } |
1558 | } |
1559 | |
1560 | $firstword = false; |
1561 | } |
1562 | |
1563 | $message .= $buf . self::$LE; |
1564 | } |
1565 | |
1566 | return $message; |
1567 | } |
1568 | |
1569 | /** |
1570 | * Encode a header value (not including its label) optimally. |
1571 | * Picks shortest of Q, B, or none. Result includes folding if needed. |
1572 | * |
1573 | * @param string $str Header value |
1574 | * @param string $position Context |
1575 | * |
1576 | * @return string |
1577 | * |
1578 | * @since 1.0.0 |
1579 | */ |
1580 | public function encodeHeader(string $str, string $position = 'text') : string |
1581 | { |
1582 | $matchcount = 0; |
1583 | switch (\strtolower($position)) { |
1584 | case 'phrase': |
1585 | if (!\preg_match('/[\200-\377]/', $str)) { |
1586 | $encoded = \addcslashes($str, "\0..\37\177\\\""); |
1587 | |
1588 | return $str === $encoded && !\preg_match('/[^A-Za-z0-9!#$%&\'*+\/=?^_`{|}~ -]/', $str) |
1589 | ? $encoded |
1590 | : '"' . $encoded . '"'; |
1591 | } |
1592 | |
1593 | $matchcount = \preg_match_all('/[^\040\041\043-\133\135-\176]/', $str, $matches); |
1594 | break; |
1595 | /* @noinspection PhpMissingBreakStatementInspection */ |
1596 | case 'comment': |
1597 | $matchcount = \preg_match_all('/[()"]/', $str, $matches); |
1598 | case 'text': |
1599 | default: |
1600 | $matchcount += \preg_match_all('/[\000-\010\013\014\016-\037\177-\377]/', $str, $matches); |
1601 | break; |
1602 | } |
1603 | |
1604 | $charset = ((bool) \preg_match('/[\x80-\xFF]/', $str)) ? $this->charset : CharsetType::ASCII; |
1605 | |
1606 | // Q/B encoding adds 8 chars and the charset ("` =?<charset>?[QB]?<content>?=`"). |
1607 | $overhead = 8 + \strlen($charset); |
1608 | $maxLen = $this->mailer === SubmitType::MAIL |
1609 | ? self::MAIL_MAX_LINE_LENGTH - $overhead |
1610 | : self::MAX_LINE_LENGTH - $overhead; |
1611 | |
1612 | // Select the encoding that produces the shortest output and/or prevents corruption. |
1613 | if ($matchcount > \strlen($str) / 3) { |
1614 | // More than 1/3 of the content needs encoding, use B-encode. |
1615 | $encoding = 'B'; |
1616 | } elseif ($matchcount > 0) { |
1617 | // Less than 1/3 of the content needs encoding, use Q-encode. |
1618 | $encoding = 'Q'; |
1619 | } elseif (\strlen($str) > $maxLen) { |
1620 | // No encoding needed, but value exceeds max line length, use Q-encode to prevent corruption. |
1621 | $encoding = 'Q'; |
1622 | } else { |
1623 | // No reformatting needed |
1624 | $encoding = ''; |
1625 | } |
1626 | |
1627 | switch ($encoding) { |
1628 | case 'B': |
1629 | if (\strlen($str) > \mb_strlen($str, $this->charset)) { |
1630 | $encoded = $this->base64EncodeWrapMB($str, "\n"); |
1631 | } else { |
1632 | $encoded = \base64_encode($str); |
1633 | $maxLen -= $maxLen % 4; |
1634 | $encoded = \trim(\chunk_split($encoded, $maxLen, "\n")); |
1635 | } |
1636 | $encoded = \preg_replace('/^(.*)$/m', ' =?' . $charset . '?' . $encoding . '?\\1?=', $encoded); |
1637 | break; |
1638 | case 'Q': |
1639 | $encoded = $this->encodeQ($str, $position); |
1640 | $encoded = $this->wrapText($encoded, $maxLen, true); |
1641 | $encoded = \str_replace('=' . self::$LE, "\n", \trim($encoded)); |
1642 | $encoded = \preg_replace('/^(.*)$/m', ' =?' . $charset . '?' . $encoding . '?\\1?=', $encoded); |
1643 | break; |
1644 | default: |
1645 | return $str; |
1646 | } |
1647 | |
1648 | return \trim(self::normalizeBreaks($encoded, self::$LE)); |
1649 | } |
1650 | |
1651 | /** |
1652 | * Encode a string using Q encoding. |
1653 | * |
1654 | * @param string $str Text to encode |
1655 | * @param string $position Where the text is going to be used, see the RFC for what that means |
1656 | * |
1657 | * @return string |
1658 | * |
1659 | * @since 1.0.0 |
1660 | */ |
1661 | private function encodeQ(string $str, string $position = 'text') : string |
1662 | { |
1663 | $pattern = ''; |
1664 | $encoded = \str_replace(["\r", "\n"], '', $str); |
1665 | |
1666 | switch (\strtolower($position)) { |
1667 | case 'phrase': |
1668 | $pattern = '^A-Za-z0-9!*+\/ -'; |
1669 | break; |
1670 | case 'comment': |
1671 | $pattern = '\(\)"'; |
1672 | case 'text': |
1673 | default: |
1674 | // Replace every high ascii, control, =, ? and _ characters |
1675 | $pattern = '\000-\011\013\014\016-\037\075\077\137\177-\377' . $pattern; |
1676 | break; |
1677 | } |
1678 | |
1679 | if (\preg_match_all("/[{$pattern}]/", $encoded, $matches) !== false) { |
1680 | return \strtr($encoded, ' ', '_'); |
1681 | } |
1682 | |
1683 | $matches = []; |
1684 | // If the string contains an '=', make sure it's the first thing we replace |
1685 | // so as to avoid double-encoding |
1686 | $eqkey = \array_search('=', $matches[0], true); |
1687 | if ($eqkey !== false) { |
1688 | unset($matches[0][$eqkey]); |
1689 | \array_unshift($matches[0], '='); |
1690 | } |
1691 | |
1692 | $unique = \array_unique($matches[0]); |
1693 | foreach ($unique as $char) { |
1694 | $encoded = \str_replace($char, '=' . \sprintf('%02X', \ord($char)), $encoded); |
1695 | } |
1696 | |
1697 | // Replace spaces with _ (more readable than =20) |
1698 | // RFC 2047 section 4.2(2) |
1699 | return \strtr($encoded, ' ', '_'); |
1700 | } |
1701 | |
1702 | /** |
1703 | * Encode and wrap long multibyte strings for mail headers |
1704 | * |
1705 | * @param string $str Multi-byte text to wrap encode |
1706 | * @param string $linebreak string to use as linefeed/end-of-line |
1707 | * |
1708 | * @return string |
1709 | * |
1710 | * @since 1.0.0 |
1711 | */ |
1712 | private function base64EncodeWrapMB(string $str, string $linebreak) : string |
1713 | { |
1714 | $start = '=?' . $this->charset . '?B?'; |
1715 | $end = '?='; |
1716 | $encoded = ''; |
1717 | |
1718 | $mbLength = \mb_strlen($str, $this->charset); |
1719 | $length = 75 - \strlen($start) - \strlen($end); |
1720 | $ratio = $mbLength / \strlen($str); |
1721 | $avgLength = \floor($length * $ratio * .75); |
1722 | |
1723 | $offset = 0; |
1724 | for ($i = 0; $i < $mbLength; $i += $offset) { |
1725 | $lookBack = 0; |
1726 | |
1727 | do { |
1728 | $offset = $avgLength - $lookBack; |
1729 | $chunk = \mb_substr($str, $i, $offset, $this->charset); |
1730 | $chunk = \base64_encode($chunk); |
1731 | ++$lookBack; |
1732 | } while (\strlen($chunk) > $length); |
1733 | |
1734 | $encoded .= $chunk . $linebreak; |
1735 | } |
1736 | |
1737 | return \substr($encoded, 0, -\strlen($linebreak)); |
1738 | } |
1739 | |
1740 | /** |
1741 | * Add an attachment from a path on the filesystem. |
1742 | * |
1743 | * @param string $path Path |
1744 | * @param string $name Overrides the attachment name |
1745 | * @param string $encoding File encoding |
1746 | * @param string $type Mime type; determined automatically from $path if not specified |
1747 | * @param string $disposition Disposition to use |
1748 | * |
1749 | * @return bool |
1750 | * |
1751 | * @since 1.0.0 |
1752 | */ |
1753 | public function addAttachment( |
1754 | string $path, |
1755 | string $name = '', |
1756 | string $encoding = EncodingType::E_BASE64, |
1757 | string $type = '', |
1758 | string $disposition = 'attachment' |
1759 | ) : bool { |
1760 | if (!FileUtils::isAccessible($path)) { |
1761 | return false; |
1762 | } |
1763 | |
1764 | // Mime from file |
1765 | if ($type === '') { |
1766 | $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($path, \PATHINFO_EXTENSION)); |
1767 | } |
1768 | |
1769 | $filename = FileUtils::mb_pathinfo($path, \PATHINFO_BASENAME); |
1770 | if ($name === '') { |
1771 | $name = $filename; |
1772 | } |
1773 | |
1774 | $this->attachment[] = [ |
1775 | 0 => $path, |
1776 | 1 => $filename, |
1777 | 2 => $name, |
1778 | 3 => $encoding, |
1779 | 4 => $type, |
1780 | 5 => false, // isStringAttachment |
1781 | 6 => $disposition, |
1782 | 7 => $name, |
1783 | ]; |
1784 | |
1785 | return true; |
1786 | } |
1787 | |
1788 | /** |
1789 | * Return the array of attachments. |
1790 | * |
1791 | * @return array |
1792 | */ |
1793 | public function getAttachments() |
1794 | { |
1795 | return $this->attachment; |
1796 | } |
1797 | |
1798 | /** |
1799 | * Add a string or binary attachment (non-filesystem). |
1800 | * |
1801 | * @param string $string String attachment data |
1802 | * @param string $filename Name of the attachment |
1803 | * @param string $encoding File encoding (see $encoding) |
1804 | * @param string $type File extension (MIME) type |
1805 | * @param string $disposition Disposition to use |
1806 | * |
1807 | * @return bool |
1808 | * |
1809 | * @since 1.0.0 |
1810 | */ |
1811 | public function addStringAttachment( |
1812 | string $string, |
1813 | string $filename, |
1814 | string $encoding = EncodingType::E_BASE64, |
1815 | string $type = '', |
1816 | string $disposition = 'attachment' |
1817 | ) : bool { |
1818 | // Mime from file |
1819 | if ($type === '') { |
1820 | $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($filename, \PATHINFO_EXTENSION)); |
1821 | } |
1822 | |
1823 | $this->attachment[] = [ |
1824 | 0 => $string, |
1825 | 1 => $filename, |
1826 | 2 => FileUtils::mb_pathinfo($filename, \PATHINFO_BASENAME), |
1827 | 3 => $encoding, |
1828 | 4 => $type, |
1829 | 5 => true, // isStringAttachment |
1830 | 6 => $disposition, |
1831 | 7 => 0, |
1832 | ]; |
1833 | |
1834 | return true; |
1835 | } |
1836 | |
1837 | /** |
1838 | * Add an embedded (inline) attachment from a file. |
1839 | * This can include images, sounds, and just about any other document type. |
1840 | * |
1841 | * @param string $path Path to the attachment |
1842 | * @param string $cid Content ID of the attachment |
1843 | * @param string $name Overrides the attachment name |
1844 | * @param string $encoding File encoding (see $encoding) |
1845 | * @param string $type File MIME type |
1846 | * @param string $disposition Disposition to use |
1847 | * |
1848 | * @return bool |
1849 | * |
1850 | * @since 1.0.0 |
1851 | */ |
1852 | public function addEmbeddedImage( |
1853 | string $path, |
1854 | string $cid, |
1855 | string $name = '', |
1856 | string $encoding = EncodingType::E_BASE64, |
1857 | string $type = '', |
1858 | string $disposition = 'inline' |
1859 | ) : bool { |
1860 | if (!FileUtils::isAccessible($path)) { |
1861 | return false; |
1862 | } |
1863 | |
1864 | // Mime from file |
1865 | if ($type === '') { |
1866 | $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($path, \PATHINFO_EXTENSION)); |
1867 | } |
1868 | |
1869 | $filename = FileUtils::mb_pathinfo($path, \PATHINFO_BASENAME); |
1870 | if ($name === '') { |
1871 | $name = $filename; |
1872 | } |
1873 | |
1874 | // Append to $attachment array |
1875 | $this->attachment[] = [ |
1876 | 0 => $path, |
1877 | 1 => $filename, |
1878 | 2 => $name, |
1879 | 3 => $encoding, |
1880 | 4 => $type, |
1881 | 5 => false, // isStringAttachment |
1882 | 6 => $disposition, |
1883 | 7 => $cid, |
1884 | ]; |
1885 | |
1886 | return true; |
1887 | } |
1888 | |
1889 | /** |
1890 | * Add an embedded stringified attachment. |
1891 | * This can include images, sounds, and just about any other document type. |
1892 | * |
1893 | * @param string $string The attachment binary data |
1894 | * @param string $cid Content ID of the attachment |
1895 | * @param string $name A filename for the attachment. Should use extension. |
1896 | * @param string $encoding File encoding (see $encoding), defaults to 'base64' |
1897 | * @param string $type MIME type - will be used in preference to any automatically derived type |
1898 | * @param string $disposition Disposition to use |
1899 | * |
1900 | * @return bool |
1901 | * |
1902 | * @since 1.0.0 |
1903 | */ |
1904 | public function addStringEmbeddedImage( |
1905 | string $string, |
1906 | string $cid, |
1907 | string $name = '', |
1908 | string $encoding = EncodingType::E_BASE64, |
1909 | string $type = '', |
1910 | string $disposition = 'inline' |
1911 | ) : bool { |
1912 | // Mime from file |
1913 | if ($type === '' && !empty($name)) { |
1914 | $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($name, \PATHINFO_EXTENSION)); |
1915 | } |
1916 | |
1917 | // Append to $attachment array |
1918 | $this->attachment[] = [ |
1919 | 0 => $string, |
1920 | 1 => $name, |
1921 | 2 => $name, |
1922 | 3 => $encoding, |
1923 | 4 => $type, |
1924 | 5 => true, // isStringAttachment |
1925 | 6 => $disposition, |
1926 | 7 => $cid, |
1927 | ]; |
1928 | |
1929 | return true; |
1930 | } |
1931 | |
1932 | /** |
1933 | * Check if an embedded attachment is present with this cid. |
1934 | * |
1935 | * @param string $cid Cid |
1936 | * |
1937 | * @return bool |
1938 | * |
1939 | * @since 1.0.0 |
1940 | */ |
1941 | protected function cidExists(string $cid) : bool |
1942 | { |
1943 | foreach ($this->attachment as $attachment) { |
1944 | if ($attachment[6] === 'inline' && $cid === $attachment[7]) { |
1945 | return true; |
1946 | } |
1947 | } |
1948 | |
1949 | return false; |
1950 | } |
1951 | |
1952 | /** |
1953 | * Add a custom header. |
1954 | * |
1955 | * @param string $name Name |
1956 | * @param null|string $value Value |
1957 | * |
1958 | * @return bool |
1959 | * |
1960 | * @since 1.0.0 |
1961 | */ |
1962 | public function addCustomHeader(string $name, string $value = null) : bool |
1963 | { |
1964 | $name = \trim($name); |
1965 | $value = \trim($value); |
1966 | |
1967 | if (empty($name) || \strpbrk($name . $value, "\r\n") !== false) { |
1968 | return false; |
1969 | } |
1970 | |
1971 | $this->customHeader[] = [$name, $value]; |
1972 | |
1973 | return true; |
1974 | } |
1975 | |
1976 | /** |
1977 | * Returns all custom headers. |
1978 | * |
1979 | * @return array |
1980 | * |
1981 | * @since 1.0.0 |
1982 | */ |
1983 | public function getCustomHeaders() : array |
1984 | { |
1985 | return $this->customHeader; |
1986 | } |
1987 | |
1988 | /** |
1989 | * Create a message body from an HTML string. |
1990 | * |
1991 | * $basedir is prepended when handling relative URLs, e.g. <img src="/images/a.png"> and must not be empty |
1992 | * will look for an image file in $basedir/images/a.png and convert it to inline. |
1993 | * If you don't provide a $basedir, relative paths will be left untouched (and thus probably break in email) |
1994 | * Converts data-uri images into embedded attachments. |
1995 | * |
1996 | * If you don't want to apply these transformations to your HTML, just set Body and AltBody directly. |
1997 | * |
1998 | * @param string $message HTML message string |
1999 | * @param string $basedir Absolute path to a base directory to prepend to relative paths to images |
2000 | * @param null|\Closure $advanced Internal or external text to html converter |
2001 | * |
2002 | * @return string |
2003 | * |
2004 | * @since 1.0.0 |
2005 | */ |
2006 | public function msgHTML(string $message, string $basedir = '', \Closure $advanced = null) |
2007 | { |
2008 | \preg_match_all('/(?<!-)(src|background)=["\'](.*)["\']/Ui', $message, $images); |
2009 | |
2010 | if (isset($images[2])) { |
2011 | if (\strlen($basedir) > 1 && \substr($basedir, -1) !== '/') { |
2012 | $basedir .= '/'; |
2013 | } |
2014 | |
2015 | foreach ($images[2] as $imgindex => $url) { |
2016 | // Convert data URIs into embedded images |
2017 | $match = []; |
2018 | if (\preg_match('#^data:(image/(?:jpe?g|gif|png));?(base64)?,(.+)#', $url, $match)) { |
2019 | if (\count($match) === 4 && $match[2] === EncodingType::E_BASE64) { |
2020 | $data = \base64_decode($match[3]); |
2021 | } elseif ($match[2] === '') { |
2022 | $data = \rawurldecode($match[3]); |
2023 | } else { |
2024 | continue; |
2025 | } |
2026 | |
2027 | $cid = \substr(\hash('sha256', $data), 0, 32) . '@phpoms.0'; // RFC2392 S 2 |
2028 | if (!$this->cidExists($cid)) { |
2029 | $this->addStringEmbeddedImage($data, $cid, 'embed' . $imgindex, EncodingType::E_BASE64, $match[1]); |
2030 | } |
2031 | |
2032 | $message = \str_replace($images[0][$imgindex], $images[1][$imgindex] . '="cid:' . $cid . '"', $message); |
2033 | |
2034 | continue; |
2035 | } |
2036 | |
2037 | if (!empty($basedir) |
2038 | && (\strpos($url, '..') === false) |
2039 | && !\str_starts_with($url, 'cid:') |
2040 | && !\preg_match('#^[a-z][a-z0-9+.-]*:?//#i', $url) |
2041 | ) { |
2042 | $filename = FileUtils::mb_pathinfo($url, \PATHINFO_BASENAME); |
2043 | $directory = \dirname($url); |
2044 | |
2045 | if ($directory === '.') { |
2046 | $directory = ''; |
2047 | } |
2048 | |
2049 | // RFC2392 S 2 |
2050 | $cid = \substr(\hash('sha256', $url), 0, 32) . '@phpoms.0'; |
2051 | if (\strlen($basedir) > 1 && \substr($basedir, -1) !== '/') { |
2052 | $basedir .= '/'; |
2053 | } |
2054 | |
2055 | if (\strlen($directory) > 1 && \substr($directory, -1) !== '/') { |
2056 | $directory .= '/'; |
2057 | } |
2058 | |
2059 | if ($this->addEmbeddedImage( |
2060 | $basedir . $directory . $filename, |
2061 | $cid, |
2062 | $filename, |
2063 | EncodingType::E_BASE64, |
2064 | MimeType::extensionToMime((string) FileUtils::mb_pathinfo($filename, \PATHINFO_EXTENSION)) |
2065 | ) |
2066 | ) { |
2067 | $message = \preg_replace( |
2068 | '/' . $images[1][$imgindex] . '=["\']' . \preg_quote($url, '/') . '["\']/Ui', |
2069 | $images[1][$imgindex] . '="cid:' . $cid . '"', |
2070 | $message |
2071 | ); |
2072 | } |
2073 | } |
2074 | } |
2075 | } |
2076 | |
2077 | $this->contentType = MimeType::M_HTML; |
2078 | $this->body = self::normalizeBreaks($message, self::$LE); |
2079 | $this->bodyAlt = self::normalizeBreaks($this->html2text($message, $advanced), self::$LE); |
2080 | |
2081 | if (empty($this->bodyAlt)) { |
2082 | $this->bodyAlt = 'This is an HTML-only message. To view it, activate HTML in your email application.' . self::$LE; |
2083 | } |
2084 | |
2085 | return $this->body; |
2086 | } |
2087 | |
2088 | /** |
2089 | * Normalize line breaks in a string. |
2090 | * |
2091 | * @param string $text |
2092 | * @param string $breaktype What kind of line break to use; defaults to self::$LE |
2093 | * |
2094 | * @return string |
2095 | * |
2096 | * @since 1.0.0 |
2097 | */ |
2098 | private static function normalizeBreaks(string $text, string $breaktype) : string |
2099 | { |
2100 | $text = \str_replace(["\r\n", "\r"], "\n", $text); |
2101 | |
2102 | if ($breaktype !== "\n") { |
2103 | $text = \str_replace("\n", $breaktype, $text); |
2104 | } |
2105 | |
2106 | return $text; |
2107 | } |
2108 | |
2109 | /** |
2110 | * Convert an HTML string into plain text. |
2111 | * |
2112 | * @param string $html The HTML text to convert |
2113 | * @param null|\Closure $advanced Internal or external text to html converter |
2114 | * |
2115 | * @return string |
2116 | * |
2117 | * @since 1.0.0 |
2118 | */ |
2119 | private function html2text(string $html, \Closure $advanced = null) : string |
2120 | { |
2121 | if ($advanced !== null) { |
2122 | return $advanced($html); |
2123 | } |
2124 | |
2125 | return \html_entity_decode( |
2126 | \trim(\strip_tags(\preg_replace('/<(head|title|style|script)[^>]*>.*?<\/\\1>/si', '', $html))), |
2127 | \ENT_QUOTES, |
2128 | $this->charset |
2129 | ); |
2130 | } |
2131 | |
2132 | /** |
2133 | * Set the public and private key files and password for S/MIME signing. |
2134 | * |
2135 | * @param string $certFile Certification file |
2136 | * @param string $keyFile Key file |
2137 | * @param string $keyPass Password for private key |
2138 | * @param string $extracertsFile Optional path to chain certificate |
2139 | * |
2140 | * @return void |
2141 | * |
2142 | * @since 1.0.0 |
2143 | */ |
2144 | public function sign($certFile, $keyFile, $keyPass, $extracertsFile = '') : void |
2145 | { |
2146 | $this->signCertFile = $certFile; |
2147 | $this->signKeyFile = $keyFile; |
2148 | $this->signKeyPass = $keyPass; |
2149 | $this->signExtracertFiles = $extracertsFile; |
2150 | } |
2151 | |
2152 | /** |
2153 | * Quoted-Printable-encode a DKIM header. |
2154 | * |
2155 | * @param string $txt Text |
2156 | * |
2157 | * @return string |
2158 | * |
2159 | * @since 1.0.0 |
2160 | */ |
2161 | public function dkimQP(string $txt) : string |
2162 | { |
2163 | $line = ''; |
2164 | $len = \strlen($txt); |
2165 | |
2166 | for ($i = 0; $i < $len; ++$i) { |
2167 | $ord = \ord($txt[$i]); |
2168 | $line .= (($ord >= 0x21) && ($ord <= 0x3A)) || $ord === 0x3C || (($ord >= 0x3E) && ($ord <= 0x7E)) |
2169 | ? $txt[$i] |
2170 | : '=' . \sprintf('%02X', $ord); |
2171 | } |
2172 | |
2173 | return $line; |
2174 | } |
2175 | |
2176 | /** |
2177 | * Generate a DKIM signature. |
2178 | * |
2179 | * @param string $signHeader |
2180 | * |
2181 | * @return string The DKIM signature value |
2182 | * |
2183 | * @since 1.0.0 |
2184 | */ |
2185 | public function dkimSign(string $signHeader) : string |
2186 | { |
2187 | if (!\defined('PKCS7_TEXT')) { |
2188 | return ''; |
2189 | } |
2190 | |
2191 | $privKeyStr = empty($this->dkimPrivateKey) |
2192 | ? \file_get_contents($this->dkimPrivatePath) |
2193 | : $this->dkimPrivateKey; |
2194 | |
2195 | $privKey = $this->dkimPass === '' |
2196 | ? \openssl_pkey_get_private($privKeyStr) |
2197 | : \openssl_pkey_get_private($privKeyStr, $this->dkimPass); |
2198 | |
2199 | return \openssl_sign($signHeader, $signature, $privKey, 'sha256WithRSAEncryption') |
2200 | ? \base64_encode($signature) |
2201 | : ''; |
2202 | } |
2203 | |
2204 | /** |
2205 | * Generate a DKIM canonicalization header. |
2206 | * |
2207 | * @param string $signHeader Header |
2208 | * |
2209 | * @return string |
2210 | * |
2211 | * @since 1.0.0 |
2212 | */ |
2213 | public function dkimHeaderC(string $signHeader) : string |
2214 | { |
2215 | $signHeader = self::normalizeBreaks($signHeader, "\r\n"); |
2216 | $signHeader = \preg_replace('/\r\n[ \t]+/', ' ', $signHeader); |
2217 | $lines = \explode("\r\n", $signHeader); |
2218 | |
2219 | foreach ($lines as $key => $line) { |
2220 | if (\strpos($line, ':') === false) { |
2221 | continue; |
2222 | } |
2223 | |
2224 | list($heading, $value) = \explode(':', $line, 2); |
2225 | $heading = \strtolower($heading); |
2226 | $value = \preg_replace('/[ \t]+/', ' ', $value); |
2227 | |
2228 | $lines[$key] = \trim($heading, " \t") . ':' . \trim($value, " \t"); |
2229 | } |
2230 | |
2231 | return \implode("\r\n", $lines); |
2232 | } |
2233 | |
2234 | /** |
2235 | * Generate a DKIM canonicalization body. |
2236 | * |
2237 | * @param string $body Message Body |
2238 | * |
2239 | * @return string |
2240 | * |
2241 | * @since 1.0.0 |
2242 | */ |
2243 | public function dkimBodyC(string $body) : string |
2244 | { |
2245 | if (empty($body)) { |
2246 | return "\r\n"; |
2247 | } |
2248 | |
2249 | $body = self::normalizeBreaks($body, "\r\n"); |
2250 | |
2251 | return \rtrim($body, " \r\n\t") . "\r\n"; |
2252 | } |
2253 | |
2254 | /** |
2255 | * Create the DKIM header and body in a new message header. |
2256 | * |
2257 | * @param string $headersLine Header lines |
2258 | * @param string $subject Subject |
2259 | * @param string $body Body |
2260 | * |
2261 | * @return string |
2262 | * |
2263 | * @since 1.0.0 |
2264 | */ |
2265 | public function dkimAdd(string $headersLine, string $subject, string $body) : string |
2266 | { |
2267 | $DKIMsignatureType = 'rsa-sha256'; |
2268 | $DKIMcanonicalization = 'relaxed/simple'; |
2269 | $DKIMquery = 'dns/txt'; |
2270 | $DKIMtime = \time(); |
2271 | |
2272 | $autoSignHeaders = [ |
2273 | 'from', |
2274 | 'to', |
2275 | 'cc', |
2276 | 'date', |
2277 | 'subject', |
2278 | 'reply-to', |
2279 | 'message-id', |
2280 | 'content-type', |
2281 | 'mime-version', |
2282 | 'x-mailer', |
2283 | ]; |
2284 | |
2285 | if (\stripos($headersLine, 'Subject') === false) { |
2286 | $headersLine .= 'Subject: ' . $subject . self::$LE; |
2287 | } |
2288 | |
2289 | $headerLines = \explode(self::$LE, $headersLine); |
2290 | $currentHeaderLabel = ''; |
2291 | $currentHeaderValue = ''; |
2292 | $parsedHeaders = []; |
2293 | $headerLineIndex = 0; |
2294 | $headerLineCount = \count($headerLines); |
2295 | |
2296 | foreach ($headerLines as $headerLine) { |
2297 | $matches = []; |
2298 | if (\preg_match('/^([^ \t]*?)(?::[ \t]*)(.*)$/', $headerLine, $matches)) { |
2299 | if ($currentHeaderLabel !== '') { |
2300 | $parsedHeaders[] = ['label' => $currentHeaderLabel, 'value' => $currentHeaderValue]; |
2301 | } |
2302 | |
2303 | $currentHeaderLabel = $matches[1]; |
2304 | $currentHeaderValue = $matches[2]; |
2305 | } elseif (\preg_match('/^[ \t]+(.*)$/', $headerLine, $matches)) { |
2306 | $currentHeaderValue .= ' ' . $matches[1]; |
2307 | } |
2308 | |
2309 | ++$headerLineIndex; |
2310 | |
2311 | if ($headerLineIndex >= $headerLineCount) { |
2312 | $parsedHeaders[] = ['label' => $currentHeaderLabel, 'value' => $currentHeaderValue]; |
2313 | } |
2314 | } |
2315 | |
2316 | $copiedHeaders = []; |
2317 | $headersToSignKeys = []; |
2318 | $headersToSign = []; |
2319 | |
2320 | foreach ($parsedHeaders as $header) { |
2321 | if (\in_array(\strtolower($header['label']), $autoSignHeaders, true)) { |
2322 | $headersToSignKeys[] = $header['label']; |
2323 | $headersToSign[] = $header['label'] . ': ' . $header['value']; |
2324 | |
2325 | if ($this->dkimCopyHeader) { |
2326 | $copiedHeaders[] = $header['label'] . ':' |
2327 | . \str_replace('|', '=7C', $this->dkimQP($header['value'])); |
2328 | } |
2329 | |
2330 | continue; |
2331 | } |
2332 | |
2333 | if (\in_array($header['label'], $this->dkimHeaders, true)) { |
2334 | foreach ($this->customHeader as $customHeader) { |
2335 | if ($customHeader[0] === $header['label']) { |
2336 | $headersToSignKeys[] = $header['label']; |
2337 | $headersToSign[] = $header['label'] . ': ' . $header['value']; |
2338 | |
2339 | if ($this->dkimCopyHeader) { |
2340 | $copiedHeaders[] = $header['label'] . ':' |
2341 | . \str_replace('|', '=7C', $this->dkimQP($header['value'])); |
2342 | } |
2343 | |
2344 | continue 2; |
2345 | } |
2346 | } |
2347 | } |
2348 | } |
2349 | |
2350 | $copiedHeaderFields = ''; |
2351 | if ($this->dkimCopyHeader && !empty($copiedHeaders)) { |
2352 | $copiedHeaderFields = ' z='; |
2353 | $first = true; |
2354 | |
2355 | foreach ($copiedHeaders as $copiedHeader) { |
2356 | if (!$first) { |
2357 | $copiedHeaderFields .= self::$LE . ' |'; |
2358 | } |
2359 | |
2360 | $copiedHeaderFields .= \strlen($copiedHeader) > self::STD_LINE_LENGTH - 3 |
2361 | ? \substr( |
2362 | \chunk_split($copiedHeader, self::STD_LINE_LENGTH - 3, self::$LE . self::FWS), |
2363 | 0, |
2364 | -\strlen(self::$LE . self::FWS) |
2365 | ) |
2366 | : $copiedHeader; |
2367 | |
2368 | $first = false; |
2369 | } |
2370 | |
2371 | $copiedHeaderFields .= ';' . self::$LE; |
2372 | } |
2373 | |
2374 | $headerKeys = ' h=' . \implode(':', $headersToSignKeys) . ';' . self::$LE; |
2375 | $headerValues = \implode(self::$LE, $headersToSign); |
2376 | $body = $this->dkimBodyC($body); |
2377 | $DKIMb64 = \base64_encode(\pack('H*', \hash('sha256', $body))); |
2378 | $ident = ''; |
2379 | |
2380 | if ($this->dkimIdentity !== '') { |
2381 | $ident = ' i=' . $this->dkimIdentity . ';' . self::$LE; |
2382 | } |
2383 | |
2384 | $dkimSignatureHeader = 'DKIM-Signature: v=1;' |
2385 | . ' d=' . $this->dkimDomain . ';' |
2386 | . ' s=' . $this->dkimSelector . ';' . self::$LE |
2387 | . ' a=' . $DKIMsignatureType . ';' |
2388 | . ' q=' . $DKIMquery . ';' |
2389 | . ' t=' . $DKIMtime . ';' |
2390 | . ' c=' . $DKIMcanonicalization . ';' . self::$LE |
2391 | . $headerKeys . $ident . $copiedHeaderFields |
2392 | . ' bh=' . $DKIMb64 . ';' . self::$LE |
2393 | . ' b='; |
2394 | |
2395 | $canonicalizedHeaders = $this->dkimHeaderC( |
2396 | $headerValues . self::$LE . $dkimSignatureHeader |
2397 | ); |
2398 | |
2399 | $signature = $this->dkimSign($canonicalizedHeaders); |
2400 | $signature = \trim(\chunk_split($signature, self::STD_LINE_LENGTH - 3, self::$LE . self::FWS)); |
2401 | |
2402 | return self::normalizeBreaks($dkimSignatureHeader . $signature, self::$LE); |
2403 | } |
2404 | } |