blob: 6502b57ec8b5e603d9b9fbd8fcffb60fbb091ca4 [file] [log] [blame]
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +01001<?php
2
3namespace PhpMimeMailParser;
4
5use PhpMimeMailParser\Contracts\CharsetManager;
6
7/**
8 * Parser of php-mime-mail-parser
9 *
10 * Fully Tested Mailparse Extension Wrapper for PHP 5.4+
11 *
12 */
13class Parser
14{
15 /**
16 * Attachment filename argument option for ->saveAttachments().
17 */
18 const ATTACHMENT_DUPLICATE_THROW = 'DuplicateThrow';
19 const ATTACHMENT_DUPLICATE_SUFFIX = 'DuplicateSuffix';
20 const ATTACHMENT_RANDOM_FILENAME = 'RandomFilename';
21
22 /**
23 * PHP MimeParser Resource ID
24 *
25 * @var resource $resource
26 */
27 protected $resource;
28
29 /**
30 * A file pointer to email
31 *
32 * @var resource $stream
33 */
34 protected $stream;
35
36 /**
37 * A text of an email
38 *
39 * @var string $data
40 */
41 protected $data;
42
43 /**
44 * Parts of an email
45 *
46 * @var array $parts
47 */
48 protected $parts;
49
50 /**
51 * @var CharsetManager object
52 */
53 protected $charset;
54
55 /**
56 * Valid stream modes for reading
57 *
58 * @var array
59 */
60 protected static $readableModes = [
61 'r', 'r+', 'w+', 'a+', 'x+', 'c+', 'rb', 'r+b', 'w+b', 'a+b',
62 'x+b', 'c+b', 'rt', 'r+t', 'w+t', 'a+t', 'x+t', 'c+t'
63 ];
64
65 /**
66 * Stack of middleware registered to process data
67 *
68 * @var MiddlewareStack
69 */
70 protected $middlewareStack;
71
72 /**
73 * Parser constructor.
74 *
75 * @param CharsetManager|null $charset
76 */
77 public function __construct(CharsetManager $charset = null)
78 {
79 if ($charset == null) {
80 $charset = new Charset();
81 }
82
83 $this->charset = $charset;
84 $this->middlewareStack = new MiddlewareStack();
85 }
86
87 /**
88 * Free the held resources
89 *
90 * @return void
91 */
92 public function __destruct()
93 {
94 // clear the email file resource
95 if (is_resource($this->stream)) {
96 fclose($this->stream);
97 }
98 // clear the MailParse resource
99 if (is_resource($this->resource)) {
100 mailparse_msg_free($this->resource);
101 }
102 }
103
104 /**
105 * Set the file path we use to get the email text
106 *
107 * @param string $path File path to the MIME mail
108 *
109 * @return Parser MimeMailParser Instance
110 */
111 public function setPath($path)
112 {
113 if (is_writable($path)) {
114 $file = fopen($path, 'a+');
115 fseek($file, -1, SEEK_END);
116 if (fread($file, 1) != "\n") {
117 fwrite($file, PHP_EOL);
118 }
119 fclose($file);
120 }
121
122 // should parse message incrementally from file
123 $this->resource = mailparse_msg_parse_file($path);
124 $this->stream = fopen($path, 'r');
125 $this->parse();
126
127 return $this;
128 }
129
130 /**
131 * Set the Stream resource we use to get the email text
132 *
133 * @param resource $stream
134 *
135 * @return Parser MimeMailParser Instance
136 * @throws Exception
137 */
138 public function setStream($stream)
139 {
140 // streams have to be cached to file first
141 $meta = @stream_get_meta_data($stream);
142 if (!$meta || !$meta['mode'] || !in_array($meta['mode'], self::$readableModes, true) || $meta['eof']) {
143 throw new Exception(
144 'setStream() expects parameter stream to be readable stream resource.'
145 );
146 }
147
148 /** @var resource $tmp_fp */
149 $tmp_fp = tmpfile();
150 if ($tmp_fp) {
151 while (!feof($stream)) {
152 fwrite($tmp_fp, fread($stream, 2028));
153 }
154
155 if (fread($tmp_fp, 1) != "\n") {
156 fwrite($tmp_fp, PHP_EOL);
157 }
158
159 fseek($tmp_fp, 0);
160 $this->stream = &$tmp_fp;
161 } else {
162 throw new Exception(
163 'Could not create temporary files for attachments. Your tmp directory may be unwritable by PHP.'
164 );
165 }
166 fclose($stream);
167
168 $this->resource = mailparse_msg_create();
169 // parses the message incrementally (low memory usage but slower)
170 while (!feof($this->stream)) {
171 mailparse_msg_parse($this->resource, fread($this->stream, 2082));
172 }
173 $this->parse();
174
175 return $this;
176 }
177
178 /**
179 * Set the email text
180 *
181 * @param string $data
182 *
183 * @return Parser MimeMailParser Instance
184 */
185 public function setText($data)
186 {
187 if (empty($data)) {
188 throw new Exception('You must not call MimeMailParser::setText with an empty string parameter');
189 }
190
191 if (substr($data, -1) != "\n") {
192 $data = $data.PHP_EOL;
193 }
194
195 $this->resource = mailparse_msg_create();
196 // does not parse incrementally, fast memory hog might explode
197 mailparse_msg_parse($this->resource, $data);
198 $this->data = $data;
199 $this->parse();
200
201 return $this;
202 }
203
204 /**
205 * Parse the Message into parts
206 *
207 * @return void
208 */
209 protected function parse()
210 {
211 $structure = mailparse_msg_get_structure($this->resource);
212 $this->parts = [];
213 foreach ($structure as $part_id) {
214 $part = mailparse_msg_get_part($this->resource, $part_id);
215 $part_data = mailparse_msg_get_part_data($part);
216 $mimePart = new MimePart($part_id, $part_data);
217 // let each middleware parse the part before saving
218 $this->parts[$part_id] = $this->middlewareStack->parse($mimePart)->getPart();
219 }
220 }
221
222 /**
223 * Retrieve a specific Email Header, without charset conversion.
224 *
225 * @param string $name Header name (case-insensitive)
226 *
227 * @return string|bool
228 * @throws Exception
229 */
230 public function getRawHeader($name)
231 {
232 $name = strtolower($name);
233 if (isset($this->parts[1])) {
234 $headers = $this->getPart('headers', $this->parts[1]);
235
236 return isset($headers[$name]) ? $headers[$name] : false;
237 } else {
238 throw new Exception(
239 'setPath() or setText() or setStream() must be called before retrieving email headers.'
240 );
241 }
242 }
243
244 /**
245 * Retrieve a specific Email Header
246 *
247 * @param string $name Header name (case-insensitive)
248 *
249 * @return string|bool
250 */
251 public function getHeader($name)
252 {
253 $rawHeader = $this->getRawHeader($name);
254 if ($rawHeader === false) {
255 return false;
256 }
257
258 return $this->decodeHeader($rawHeader);
259 }
260
261 /**
262 * Retrieve all mail headers
263 *
264 * @return array
265 * @throws Exception
266 */
267 public function getHeaders()
268 {
269 if (isset($this->parts[1])) {
270 $headers = $this->getPart('headers', $this->parts[1]);
271 foreach ($headers as &$value) {
272 if (is_array($value)) {
273 foreach ($value as &$v) {
274 $v = $this->decodeSingleHeader($v);
275 }
276 } else {
277 $value = $this->decodeSingleHeader($value);
278 }
279 }
280
281 return $headers;
282 } else {
283 throw new Exception(
284 'setPath() or setText() or setStream() must be called before retrieving email headers.'
285 );
286 }
287 }
288
289 /**
290 * Retrieve the raw mail headers as a string
291 *
292 * @return string
293 * @throws Exception
294 */
295 public function getHeadersRaw()
296 {
297 if (isset($this->parts[1])) {
298 return $this->getPartHeader($this->parts[1]);
299 } else {
300 throw new Exception(
301 'setPath() or setText() or setStream() must be called before retrieving email headers.'
302 );
303 }
304 }
305
306 /**
307 * Retrieve the raw Header of a MIME part
308 *
309 * @return String
310 * @param $part Object
311 * @throws Exception
312 */
313 protected function getPartHeader(&$part)
314 {
315 $header = '';
316 if ($this->stream) {
317 $header = $this->getPartHeaderFromFile($part);
318 } elseif ($this->data) {
319 $header = $this->getPartHeaderFromText($part);
320 }
321 return $header;
322 }
323
324 /**
325 * Retrieve the Header from a MIME part from file
326 *
327 * @return String Mime Header Part
328 * @param $part Array
329 */
330 protected function getPartHeaderFromFile(&$part)
331 {
332 $start = $part['starting-pos'];
333 $end = $part['starting-pos-body'];
334 fseek($this->stream, $start, SEEK_SET);
335 $header = fread($this->stream, $end - $start);
336 return $header;
337 }
338
339 /**
340 * Retrieve the Header from a MIME part from text
341 *
342 * @return String Mime Header Part
343 * @param $part Array
344 */
345 protected function getPartHeaderFromText(&$part)
346 {
347 $start = $part['starting-pos'];
348 $end = $part['starting-pos-body'];
349 $header = substr($this->data, $start, $end - $start);
350 return $header;
351 }
352
353 /**
354 * Checks whether a given part ID is a child of another part
355 * eg. an RFC822 attachment may have one or more text parts
356 *
357 * @param string $partId
358 * @param string $parentPartId
359 * @return bool
360 */
361 protected function partIdIsChildOfPart($partId, $parentPartId)
362 {
363 $parentPartId = $parentPartId.'.';
364 return substr($partId, 0, strlen($parentPartId)) == $parentPartId;
365 }
366
367 /**
368 * Whether the given part ID is a child of any attachment part in the message.
369 *
370 * @param string $checkPartId
371 * @return bool
372 */
373 protected function partIdIsChildOfAnAttachment($checkPartId)
374 {
375 foreach ($this->parts as $partId => $part) {
376 if ($this->getPart('content-disposition', $part) == 'attachment') {
377 if ($this->partIdIsChildOfPart($checkPartId, $partId)) {
378 return true;
379 }
380 }
381 }
382 return false;
383 }
384
385 /**
386 * Returns the email message body in the specified format
387 *
388 * @param string $type text, html or htmlEmbedded
389 *
390 * @return string Body
391 * @throws Exception
392 */
393 public function getMessageBody($type = 'text')
394 {
395 $mime_types = [
396 'text' => 'text/plain',
397 'html' => 'text/html',
398 'htmlEmbedded' => 'text/html',
399 ];
400
401 if (in_array($type, array_keys($mime_types))) {
402 $part_type = $type === 'htmlEmbedded' ? 'html' : $type;
403 $inline_parts = $this->getInlineParts($part_type);
404 $body = empty($inline_parts) ? '' : $inline_parts[0];
405 } else {
406 throw new Exception(
407 'Invalid type specified for getMessageBody(). Expected: text, html or htmlEmbeded.'
408 );
409 }
410
411 if ($type == 'htmlEmbedded') {
412 $attachments = $this->getAttachments();
413 foreach ($attachments as $attachment) {
414 if ($attachment->getContentID() != '') {
415 $body = str_replace(
416 '"cid:'.$attachment->getContentID().'"',
417 '"'.$this->getEmbeddedData($attachment->getContentID()).'"',
418 $body
419 );
420 }
421 }
422 }
423
424 return $body;
425 }
426
427 /**
428 * Returns the embedded data structure
429 *
430 * @param string $contentId Content-Id
431 *
432 * @return string
433 */
434 protected function getEmbeddedData($contentId)
435 {
436 foreach ($this->parts as $part) {
437 if ($this->getPart('content-id', $part) == $contentId) {
438 $embeddedData = 'data:';
439 $embeddedData .= $this->getPart('content-type', $part);
440 $embeddedData .= ';'.$this->getPart('transfer-encoding', $part);
441 $embeddedData .= ','.$this->getPartBody($part);
442 return $embeddedData;
443 }
444 }
445 return '';
446 }
447
448 /**
449 * Return an array with the following keys display, address, is_group
450 *
451 * @param string $name Header name (case-insensitive)
452 *
453 * @return array
454 */
455 public function getAddresses($name)
456 {
457 $value = $this->getRawHeader($name);
458 $value = (is_array($value)) ? $value[0] : $value;
459 $addresses = mailparse_rfc822_parse_addresses($value);
460 foreach ($addresses as $i => $item) {
461 $addresses[$i]['display'] = $this->decodeHeader($item['display']);
462 }
463 return $addresses;
464 }
465
466 /**
467 * Returns the attachments contents in order of appearance
468 *
469 * @return Attachment[]
470 */
471 public function getInlineParts($type = 'text')
472 {
473 $inline_parts = [];
474 $mime_types = [
475 'text' => 'text/plain',
476 'html' => 'text/html',
477 ];
478
479 if (!in_array($type, array_keys($mime_types))) {
480 throw new Exception('Invalid type specified for getInlineParts(). "type" can either be text or html.');
481 }
482
483 foreach ($this->parts as $partId => $part) {
484 if ($this->getPart('content-type', $part) == $mime_types[$type]
485 && $this->getPart('content-disposition', $part) != 'attachment'
486 && !$this->partIdIsChildOfAnAttachment($partId)
487 ) {
488 $headers = $this->getPart('headers', $part);
489 $encodingType = array_key_exists('content-transfer-encoding', $headers) ?
490 $headers['content-transfer-encoding'] : '';
491 $undecoded_body = $this->decodeContentTransfer($this->getPartBody($part), $encodingType);
492 $inline_parts[] = $this->charset->decodeCharset($undecoded_body, $this->getPartCharset($part));
493 }
494 }
495
496 return $inline_parts;
497 }
498
499 /**
500 * Returns the attachments contents in order of appearance
501 *
502 * @return Attachment[]
503 */
504 public function getAttachments($include_inline = true)
505 {
506 $attachments = [];
507 $dispositions = $include_inline ? ['attachment', 'inline'] : ['attachment'];
508 $non_attachment_types = ['text/plain', 'text/html'];
509 $nonameIter = 0;
510
511 foreach ($this->parts as $part) {
512 $disposition = $this->getPart('content-disposition', $part);
513 $filename = 'noname';
514
515 if (isset($part['disposition-filename'])) {
516 $filename = $this->decodeHeader($part['disposition-filename']);
517 } elseif (isset($part['content-name'])) {
518 // if we have no disposition but we have a content-name, it's a valid attachment.
519 // we simulate the presence of an attachment disposition with a disposition filename
520 $filename = $this->decodeHeader($part['content-name']);
521 $disposition = 'attachment';
522 } elseif (in_array($part['content-type'], $non_attachment_types, true)
523 && $disposition !== 'attachment') {
524 // it is a message body, no attachment
525 continue;
526 } elseif (substr($part['content-type'], 0, 10) !== 'multipart/'
527 && $part['content-type'] !== 'text/plain; (error)') {
528 // if we cannot get it by getMessageBody(), we assume it is an attachment
529 $disposition = 'attachment';
530 }
531 if (in_array($disposition, ['attachment', 'inline']) === false && !empty($disposition)) {
532 $disposition = 'attachment';
533 }
534
535 if (in_array($disposition, $dispositions) === true) {
536 if ($filename == 'noname') {
537 $nonameIter++;
538 $filename = 'noname'.$nonameIter;
539 } else {
540 // Escape all potentially unsafe characters from the filename
541 $filename = preg_replace('((^\.)|\/|[\n|\r|\n\r]|(\.$))', '_', $filename);
542 }
543
544 $headersAttachments = $this->getPart('headers', $part);
545 $contentidAttachments = $this->getPart('content-id', $part);
546
547 $attachmentStream = $this->getAttachmentStream($part);
548 $mimePartStr = $this->getPartComplete($part);
549
550 $attachments[] = new Attachment(
551 $filename,
552 $this->getPart('content-type', $part),
553 $attachmentStream,
554 $disposition,
555 $contentidAttachments,
556 $headersAttachments,
557 $mimePartStr
558 );
559 }
560 }
561
562 return $attachments;
563 }
564
565 /**
566 * Save attachments in a folder
567 *
568 * @param string $attach_dir directory
569 * @param bool $include_inline
570 * @param string $filenameStrategy How to generate attachment filenames
571 *
572 * @return array Saved attachments paths
573 * @throws Exception
574 */
575 public function saveAttachments(
576 $attach_dir,
577 $include_inline = true,
578 $filenameStrategy = self::ATTACHMENT_DUPLICATE_SUFFIX
579 ) {
580 $attachments = $this->getAttachments($include_inline);
581
582 $attachments_paths = [];
583 foreach ($attachments as $attachment) {
584 $attachments_paths[] = $attachment->save($attach_dir, $filenameStrategy);
585 }
586
587 return $attachments_paths;
588 }
589
590 /**
591 * Read the attachment Body and save temporary file resource
592 *
593 * @param array $part
594 *
595 * @return resource Mime Body Part
596 * @throws Exception
597 */
598 protected function getAttachmentStream(&$part)
599 {
600 /** @var resource $temp_fp */
601 $temp_fp = tmpfile();
602
603 $headers = $this->getPart('headers', $part);
604 $encodingType = array_key_exists('content-transfer-encoding', $headers) ?
605 $headers['content-transfer-encoding'] : '';
606
607 if ($temp_fp) {
608 if ($this->stream) {
609 $start = $part['starting-pos-body'];
610 $end = $part['ending-pos-body'];
611 fseek($this->stream, $start, SEEK_SET);
612 $len = $end - $start;
613 $written = 0;
614 while ($written < $len) {
615 $write = $len;
616 $data = fread($this->stream, $write);
617 fwrite($temp_fp, $this->decodeContentTransfer($data, $encodingType));
618 $written += $write;
619 }
620 } elseif ($this->data) {
621 $attachment = $this->decodeContentTransfer($this->getPartBodyFromText($part), $encodingType);
622 fwrite($temp_fp, $attachment, strlen($attachment));
623 }
624 fseek($temp_fp, 0, SEEK_SET);
625 } else {
626 throw new Exception(
627 'Could not create temporary files for attachments. Your tmp directory may be unwritable by PHP.'
628 );
629 }
630
631 return $temp_fp;
632 }
633
634 /**
635 * Decode the string from Content-Transfer-Encoding
636 *
637 * @param string $encodedString The string in its original encoded state
638 * @param string $encodingType The encoding type from the Content-Transfer-Encoding header of the part.
639 *
640 * @return string The decoded string
641 */
642 protected function decodeContentTransfer($encodedString, $encodingType)
643 {
644 if (is_array($encodingType)) {
645 $encodingType = $encodingType[0];
646 }
647
648 $encodingType = strtolower($encodingType);
649 if ($encodingType == 'base64') {
650 return base64_decode($encodedString);
651 } elseif ($encodingType == 'quoted-printable') {
652 return quoted_printable_decode($encodedString);
653 } else {
654 return $encodedString;
655 }
656 }
657
658 /**
659 * $input can be a string or array
660 *
661 * @param string|array $input
662 *
663 * @return string
664 */
665 protected function decodeHeader($input)
666 {
667 //Sometimes we have 2 label From so we take only the first
668 if (is_array($input)) {
669 return $this->decodeSingleHeader($input[0]);
670 }
671
672 return $this->decodeSingleHeader($input);
673 }
674
675 /**
676 * Decodes a single header (= string)
677 *
678 * @param string $input
679 *
680 * @return string
681 */
682 protected function decodeSingleHeader($input)
683 {
684 // For each encoded-word...
685 while (preg_match('/(=\?([^?]+)\?(q|b)\?([^?]*)\?=)((\s+)=\?)?/i', $input, $matches)) {
686 $encoded = $matches[1];
687 $charset = $matches[2];
688 $encoding = $matches[3];
689 $text = $matches[4];
690 $space = isset($matches[6]) ? $matches[6] : '';
691
692 switch (strtolower($encoding)) {
693 case 'b':
694 $text = $this->decodeContentTransfer($text, 'base64');
695 break;
696
697 case 'q':
698 $text = str_replace('_', ' ', $text);
699 preg_match_all('/=([a-f0-9]{2})/i', $text, $matches);
700 foreach ($matches[1] as $value) {
701 $text = str_replace('='.$value, chr(hexdec($value)), $text);
702 }
703 break;
704 }
705
706 $text = $this->charset->decodeCharset($text, $this->charset->getCharsetAlias($charset));
707 $input = str_replace($encoded.$space, $text, $input);
708 }
709
710 return $input;
711 }
712
713 /**
714 * Return the charset of the MIME part
715 *
716 * @param array $part
717 *
718 * @return string
719 */
720 protected function getPartCharset($part)
721 {
722 if (isset($part['charset'])) {
723 return $this->charset->getCharsetAlias($part['charset']);
724 } else {
725 return 'us-ascii';
726 }
727 }
728
729 /**
730 * Retrieve a specified MIME part
731 *
732 * @param string $type
733 * @param array $parts
734 *
735 * @return string|array
736 */
737 protected function getPart($type, $parts)
738 {
739 return (isset($parts[$type])) ? $parts[$type] : false;
740 }
741
742 /**
743 * Retrieve the Body of a MIME part
744 *
745 * @param array $part
746 *
747 * @return string
748 */
749 protected function getPartBody(&$part)
750 {
751 $body = '';
752 if ($this->stream) {
753 $body = $this->getPartBodyFromFile($part);
754 } elseif ($this->data) {
755 $body = $this->getPartBodyFromText($part);
756 }
757
758 return $body;
759 }
760
761 /**
762 * Retrieve the Body from a MIME part from file
763 *
764 * @param array $part
765 *
766 * @return string Mime Body Part
767 */
768 protected function getPartBodyFromFile(&$part)
769 {
770 $start = $part['starting-pos-body'];
771 $end = $part['ending-pos-body'];
772 $body = '';
773 if ($end - $start > 0) {
774 fseek($this->stream, $start, SEEK_SET);
775 $body = fread($this->stream, $end - $start);
776 }
777
778 return $body;
779 }
780
781 /**
782 * Retrieve the Body from a MIME part from text
783 *
784 * @param array $part
785 *
786 * @return string Mime Body Part
787 */
788 protected function getPartBodyFromText(&$part)
789 {
790 $start = $part['starting-pos-body'];
791 $end = $part['ending-pos-body'];
792
793 return substr($this->data, $start, $end - $start);
794 }
795
796 /**
797 * Retrieve the content of a MIME part
798 *
799 * @param array $part
800 *
801 * @return string
802 */
803 protected function getPartComplete(&$part)
804 {
805 $body = '';
806 if ($this->stream) {
807 $body = $this->getPartFromFile($part);
808 } elseif ($this->data) {
809 $body = $this->getPartFromText($part);
810 }
811
812 return $body;
813 }
814
815 /**
816 * Retrieve the content from a MIME part from file
817 *
818 * @param array $part
819 *
820 * @return string Mime Content
821 */
822 protected function getPartFromFile(&$part)
823 {
824 $start = $part['starting-pos'];
825 $end = $part['ending-pos'];
826 $body = '';
827 if ($end - $start > 0) {
828 fseek($this->stream, $start, SEEK_SET);
829 $body = fread($this->stream, $end - $start);
830 }
831
832 return $body;
833 }
834
835 /**
836 * Retrieve the content from a MIME part from text
837 *
838 * @param array $part
839 *
840 * @return string Mime Content
841 */
842 protected function getPartFromText(&$part)
843 {
844 $start = $part['starting-pos'];
845 $end = $part['ending-pos'];
846
847 return substr($this->data, $start, $end - $start);
848 }
849
850 /**
851 * Retrieve the resource
852 *
853 * @return resource resource
854 */
855 public function getResource()
856 {
857 return $this->resource;
858 }
859
860 /**
861 * Retrieve the file pointer to email
862 *
863 * @return resource stream
864 */
865 public function getStream()
866 {
867 return $this->stream;
868 }
869
870 /**
871 * Retrieve the text of an email
872 *
873 * @return string data
874 */
875 public function getData()
876 {
877 return $this->data;
878 }
879
880 /**
881 * Retrieve the parts of an email
882 *
883 * @return array parts
884 */
885 public function getParts()
886 {
887 return $this->parts;
888 }
889
890 /**
891 * Retrieve the charset manager object
892 *
893 * @return CharsetManager charset
894 */
895 public function getCharset()
896 {
897 return $this->charset;
898 }
899
900 /**
901 * Add a middleware to the parser MiddlewareStack
902 * Each middleware is invoked when:
903 * a MimePart is retrieved by mailparse_msg_get_part_data() during $this->parse()
904 * The middleware will receive MimePart $part and the next MiddlewareStack $next
905 *
906 * Eg:
907 *
908 * $Parser->addMiddleware(function(MimePart $part, MiddlewareStack $next) {
909 * // do something with the $part
910 * return $next($part);
911 * });
912 *
913 * @param callable $middleware Plain Function or Middleware Instance to execute
914 * @return void
915 */
916 public function addMiddleware(callable $middleware)
917 {
918 if (!$middleware instanceof Middleware) {
919 $middleware = new Middleware($middleware);
920 }
921 $this->middlewareStack = $this->middlewareStack->add($middleware);
922 }
923}