saveAttachments(). */ const ATTACHMENT_DUPLICATE_THROW = 'DuplicateThrow'; const ATTACHMENT_DUPLICATE_SUFFIX = 'DuplicateSuffix'; const ATTACHMENT_RANDOM_FILENAME = 'RandomFilename'; /** * PHP MimeParser Resource ID * * @var resource $resource */ protected $resource; /** * A file pointer to email * * @var resource $stream */ protected $stream; /** * A text of an email * * @var string $data */ protected $data; /** * Parts of an email * * @var array $parts */ protected $parts; /** * @var CharsetManager object */ protected $charset; /** * Valid stream modes for reading * * @var array */ protected static $readableModes = [ 'r', 'r+', 'w+', 'a+', 'x+', 'c+', 'rb', 'r+b', 'w+b', 'a+b', 'x+b', 'c+b', 'rt', 'r+t', 'w+t', 'a+t', 'x+t', 'c+t' ]; /** * Stack of middleware registered to process data * * @var MiddlewareStack */ protected $middlewareStack; /** * Parser constructor. * * @param CharsetManager|null $charset */ public function __construct(?CharsetManager $charset = null) { if ($charset == null) { $charset = new Charset(); } $this->charset = $charset; $this->middlewareStack = new MiddlewareStack(); } /** * Free the held resources * * @return void */ public function __destruct() { // clear the email file resource if (is_resource($this->stream)) { fclose($this->stream); } // clear the MailParse resource if (is_resource($this->resource)) { mailparse_msg_free($this->resource); } } /** * Set the file path we use to get the email text * * @param string $path File path to the MIME mail * * @return Parser MimeMailParser Instance */ public function setPath($path) { if (is_writable($path)) { $file = fopen($path, 'a+'); fseek($file, -1, SEEK_END); if (fread($file, 1) != "\n") { fwrite($file, PHP_EOL); } fclose($file); } // should parse message incrementally from file $this->resource = mailparse_msg_parse_file($path); $this->stream = fopen($path, 'r'); $this->parse(); return $this; } /** * Set the Stream resource we use to get the email text * * @param resource $stream * * @return Parser MimeMailParser Instance * @throws Exception */ public function setStream($stream) { // streams have to be cached to file first $meta = @stream_get_meta_data($stream); if (!$meta || !$meta['mode'] || !in_array($meta['mode'], self::$readableModes, true)) { throw new Exception( 'setStream() expects parameter stream to be readable stream resource.' ); } /** @var resource $tmp_fp */ $tmp_fp = tmpfile(); if ($tmp_fp) { while (!feof($stream)) { fwrite($tmp_fp, fread($stream, 2028)); } if (fread($tmp_fp, 1) != "\n") { fwrite($tmp_fp, PHP_EOL); } fseek($tmp_fp, 0); $this->stream = &$tmp_fp; } else { throw new Exception( 'Could not create temporary files for attachments. Your tmp directory may be unwritable by PHP.' ); } fclose($stream); $this->resource = mailparse_msg_create(); // parses the message incrementally (low memory usage but slower) while (!feof($this->stream)) { mailparse_msg_parse($this->resource, fread($this->stream, 2082)); } $this->parse(); return $this; } /** * Set the email text * * @param string $data * * @return Parser MimeMailParser Instance */ public function setText($data) { if (empty($data)) { throw new Exception('You must not call MimeMailParser::setText with an empty string parameter'); } if (substr($data, -1) != "\n") { $data = $data.PHP_EOL; } $this->resource = mailparse_msg_create(); // does not parse incrementally, fast memory hog might explode mailparse_msg_parse($this->resource, $data); $this->data = $data; $this->parse(); return $this; } /** * Parse the Message into parts * * @return void */ protected function parse() { $structure = mailparse_msg_get_structure($this->resource); $this->parts = []; foreach ($structure as $part_id) { $part = mailparse_msg_get_part($this->resource, $part_id); $part_data = mailparse_msg_get_part_data($part); $mimePart = new MimePart($part_id, $part_data); // let each middleware parse the part before saving $this->parts[$part_id] = $this->middlewareStack->parse($mimePart)->getPart(); } } /** * Retrieve a specific Email Header, without charset conversion. * * @param string $name Header name (case-insensitive) * * @return string|bool * @throws Exception */ public function getRawHeader($name) { $name = strtolower($name); if (isset($this->parts[1])) { $headers = $this->getPart('headers', $this->parts[1]); return isset($headers[$name]) ? $headers[$name] : false; } else { throw new Exception( 'setPath() or setText() or setStream() must be called before retrieving email headers.' ); } } /** * Retrieve a specific Email Header * * @param string $name Header name (case-insensitive) * * @return string|false */ public function getHeader($name) { $rawHeader = $this->getRawHeader($name); if ($rawHeader === false) { return false; } return $this->decodeHeader($rawHeader); } /** * Retrieve all mail headers * * @return array * @throws Exception */ public function getHeaders() { if (isset($this->parts[1])) { $headers = $this->getPart('headers', $this->parts[1]); foreach ($headers as &$value) { if (is_array($value)) { foreach ($value as &$v) { $v = $this->decodeSingleHeader($v); } } else { $value = $this->decodeSingleHeader($value); } } return $headers; } else { throw new Exception( 'setPath() or setText() or setStream() must be called before retrieving email headers.' ); } } /** * Retrieve the raw mail headers as a string * * @return string * @throws Exception */ public function getHeadersRaw() { if (isset($this->parts[1])) { return $this->getPartHeader($this->parts[1]); } else { throw new Exception( 'setPath() or setText() or setStream() must be called before retrieving email headers.' ); } } /** * Retrieve the raw Header of a MIME part * * @return String * @param $part Object * @throws Exception */ protected function getPartHeader(&$part) { $header = ''; if ($this->stream) { $header = $this->getPartHeaderFromFile($part); } elseif ($this->data) { $header = $this->getPartHeaderFromText($part); } return $header; } /** * Retrieve the Header from a MIME part from file * * @return String Mime Header Part * @param $part Array */ protected function getPartHeaderFromFile(&$part) { $start = $part['starting-pos']; $end = $part['starting-pos-body']; fseek($this->stream, $start, SEEK_SET); $header = fread($this->stream, $end - $start); return $header; } /** * Retrieve the Header from a MIME part from text * * @return String Mime Header Part * @param $part Array */ protected function getPartHeaderFromText(&$part) { $start = $part['starting-pos']; $end = $part['starting-pos-body']; $header = substr($this->data, $start, $end - $start); return $header; } /** * Checks whether a given part ID is a child of another part * eg. an RFC822 attachment may have one or more text parts * * @param string $partId * @param string $parentPartId * @return bool */ protected function partIdIsChildOfPart($partId, $parentPartId) { $parentPartId = $parentPartId.'.'; return substr($partId, 0, strlen($parentPartId)) == $parentPartId; } /** * Whether the given part ID is a child of any attachment part in the message. * * @param string $checkPartId * @return bool */ protected function partIdIsChildOfAnAttachment($checkPartId) { foreach ($this->parts as $partId => $part) { if ($this->getPart('content-disposition', $part) == 'attachment') { if ($this->partIdIsChildOfPart($checkPartId, $partId)) { return true; } } } return false; } /** * Returns the email message body in the specified format * * @param string $type text, html or htmlEmbedded * * @return string Body * @throws Exception */ public function getMessageBody($type = 'text') { $mime_types = [ 'text' => 'text/plain', 'html' => 'text/html', 'htmlEmbedded' => 'text/html', ]; if (in_array($type, array_keys($mime_types))) { $part_type = $type === 'htmlEmbedded' ? 'html' : $type; $inline_parts = $this->getInlineParts($part_type); $body = empty($inline_parts) ? '' : $inline_parts[0]; } else { throw new Exception( 'Invalid type specified for getMessageBody(). Expected: text, html or htmlEmbeded.' ); } if ($type == 'htmlEmbedded') { $attachments = $this->getAttachments(); foreach ($attachments as $attachment) { if ($attachment->getContentID() != '') { $body = str_replace( '"cid:'.$attachment->getContentID().'"', '"'.$this->getEmbeddedData($attachment->getContentID()).'"', $body ); } } } return $body; } /** * Returns the embedded data structure * * @param string $contentId Content-Id * * @return string */ protected function getEmbeddedData($contentId) { foreach ($this->parts as $part) { if ($this->getPart('content-id', $part) == $contentId) { $embeddedData = 'data:'; $embeddedData .= $this->getPart('content-type', $part); $embeddedData .= ';'.$this->getPart('transfer-encoding', $part); $embeddedData .= ','.$this->getPartBody($part); return $embeddedData; } } return ''; } /** * Return an array with the following keys display, address, is_group * * @param string $name Header name (case-insensitive) * * @return array */ public function getAddresses($name) { $value = $this->getRawHeader($name); $value = (is_array($value)) ? $value[0] : $value; $addresses = mailparse_rfc822_parse_addresses($value); foreach ($addresses as $i => $item) { $addresses[$i]['display'] = $this->decodeHeader($item['display']); } return $addresses; } /** * Returns the attachments contents in order of appearance * * @return Attachment[] */ public function getInlineParts($type = 'text') { $inline_parts = []; $mime_types = [ 'text' => 'text/plain', 'html' => 'text/html', ]; if (!in_array($type, array_keys($mime_types))) { throw new Exception('Invalid type specified for getInlineParts(). "type" can either be text or html.'); } foreach ($this->parts as $partId => $part) { if ($this->getPart('content-type', $part) == $mime_types[$type] && $this->getPart('content-disposition', $part) != 'attachment' && !$this->partIdIsChildOfAnAttachment($partId) ) { $headers = $this->getPart('headers', $part); $encodingType = array_key_exists('content-transfer-encoding', $headers) ? $headers['content-transfer-encoding'] : ''; $undecoded_body = $this->decodeContentTransfer($this->getPartBody($part), $encodingType); $inline_parts[] = $this->charset->decodeCharset($undecoded_body, $this->getPartCharset($part)); } } return $inline_parts; } /** * Returns the attachments contents in order of appearance * * @return Attachment[] */ public function getAttachments($include_inline = true) { $attachments = []; $dispositions = $include_inline ? ['attachment', 'inline'] : ['attachment']; $non_attachment_types = ['text/plain', 'text/html']; $nonameIter = 0; foreach ($this->parts as $part) { $disposition = $this->getPart('content-disposition', $part); $filename = 'noname'; if (isset($part['disposition-filename'])) { $filename = $this->decodeHeader($part['disposition-filename']); } elseif (isset($part['content-name'])) { // if we have no disposition but we have a content-name, it's a valid attachment. // we simulate the presence of an attachment disposition with a disposition filename $filename = $this->decodeHeader($part['content-name']); $disposition = 'attachment'; } elseif (in_array($part['content-type'], $non_attachment_types, true) && $disposition !== 'attachment') { // it is a message body, no attachment continue; } elseif (substr($part['content-type'], 0, 10) !== 'multipart/' && $part['content-type'] !== 'text/plain; (error)' && $disposition != 'inline') { // if we cannot get it by getMessageBody(), we assume it is an attachment $disposition = 'attachment'; } if (in_array($disposition, ['attachment', 'inline']) === false && !empty($disposition)) { $disposition = 'attachment'; } if (in_array($disposition, $dispositions) === true) { if ($filename == 'noname') { $nonameIter++; $filename = 'noname'.$nonameIter; } else { // Escape all potentially unsafe characters from the filename $filename = preg_replace('((^\.)|\/|[\n|\r|\n\r]|(\.$))', '_', $filename); } $headersAttachments = $this->getPart('headers', $part); $contentidAttachments = $this->getPart('content-id', $part); $attachmentStream = $this->getAttachmentStream($part); $mimePartStr = $this->getPartComplete($part); $attachments[] = new Attachment( $filename, $this->getPart('content-type', $part), $attachmentStream, $disposition, $contentidAttachments, $headersAttachments, $mimePartStr ); } } return $attachments; } /** * Save attachments in a folder * * @param string $attach_dir directory * @param bool $include_inline * @param string $filenameStrategy How to generate attachment filenames * * @return array Saved attachments paths * @throws Exception */ public function saveAttachments( $attach_dir, $include_inline = true, $filenameStrategy = self::ATTACHMENT_DUPLICATE_SUFFIX ) { $attachments = $this->getAttachments($include_inline); $attachments_paths = []; foreach ($attachments as $attachment) { $attachments_paths[] = $attachment->save($attach_dir, $filenameStrategy); } return $attachments_paths; } /** * Read the attachment Body and save temporary file resource * * @param array $part * * @return resource Mime Body Part * @throws Exception */ protected function getAttachmentStream(&$part) { /** @var resource $temp_fp */ $temp_fp = tmpfile(); $headers = $this->getPart('headers', $part); $encodingType = array_key_exists('content-transfer-encoding', $headers) ? $headers['content-transfer-encoding'] : ''; if ($temp_fp) { if ($this->stream) { $start = $part['starting-pos-body']; $end = $part['ending-pos-body']; fseek($this->stream, $start, SEEK_SET); $len = $end - $start; $written = 0; while ($written < $len) { $write = $len; $data = fread($this->stream, $write); fwrite($temp_fp, $this->decodeContentTransfer($data, $encodingType)); $written += $write; } } elseif ($this->data) { $attachment = $this->decodeContentTransfer($this->getPartBodyFromText($part), $encodingType); fwrite($temp_fp, $attachment, strlen($attachment)); } fseek($temp_fp, 0, SEEK_SET); } else { throw new Exception( 'Could not create temporary files for attachments. Your tmp directory may be unwritable by PHP.' ); } return $temp_fp; } /** * Decode the string from Content-Transfer-Encoding * * @param string $encodedString The string in its original encoded state * @param string $encodingType The encoding type from the Content-Transfer-Encoding header of the part. * * @return string The decoded string */ protected function decodeContentTransfer($encodedString, $encodingType) { if (is_array($encodingType)) { $encodingType = $encodingType[0]; } $encodingType = strtolower($encodingType); if ($encodingType == 'base64') { return base64_decode($encodedString); } elseif ($encodingType == 'quoted-printable') { return quoted_printable_decode($encodedString); } else { return $encodedString; } } /** * $input can be a string or array * * @param string|array $input * * @return string */ protected function decodeHeader($input) { //Sometimes we have 2 label From so we take only the first if (is_array($input)) { return $this->decodeSingleHeader($input[0]); } return $this->decodeSingleHeader($input); } /** * Decodes a single header (= string) * * @param string $input * * @return string */ protected function decodeSingleHeader($input) { // For each encoded-word... while (preg_match('/(=\?([^?]+)\?(q|b)\?([^?]*)\?=)((\s+)=\?)?/i', $input, $matches)) { $encoded = $matches[1]; $charset = $matches[2]; $encoding = $matches[3]; $text = $matches[4]; $space = isset($matches[6]) ? $matches[6] : ''; switch (strtolower($encoding)) { case 'b': $text = $this->decodeContentTransfer($text, 'base64'); break; case 'q': $text = str_replace('_', ' ', $text); preg_match_all('/=([a-f0-9]{2})/i', $text, $matches); foreach ($matches[1] as $value) { $text = str_replace('='.$value, chr(hexdec($value)), $text); } break; } $text = $this->charset->decodeCharset($text, $this->charset->getCharsetAlias($charset)); $input = str_replace($encoded.$space, $text, $input); } return $input; } /** * Return the charset of the MIME part * * @param array $part * * @return string */ protected function getPartCharset($part) { if (isset($part['charset'])) { return $this->charset->getCharsetAlias($part['charset']); } else { return 'us-ascii'; } } /** * Retrieve a specified MIME part * * @param string $type * @param array $parts * * @return string|array */ protected function getPart($type, $parts) { return (isset($parts[$type])) ? $parts[$type] : false; } /** * Retrieve the Body of a MIME part * * @param array $part * * @return string */ protected function getPartBody(&$part) { $body = ''; if ($this->stream) { $body = $this->getPartBodyFromFile($part); } elseif ($this->data) { $body = $this->getPartBodyFromText($part); } return $body; } /** * Retrieve the Body from a MIME part from file * * @param array $part * * @return string Mime Body Part */ protected function getPartBodyFromFile(&$part) { $start = $part['starting-pos-body']; $end = $part['ending-pos-body']; $body = ''; if ($end - $start > 0) { fseek($this->stream, $start, SEEK_SET); $body = fread($this->stream, $end - $start); } return $body; } /** * Retrieve the Body from a MIME part from text * * @param array $part * * @return string Mime Body Part */ protected function getPartBodyFromText(&$part) { $start = $part['starting-pos-body']; $end = $part['ending-pos-body']; return substr($this->data, $start, $end - $start); } /** * Retrieve the content of a MIME part * * @param array $part * * @return string */ protected function getPartComplete(&$part) { $body = ''; if ($this->stream) { $body = $this->getPartFromFile($part); } elseif ($this->data) { $body = $this->getPartFromText($part); } return $body; } /** * Retrieve the content from a MIME part from file * * @param array $part * * @return string Mime Content */ protected function getPartFromFile(&$part) { $start = $part['starting-pos']; $end = $part['ending-pos']; $body = ''; if ($end - $start > 0) { fseek($this->stream, $start, SEEK_SET); $body = fread($this->stream, $end - $start); } return $body; } /** * Retrieve the content from a MIME part from text * * @param array $part * * @return string Mime Content */ protected function getPartFromText(&$part) { $start = $part['starting-pos']; $end = $part['ending-pos']; return substr($this->data, $start, $end - $start); } /** * Retrieve the resource * * @return resource resource */ public function getResource() { return $this->resource; } /** * Retrieve the file pointer to email * * @return resource stream */ public function getStream() { return $this->stream; } /** * Retrieve the text of an email * * @return string data */ public function getData() { return $this->data; } /** * Retrieve the parts of an email * * @return array parts */ public function getParts() { return $this->parts; } /** * Retrieve the charset manager object * * @return CharsetManager charset */ public function getCharset() { return $this->charset; } /** * Add a middleware to the parser MiddlewareStack * Each middleware is invoked when: * a MimePart is retrieved by mailparse_msg_get_part_data() during $this->parse() * The middleware will receive MimePart $part and the next MiddlewareStack $next * * Eg: * * $Parser->addMiddleware(function(MimePart $part, MiddlewareStack $next) { * // do something with the $part * return $next($part); * }); * * @param callable $middleware Plain Function or Middleware Instance to execute * @return void */ public function addMiddleware(callable $middleware) { if (!$middleware instanceof Middleware) { $middleware = new Middleware($middleware); } $this->middlewareStack = $this->middlewareStack->add($middleware); } }