uid = $uid; $this->mailbox = $mailbox; $this->client = $client; $this->userId = $userId; $this->htmlService = $htmlService; $this->smimeService = $smimeService; $this->phishingDetectionService = $phishingDetectionService; } /** * Configure the fetcher to fetch the body of the message. * * @param bool $value * @return $this */ public function withBody(bool $value): ImapMessageFetcher { $this->loadBody = $value; return $this; } /** * Configure the fetcher to check for phishing. * * @param bool $value * @return $this */ public function withPhishingCheck(bool $value): ImapMessageFetcher { $this->runPhishingCheck = $value; return $this; } /** * @param Horde_Imap_Client_Data_Fetch|null $fetch * Will be reused if no body is requested. * It should at least contain envelope, flags, imapDate and headerText. * Otherwise, some data might not be parsed correctly. * @return IMAPMessage * * @throws DoesNotExistException * @throws Horde_Imap_Client_Exception * @throws Horde_Imap_Client_Exception_NoSupportExtension * @throws Horde_Mime_Exception * @throws ServiceException * @throws SmimeDecryptException */ public function fetchMessage(?Horde_Imap_Client_Data_Fetch $fetch = null): IMAPMessage { $ids = new Horde_Imap_Client_Ids($this->uid); $isSigned = false; $signatureIsValid = false; $isEncrypted = false; if ($this->loadBody) { // Ignore given query because lots of data needs to be fetched anyway // TODO: reuse given query if beneficial for performance and worth the refactoring effort $query = new Horde_Imap_Client_Fetch_Query(); $query->envelope(); $query->structure(); $query->flags(); $query->imapDate(); $query->headerText([ 'peek' => true, ]); $this->smimeService->addEncryptionCheckQueries($query); $headers = $this->client->fetch($this->mailbox, $query, ['ids' => $ids]); /** @var Horde_Imap_Client_Data_Fetch $fetch */ $fetch = $headers[$this->uid]; if (is_null($fetch)) { throw new DoesNotExistException("This email ($this->uid) can't be found. Probably it was deleted from the server recently. Please reload."); } // analyse the body part $structure = $fetch->getStructure(); $this->isPgpMimeEncrypted = ($structure->getType() === 'multipart/encrypted' && $structure->getContentTypeParameter('protocol') === 'application/pgp-encrypted'); if ($this->isPgpMimeEncrypted) { $this->plainMessage = $this->loadBodyData($structure, '2', false); $this->attachmentsToIgnore[] = $structure->getPartByIndex(1)->getName(); } $this->hasAnyAttachment = $this->hasAttachments($structure); $isEncrypted = $this->smimeService->isEncrypted($fetch); $isOpaqueSigned = $structure->getContentTypeParameter('smime-type') === 'signed-data' && ($structure->getType() === 'application/pkcs7-mime' || $structure->getType() === 'application/x-pkcs7-mime'); if ($isEncrypted) { // Fetch and parse full text if message is encrypted in order to analyze the // structure. Conditional fetching doesn't work for encrypted messages. $query = new Horde_Imap_Client_Fetch_Query(); $this->smimeService->addDecryptQueries($query, true); $headers = $this->client->fetch($this->mailbox, $query, ['ids' => $ids]); /** @var Horde_Imap_Client_Data_Fetch $fullTextFetch */ $fullTextFetch = $headers[$this->uid]; if (is_null($fullTextFetch)) { throw new DoesNotExistException("This email ($this->uid) can't be found. Probably it was deleted from the server recently. Please reload."); } $decryptionResult = $this->smimeService->decryptDataFetch($fullTextFetch, $this->userId); $isSigned = $decryptionResult->isSigned(); $signatureIsValid = $decryptionResult->isSignatureValid(); $structure = Horde_Mime_Part::parseMessage( $decryptionResult->getDecryptedMessage(), ['forcemime' => true], ); } elseif ($isOpaqueSigned || $structure->getType() === 'multipart/signed') { $query = new Horde_Imap_Client_Fetch_Query(); $query->fullText([ 'peek' => true, ]); $headers = $this->client->fetch($this->mailbox, $query, ['ids' => $ids]); /** @var Horde_Imap_Client_Data_Fetch $fullTextFetch */ $fullTextFetch = $headers[$this->uid]; if (is_null($fullTextFetch)) { throw new DoesNotExistException("This email ($this->uid) can't be found. Probably it was deleted from the server recently. Please reload."); } $signedText = $fullTextFetch->getFullMsg(); $isSigned = true; $signatureIsValid = $this->smimeService->verifyMessage($signedText); // Extract opaque signed content (smime-type="signed-data") if ($isOpaqueSigned) { $signedText = $this->smimeService->extractSignedContent($signedText); } $structure = Horde_Mime_Part::parseMessage($signedText, [ 'forcemime' => true, ]); } // debugging below $structure_type = $structure->getPrimaryType(); if ($structure_type === 'multipart') { $i = 1; foreach ($structure->getParts() as $p) { $this->getPart($p, (string)$i++, $isEncrypted || $isSigned); } } else { $bodyPartId = $structure->findBody(); if (!is_null($bodyPartId)) { $this->getPart($structure[$bodyPartId], $bodyPartId, $isEncrypted || $isSigned); } } } elseif (is_null($fetch)) { // Reuse given query or construct a new minimal one $query = new Horde_Imap_Client_Fetch_Query(); $query->envelope(); $query->flags(); $query->imapDate(); $query->headerText([ 'peek' => true, ]); $result = $this->client->fetch($this->mailbox, $query, ['ids' => $ids]); $fetch = $result[$this->uid]; if (is_null($fetch)) { throw new DoesNotExistException("This email ($this->uid) can't be found. Probably it was deleted from the server recently. Please reload."); } } $this->parseHeaders($fetch); $envelope = $fetch->getEnvelope(); return new IMAPMessage( $this->uid, $envelope->message_id, $fetch->getFlags(), AddressList::fromHorde($envelope->from), AddressList::fromHorde($envelope->to), AddressList::fromHorde($envelope->cc), AddressList::fromHorde($envelope->bcc), AddressList::fromHorde($envelope->reply_to), $this->decodeSubject($envelope), $this->plainMessage, $this->htmlMessage, $this->hasHtmlMessage, $this->attachments, $this->inlineAttachments, $this->hasAnyAttachment, $this->scheduling, $fetch->getImapDate(), $this->rawReferences, $this->dispositionNotificationTo, $this->hasDkimSignature, $this->phishingDetails, $this->unsubscribeUrl, $this->isOneClickUnsubscribe, $this->unsubscribeMailto, $envelope->in_reply_to, $isEncrypted, $isSigned, $signatureIsValid, $this->htmlService, // TODO: drop the html service dependency $this->isPgpMimeEncrypted, ); } /** * @param Horde_Mime_Part $p * @param string $partNo * @param bool $isFetched Body is already fetched and contained within the mime part object * @return void * * @throws DoesNotExistException * @throws Horde_Imap_Client_Exception * @throws Horde_Imap_Client_Exception_NoSupportExtension */ private function getPart(Horde_Mime_Part $p, string $partNo, bool $isFetched): void { // iMIP messages // Handle text/calendar parts first because they might be attachments at the same time. // Otherwise, some of the following if-conditions might break the handling and treat iMIP // data like regular attachments. $allContentTypeParameters = $p->getAllContentTypeParameters(); if ($p->getType() === 'text/calendar') { // Handle event data like a regular attachment // Outlook doesn't set a content disposition // We work around this by checking for the name only if ($p->getName() !== null) { $this->attachments[] = [ 'id' => $p->getMimeId(), 'messageId' => $this->uid, 'fileName' => $p->getName(), 'mime' => $p->getType(), 'size' => $p->getBytes(), 'cid' => $p->getContentId(), 'disposition' => $p->getDisposition() ]; } // return if this is an event attachment only // the method parameter determines if this is a iMIP message if (!isset($allContentTypeParameters['method'])) { return; } if (in_array(strtoupper($allContentTypeParameters['method']), ['REQUEST', 'REPLY', 'CANCEL'])) { $this->scheduling[] = [ 'id' => $p->getMimeId(), 'messageId' => $this->uid, 'method' => strtoupper($allContentTypeParameters['method']), 'contents' => $this->loadBodyData($p, $partNo, $isFetched), ]; return; } } $isAttachment = ($p->isAttachment() || $p->getType() === 'message/rfc822') && !in_array($p->getType(), ['application/pgp-signature', 'application/pkcs7-signature', 'application/x-pkcs7-signature']); // Regular attachments if ($isAttachment) { $this->attachments[] = [ 'id' => $p->getMimeId(), 'messageId' => $this->uid, 'fileName' => $p->getName(), 'mime' => $p->getType(), 'size' => $p->getBytes(), 'cid' => $p->getContentId(), 'disposition' => $p->getDisposition() ]; return; } // Inline attachments // Horde doesn't consider parts with content-disposition set to inline as // attachment so we need to use another way to get them. // We use these inline attachments to render a message's html body in $this->getHtmlBody() $filename = $p->getName(); if ($p->getType() === 'message/rfc822' || isset($filename)) { if (in_array($filename, $this->attachmentsToIgnore)) { return; } $this->inlineAttachments[] = [ 'id' => $p->getMimeId(), 'messageId' => $this->uid, 'fileName' => $filename, 'mime' => $p->getType(), 'size' => $p->getBytes(), 'cid' => $p->getContentId() ]; return; } if ($p->getPrimaryType() === 'multipart') { $this->handleMultiPartMessage($p, $partNo, $isFetched); return; } if ($p->getType() === 'text/plain') { $this->handleTextMessage($p, $partNo, $isFetched); return; } if ($p->getType() === 'text/html') { $this->handleHtmlMessage($p, $partNo, $isFetched); return; } // EMBEDDED MESSAGE // Many bounce notifications embed the original message as type 2, // but AOL uses type 1 (multipart), which is not handled here. // There are no PHP functions to parse embedded messages, // so this just appends the raw source to the main message. if ($p[0] === 'message') { $data = $this->loadBodyData($p, $partNo, $isFetched); $this->plainMessage .= trim($data) . "\n\n"; } } /** * @param Horde_Mime_Part $part * @param string $partNo * @param bool $isFetched Body is already fetched and contained within the mime part object * @return void * * @throws DoesNotExistException * @throws Horde_Imap_Client_Exception * @throws Horde_Imap_Client_Exception_NoSupportExtension */ private function handleMultiPartMessage(Horde_Mime_Part $part, string $partNo, bool $isFetched): void { $i = 1; foreach ($part->getParts() as $p) { $this->getPart($p, "$partNo.$i", $isFetched); $i++; } } /** * @param Horde_Mime_Part $p * @param string $partNo * @param bool $isFetched Body is already fetched and contained within the mime part object * @return void * * @throws DoesNotExistException * @throws Horde_Imap_Client_Exception * @throws Horde_Imap_Client_Exception_NoSupportExtension */ private function handleTextMessage(Horde_Mime_Part $p, string $partNo, bool $isFetched): void { $data = $this->loadBodyData($p, $partNo, $isFetched); $this->plainMessage .= trim($data) . "\n\n"; } /** * @param Horde_Mime_Part $p * @param string $partNo * @param bool $isFetched Body is already fetched and contained within the mime part object * @return void * * @throws DoesNotExistException * @throws Horde_Imap_Client_Exception * @throws Horde_Imap_Client_Exception_NoSupportExtension */ private function handleHtmlMessage(Horde_Mime_Part $p, string $partNo, bool $isFetched): void { $this->hasHtmlMessage = true; $data = $this->loadBodyData($p, $partNo, $isFetched); $this->htmlMessage .= $data . '

'; } /** * @param Horde_Mime_Part $p * @param string $partNo * @param bool $isFetched Body is already fetched and contained within the mime part object * @return string * * @throws DoesNotExistException * @throws Horde_Imap_Client_Exception * @throws Horde_Imap_Client_Exception_NoSupportExtension * @throws ServiceException */ private function loadBodyData(Horde_Mime_Part $p, string $partNo, bool $isFetched): string { if (!$isFetched) { $fetch_query = new Horde_Imap_Client_Fetch_Query(); $ids = new Horde_Imap_Client_Ids($this->uid); $fetch_query->bodyPart($partNo, [ 'peek' => true ]); $fetch_query->mimeHeader($partNo, [ 'peek' => true ]); $headers = $this->client->fetch($this->mailbox, $fetch_query, ['ids' => $ids]); /* @var $fetch Horde_Imap_Client_Data_Fetch */ $fetch = $headers[$this->uid]; if (is_null($fetch)) { throw new DoesNotExistException("Mail body for this mail($this->uid) could not be loaded"); } $mimeHeaders = $fetch->getMimeHeader($partNo, Horde_Imap_Client_Data_Fetch::HEADER_PARSE); if ($enc = $mimeHeaders->getValue('content-transfer-encoding')) { $p->setTransferEncoding($enc); } $data = $fetch->getBodyPart($partNo); $p->setContents($data); } return $this->converter->convert($p); } private function hasAttachments(Horde_Mime_Part $part): bool { foreach ($part->getParts() as $p) { if ($p->isAttachment() || $p->getType() === 'message/rfc822') { return true; } if ($this->hasAttachments($p)) { return true; } } return false; } private function decodeSubject(Horde_Imap_Client_Data_Envelope $envelope): string { // Try a soft conversion first (some installations, eg: Alpine linux, // have issues with the '//IGNORE' option) $subject = $envelope->subject; $utf8 = iconv('UTF-8', 'UTF-8', $subject); if ($utf8 !== false) { return $utf8; } $utf8Ignored = iconv('UTF-8', 'UTF-8//IGNORE', $subject); if ($utf8Ignored === false) { // Give up return $subject; } return $utf8Ignored; } private function parseHeaders(Horde_Imap_Client_Data_Fetch $fetch): void { /** @var resource $headersStream */ $headersStream = $fetch->getHeaderText('0', Horde_Imap_Client_Data_Fetch::HEADER_STREAM); $parsedHeaders = Horde_Mime_Headers::parseHeaders($headersStream); fclose($headersStream); $references = $parsedHeaders->getHeader('references'); if ($references !== null) { $this->rawReferences = $references->value_single; } $dispositionNotificationTo = $parsedHeaders->getHeader('disposition-notification-to'); if ($dispositionNotificationTo !== null) { $this->dispositionNotificationTo = $dispositionNotificationTo->value_single; } $dkimSignatureHeader = $parsedHeaders->getHeader('dkim-signature'); $this->hasDkimSignature = $dkimSignatureHeader !== null; if ($this->runPhishingCheck) { $this->phishingDetails = $this->phishingDetectionService->checkHeadersForPhishing($parsedHeaders, $this->hasHtmlMessage, $this->htmlMessage); } $listUnsubscribeHeader = $parsedHeaders->getHeader('list-unsubscribe'); if ($listUnsubscribeHeader !== null) { $listHeaders = new Horde_ListHeaders(); $headers = $listHeaders->parse($listUnsubscribeHeader->name, $listUnsubscribeHeader->value_single); if (!$headers) { // Unable to parse headers return; } foreach ($headers as $header) { if (str_starts_with($header->url, 'http')) { $this->unsubscribeUrl = $header->url; $unsubscribePostHeader = $parsedHeaders->getHeader('List-Unsubscribe-Post'); if ($unsubscribePostHeader !== null) { $this->isOneClickUnsubscribe = strtolower($unsubscribePostHeader->value_single) === 'list-unsubscribe=one-click'; } break; } if (str_starts_with($header->url, 'mailto')) { $this->unsubscribeMailto = $header->url; break; } } } } }