diff --git a/appinfo/info.xml b/appinfo/info.xml index 9ab13f49cb..ec6726f493 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -34,7 +34,7 @@ The rating depends on the installed text processing backend. See [the rating ove Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud.com/blog/nextcloud-ethical-ai-rating/). ]]> - 5.8.0-dev.2 + 5.8.0-dev.3 agpl Christoph Wurst GretaD diff --git a/composer.json b/composer.json index 2a77c697b2..e20d92d22d 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,7 @@ "scripts": { "cs:check": "php-cs-fixer fix --dry-run --diff", "cs:fix": "php-cs-fixer fix", - "lint": "find . -name \\*.php -not -path './vendor*/*' -print0 | xargs -0 -n1 php -l", + "lint": "find . -name \\*.php -not -path './vendor*/*' -not -path './tests/stubs/*' -print0 | xargs -0 -n1 php -l", "psalm": "psalm.phar", "psalm:fix": "psalm.phar --alter --issues=InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType,MismatchingDocblockReturnType,MissingParamType,InvalidFalsableReturnType", "post-install-cmd": [ diff --git a/lib/Attachment.php b/lib/Attachment.php index 5912b7e465..5c1d1bb0c8 100644 --- a/lib/Attachment.php +++ b/lib/Attachment.php @@ -23,6 +23,8 @@ public function __construct( string $type, string $content, int $size, + public readonly ?string $contentId, + public readonly ?string $disposition, ) { $this->id = $id; $this->name = $name; @@ -32,12 +34,19 @@ public function __construct( } public static function fromMimePart(Horde_Mime_Part $mimePart): self { + $disposition = $mimePart->getDisposition(); + if ($disposition === '') { + $disposition = null; + } + return new Attachment( $mimePart->getMimeId(), $mimePart->getName(), $mimePart->getType(), $mimePart->getContents(), (int)$mimePart->getBytes(), + $mimePart->getContentId(), + $disposition, ); } diff --git a/lib/Contracts/IAttachmentService.php b/lib/Contracts/IAttachmentService.php index 243cdd186a..37e2109692 100644 --- a/lib/Contracts/IAttachmentService.php +++ b/lib/Contracts/IAttachmentService.php @@ -12,6 +12,7 @@ use OCA\Mail\Db\LocalAttachment; use OCA\Mail\Exception\AttachmentNotFoundException; use OCA\Mail\Service\Attachment\UploadedFile; +use OCP\Files\SimpleFS\ISimpleFile; interface IAttachmentService { /** @@ -22,8 +23,8 @@ public function addFile(string $userId, UploadedFile $file): LocalAttachment; /** * Try to get an attachment by id * + * @return array{0: LocalAttachment, 1: ISimpleFile} * @throws AttachmentNotFoundException - * @return array of LocalAttachment and ISimpleFile */ public function getAttachment(string $userId, int $id): array; diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php index 84ebc9fab8..f29e63f4e3 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -35,6 +35,7 @@ use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\JSONResponse; @@ -645,9 +646,7 @@ public function getHtmlBody(int $id, bool $plain = false): Response { $mailbox, $message->getUid(), true - )->getHtmlBody( - $id - ); + )->getHtmlBody($id); } finally { $client->logout(); } diff --git a/lib/Db/LocalAttachment.php b/lib/Db/LocalAttachment.php index 661ad1823d..8c2e893c77 100644 --- a/lib/Db/LocalAttachment.php +++ b/lib/Db/LocalAttachment.php @@ -20,12 +20,20 @@ * @method void setFileName(string $fileName) * @method string getMimeType() * @method void setMimeType(string $mimeType) + * @method string|null getContentId() + * @method void setContentId(?string $contentId) + * @method string|null getDisposition() + * @method void setDisposition(?string $disposition) * @method int|null getCreatedAt() * @method void setCreatedAt(int $createdAt) * @method int|null getLocalMessageId() * @method void setLocalMessageId(int $localMessageId) */ class LocalAttachment extends Entity implements JsonSerializable { + public const DISPOSITION_ATTACHMENT = 'attachment'; + public const DISPOSITION_INLINE = 'inline'; + public const DISPOSITION_OMIT = null; + /** @var string */ protected $userId; @@ -35,6 +43,12 @@ class LocalAttachment extends Entity implements JsonSerializable { /** @var string */ protected $mimeType; + /** @var ?string */ + protected $contentId; + + /** @var ?string */ + protected $disposition; + /** @var int|null */ protected $createdAt; @@ -49,8 +63,14 @@ public function jsonSerialize() { 'type' => 'local', 'fileName' => $this->fileName, 'mimeType' => $this->mimeType, + 'contentId' => $this->contentId, + 'disposition' => $this->disposition, 'createdAt' => $this->createdAt, 'localMessageId' => $this->localMessageId ]; } + + public function isDispositionAttachmentOrInline(): bool { + return $this->disposition === self::DISPOSITION_ATTACHMENT || $this->disposition === self::DISPOSITION_INLINE; + } } diff --git a/lib/IMAP/ImapMessageFetcher.php b/lib/IMAP/ImapMessageFetcher.php index 8ae03cee31..191aba6eb3 100644 --- a/lib/IMAP/ImapMessageFetcher.php +++ b/lib/IMAP/ImapMessageFetcher.php @@ -33,6 +33,9 @@ use function str_starts_with; use function strtolower; +/** + * @psalm-import-type IMAPAttachment from IMAPMessage + */ class ImapMessageFetcher { /** @var string[] */ private array $attachmentsToIgnore = ['signature.asc', 'smime.p7s']; @@ -50,7 +53,9 @@ class ImapMessageFetcher { private Horde_Imap_Client_Base $client; private string $htmlMessage = ''; private string $plainMessage = ''; + /** @var list */ private array $attachments = []; + /** @var list */ private array $inlineAttachments = []; private bool $hasAnyAttachment = false; private array $scheduling = []; @@ -369,7 +374,8 @@ private function getPart(Horde_Mime_Part $p, string $partNo, bool $isFetched): v 'fileName' => $filename, 'mime' => $p->getType(), 'size' => $p->getBytes(), - 'cid' => $p->getContentId() + 'cid' => $p->getContentId(), + 'disposition' => $p->getDisposition() ]; return; } diff --git a/lib/IMAP/MessageMapper.php b/lib/IMAP/MessageMapper.php index 2602203bc4..67ab76fdd7 100644 --- a/lib/IMAP/MessageMapper.php +++ b/lib/IMAP/MessageMapper.php @@ -20,7 +20,7 @@ use Horde_Imap_Client_Socket; use Horde_Mime_Exception; use Horde_Mime_Headers; -use Horde_Mime_Headers_ContentParam; +use Horde_Mime_Headers_ContentParam_ContentDisposition; use Horde_Mime_Headers_ContentParam_ContentType; use Horde_Mime_Headers_ContentTransferEncoding; use Horde_Mime_Part; @@ -801,24 +801,15 @@ public function getAttachment(Horde_Imap_Client_Base $client, $mimePart = new Horde_Mime_Part(); - // Serve all files with a content-disposition of "attachment" to prevent Cross-Site Scripting - $mimePart->setDisposition('attachment'); + $contentId = $mimeHeaders['content-id']?->value_single; + if ($contentId !== null) { + $mimePart->setContentId($contentId); + } - // Extract headers from part - $cdEl = $mimeHeaders['content-disposition']; - $contentDisposition = $cdEl instanceof Horde_Mime_Headers_ContentParam - ? array_change_key_case($cdEl->params, CASE_LOWER) - : null; - if (!is_null($contentDisposition) && isset($contentDisposition['filename'])) { - $mimePart->setDispositionParameter('filename', $contentDisposition['filename']); - } else { - $ctEl = $mimeHeaders['content-type']; - $contentTypeParams = $ctEl instanceof Horde_Mime_Headers_ContentParam - ? array_change_key_case($ctEl->params, CASE_LOWER) - : null; - if (isset($contentTypeParams['name'])) { - $mimePart->setContentTypeParameter('name', $contentTypeParams['name']); - } + $contentDisposition = $mimeHeaders['content-disposition']; + if ($contentDisposition instanceof Horde_Mime_Headers_ContentParam_ContentDisposition) { + $mimePart->setDisposition($contentDisposition->value_single); + $mimePart->setDispositionParameter('filename', $contentDisposition['filename'] ?? null); } // Content transfer encoding @@ -827,17 +818,15 @@ public function getAttachment(Horde_Imap_Client_Base $client, $mimePart->setTransferEncoding($tmp); } - /* Content type */ - $contentType = $mimeHeaders['content-type']?->value_single; - if (!is_null($contentType) && str_contains($contentType, 'text/calendar')) { - $mimePart->setType('text/calendar'); - if ($mimePart->getContentTypeParameter('name') === null) { - $mimePart->setContentTypeParameter('name', 'calendar.ics'); + $contentType = $mimeHeaders['content-type']; + if ($contentType instanceof Horde_Mime_Headers_ContentParam_ContentType) { + if (str_contains($contentType->value_single, 'text/calendar')) { + $mimePart->setType('text/calendar'); + $mimePart->setContentTypeParameter('name', $contentType['name'] ?? 'calendar.ics'); + } else { + $mimePart->setType($contentType->value_single); + $mimePart->setContentTypeParameter('name', $contentType['name'] ?? null); } - } else { - // To prevent potential problems with the SOP we serve all files but calendar entries with the - // MIME type "application/octet-stream" - $mimePart->setType('application/octet-stream'); } $mimePart->setContents($body); diff --git a/lib/Migration/Version5008Date20260320125737.php b/lib/Migration/Version5008Date20260320125737.php new file mode 100644 index 0000000000..c05515b8b9 --- /dev/null +++ b/lib/Migration/Version5008Date20260320125737.php @@ -0,0 +1,47 @@ +hasTable('mail_attachments')) { + $attachmentsTable = $schema->getTable('mail_attachments'); + $attachmentsTable->addColumn('content_id', Types::STRING, [ + 'notnull' => false, + ]); + $attachmentsTable->addColumn('disposition', Types::STRING, [ + 'notnull' => false, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/IMAPMessage.php b/lib/Model/IMAPMessage.php index d4608805bc..040d59c5b7 100644 --- a/lib/Model/IMAPMessage.php +++ b/lib/Model/IMAPMessage.php @@ -33,6 +33,16 @@ /** * @psalm-import-type MailIMAPFullMessage from ResponseDefinitions + * + * @psalm-type IMAPAttachment = array{ + * id: string|null, + * messageId: int, + * fileName: string|null, + * mime: string, + * size: int, + * cid: string|null, + * disposition: string, + * } */ class IMAPMessage implements IMessage, JsonSerializable { use ConvertAddresses; @@ -53,6 +63,7 @@ class IMAPMessage implements IMessage, JsonSerializable { public string $plainMessage; public string $htmlMessage; public array $attachments; + /** @var list */ public array $inlineAttachments; private bool $hasAttachments; public array $scheduling; @@ -71,6 +82,9 @@ class IMAPMessage implements IMessage, JsonSerializable { private bool $signatureIsValid; private bool $isPgpMimeEncrypted; + /** + * @param list $inlineAttachments + */ public function __construct(int $uid, string $messageId, array $flags, @@ -304,6 +318,7 @@ public function getFullMessage(int $id, bool $loadBody = true): array { if ($this->hasHtmlMessage) { $data['hasHtmlBody'] = true; $data['attachments'] = $this->attachments; + $data['inlineAttachments'] = $this->inlineAttachments; return $data; } @@ -349,17 +364,11 @@ public function jsonSerialize() { * @return string */ public function getHtmlBody(int $id): string { - return $this->htmlService->sanitizeHtmlMailBody($this->htmlMessage, [ - 'id' => $id, - ], function ($cid) { - $match = array_filter($this->inlineAttachments, - static fn ($a) => $a['cid'] === $cid); - $match = array_shift($match); - if ($match === null) { - return null; - } - return $match['id']; - }); + return $this->htmlService->sanitizeHtmlMailBody( + $id, + $this->htmlMessage, + $this->inlineAttachments, + ); } /** diff --git a/lib/Provider/Command/MessageSend.php b/lib/Provider/Command/MessageSend.php index 801db70d3b..bad5ea7dd4 100644 --- a/lib/Provider/Command/MessageSend.php +++ b/lib/Provider/Command/MessageSend.php @@ -91,11 +91,29 @@ public function perform(string $userId, string $serviceId, IMessage $message, ar $attachments = []; try { foreach ($message->getAttachments() as $entry) { + /* + * The mail provider API has no disposition field, so we infer it: + * omit the Content-Disposition header (null) when name is null, + * otherwise default to attachment. + * + * See https://github.com/nextcloud/mail/issues/10416 for the original motivation. + * + * Previously handled in TransmissionService::handleAttachment; + * moved here now that LocalAttachment carries a disposition field. + */ + if ($entry->getName() === null) { + $disposition = LocalAttachment::DISPOSITION_OMIT; + } else { + $disposition = LocalAttachment::DISPOSITION_ATTACHMENT; + } + $attachments[] = $this->attachmentService->addFileFromString( $userId, (string)$entry->getName(), (string)$entry->getType(), - (string)$entry->getContents() + (string)$entry->getContents(), + null, + $disposition, ); } } catch (UploadException $e) { diff --git a/lib/Service/AntiSpamService.php b/lib/Service/AntiSpamService.php index d991f036c9..464edbc9ab 100644 --- a/lib/Service/AntiSpamService.php +++ b/lib/Service/AntiSpamService.php @@ -157,10 +157,12 @@ public function sendReportEmail(Account $account, Mailbox $mailbox, int $uid, st $mimeMessage = new MimeMessage( new DataUriParser() ); + $mimePart = $mimeMessage->build( null, $message->getContent(), - $message->getAttachments() + false, + array_values($message->getAttachments()), ); $mail->setBasePart($mimePart); diff --git a/lib/Service/Attachment/AttachmentService.php b/lib/Service/Attachment/AttachmentService.php index 2716101d3e..4706442474 100644 --- a/lib/Service/Attachment/AttachmentService.php +++ b/lib/Service/Attachment/AttachmentService.php @@ -32,6 +32,7 @@ use OCP\Files\IMimeTypeDetector; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFile; use OCP\ICacheFactory; use OCP\IURLGenerator; use Psr\Log\LoggerInterface; @@ -94,6 +95,8 @@ public function addFile(string $userId, UploadedFile $file): LocalAttachment { $attachment->setUserId($userId); $attachment->setFileName($file->getFileName()); $attachment->setMimeType($file->getMimeType()); + $attachment->setContentId(null); + $attachment->setDisposition(LocalAttachment::DISPOSITION_ATTACHMENT); $attachment->setCreatedAt($this->timeFactory->getTime()); $persisted = $this->mapper->insert($attachment); @@ -108,11 +111,21 @@ public function addFile(string $userId, UploadedFile $file): LocalAttachment { return $attachment; } - public function addFileFromString(string $userId, string $name, string $mime, string $fileContents): LocalAttachment { + public function addFileFromString( + string $userId, + string $name, + string $mime, + string $fileContents, + ?string $contentId, + ?string $disposition, + + ): LocalAttachment { $attachment = new LocalAttachment(); $attachment->setUserId($userId); $attachment->setFileName($name); $attachment->setMimeType($mime); + $attachment->setContentId($contentId); + $attachment->setDisposition($disposition); $attachment->setCreatedAt($this->timeFactory->getTime()); $persisted = $this->mapper->insert($attachment); @@ -128,11 +141,9 @@ public function addFileFromString(string $userId, string $name, string $mime, st } /** - * @param string $userId - * @param int $id - * - * @return array of LocalAttachment and ISimpleFile + * Try to get an attachment by id * + * @return array{0: LocalAttachment, 1: ISimpleFile} * @throws AttachmentNotFoundException */ #[\Override] @@ -252,7 +263,7 @@ public function handleAttachments(Account $account, array $attachments, \Horde_I $attachmentIds[] = $this->handleForwardedMessageAttachment($account, $attachment, $client); continue; } - if ($attachment['type'] === 'message-attachment') { + if ($attachment['type'] === 'message-attachment' || $attachment['type'] === 'message-attachment-inline') { // Adds an attachment from another email (use case is, eg., a mail forward) $attachmentIds[] = $this->handleForwardedAttachment($account, $attachment, $client); continue; @@ -345,7 +356,7 @@ private function handleForwardedMessageAttachment(Account $account, array $attac } try { - $localAttachment = $this->addFileFromString($account->getUserId(), $attachment['fileName'] ?? $attachmentMessage->getSubject() . '.eml', $mime, $fullText); + $localAttachment = $this->addFileFromString($account->getUserId(), $attachment['fileName'] ?? $attachmentMessage->getSubject() . '.eml', $mime, $fullText, null, null); } catch (UploadException $e) { $this->logger->error('Could not create attachment', ['exception' => $e]); return null; @@ -365,32 +376,23 @@ private function handleForwardedMessageAttachment(Account $account, array $attac private function handleForwardedAttachment(Account $account, array $attachment, \Horde_Imap_Client_Socket $client): ?int { $mailbox = $this->mailManager->getMailbox($account->getUserId(), $attachment['mailboxId']); - $attachments = $this->messageMapper->getRawAttachments( + $imapAttachment = $this->messageMapper->getAttachment( $client, $mailbox->getName(), (int)$attachment['uid'], + $attachment['id'], $account->getUserId(), - [ - $attachment['id'] ?? [] - ] ); - if ($attachments === []) { - return null; - } - - // detect mime type - $mime = 'application/octet-stream'; - if (extension_loaded('fileinfo')) { - $finfo = new finfo(FILEINFO_MIME_TYPE); - $detectedMime = $finfo->buffer($attachments[0]); - if ($detectedMime !== false) { - $mime = $detectedMime; - } - } - try { - $localAttachment = $this->addFileFromString($account->getUserId(), $attachment['fileName'], $mime, $attachments[0]); + $localAttachment = $this->addFileFromString( + $account->getUserId(), + $imapAttachment->getName() ?? 'unknown', + $imapAttachment->getType(), + $imapAttachment->getContent(), + $imapAttachment->contentId, + $imapAttachment->disposition, + ); } catch (UploadException $e) { $this->logger->error('Could not create attachment', ['exception' => $e]); return null; @@ -439,7 +441,7 @@ private function handleCloudAttachment(Account $account, array $attachment): ?in } try { - $localAttachment = $this->addFileFromString($account->getUserId(), $file->getName(), $file->getMimeType(), $file->getContent()); + $localAttachment = $this->addFileFromString($account->getUserId(), $file->getName(), $file->getMimeType(), $file->getContent(), null, null); } catch (UploadException $e) { $this->logger->error('Could not create attachment', ['exception' => $e]); return null; diff --git a/lib/Service/DataUri/DataUri.php b/lib/Service/DataUri/DataUri.php index 0bda5ec072..1bcf6799a9 100644 --- a/lib/Service/DataUri/DataUri.php +++ b/lib/Service/DataUri/DataUri.php @@ -10,16 +10,16 @@ namespace OCA\Mail\Service\DataUri; final class DataUri { - private string $mediaType; - private array $parameters; - private bool $base64; - private string $data; - - public function __construct(string $mediaType, array $parameters, bool $base64, $data) { - $this->mediaType = $mediaType; - $this->parameters = $parameters; - $this->base64 = $base64; - $this->data = $data; + + /** + * @param array{charset: string, ...} $parameters + */ + public function __construct( + private readonly string $mediaType, + private readonly array $parameters, + private readonly bool $base64, + private readonly string $data, + ) { } public function getMediaType(): string { diff --git a/lib/Service/Html.php b/lib/Service/Html.php index fd6c0cf087..e5524df941 100755 --- a/lib/Service/Html.php +++ b/lib/Service/Html.php @@ -10,13 +10,14 @@ namespace OCA\Mail\Service; -use Closure; use HTMLPurifier; use HTMLPurifier_Config; use HTMLPurifier_HTMLDefinition; use HTMLPurifier_URIDefinition; use HTMLPurifier_URISchemeRegistry; +use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Service\HtmlPurify\CidURIScheme; +use OCA\Mail\Service\HtmlPurify\TransformCidDataAttr; use OCA\Mail\Service\HtmlPurify\TransformHTMLLinks; use OCA\Mail\Service\HtmlPurify\TransformImageSrc; use OCA\Mail\Service\HtmlPurify\TransformNoReferrer; @@ -33,6 +34,9 @@ require_once __DIR__ . '/../../vendor/cerdic/css-tidy/class.csstidy.php'; +/** + * @psalm-import-type IMAPAttachment from IMAPMessage + */ class Html { /** @var IURLGenerator */ private $urlGenerator; @@ -73,9 +77,9 @@ public function convertLinks(string $data): string { // TODO: Fix this - requires https://github.com/owncloud/core/issues/10767 to be fixed $config->set('Cache.DefinitionImpl', null); - /** @var HTMLPurifier_HTMLDefinition $uri */ - $uri = $config->getDefinition('HTML'); - $uri->info_attr_transform_post['noreferrer'] = new TransformNoReferrer(); + /** @var HTMLPurifier_HTMLDefinition $def */ + $def = $config->getHTMLDefinition(true); + $def->info_attr_transform_post['noreferrer'] = new TransformNoReferrer(); $purifier = new HTMLPurifier($config); @@ -102,7 +106,28 @@ public function parseMailBody(string $body): array { ]; } - public function sanitizeHtmlMailBody(string $mailBody, array $messageParameters, Closure $mapCidToAttachmentId): string { + /** + * @param list $inlineAttachments + * @return list + */ + private function addAttachmentUrl(int $messageId, array $inlineAttachments): array { + return array_map(function (array $inlineAttachment) use ($messageId) { + $inlineAttachment['url'] = $this->urlGenerator->linkToRouteAbsolute( + 'mail.messages.downloadAttachment', [ + 'id' => $messageId, + 'attachmentId' => $inlineAttachment['id'] + ] + ); + return $inlineAttachment; + }, $inlineAttachments); + } + + /** + * @param list $inlineAttachments + */ + public function sanitizeHtmlMailBody(int $messageId, string $mailBody, array $inlineAttachments): string { + $inlineAttachments = $this->addAttachmentUrl($messageId, $inlineAttachments); + $config = HTMLPurifier_Config::createDefault(); // Append target="_blank" to all link (a) elements @@ -122,15 +147,18 @@ public function sanitizeHtmlMailBody(string $mailBody, array $messageParameters, $config->set('Cache.DefinitionImpl', null); // Rewrite URL for redirection and proxying of content - /** @var HTMLPurifier_HTMLDefinition $html */ - $html = $config->getDefinition('HTML'); - $html->info_attr_transform_post['imagesrc'] = new TransformImageSrc($this->urlGenerator); - $html->info_attr_transform_post['cssbackground'] = new TransformStyleURLs($this->urlGenerator); - $html->info_attr_transform_post['htmllinks'] = new TransformHTMLLinks(); + /** @var HTMLPurifier_HTMLDefinition $def */ + $def = $config->getHTMLDefinition(true); + $def->info_attr_transform_post['imagesrc'] = new TransformImageSrc($this->urlGenerator); + $def->info_attr_transform_post['cssbackground'] = new TransformStyleURLs($this->urlGenerator); + $def->info_attr_transform_post['htmllinks'] = new TransformHTMLLinks(); + if (count($inlineAttachments) > 0) { + $def->info_attr_transform_post['datacid'] = new TransformCidDataAttr($inlineAttachments); + } /** @var HTMLPurifier_URIDefinition $uri */ - $uri = $config->getDefinition('URI'); - $uri->addFilter(new TransformURLScheme($messageParameters, $mapCidToAttachmentId, $this->urlGenerator, $this->request), $config); + $uri = $config->getURIDefinition(true); + $uri->addFilter(new TransformURLScheme($inlineAttachments, $this->urlGenerator, $this->request), $config); $uriSchemeRegistry = HTMLPurifier_URISchemeRegistry::instance(); $uriSchemeRegistry->register('cid', new CidURIScheme()); diff --git a/lib/Service/HtmlPurify/TransformCidDataAttr.php b/lib/Service/HtmlPurify/TransformCidDataAttr.php new file mode 100644 index 0000000000..bfe5083e64 --- /dev/null +++ b/lib/Service/HtmlPurify/TransformCidDataAttr.php @@ -0,0 +1,59 @@ + path => cid */ + private array $pathToCid; + + public function __construct(array $inlineAttachments) { + $this->parser = new HTMLPurifier_URIParser(); + $this->pathToCid = []; + foreach ($inlineAttachments as $inlineAttachment) { + if (isset($inlineAttachment['cid'], $inlineAttachment['url'])) { + $url = $this->parser->parse($inlineAttachment['url']); + $this->pathToCid[$url->path] = $inlineAttachment['cid']; + } + } + } + + /** + * @param array $attr + * @param HTMLPurifier_Config $config + * @param HTMLPurifier_Context $context + * @return array + */ + #[\Override] + public function transform($attr, $config, $context) { + if ($context->get('CurrentToken')->name !== 'img' || !isset($attr['src'])) { + return $attr; + } + + $url = $this->parser->parse($attr['src']); + $cid = $this->pathToCid[$url->path] ?? null; + + if ($cid !== null) { + $attr['data-cid'] = $cid; + } + + return $attr; + } +} diff --git a/lib/Service/HtmlPurify/TransformURLScheme.php b/lib/Service/HtmlPurify/TransformURLScheme.php index 61bab3eaed..44c001c134 100644 --- a/lib/Service/HtmlPurify/TransformURLScheme.php +++ b/lib/Service/HtmlPurify/TransformURLScheme.php @@ -10,8 +10,8 @@ namespace OCA\Mail\Service\HtmlPurify; -use Closure; use HTMLPurifier_Config; +use HTMLPurifier_Context; use HTMLPurifier_URI; use HTMLPurifier_URIFilter; use HTMLPurifier_URIParser; @@ -19,31 +19,13 @@ use OCP\IURLGenerator; class TransformURLScheme extends HTMLPurifier_URIFilter { - public $name = 'TransformURLScheme'; - public $post = true; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** @var IRequest */ - private $request; - - /** - * @var \Closure - */ - private $mapCidToAttachmentId; - - /** @var array */ - private $messageParameters; - - public function __construct(array $messageParameters, - Closure $mapCidToAttachmentId, - IURLGenerator $urlGenerator, - IRequest $request) { - $this->messageParameters = $messageParameters; - $this->mapCidToAttachmentId = $mapCidToAttachmentId; - $this->urlGenerator = $urlGenerator; - $this->request = $request; + public function __construct( + private array $inlineAttachments, + private IURLGenerator $urlGenerator, + private IRequest $request, + ) { + $this->name = 'TransformURLScheme'; + $this->post = true; } /** @@ -51,7 +33,7 @@ public function __construct(array $messageParameters, * * @param \HTMLPurifier_URI $uri * @param HTMLPurifier_Config $config - * @param \HTMLPurifier_Context $context + * @param HTMLPurifier_Context $context * @return bool */ #[\Override] @@ -67,27 +49,13 @@ public function filter(&$uri, $config, $context) { } if ($uri->scheme === 'cid') { - $attachmentId = $this->mapCidToAttachmentId->__invoke($uri->path); - if (is_null($attachmentId)) { - return true; - } - $this->messageParameters['attachmentId'] = $attachmentId; - - $imgUrl = $this->urlGenerator->linkToRouteAbsolute('mail.messages.downloadAttachment', - $this->messageParameters); - $parser = new HTMLPurifier_URIParser(); - $uri = $parser->parse($imgUrl); + $uri = $this->replaceCidWithUrl($uri); } return true; } - /** - * @param HTMLPurifier_URI $uri - * @param \HTMLPurifier_Context $context - * @return HTMLPurifier_URI - */ - private function filterHttpFtp(&$uri, $context) { + private function filterHttpFtp(HTMLPurifier_URI $uri, HTMLPurifier_Context $context): HTMLPurifier_URI { $originalURL = urlencode($uri->scheme . '://' . $uri->host); // Add the port if it's not a default port @@ -125,4 +93,16 @@ private function filterHttpFtp(&$uri, $context) { null ); } + + private function replaceCidWithUrl(HTMLPurifier_URI $uri): HTMLPurifier_URI { + $inlineAttachment = array_find($this->inlineAttachments, static function ($attachment) use ($uri) { + return $attachment['cid'] === $uri->path; + }); + + if ($inlineAttachment === null) { + return $uri; + } + + return (new HTMLPurifier_URIParser())->parse($inlineAttachment['url']); + } } diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index be808fc6fb..eed9ed8b6f 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -137,8 +137,8 @@ public function sendMessage(Account $account, LocalMessage $localMessage): void $mimePart = $mimeMessage->build( $localMessage->getBodyPlain(), $localMessage->getBodyHtml(), + $localMessage->isPgpMime() === true, $attachmentParts, - $localMessage->isPgpMime() === true ); // TODO: add smimeEncrypt check if implemented diff --git a/lib/Service/MimeMessage.php b/lib/Service/MimeMessage.php index 57873b0f18..1188d18500 100644 --- a/lib/Service/MimeMessage.php +++ b/lib/Service/MimeMessage.php @@ -26,15 +26,19 @@ public function __construct(DataUriParser $uriParser) { } /** - * generates mime message + * Generates mime message * - * @param string $contentPlain - * @param string $contentHtml - * @param Horde_Mime_Part[] $attachments + * @param list $attachments * * @return Horde_Mime_Part */ - public function build(?string $contentPlain, ?string $contentHtml, array $attachments, bool $isPgpEncrypted = false): Horde_Mime_Part { + public function build( + ?string $contentPlain, + ?string $contentHtml, + bool $isPgpEncrypted, + array $attachments, + ): Horde_Mime_Part { + [$inlineAttachments, $attachments] = $this->separateAttachments($attachments); if ($isPgpEncrypted === true && isset($contentPlain)) { $basePart = $this->buildPgpPart($contentPlain); @@ -44,12 +48,12 @@ public function build(?string $contentPlain, ?string $contentHtml, array $attach */ $basePart = new Horde_Mime_Part(); $basePart->setType('multipart/mixed'); - $basePart[] = $this->buildMessagePart($contentPlain, $contentHtml); + $basePart[] = $this->buildMessagePart($contentPlain, $contentHtml, $inlineAttachments); foreach ($attachments as $attachment) { $basePart[] = $attachment; } } else { - $basePart = $this->buildMessagePart($contentPlain, $contentHtml); + $basePart = $this->buildMessagePart($contentPlain, $contentHtml, $inlineAttachments); } $basePart->isBasePart(true); @@ -60,9 +64,11 @@ public function build(?string $contentPlain, ?string $contentHtml, array $attach /** * generates html/plain message part * + * @param list $inlineAttachments + * * @return Horde_Mime_Part */ - private function buildMessagePart(?string $contentPlain, ?string $contentHtml): Horde_Mime_Part { + private function buildMessagePart(?string $contentPlain, ?string $contentHtml, array $inlineAttachments): Horde_Mime_Part { if (isset($contentHtml)) { @@ -73,39 +79,9 @@ private function buildMessagePart(?string $contentPlain, ?string $contentHtml): $source = ' ' . $contentHtml; } - // determine if content has any embedded images - $embeddedParts = []; $doc = Parser::parseToDomDocument($source); - foreach ($doc->getElementsByTagName('img') as $id => $image) { - if (!($image instanceof DOMElement)) { - continue; - } - - $src = $image->getAttribute('src'); - if ($src === '') { - continue; - } - try { - $dataUri = $this->uriParser->parse($src); - } catch (InvalidDataUriException $e) { - continue; - } - - $part = new Horde_Mime_Part(); - $part->setType($dataUri->getMediaType()); - $part->setCharset($dataUri->getParameters()['charset']); - $part->setName('embedded_image_' . $id); - $part->setDisposition('inline'); - if ($dataUri->isBase64()) { - $part->setTransferEncoding('base64'); - } - $part->setContents($dataUri->getData()); - - $cid = $part->setContentId(); - $embeddedParts[] = $part; - - $image->setAttribute('src', 'cid:' . $cid); - } + array_push($inlineAttachments, ...$this->extractDataUriImages($doc)); + $this->rewriteSrcToCid($doc); $htmlContent = $doc->saveHTML(); $htmlPart = new Horde_Mime_Part(); @@ -145,14 +121,14 @@ private function buildMessagePart(?string $contentPlain, ?string $contentHtml): $messagePart = new Horde_Mime_Part(); } - if (isset($embeddedParts) && count($embeddedParts) > 0) { + if (count($inlineAttachments) > 0) { /* * Text parts with embedded content (e.g. inline images, etc) need be wrapped in multipart/related part */ $basePart = new Horde_Mime_Part(); $basePart->setType('multipart/related'); $basePart[] = $messagePart; - foreach ($embeddedParts as $part) { + foreach ($inlineAttachments as $part) { $basePart[] = $part; } } else { @@ -162,6 +138,74 @@ private function buildMessagePart(?string $contentPlain, ?string $contentHtml): return $basePart; } + /** + * Extracts data URI images from the HTML document, converts each to an + * inline MIME part with a generated Content-ID, and rewrites the src + * attribute to the corresponding cid: reference. + * + * @return Horde_Mime_Part[] + */ + private function extractDataUriImages(DOMDocument $doc): array { + $parts = []; + foreach ($doc->getElementsByTagName('img') as $id => $image) { + if (!($image instanceof DOMElement)) { + continue; + } + + $src = $image->getAttribute('src'); + if ($src === '') { + continue; + } + + try { + $dataUri = $this->uriParser->parse($src); + } catch (InvalidDataUriException) { + continue; + } + + if (!str_starts_with($dataUri->getMediaType(), 'image/')) { + continue; + } + + $part = new Horde_Mime_Part(); + $part->setType($dataUri->getMediaType()); + $part->setCharset($dataUri->getParameters()['charset']); + $part->setName('embedded_image_' . $id); + $part->setDisposition('inline'); + if ($dataUri->isBase64()) { + $part->setTransferEncoding('base64'); + } + $part->setContents($dataUri->getData()); + + $cid = $part->setContentId(); + $parts[] = $part; + + $image->setAttribute('src', 'cid:' . $cid); + } + return $parts; + } + + /** + * Rewrites src attributes of img elements that carry a data-cid attribute + * back to their cid: reference, as required by the MIME structure for + * inline attachments when forwarding messages. + */ + private function rewriteSrcToCid(DOMDocument $doc): void { + foreach ($doc->getElementsByTagName('img') as $image) { + if (!($image instanceof DOMElement)) { + continue; + } + + $cid = $image->getAttribute('data-cid'); + if ($cid === '') { + continue; + } + + $image->setAttribute('src', 'cid:' . $cid); + $image->removeAttribute('data-cid'); + } + } + /** * generates pgp encrypted message part * @@ -196,6 +240,25 @@ private function buildPgpPart(string $content): Horde_Mime_Part { } + /** + * @param list $attachments + * @return array{0: list, 1: list} + */ + private function separateAttachments(array $attachments): array { + $inline = []; + $normal = []; + + foreach ($attachments as $attachment) { + if ($attachment->getDisposition() === 'inline') { + $inline[] = $attachment; + } else { + $normal[] = $attachment; + } + } + + return [$inline, $normal]; + } + /** * A callback for Horde_Text_Filter. * diff --git a/lib/Service/TransmissionService.php b/lib/Service/TransmissionService.php index ce8e1ca459..af3ac3c4a4 100644 --- a/lib/Service/TransmissionService.php +++ b/lib/Service/TransmissionService.php @@ -75,10 +75,20 @@ public function handleAttachment(Account $account, array $attachment): ?Horde_Mi [$localAttachment, $file] = $this->attachmentService->getAttachment($account->getMailAccount()->getUserId(), (int)$attachment['id']); $part = new Horde_Mime_Part(); $part->setCharset('us-ascii'); - if (!empty($localAttachment->getFileName())) { - $part->setDisposition('attachment'); + + if ($localAttachment->isDispositionAttachmentOrInline()) { + $part->setDisposition($localAttachment->getDisposition()); + /* + * Setting a name implicitly adds a Content-Disposition header in Horde, + * which would override the intentional omission. Only set it for attachment/inline dispositions. + */ $part->setName($localAttachment->getFileName()); } + + if ($localAttachment->getContentId() !== null) { + $part->setContentId($localAttachment->getContentId()); + } + $part->setContents($file->getContent()); /* * Horde_Mime_Part.setType takes the mimetype (e.g. text/calendar) diff --git a/psalm.xml b/psalm.xml index 2d07575f04..b2d115ea6d 100644 --- a/psalm.xml +++ b/psalm.xml @@ -24,6 +24,9 @@ + + + @@ -68,10 +71,5 @@ - - - - - diff --git a/src/components/ComposerAttachment.vue b/src/components/ComposerAttachment.vue index bb5f8a9abc..14b3cb27a2 100644 --- a/src/components/ComposerAttachment.vue +++ b/src/components/ComposerAttachment.vue @@ -5,9 +5,7 @@