@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?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)) {
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 if (!$this->resource) {
212 throw new Exception(
213 'MIME message cannot be parsed'
214 );
215 }
216 $structure = mailparse_msg_get_structure($this->resource);
217 $this->parts = [];
218 foreach ($structure as $part_id) {
219 $part = mailparse_msg_get_part($this->resource, $part_id);
220 $part_data = mailparse_msg_get_part_data($part);
221 $mimePart = new MimePart($part_id, $part_data);
222 // let each middleware parse the part before saving
223 $this->parts[$part_id] = $this->middlewareStack->parse($mimePart)->getPart();
224 }
225 }
226
227 /**
228 * Retrieve a specific Email Header, without charset conversion.
229 *
230 * @param string $name Header name (case-insensitive)
231 *
232 * @return string|bool
233 * @throws Exception
234 */
235 public function getRawHeader($name)
236 {
237 $name = strtolower($name);
238 if (isset($this->parts[1])) {
239 $headers = $this->getPart('headers', $this->parts[1]);
240
241 return isset($headers[$name]) ? $headers[$name] : false;
242 } else {
243 throw new Exception(
244 'setPath() or setText() or setStream() must be called before retrieving email headers.'
245 );
246 }
247 }
248
249 /**
250 * Retrieve a specific Email Header
251 *
252 * @param string $name Header name (case-insensitive)
253 *
254 * @return string|false
255 */
256 public function getHeader($name)
257 {
258 $rawHeader = $this->getRawHeader($name);
259 if ($rawHeader === false) {
260 return false;
261 }
262
263 return $this->decodeHeader($rawHeader);
264 }
265
266 /**
267 * Retrieve all mail headers
268 *
269 * @return array
270 * @throws Exception
271 */
272 public function getHeaders()
273 {
274 if (isset($this->parts[1])) {
275 $headers = $this->getPart('headers', $this->parts[1]);
276 foreach ($headers as &$value) {
277 if (is_array($value)) {
278 foreach ($value as &$v) {
279 $v = $this->decodeSingleHeader($v);
280 }
281 } else {
282 $value = $this->decodeSingleHeader($value);
283 }
284 }
285
286 return $headers;
287 } else {
288 throw new Exception(
289 'setPath() or setText() or setStream() must be called before retrieving email headers.'
290 );
291 }
292 }
293
294 /**
295 * Retrieve the raw mail headers as a string
296 *
297 * @return string
298 * @throws Exception
299 */
300 public function getHeadersRaw()
301 {
302 if (isset($this->parts[1])) {
303 return $this->getPartHeader($this->parts[1]);
304 } else {
305 throw new Exception(
306 'setPath() or setText() or setStream() must be called before retrieving email headers.'
307 );
308 }
309 }
310
311 /**
312 * Retrieve the raw Header of a MIME part
313 *
314 * @return String
315 * @param $part Object
316 * @throws Exception
317 */
318 protected function getPartHeader(&$part)
319 {
320 $header = '';
321 if ($this->stream) {
322 $header = $this->getPartHeaderFromFile($part);
323 } elseif ($this->data) {
324 $header = $this->getPartHeaderFromText($part);
325 }
326 return $header;
327 }
328
329 /**
330 * Retrieve the Header from a MIME part from file
331 *
332 * @return String Mime Header Part
333 * @param $part Array
334 */
335 protected function getPartHeaderFromFile(&$part)
336 {
337 $start = $part['starting-pos'];
338 $end = $part['starting-pos-body'];
339 fseek($this->stream, $start, SEEK_SET);
340 $header = fread($this->stream, $end - $start);
341 return $header;
342 }
343
344 /**
345 * Retrieve the Header from a MIME part from text
346 *
347 * @return String Mime Header Part
348 * @param $part Array
349 */
350 protected function getPartHeaderFromText(&$part)
351 {
352 $start = $part['starting-pos'];
353 $end = $part['starting-pos-body'];
354 $header = substr($this->data, $start, $end - $start);
355 return $header;
356 }
357
358 /**
359 * Checks whether a given part ID is a child of another part
360 * eg. an RFC822 attachment may have one or more text parts
361 *
362 * @param string $partId
363 * @param string $parentPartId
364 * @return bool
365 */
366 protected function partIdIsChildOfPart($partId, $parentPartId)
367 {
368 $parentPartId = $parentPartId.'.';
369 return substr($partId, 0, strlen($parentPartId)) == $parentPartId;
370 }
371
372 /**
373 * Whether the given part ID is a child of any attachment part in the message.
374 *
375 * @param string $checkPartId
376 * @return bool
377 */
378 protected function partIdIsChildOfAnAttachment($checkPartId)
379 {
380 foreach ($this->parts as $partId => $part) {
381 if ($this->getPart('content-disposition', $part) == 'attachment') {
382 if ($this->partIdIsChildOfPart($checkPartId, $partId)) {
383 return true;
384 }
385 }
386 }
387 return false;
388 }
389
390 /**
391 * Returns the email message body in the specified format
392 *
393 * @param string $type text, html or htmlEmbedded
394 *
395 * @return string Body
396 * @throws Exception
397 */
398 public function getMessageBody($type = 'text')
399 {
400 $mime_types = [
401 'text' => 'text/plain',
402 'html' => 'text/html',
403 'htmlEmbedded' => 'text/html',
404 ];
405
406 if (in_array($type, array_keys($mime_types))) {
407 $part_type = $type === 'htmlEmbedded' ? 'html' : $type;
408 $inline_parts = $this->getInlineParts($part_type);
409 $body = empty($inline_parts) ? '' : $inline_parts[0];
410 } else {
411 throw new Exception(
412 'Invalid type specified for getMessageBody(). Expected: text, html or htmlEmbedded.'
413 );
414 }
415
416 if ($type == 'htmlEmbedded') {
417 $attachments = $this->getAttachments();
418 foreach ($attachments as $attachment) {
419 if ($attachment->getContentID() != '') {
420 $body = str_replace(
421 '"cid:'.$attachment->getContentID().'"',
422 '"'.$this->getEmbeddedData($attachment->getContentID()).'"',
423 $body
424 );
425 }
426 }
427 }
428
429 return $body;
430 }
431
432 /**
433 * Returns the embedded data structure
434 *
435 * @param string $contentId Content-Id
436 *
437 * @return string
438 */
439 protected function getEmbeddedData($contentId)
440 {
441 foreach ($this->parts as $part) {
442 if ($this->getPart('content-id', $part) == $contentId) {
443 $embeddedData = 'data:';
444 $embeddedData .= $this->getPart('content-type', $part);
445 $embeddedData .= ';'.$this->getPart('transfer-encoding', $part);
446 $embeddedData .= ','.$this->getPartBody($part);
447 return $embeddedData;
448 }
449 }
450 return '';
451 }
452
453 /**
454 * Return an array with the following keys display, address, is_group
455 *
456 * @param string $name Header name (case-insensitive)
457 *
458 * @return array<int, array{'display': string, 'address': string, 'is_group': bool}>
459 */
460 public function getAddresses($name)
461 {
462 $value = $this->getRawHeader($name);
463 $value = (is_array($value)) ? $value[0] : $value;
464 $addresses = mailparse_rfc822_parse_addresses($value);
465 foreach ($addresses as $i => $item) {
466 $addresses[$i]['display'] = $this->decodeHeader($item['display']);
467 }
468 return $addresses;
469 }
470
471 /**
472 * Returns the inline parts contents (text or HTML)
473 *
474 * @return string[] The decoded inline parts.
475 */
476 public function getInlineParts($type = 'text')
477 {
478 $inline_parts = [];
479 $mime_types = [
480 'text' => 'text/plain',
481 'html' => 'text/html',
482 ];
483
484 if (!in_array($type, array_keys($mime_types))) {
485 throw new Exception('Invalid type specified for getInlineParts(). "type" can either be text or html.');
486 }
487
488 foreach ($this->parts as $partId => $part) {
489 if ($this->getPart('content-type', $part) == $mime_types[$type]
490 && $this->getPart('content-disposition', $part) != 'attachment'
491 && !$this->partIdIsChildOfAnAttachment($partId)
492 ) {
493 $headers = $this->getPart('headers', $part);
494 $encodingType = array_key_exists('content-transfer-encoding', $headers) ?
495 $headers['content-transfer-encoding'] : '';
496 $undecoded_body = $this->decodeContentTransfer($this->getPartBody($part), $encodingType);
497 $inline_parts[] = $this->charset->decodeCharset($undecoded_body, $this->getPartCharset($part));
498 }
499 }
500
501 return $inline_parts;
502 }
503
504 /**
505 * Returns the attachments contents in order of appearance
506 *
507 * @return Attachment[]
508 */
509 public function getAttachments($include_inline = true)
510 {
511 $attachments = [];
512 $dispositions = $include_inline ? ['attachment', 'inline'] : ['attachment'];
513 $non_attachment_types = ['text/plain', 'text/html'];
514 $nonameIter = 0;
515
516 foreach ($this->parts as $part) {
517 $disposition = $this->getPart('content-disposition', $part);
518 $filename = 'noname';
519
520 if (isset($part['disposition-filename'])) {
521 $filename = $this->decodeHeader($part['disposition-filename']);
522 } elseif (isset($part['content-name'])) {
523 // if we have no disposition but we have a content-name, it's a valid attachment.
524 // we simulate the presence of an attachment disposition with a disposition filename
525 $filename = $this->decodeHeader($part['content-name']);
526 $disposition = 'attachment';
527 } elseif (in_array($part['content-type'], $non_attachment_types, true)
528 && $disposition !== 'attachment') {
529 // it is a message body, no attachment
530 continue;
531 } elseif (substr($part['content-type'], 0, 10) !== 'multipart/'
532 && $part['content-type'] !== 'text/plain; (error)' && $disposition != 'inline') {
533 // if we cannot get it by getMessageBody(), we assume it is an attachment
534 $disposition = 'attachment';
535 }
536 if (in_array($disposition, ['attachment', 'inline']) === false && !empty($disposition)) {
537 $disposition = 'attachment';
538 }
539
540 if (in_array($disposition, $dispositions) === true) {
541 if ($filename == 'noname') {
542 $nonameIter++;
543 $filename = 'noname'.$nonameIter;
544 } else {
545 // Escape all potentially unsafe characters from the filename
546 $filename = preg_replace('((^\.)|\/|[\n|\r|\n\r]|(\.$))', '_', $filename);
547 }
548
549 $headersAttachments = $this->getPart('headers', $part);
550 $contentidAttachments = $this->getPart('content-id', $part);
551
552 $attachmentStream = $this->getAttachmentStream($part);
553 $mimePartStr = $this->getPartComplete($part);
554
555 $attachments[] = new Attachment(
556 $filename,
557 $this->getPart('content-type', $part),
558 $attachmentStream,
559 $disposition,
560 $contentidAttachments,
561 $headersAttachments,
562 $mimePartStr
563 );
564 }
565 }
566
567 return $attachments;
568 }
569
570 /**
571 * Save attachments in a folder
572 *
573 * @param string $attach_dir directory
574 * @param bool $include_inline
575 * @param string $filenameStrategy How to generate attachment filenames
576 *
577 * @return array Saved attachments paths
578 * @throws Exception
579 */
580 public function saveAttachments(
581 $attach_dir,
582 $include_inline = true,
583 $filenameStrategy = self::ATTACHMENT_DUPLICATE_SUFFIX
584 ) {
585 $attachments = $this->getAttachments($include_inline);
586
587 $attachments_paths = [];
588 foreach ($attachments as $attachment) {
589 $attachments_paths[] = $attachment->save($attach_dir, $filenameStrategy);
590 }
591
592 return $attachments_paths;
593 }
594
595 /**
596 * Read the attachment Body and save temporary file resource
597 *
598 * @param array $part
599 *
600 * @return resource Mime Body Part
601 * @throws Exception
602 */
603 protected function getAttachmentStream(&$part)
604 {
605 /** @var resource $temp_fp */
606 $temp_fp = tmpfile();
607
608 $headers = $this->getPart('headers', $part);
609 $encodingType = array_key_exists('content-transfer-encoding', $headers) ?
610 $headers['content-transfer-encoding'] : '';
611
612 if ($temp_fp) {
613 if ($this->stream) {
614 $start = $part['starting-pos-body'];
615 $end = $part['ending-pos-body'];
616 fseek($this->stream, $start, SEEK_SET);
617 $len = $end - $start;
618 $written = 0;
619 while ($written < $len) {
620 $write = $len;
621 $data = fread($this->stream, $write);
622 fwrite($temp_fp, $this->decodeContentTransfer($data, $encodingType));
623 $written += $write;
624 }
625 } elseif ($this->data) {
626 $attachment = $this->decodeContentTransfer($this->getPartBodyFromText($part), $encodingType);
627 fwrite($temp_fp, $attachment, strlen($attachment));
628 }
629 fseek($temp_fp, 0, SEEK_SET);
630 } else {
631 throw new Exception(
632 'Could not create temporary files for attachments. Your tmp directory may be unwritable by PHP.'
633 );
634 }
635
636 return $temp_fp;
637 }
638
639 /**
640 * Decode the string from Content-Transfer-Encoding
641 *
642 * @param string $encodedString The string in its original encoded state
643 * @param string $encodingType The encoding type from the Content-Transfer-Encoding header of the part.
644 *
645 * @return string The decoded string
646 */
647 protected function decodeContentTransfer($encodedString, $encodingType)
648 {
649 if (is_array($encodingType)) {
650 $encodingType = $encodingType[0];
651 }
652
653 $encodingType = strtolower($encodingType);
654 if ($encodingType == 'base64') {
655 return base64_decode($encodedString);
656 } elseif ($encodingType == 'quoted-printable') {
657 return quoted_printable_decode($encodedString);
658 } else {
659 return $encodedString;
660 }
661 }
662
663 /**
664 * $input can be a string or array
665 *
666 * @param string|array $input
667 *
668 * @return string
669 */
670 protected function decodeHeader($input)
671 {
672 //Sometimes we have 2 label From so we take only the first
673 if (is_array($input)) {
674 return $this->decodeSingleHeader($input[0]);
675 }
676
677 return $this->decodeSingleHeader($input);
678 }
679
680 /**
681 * Decodes a single header (= string)
682 *
683 * @param string $input
684 *
685 * @return string
686 */
687 protected function decodeSingleHeader($input)
688 {
689 // For each encoded-word...
690 while (preg_match('/(=\?([^?]+)\?(q|b)\?([^?]*)\?=)((\s+)=\?)?/i', $input, $matches)) {
691 $encoded = $matches[1];
692 $charset = $matches[2];
693 $encoding = $matches[3];
694 $text = $matches[4];
695 $space = isset($matches[6]) ? $matches[6] : '';
696
697 switch (strtolower($encoding)) {
698 case 'b':
699 $text = $this->decodeContentTransfer($text, 'base64');
700 break;
701
702 case 'q':
703 $text = str_replace('_', ' ', $text);
704 preg_match_all('/=([a-f0-9]{2})/i', $text, $matches);
705 foreach ($matches[1] as $value) {
706 $text = str_replace('='.$value, chr(hexdec($value)), $text);
707 }
708 break;
709 }
710
711 $text = $this->charset->decodeCharset($text, $this->charset->getCharsetAlias($charset));
712 $input = str_replace($encoded.$space, $text, $input);
713 }
714
715 return $input;
716 }
717
718 /**
719 * Return the charset of the MIME part
720 *
721 * @param array $part
722 *
723 * @return string
724 */
725 protected function getPartCharset($part)
726 {
727 if (isset($part['charset'])) {
728 return $this->charset->getCharsetAlias($part['charset']);
729 } else {
730 return 'us-ascii';
731 }
732 }
733
734 /**
735 * Retrieve a specified MIME part
736 *
737 * @param string $type
738 * @param array $parts
739 *
740 * @return string|array
741 */
742 protected function getPart($type, $parts)
743 {
744 return (isset($parts[$type])) ? $parts[$type] : false;
745 }
746
747 /**
748 * Retrieve the Body of a MIME part
749 *
750 * @param array $part
751 *
752 * @return string
753 */
754 protected function getPartBody(&$part)
755 {
756 $body = '';
757 if ($this->stream) {
758 $body = $this->getPartBodyFromFile($part);
759 } elseif ($this->data) {
760 $body = $this->getPartBodyFromText($part);
761 }
762
763 return $body;
764 }
765
766 /**
767 * Retrieve the Body from a MIME part from file
768 *
769 * @param array $part
770 *
771 * @return string Mime Body Part
772 */
773 protected function getPartBodyFromFile(&$part)
774 {
775 $start = $part['starting-pos-body'];
776 $end = $part['ending-pos-body'];
777 $body = '';
778 if ($end - $start > 0) {
779 fseek($this->stream, $start, SEEK_SET);
780 $body = fread($this->stream, $end - $start);
781 }
782
783 return $body;
784 }
785
786 /**
787 * Retrieve the Body from a MIME part from text
788 *
789 * @param array $part
790 *
791 * @return string Mime Body Part
792 */
793 protected function getPartBodyFromText(&$part)
794 {
795 $start = $part['starting-pos-body'];
796 $end = $part['ending-pos-body'];
797
798 return substr($this->data, $start, $end - $start);
799 }
800
801 /**
802 * Retrieve the content of a MIME part
803 *
804 * @param array $part
805 *
806 * @return string
807 */
808 protected function getPartComplete(&$part)
809 {
810 $body = '';
811 if ($this->stream) {
812 $body = $this->getPartFromFile($part);
813 } elseif ($this->data) {
814 $body = $this->getPartFromText($part);
815 }
816
817 return $body;
818 }
819
820 /**
821 * Retrieve the content from a MIME part from file
822 *
823 * @param array $part
824 *
825 * @return string Mime Content
826 */
827 protected function getPartFromFile(&$part)
828 {
829 $start = $part['starting-pos'];
830 $end = $part['ending-pos'];
831 $body = '';
832 if ($end - $start > 0) {
833 fseek($this->stream, $start, SEEK_SET);
834 $body = fread($this->stream, $end - $start);
835 }
836
837 return $body;
838 }
839
840 /**
841 * Retrieve the content from a MIME part from text
842 *
843 * @param array $part
844 *
845 * @return string Mime Content
846 */
847 protected function getPartFromText(&$part)
848 {
849 $start = $part['starting-pos'];
850 $end = $part['ending-pos'];
851
852 return substr($this->data, $start, $end - $start);
853 }
854
855 /**
856 * Retrieve the resource
857 *
858 * @return resource resource
859 */
860 public function getResource()
861 {
862 return $this->resource;
863 }
864
865 /**
866 * Retrieve the file pointer to email
867 *
868 * @return resource stream
869 */
870 public function getStream()
871 {
872 return $this->stream;
873 }
874
875 /**
876 * Retrieve the text of an email
877 *
878 * @return string data
879 */
880 public function getData()
881 {
882 return $this->data;
883 }
884
885 /**
886 * Retrieve the parts of an email
887 *
888 * @return array parts
889 */
890 public function getParts()
891 {
892 return $this->parts;
893 }
894
895 /**
896 * Retrieve the charset manager object
897 *
898 * @return CharsetManager charset
899 */
900 public function getCharset()
901 {
902 return $this->charset;
903 }
904
905 /**
906 * Add a middleware to the parser MiddlewareStack
907 * Each middleware is invoked when:
908 * a MimePart is retrieved by mailparse_msg_get_part_data() during $this->parse()
909 * The middleware will receive MimePart $part and the next MiddlewareStack $next
910 *
911 * Eg:
912 *
913 * $Parser->addMiddleware(function(MimePart $part, MiddlewareStack $next) {
914 * // do something with the $part
915 * return $next($part);
916 * });
917 *
918 * @param callable $middleware Plain Function or Middleware Instance to execute
919 * @return void
920 */
921 public function addMiddleware(callable $middleware)
922 {
923 if (!$middleware instanceof Middleware) {
924 $middleware = new Middleware($middleware);
925 }
926 $this->middlewareStack = $this->middlewareStack->add($middleware);
927 }
928}