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

1180 lines
34 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016-2024 F7cloud GmbH and F7cloud contributors
* SPDX-FileCopyrightText: 2014-2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Mail\Controller;
use Exception;
use OC\Security\CSP\ContentSecurityPolicyNonceManager;
use OCA\Mail\Attachment;
use OCA\Mail\Contracts\IDkimService;
use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Contracts\IMailSearch;
use OCA\Mail\Contracts\IMailTransmission;
use OCA\Mail\Contracts\ITrustedSenderService;
use OCA\Mail\Contracts\IUserPreferences;
use OCA\Mail\Db\Message;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\MailboxLockedException;
use OCA\Mail\Exception\MailboxNotCachedException;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Http\AttachmentDownloadResponse;
use OCA\Mail\Http\HtmlResponse;
use OCA\Mail\Http\TrapError;
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\Model\SmimeData;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCA\Mail\Service\ItineraryService;
use OCA\Mail\Service\MailboxShareService;
use OCA\Mail\Service\MessageEnvelopeEnricher;
use OCA\Mail\Service\SmimeService;
use OCA\Mail\Service\SnoozeService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Http\ZipResponse;
use OCP\Files\Folder;
use OCP\Files\GenericFileException;
use OCP\Files\IMimeTypeDetector;
use OCP\Files\NotPermittedException;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\Lock\LockedException;
use Psr\Log\LoggerInterface;
use function array_map;
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class MessagesController extends Controller {
private AccountService $accountService;
private IMailManager $mailManager;
private IMailSearch $mailSearch;
private ItineraryService $itineraryService;
private ?string $currentUserId;
private LoggerInterface $logger;
private ?Folder $userFolder;
private IMimeTypeDetector $mimeTypeDetector;
private IL10N $l10n;
private IURLGenerator $urlGenerator;
private ContentSecurityPolicyNonceManager $nonceManager;
private ITrustedSenderService $trustedSenderService;
private IMailTransmission $mailTransmission;
private SmimeService $smimeService;
private IMAPClientFactory $clientFactory;
private IDkimService $dkimService;
private IUserPreferences $preferences;
private SnoozeService $snoozeService;
private AiIntegrationsService $aiIntegrationService;
private MessageEnvelopeEnricher $messageEnvelopeEnricher;
private MailboxShareService $mailboxShareService;
public function __construct(
string $appName,
IRequest $request,
AccountService $accountService,
IMailManager $mailManager,
IMailSearch $mailSearch,
ItineraryService $itineraryService,
?string $UserId,
$userFolder,
LoggerInterface $logger,
IL10N $l10n,
IMimeTypeDetector $mimeTypeDetector,
IURLGenerator $urlGenerator,
ContentSecurityPolicyNonceManager $nonceManager,
ITrustedSenderService $trustedSenderService,
IMailTransmission $mailTransmission,
SmimeService $smimeService,
IMAPClientFactory $clientFactory,
IDkimService $dkimService,
IUserPreferences $preferences,
SnoozeService $snoozeService,
AiIntegrationsService $aiIntegrationService,
MessageEnvelopeEnricher $messageEnvelopeEnricher,
MailboxShareService $mailboxShareService,
private ICacheFactory $cacheFactory,
) {
parent::__construct($appName, $request);
$this->accountService = $accountService;
$this->mailManager = $mailManager;
$this->mailSearch = $mailSearch;
$this->itineraryService = $itineraryService;
$this->currentUserId = $UserId;
$this->userFolder = $userFolder;
$this->logger = $logger;
$this->l10n = $l10n;
$this->mimeTypeDetector = $mimeTypeDetector;
$this->urlGenerator = $urlGenerator;
$this->nonceManager = $nonceManager;
$this->trustedSenderService = $trustedSenderService;
$this->mailTransmission = $mailTransmission;
$this->smimeService = $smimeService;
$this->clientFactory = $clientFactory;
$this->dkimService = $dkimService;
$this->preferences = $preferences;
$this->snoozeService = $snoozeService;
$this->aiIntegrationService = $aiIntegrationService;
$this->messageEnvelopeEnricher = $messageEnvelopeEnricher;
$this->mailboxShareService = $mailboxShareService;
}
/**
* @NoAdminRequired
*
* @param int $mailboxId
* @param int $cursor
* @param string $filter
* @param int|null $limit
* @param string $view returns messages in requested view ('singleton' or 'threaded')
* @param string|null $v Cache buster version to guarantee unique urls (will trigger HTTP caching if set)
*
* @return JSONResponse
*
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function index(int $mailboxId,
?int $cursor = null,
?string $filter = null,
?int $limit = null,
?string $view = null,
?string $v = null,
?int $shareId = null): JSONResponse {
$limit = min(100, max(1, $limit));
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMailboxForAccess($this->currentUserId, $mailboxId, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
$this->logger->debug("loading messages of mailbox <$mailboxId>");
$sort = $this->preferences->getPreference($this->currentUserId, 'sort-order', 'newest') === 'newest' ? IMailSearch::ORDER_NEWEST_FIRST : IMailSearch::ORDER_OLDEST_FIRST;
$view = $view === 'singleton' ? IMailSearch::VIEW_SINGLETON : IMailSearch::VIEW_THREADED;
try {
$messages = $this->mailSearch->findMessages(
$account,
$mailbox,
$sort,
$filter === '' ? null : $filter,
$cursor,
$limit,
$this->currentUserId,
$view
);
} catch (MailboxNotCachedException|MailboxLockedException $e) {
$this->logger->debug('Mailbox not ready for message list, returning empty', [
'mailboxId' => $mailboxId,
'message' => $e->getMessage(),
]);
return new JSONResponse([]);
}
$response = new JSONResponse($messages);
if ($v !== null && $v !== '') {
$response->cacheFor(7 * 24 * 3600, false, true);
}
return $response;
}
/**
* @NoAdminRequired
*
* @param int $id
*
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function show(int $id, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
$this->logger->debug("loading message <$id>");
$message = $this->mailSearch->findMessage(
$account,
$mailbox,
$message
);
$this->messageEnvelopeEnricher->enrichFromImapWhenEmpty($account, $mailbox, $message);
return new JSONResponse($message);
}
/**
* Return headers (subject, from, to) for the given message to prefill the "Create mail filter" form.
* Used when the frontend opens the filter modal so it can show the list even if the message
* wasn't passed from the view (e.g. thread context). Enriches from IMAP when DB has empty fields.
*
* @NoAdminRequired
*
* @param int $id Message database id
* @return JSONResponse { headers: Array<{ field: string, label: string, values: string[] }> }
*
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function getFilterHeaders(int $id, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $this->mailSearch->findMessage($resolved['account'], $resolved['mailbox'], $resolved['message']);
$this->messageEnvelopeEnricher->enrichFromImapWhenEmpty($resolved['account'], $resolved['mailbox'], $message);
$headers = [];
$subject = trim((string)$message->getSubject());
if ($subject !== '') {
$headers[] = [
'field' => 'subject',
'label' => $this->l10n->t('Subject'),
'values' => [$subject],
];
}
$fromEmails = array_values(array_filter(array_map(
static fn ($a) => $a['email'] ?? null,
$message->getFrom()->jsonSerialize()
)));
if ($fromEmails !== []) {
$headers[] = [
'field' => 'from',
'label' => $this->l10n->t('From'),
'values' => $fromEmails,
];
}
$toEmails = array_values(array_filter(array_map(
static fn ($a) => $a['email'] ?? null,
$message->getTo()->jsonSerialize()
)));
if ($toEmails !== []) {
$headers[] = [
'field' => 'to',
'label' => $this->l10n->t('To'),
'values' => $toEmails,
];
}
return new JSONResponse(['headers' => $headers]);
}
/**
* @NoAdminRequired
*
* @param int $id
*
* @return JSONResponse
*
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function getBody(int $id, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$shareId = $shareId ?? ($this->request->getParam('shareId') !== null ? (int)$this->request->getParam('shareId') : null);
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
$cacheInstance = $this->getCacheForAccount($account->getId());
$imapMessageCacheKey = 'message_' . $id;
$client = $this->clientFactory->getClient($account);
try {
$imapMessage = $this->mailManager->getImapMessage(
$client,
$account,
$mailbox,
$message->getUid(), true
);
if ($imapMessage->hasHtmlMessage()) {
$cacheInstance->set($imapMessageCacheKey, $imapMessage->getHtmlBody($id), 600);
}
$json = $imapMessage->getFullMessage($id);
} finally {
$client->logout();
}
$itineraries = $this->itineraryService->getCached($account, $mailbox, $message->getUid());
if ($itineraries) {
$json['itineraries'] = $itineraries;
}
$json['attachments'] = array_map(fn ($a) => $this->enrichDownloadUrl(
$id,
$a,
$shareId
), $json['attachments']);
$json['accountId'] = $account->getId();
$json['mailboxId'] = $mailbox->getId();
$json['databaseId'] = $message->getId();
$json['isSenderTrusted'] = $this->isSenderTrusted($message);
$smimeData = new SmimeData();
$smimeData->setIsEncrypted($message->isEncrypted() || $imapMessage->isEncrypted());
if ($imapMessage->isSigned()) {
$smimeData->setIsSigned(true);
$smimeData->setSignatureIsValid($imapMessage->isSignatureValid());
}
$json['smime'] = $smimeData;
$dkimResult = $this->dkimService->getCached($account, $mailbox, $message->getUid());
if (is_bool($dkimResult)) {
$json['dkimValid'] = $dkimResult;
}
$response = new JSONResponse($json);
// Enable caching
$response->cacheFor(60 * 60, false, true);
return $response;
}
/**
* @NoAdminRequired
*
* @param int $id
*
* @return JSONResponse
*
* @throws ClientException
*/
#[TrapError]
public function getItineraries(int $id, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
$response = new JsonResponse($this->itineraryService->extract($account, $mailbox, $message->getUid()));
$response->cacheFor(24 * 60 * 60, false, true);
return $response;
}
/**
* @NoAdminRequired
* @param int $id
* @return JSONResponse
*/
public function getDkim(int $id, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
$response = new JSONResponse(['valid' => $this->dkimService->validate($account, $mailbox, $message->getUid())]);
$response->cacheFor(24 * 60 * 60, false, true);
return $response;
}
private function isSenderTrusted(Message $message): bool {
$from = $message->getFrom();
$first = $from->first();
if ($first === null) {
return false;
}
$email = $first->getEmail();
if ($email === null) {
return false;
}
return $this->trustedSenderService->isTrusted(
$this->currentUserId,
$email
);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param int $id
*
* @return JSONResponse
* @throws ClientException
*/
#[TrapError]
public function getThread(int $id, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$shareId = $shareId ?? ($this->request->getParam('shareId') !== null ? (int)$this->request->getParam('shareId') : null);
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$account = $resolved['account'];
if (empty($message->getThreadRootId())) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
return new JSONResponse($this->mailManager->getThread($resolved['account'], $message->getThreadRootId()));
}
/**
* @NoAdminRequired
*
* @param int $id
* @param int $destFolderId
*
* @return JSONResponse
*
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function move(int $id, int $destFolderId, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolvedMsg = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
$resolvedDst = $this->mailboxShareService->resolveMailboxForAccess($this->currentUserId, $destFolderId, $shareId);
if ($resolvedMsg === null || $resolvedDst === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
if ($resolvedMsg['account']->getId() !== $resolvedDst['account']->getId()) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$this->mailManager->moveMessage(
$resolvedMsg['account'],
$resolvedMsg['mailbox']->getName(),
$resolvedMsg['message']->getUid(),
$resolvedDst['account'],
$resolvedDst['mailbox']->getName()
);
return new JSONResponse();
}
/**
* @NoAdminRequired
*
* @param int $id
* @param int $unixTimestamp
* @param int $destMailboxId
*
* @return JSONResponse
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function snooze(int $id, int $unixTimestamp, int $destMailboxId, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolvedMsg = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
$resolvedDst = $this->mailboxShareService->resolveMailboxForAccess($this->currentUserId, $destMailboxId, $shareId);
if ($resolvedMsg === null || $resolvedDst === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
if ($resolvedMsg['account']->getId() !== $resolvedDst['account']->getId()) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$this->snoozeService->snoozeMessage($resolvedMsg['message'], $unixTimestamp, $resolvedMsg['account'], $resolvedMsg['mailbox'], $resolvedDst['account'], $resolvedDst['mailbox']);
return new JSONResponse();
}
/**
* @NoAdminRequired
*
* @param int $id
*
* @return JSONResponse
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function unSnooze(int $id, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$this->snoozeService->unSnoozeMessage($resolved['message'], $this->currentUserId);
return new JSONResponse();
}
/**
* @NoAdminRequired
*
* @param int $id
*
* @return JSONResponse
*
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function mdn(int $id, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
if ($message->getFlagMdnsent()) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
try {
$this->mailTransmission->sendMdn($account, $mailbox, $message);
$this->mailManager->flagMessage($account, $mailbox->getName(), $message->getUid(), '$mdnsent', true);
} catch (ServiceException $ex) {
$this->logger->error('Sending mdn failed: ' . $ex->getMessage());
throw $ex;
}
return new JSONResponse();
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @throws ServiceException
*/
#[TrapError]
public function getSource(int $id, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
$client = $this->clientFactory->getClient($account);
try {
$response = new JSONResponse([
'source' => $this->mailManager->getSource(
$client,
$account,
$mailbox->getName(),
$message->getUid()
)
]);
} finally {
$client->logout();
}
// Enable caching
$response->cacheFor(60 * 60, false, true);
return $response;
}
/**
* Export a whole message as an .eml file.
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @param int $id
* @return Response
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function export(int $id, ?int $shareId = null): Response {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
$client = $this->clientFactory->getClient($account);
try {
$source = $this->mailManager->getSource(
$client,
$account,
$mailbox->getName(),
$message->getUid()
);
} finally {
$client->logout();
}
return new AttachmentDownloadResponse(
$source,
$message->getSubject() . '.eml',
'message/rfc822',
);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param int $id
* @param bool $plain do not inject scripts if true (default=false)
*
* @return HtmlResponse|TemplateResponse
*
* @throws ClientException
*/
#[TrapError]
public function getHtmlBody(int $id, bool $plain = false, ?int $shareId = null): Response {
if ($this->currentUserId === null) {
return new TemplateResponse($this->appName, 'error', ['message' => 'Not allowed'], 'none');
}
$shareId = $shareId ?? ($this->request->getParam('shareId') !== null ? (int)$this->request->getParam('shareId') : null);
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new TemplateResponse(
$this->appName,
'error',
['message' => 'Not allowed'],
'none'
);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
try {
$cacheInstance = $this->getCacheForAccount($account->getId());
$imapMessageCacheKey = 'message_' . $id;
$html = $cacheInstance->get($imapMessageCacheKey);
if ($html === null) {
$client = $this->clientFactory->getClient($account);
try {
$html = $this->mailManager->getImapMessage(
$client,
$account,
$mailbox,
$message->getUid(),
true
)->getHtmlBody(
$id
);
} finally {
$client->logout();
}
}
$htmlResponse = $plain
? HtmlResponse::plain($html)
: HtmlResponse::withResizer(
$html,
$this->nonceManager->getNonce(),
$this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->linkTo('mail', 'js/htmlresponse.js')
)
);
// Harden the default security policy
$policy = new ContentSecurityPolicy();
$policy->allowEvalScript(false);
$policy->disallowScriptDomain('\'self\'');
$policy->disallowConnectDomain('\'self\'');
$policy->disallowFontDomain('\'self\'');
$policy->disallowMediaDomain('\'self\'');
$htmlResponse->setContentSecurityPolicy($policy);
// Enable caching
$htmlResponse->cacheFor(60 * 60, false, true);
return $htmlResponse;
} catch (Exception $ex) {
return new TemplateResponse(
$this->appName,
'error',
['message' => $ex->getMessage()],
'none'
);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param int $id
* @param string $attachmentId
*
* @return Response
*
* @throws ClientException
*/
#[TrapError]
public function downloadAttachment(int $id,
string $attachmentId,
?int $shareId = null): Response {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$shareId = $shareId ?? ($this->request->getParam('shareId') !== null ? (int)$this->request->getParam('shareId') : null);
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
$attachment = $this->mailManager->getMailAttachment(
$account,
$mailbox,
$message,
$attachmentId,
);
// Body party and embedded messages do not have a name
if ($attachment->getName() === null) {
return new AttachmentDownloadResponse(
$attachment->getContent(),
$this->l10n->t('Embedded message %s', [
$attachmentId,
]) . '.eml',
$attachment->getType()
);
}
return new AttachmentDownloadResponse(
$attachment->getContent(),
$attachment->getName(),
$attachment->getType()
);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param int $id the message id
*
* @return ZipResponse|JSONResponse
*
* @throws ClientException
* @throws ServiceException
* @throws DoesNotExistException
*/
#[TrapError]
public function downloadAttachments(int $id, ?int $shareId = null): Response {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$shareId = $shareId ?? ($this->request->getParam('shareId') !== null ? (int)$this->request->getParam('shareId') : null);
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
$attachments = $this->mailManager->getMailAttachments($account, $mailbox, $message);
$zip = new ZipResponse($this->request, 'attachments');
foreach ($attachments as $attachment) {
$fileName = $attachment->getName();
$fh = fopen('php://temp', 'r+');
fputs($fh, $attachment->getContent());
$size = $attachment->getSize();
rewind($fh);
$zip->addResource($fh, $fileName, $size);
}
return $zip;
}
/**
* @NoAdminRequired
*
* @param int $id
* @param string $attachmentId
* @param string $targetPath
*
* @return JSONResponse
*
* @throws ClientException
* @throws GenericFileException
* @throws NotPermittedException
* @throws LockedException
*/
#[TrapError]
public function saveAttachment(int $id,
string $attachmentId,
string $targetPath,
?int $shareId = null) {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$shareId = $shareId ?? ($this->request->getParam('shareId') !== null ? (int)$this->request->getParam('shareId') : null);
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
/** @var Attachment[] $attachments */
$attachments = [];
if ($attachmentId === '0') {
$attachments = $this->mailManager->getMailAttachments(
$account,
$mailbox,
$message,
);
} else {
$attachments[] = $this->mailManager->getMailAttachment(
$account,
$mailbox,
$message,
$attachmentId,
);
}
foreach ($attachments as $attachment) {
$fileName = $attachment->getName() ?? $this->l10n->t('Embedded message %s', [
$attachment->getId(),
]) . '.eml';
$fileParts = pathinfo($fileName);
$fileName = $fileParts['filename'];
$fileExtension = $fileParts['extension'];
$fullPath = "$targetPath/$fileName.$fileExtension";
$counter = 2;
while ($this->userFolder->nodeExists($fullPath)) {
$fullPath = "$targetPath/$fileName ($counter).$fileExtension";
$counter++;
}
$newFile = $this->userFolder->newFile($fullPath);
$newFile->putContent($attachment->getContent());
}
return new JSONResponse();
}
/**
* @NoAdminRequired
*
* @param int $id
* @param array $flags
*
* @return JSONResponse
*
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function setFlags(int $id, array $flags, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
foreach ($flags as $flag => $value) {
$value = filter_var($value, FILTER_VALIDATE_BOOLEAN);
$this->mailManager->flagMessage($account, $mailbox->getName(), $message->getUid(), $flag, $value);
}
return new JSONResponse();
}
/**
* @NoAdminRequired
*
* @param int $id
* @param string $imapLabel
*
* @return JSONResponse
*
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function setTag(int $id, string $imapLabel, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
try {
$tag = $this->mailManager->getTagByImapLabel($imapLabel, $this->currentUserId);
} catch (ClientException $e) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$this->mailManager->tagMessage($account, $mailbox->getName(), $message, $tag, true);
return new JSONResponse($tag);
}
/**
* @NoAdminRequired
*
* @param int $id
* @param string $imapLabel
*
* @return JSONResponse
*
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function removeTag(int $id, string $imapLabel, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
try {
$tag = $this->mailManager->getTagByImapLabel($imapLabel, $this->currentUserId);
} catch (ClientException $e) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$this->mailManager->tagMessage($account, $mailbox->getName(), $message, $tag, false);
return new JSONResponse($tag);
}
/**
* @NoAdminRequired
*
* @param int $id
*
* @throws ClientException
* @throws ServiceException
*/
#[TrapError]
public function destroy(int $id, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $id, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
$this->logger->debug("deleting message <$id>");
$this->mailManager->deleteMessage(
$account,
$mailbox->getName(),
$message->getUid()
);
return new JSONResponse();
}
/**
* @NoAdminRequired
*
* @param int $messageId
*
* @return JSONResponse
*/
#[TrapError]
public function smartReply(int $messageId):JSONResponse {
try {
$message = $this->mailManager->getMessage($this->currentUserId, $messageId);
$mailbox = $this->mailManager->getMailbox($this->currentUserId, $message->getMailboxId());
$account = $this->accountService->find($this->currentUserId, $mailbox->getAccountId());
} catch (DoesNotExistException $e) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
try {
$replies = array_values($this->aiIntegrationService->getSmartReply($account, $mailbox, $message, $this->currentUserId));
} catch (ServiceException $e) {
$this->logger->error('Smart reply failed: ' . $e->getMessage(), [
'exception' => $e,
]);
return new JSONResponse([], Http::STATUS_NO_CONTENT);
}
return new JSONResponse($replies);
}
/**
* @NoAdminRequired
*
* @param int $messageId
*
* @return JSONResponse
*/
#[TrapError]
public function needsTranslation(int $messageId, ?int $shareId = null): JSONResponse {
if ($this->currentUserId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$resolved = $this->mailboxShareService->resolveMessageAccess($this->currentUserId, $messageId, $shareId);
if ($resolved === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$message = $resolved['message'];
$mailbox = $resolved['mailbox'];
$account = $resolved['account'];
if (!$this->aiIntegrationService->isLlmProcessingEnabled()) {
$response = new JSONResponse([], Http::STATUS_NOT_IMPLEMENTED);
$response->cacheFor(60 * 60 * 24, false, true);
return $response;
}
try {
$requiresTranslation = $this->aiIntegrationService->requiresTranslation(
$account,
$mailbox,
$message,
$this->currentUserId
);
$response = new JSONResponse(['requiresTranslation' => $requiresTranslation === true]);
$response->cacheFor(60 * 60 * 24, false, true);
return $response;
} catch (ServiceException $e) {
$this->logger->error('Translation check failed: ' . $e->getMessage(), [
'exception' => $e,
]);
return new JSONResponse([], Http::STATUS_NO_CONTENT);
}
}
/**
* @param int $id
* @param array $attachment
* @param int|null $shareId for shared mailbox access
*
* @return array
*/
private function enrichDownloadUrl(int $id,
array $attachment,
?int $shareId = null) {
$params = [
'id' => $id,
'attachmentId' => $attachment['id'],
];
if ($shareId !== null) {
$params['shareId'] = $shareId;
}
$downloadUrl = $this->urlGenerator->linkToRoute('mail.messages.downloadAttachment', $params);
$downloadUrl = $this->urlGenerator->getAbsoluteURL($downloadUrl);
$attachment['downloadUrl'] = $downloadUrl;
$attachment['mimeUrl'] = $this->mimeTypeDetector->mimeTypeIcon($attachment['mime']);
$attachment['isImage'] = $this->attachmentIsImage($attachment);
$attachment['isCalendarEvent'] = $this->attachmentIsCalendarEvent($attachment);
return $attachment;
}
/**
* Determines if the content of this attachment is an image
*
* @param array $attachment
*
* @return boolean
*/
private function attachmentIsImage(array $attachment): bool {
return in_array(
$attachment['mime'], [
'image/jpeg',
'image/png',
'image/gif'
]);
}
/**
* @param array $attachment
*
* @return boolean
*/
private function attachmentIsCalendarEvent(array $attachment): bool {
return in_array($attachment['mime'], ['text/calendar', 'application/ics'], true);
}
private function getCacheForAccount(int $accountId): ICache {
return $this->cacheFactory->createDistributed('mail_account_' . $accountId);
}
}