← Back
Editing: AttachmentService.php
<?php declare(strict_types=1); /** * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Mail\Service\Attachment; use finfo; use InvalidArgumentException; use OCA\Files_Sharing\SharedStorage; use OCA\Mail\Account; use OCA\Mail\Contracts\IAttachmentService; use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\LocalAttachment; use OCA\Mail\Db\LocalAttachmentMapper; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; use OCA\Mail\Exception\AttachmentNotFoundException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Exception\SmimeDecryptException; use OCA\Mail\Exception\UploadException; use OCA\Mail\IMAP\MessageMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Files\File; use OCP\Files\Folder; 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; class AttachmentService implements IAttachmentService { /** @var LocalAttachmentMapper */ private $mapper; /** @var AttachmentStorage */ private $storage; /** * @var IMailManager */ private $mailManager; /** * @var MessageMapper */ private $messageMapper; /** * @var Folder */ private $userFolder; /** * @var LoggerInterface */ private $logger; /** * @param Folder $userFolder */ public function __construct( $userFolder, LocalAttachmentMapper $mapper, AttachmentStorage $storage, IMailManager $mailManager, MessageMapper $imapMessageMapper, private ICacheFactory $cacheFactory, private IURLGenerator $urlGenerator, private IMimeTypeDetector $mimeTypeDetector, LoggerInterface $logger, private ITimeFactory $timeFactory, ) { $this->mapper = $mapper; $this->storage = $storage; $this->mailManager = $mailManager; $this->messageMapper = $imapMessageMapper; $this->userFolder = $userFolder; $this->logger = $logger; } /** * @param string $userId * @param UploadedFile $file * @return LocalAttachment */ #[\Override] public function addFile(string $userId, UploadedFile $file): LocalAttachment { $attachment = new 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); try { $this->storage->save($userId, $persisted->id, $file); } catch (UploadException $ex) { // Clean-up $this->mapper->delete($persisted); throw $ex; } return $attachment; } 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); try { $this->storage->saveContent($userId, $persisted->id, $fileContents); } catch (NotFoundException|NotPermittedException $e) { // Clean-up $this->mapper->delete($persisted); throw new UploadException($e->getMessage(), $e->getCode(), $e); } return $attachment; } /** * Try to get an attachment by id * * @return array{0: LocalAttachment, 1: ISimpleFile} * @throws AttachmentNotFoundException */ #[\Override] public function getAttachment(string $userId, int $id): array { try { $attachment = $this->mapper->find($userId, $id); $file = $this->storage->retrieve($userId, $id); return [$attachment, $file]; } catch (DoesNotExistException $ex) { throw new AttachmentNotFoundException(); } } /** * @param string $userId * @param int $id * * @return void */ #[\Override] public function deleteAttachment(string $userId, int $id) { try { $attachment = $this->mapper->find($userId, $id); $this->mapper->delete($attachment); } catch (DoesNotExistException $ex) { // Nothing to do then } $this->storage->delete($userId, $id); } public function deleteLocalMessageAttachments(string $userId, int $localMessageId): void { $attachments = $this->mapper->findByLocalMessageId($userId, $localMessageId); // delete db entries $this->mapper->deleteForLocalMessage($userId, $localMessageId); // delete storage foreach ($attachments as $attachment) { $this->storage->delete($userId, $attachment->getId()); } } public function deleteLocalMessageAttachmentsById(string $userId, int $localMessageId, array $attachmentIds): void { $attachments = $this->mapper->findByIds($userId, $attachmentIds); // delete storage foreach ($attachments as $attachment) { $this->mapper->delete($attachment); $this->storage->delete($userId, $attachment->getId()); } } /** * @param int[] $attachmentIds * @return LocalAttachment[] */ public function saveLocalMessageAttachments(string $userId, int $messageId, array $attachmentIds): array { if ($attachmentIds === []) { return []; } $this->mapper->saveLocalMessageAttachments($userId, $messageId, $attachmentIds); return $this->mapper->findByLocalMessageId($userId, $messageId); } /** * @return LocalAttachment[] */ public function updateLocalMessageAttachments(string $userId, LocalMessage $message, array $newAttachmentIds): array { // no attachments any more. Delete any old ones and we're done if ($newAttachmentIds === []) { $this->deleteLocalMessageAttachments($userId, $message->getId()); return []; } // no need to diff, no old attachments if (empty($message->getAttachments())) { $this->mapper->saveLocalMessageAttachments($userId, $message->getId(), $newAttachmentIds); return $this->mapper->findByLocalMessageId($userId, $message->getId()); } $oldAttachmentIds = array_map(static fn ($attachment) => $attachment->getId(), $message->getAttachments()); $add = array_diff($newAttachmentIds, $oldAttachmentIds); if ($add !== []) { $this->mapper->saveLocalMessageAttachments($userId, $message->getId(), $add); } $delete = array_diff($oldAttachmentIds, $newAttachmentIds); if ($delete !== []) { $this->deleteLocalMessageAttachmentsById($userId, $message->getId(), $delete); } return $this->mapper->findByLocalMessageId($userId, $message->getId()); } /** * @param array $attachments * @return int[] */ public function handleAttachments(Account $account, array $attachments, \Horde_Imap_Client_Socket $client): array { $attachmentIds = []; if ($attachments === []) { return $attachmentIds; } foreach ($attachments as $attachment) { if (!isset($attachment['type'])) { throw new InvalidArgumentException('Attachment does not have a type'); } if ($attachment['type'] === 'local' && isset($attachment['id'])) { // attachment already exists, only return the id $attachmentIds[] = (int)$attachment['id']; continue; } if ($attachment['type'] === 'message' || $attachment['type'] === 'message/rfc822') { // Adds another message as attachment $attachmentIds[] = $this->handleForwardedMessageAttachment($account, $attachment, $client); continue; } 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; } $attachmentIds[] = $this->handleCloudAttachment($account, $attachment); } return array_values(array_filter($attachmentIds)); } /** * @return list<array{id: string, fileName: string|null, mime: string, downloadUrl: string, mimeUrl: string}> */ public function getAttachmentNames(Account $account, Mailbox $mailbox, Message $message, \Horde_Imap_Client_Socket $client): array { if ($message->getStructureAnalyzed() === true && $message->getFlagAttachments() === false) { // Structure analysis already confirmed no attachments, nothing to fetch. return []; } $cache = $this->cacheFactory->createDistributed('mail_attachment_names'); $cacheKey = hash('xxh128', implode('_', [ $account->getUserId(), (string)$account->getId(), (string)$mailbox->getId(), (string)$message->getId() ])); $cached = $cache->get($cacheKey); if (is_array($cached)) { /** @var list<array{id: string, fileName: string|null, mime: string, downloadUrl: string, mimeUrl: string}> $cached */ return $cached; } $attachments = []; try { $imapMessage = $this->mailManager->getImapMessage( $client, $account, $mailbox, $message->getUid(), true ); $attachments = $imapMessage->getAttachments(); } catch (SmimeDecryptException $e) { $this->logger->debug('Could not get attachment names for S/MIME encrypted message', ['exception' => $e, 'messageId' => $message->getUid()]); } catch (ServiceException $e) { $this->logger->warning('Could not get attachment names', ['exception' => $e, 'messageId' => $message->getUid()]); } $result = array_values(array_map(function ($attachment) use ($message) { $downloadUrl = $this->urlGenerator->linkToRouteAbsolute('mail.messages.downloadAttachment', [ 'id' => $message->getId(), 'attachmentId' => $attachment['id'], ]); $mimeUrl = $this->mimeTypeDetector->mimeTypeIcon($attachment['mime']); return ['id' => $attachment['id'], 'fileName' => $attachment['fileName'], 'mime' => $attachment['mime'], 'downloadUrl' => $downloadUrl, 'mimeUrl' => $mimeUrl]; }, $attachments)); $cache->set($cacheKey, $result, 604800); return $result; } /** * Add a message as attachment * * @param Account $account * @param mixed[] $attachment * @param \Horde_Imap_Client_Socket $client * @return int|null */ private function handleForwardedMessageAttachment(Account $account, array $attachment, \Horde_Imap_Client_Socket $client): ?int { $attachmentMessage = $this->mailManager->getMessage($account->getUserId(), (int)$attachment['id']); $mailbox = $this->mailManager->getMailbox($account->getUserId(), $attachmentMessage->getMailboxId()); $fullText = $this->messageMapper->getFullText( $client, $mailbox->getName(), $attachmentMessage->getUid(), $account->getUserId() ); // detect mime type $mime = 'application/octet-stream'; if (extension_loaded('fileinfo')) { $finfo = new finfo(FILEINFO_MIME_TYPE); $detectedMime = $finfo->buffer($fullText); if ($detectedMime !== false) { $mime = $detectedMime; } } try { $localAttachment = $this->addFileFromString($account->getUserId(), $attachment['fileName'] ?? $attachmentMessage->getSubject() . '.eml', $mime, $fullText, null, LocalAttachment::DISPOSITION_ATTACHMENT); } catch (UploadException $e) { $this->logger->error('Could not create attachment', ['exception' => $e]); return null; } return $localAttachment->getId(); } /** * Adds an emails attachments * * @param Account $account * @param mixed[] $attachment * @param \Horde_Imap_Client_Socket $client * @return int * @throws DoesNotExistException */ private function handleForwardedAttachment(Account $account, array $attachment, \Horde_Imap_Client_Socket $client): ?int { $mailbox = $this->mailManager->getMailbox($account->getUserId(), $attachment['mailboxId']); $imapAttachment = $this->messageMapper->getAttachment( $client, $mailbox->getName(), (int)$attachment['uid'], $attachment['id'], $account->getUserId(), ); try { $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; } return $localAttachment->getId(); } private function hasDownloadPermissions(File $file, string $fileName): bool { $storage = $file->getStorage(); if ($storage->instanceOfStorage(SharedStorage::class)) { /** @var SharedStorage $storage */ $share = $storage->getShare(); $attributes = $share->getAttributes(); if ($attributes->getAttribute('permissions', 'download') === false) { $this->logger->warning('Could not create attachment, no download permission for file: ' . $fileName); return false; } } return true; } /** * @param Account $account * @param array $attachment * @return int|null */ private function handleCloudAttachment(Account $account, array $attachment): ?int { if (!isset($attachment['fileName'])) { return null; } $fileName = $attachment['fileName']; if (!$this->userFolder->nodeExists($fileName)) { return null; } $file = $this->userFolder->get($fileName); if (!$file instanceof File) { return null; } if (!$this->hasDownloadPermissions($file, $fileName)) { return null; } try { $localAttachment = $this->addFileFromString($account->getUserId(), $file->getName(), $file->getMimeType(), $file->getContent(), null, LocalAttachment::DISPOSITION_ATTACHMENT); } catch (UploadException $e) { $this->logger->error('Could not create attachment', ['exception' => $e]); return null; } return $localAttachment->getId(); } }
Save File
Cancel