f7cloud_client/apps/mail/lib/IMAP/ImapMessageFetcher.php
root 8b6a0139db f7cloud_client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 22:59:26 +00:00

564 lines
18 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 F7cloud GmbH and F7cloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\IMAP;
use Horde_Imap_Client_Base;
use Horde_Imap_Client_Data_Envelope;
use Horde_Imap_Client_Data_Fetch;
use Horde_Imap_Client_Exception;
use Horde_Imap_Client_Exception_NoSupportExtension;
use Horde_Imap_Client_Fetch_Query;
use Horde_Imap_Client_Ids;
use Horde_ListHeaders;
use Horde_Mime_Exception;
use Horde_Mime_Headers;
use Horde_Mime_Part;
use OCA\Mail\AddressList;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Exception\SmimeDecryptException;
use OCA\Mail\IMAP\Charset\Converter;
use OCA\Mail\Model\IMAPMessage;
use OCA\Mail\Service\Html;
use OCA\Mail\Service\PhishingDetection\PhishingDetectionService;
use OCA\Mail\Service\SmimeService;
use OCP\AppFramework\Db\DoesNotExistException;
use function str_starts_with;
use function strtolower;
class ImapMessageFetcher {
/** @var string[] */
private array $attachmentsToIgnore = ['signature.asc', 'smime.p7s'];
private Html $htmlService;
private SmimeService $smimeService;
private PhishingDetectionService $phishingDetectionService;
private string $userId;
private bool $runPhishingCheck = false;
// Conditional fetching/parsing
private bool $loadBody = false;
private int $uid;
private Horde_Imap_Client_Base $client;
private string $htmlMessage = '';
private string $plainMessage = '';
private array $attachments = [];
private array $inlineAttachments = [];
private bool $hasAnyAttachment = false;
private array $scheduling = [];
private bool $hasHtmlMessage = false;
private string $mailbox;
private string $rawReferences = '';
private string $dispositionNotificationTo = '';
private bool $hasDkimSignature = false;
private array $phishingDetails = [];
private ?string $unsubscribeUrl = null;
private bool $isOneClickUnsubscribe = false;
private ?string $unsubscribeMailto = null;
private bool $isPgpMimeEncrypted = false;
public function __construct(
int $uid,
string $mailbox,
Horde_Imap_Client_Base $client,
string $userId,
Html $htmlService,
SmimeService $smimeService,
private Converter $converter,
PhishingDetectionService $phishingDetectionService,
) {
$this->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 . '<br><br>';
}
/**
* @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;
}
}
}
}
}