diff --git a/appinfo/info.xml b/appinfo/info.xml index ff85216a7b..e5d0a32329 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -29,7 +29,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/). ]]> - 3.6.0-rc.2 + 3.6.0-rc.3 agpl Christoph Wurst Nextcloud Groupware Team diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index bc346678ca..2dac019913 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -40,6 +40,7 @@ use OCA\Mail\Dashboard\UnreadMailWidget; use OCA\Mail\Dashboard\UnreadMailWidgetV2; use OCA\Mail\Events\BeforeImapClientCreated; +use OCA\Mail\Events\BeforeMessageSentEvent; use OCA\Mail\Events\DraftMessageCreatedEvent; use OCA\Mail\Events\DraftSavedEvent; use OCA\Mail\Events\MailboxesSynchronizedEvent; @@ -54,7 +55,9 @@ use OCA\Mail\Http\Middleware\ProvisioningMiddleware; use OCA\Mail\Listener\AccountSynchronizedThreadUpdaterListener; use OCA\Mail\Listener\AddressCollectionListener; +use OCA\Mail\Listener\AntiAbuseListener; use OCA\Mail\Listener\DeleteDraftListener; +use OCA\Mail\Listener\FlagRepliedMessageListener; use OCA\Mail\Listener\HamReportListener; use OCA\Mail\Listener\InteractionListener; use OCA\Mail\Listener\MailboxesSynchronizedSpecialMailboxesUpdater; @@ -65,6 +68,7 @@ use OCA\Mail\Listener\OauthTokenRefreshListener; use OCA\Mail\Listener\OptionalIndicesListener; use OCA\Mail\Listener\OutOfOfficeListener; +use OCA\Mail\Listener\SaveSentMessageListener; use OCA\Mail\Listener\SpamReportListener; use OCA\Mail\Listener\UserDeletedListener; use OCA\Mail\Notification\Notifier; @@ -128,6 +132,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(AddMissingIndicesEvent::class, OptionalIndicesListener::class); $context->registerEventListener(BeforeImapClientCreated::class, OauthTokenRefreshListener::class); + $context->registerEventListener(BeforeMessageSentEvent::class, AntiAbuseListener::class); $context->registerEventListener(DraftSavedEvent::class, DeleteDraftListener::class); $context->registerEventListener(DraftMessageCreatedEvent::class, DeleteDraftListener::class); $context->registerEventListener(OutboxMessageCreatedEvent::class, DeleteDraftListener::class); @@ -138,7 +143,9 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(MessageFlaggedEvent::class, MoveJunkListener::class); $context->registerEventListener(MessageDeletedEvent::class, MessageCacheUpdaterListener::class); $context->registerEventListener(MessageSentEvent::class, AddressCollectionListener::class); + $context->registerEventListener(MessageSentEvent::class, FlagRepliedMessageListener::class); $context->registerEventListener(MessageSentEvent::class, InteractionListener::class); + $context->registerEventListener(MessageSentEvent::class, SaveSentMessageListener::class); $context->registerEventListener(NewMessagesSynchronized::class, NewMessageClassificationListener::class); $context->registerEventListener(NewMessagesSynchronized::class, MessageKnownSinceListener::class); $context->registerEventListener(SynchronizationEvent::class, AccountSynchronizedThreadUpdaterListener::class); diff --git a/lib/BackgroundJob/QuotaJob.php b/lib/BackgroundJob/QuotaJob.php index c7ab218f39..05f394ea99 100644 --- a/lib/BackgroundJob/QuotaJob.php +++ b/lib/BackgroundJob/QuotaJob.php @@ -95,7 +95,7 @@ protected function run($argument): void { } $quota = $this->mailManager->getQuota($account); - if ($quota === null) { + if($quota === null) { $this->logger->debug('Could not get quota information for account <' . $account->getEmail() . '>', ['app' => 'mail']); return; } diff --git a/lib/Contracts/IMailTransmission.php b/lib/Contracts/IMailTransmission.php index c4f6004136..82732d1e63 100644 --- a/lib/Contracts/IMailTransmission.php +++ b/lib/Contracts/IMailTransmission.php @@ -24,6 +24,7 @@ namespace OCA\Mail\Contracts; use OCA\Mail\Account; +use OCA\Mail\Db\Alias; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; @@ -36,12 +37,27 @@ interface IMailTransmission { /** * Send a new message or reply to an existing one * - * @param Account $account - * @param LocalMessage $localMessage + * @param NewMessageData $messageData + * @param string|null $repliedToMessageId + * @param Alias|null $alias + * @param Message|null $draft + * * @throws SentMailboxNotSetException * @throws ServiceException */ - public function sendMessage(Account $account, LocalMessage $localMessage): void; + public function sendMessage(NewMessageData $messageData, + ?string $repliedToMessageId = null, + ?Alias $alias = null, + ?Message $draft = null): void; + + /** + * @param Account $account + * @param LocalMessage $message + * @throws ClientException + * @throws ServiceException + * @return void + */ + public function sendLocalMessage(Account $account, LocalMessage $message): void; /** * @param Account $account diff --git a/lib/Controller/DraftsController.php b/lib/Controller/DraftsController.php index 7a96ee801f..47a7f558c7 100644 --- a/lib/Controller/DraftsController.php +++ b/lib/Controller/DraftsController.php @@ -172,6 +172,7 @@ public function update(int $id, $message = $this->service->getMessage($id, $this->userId); $account = $this->accountService->find($this->userId, $accountId); + $message->setType(LocalMessage::TYPE_DRAFT); $message->setAccountId($accountId); $message->setAliasId($aliasId); diff --git a/lib/Controller/OutboxController.php b/lib/Controller/OutboxController.php index 7613f24dea..e62fccb224 100644 --- a/lib/Controller/OutboxController.php +++ b/lib/Controller/OutboxController.php @@ -163,7 +163,7 @@ public function createFromDraft(DraftsService $draftsService, int $id, ?int $sen $outboxMessage = $this->service->convertDraft($draftMessage, $sendAt); - return JsonResponse::success( + return JsonResponse::success( $outboxMessage, Http::STATUS_CREATED, ); @@ -209,9 +209,6 @@ public function update( ?int $sendAt = null ): JsonResponse { $message = $this->service->getMessage($id, $this->userId); - if ($message->getStatus() === LocalMessage::STATUS_PROCESSED) { - return JsonResponse::error('Cannot modify already sent message', Http::STATUS_FORBIDDEN, [$message]); - } $account = $this->accountService->find($this->userId, $accountId); $message->setAccountId($accountId); @@ -220,6 +217,7 @@ public function update( $message->setBody($body); $message->setEditorBody($editorBody); $message->setHtml($isHtml); + $message->setFailed($failed); $message->setInReplyToMessageId($inReplyToMessageId); $message->setSendAt($sendAt); $message->setSmimeSign($smimeSign); @@ -246,12 +244,8 @@ public function send(int $id): JsonResponse { $message = $this->service->getMessage($id, $this->userId); $account = $this->accountService->find($this->userId, $message->getAccountId()); - $message = $this->service->sendMessage($message, $account); - - if($message->getStatus() !== LocalMessage::STATUS_PROCESSED) { - return JsonResponse::error('Could not send message', Http::STATUS_INTERNAL_SERVER_ERROR, [$message]); - } - return JsonResponse::success( + $this->service->sendMessage($message, $account); + return JsonResponse::success( 'Message sent', Http::STATUS_ACCEPTED ); } diff --git a/lib/Db/LocalMessage.php b/lib/Db/LocalMessage.php index c71e910c25..db3e239eb2 100644 --- a/lib/Db/LocalMessage.php +++ b/lib/Db/LocalMessage.php @@ -60,30 +60,13 @@ * @method setSmimeCertificateId(?int $smimeCertificateId) * @method bool|null getSmimeEncrypt() * @method setSmimeEncrypt (bool $smimeEncryt) - * @method int|null getStatus(); - * @method setStatus(?int $status); - * @method string|null getRaw() - * @method setRaw(string|null $raw) */ class LocalMessage extends Entity implements JsonSerializable { public const TYPE_OUTGOING = 0; public const TYPE_DRAFT = 1; - public const STATUS_RAW = 0; - public const STATUS_NO_SENT_MAILBOX = 1; - public const STATUS_SMIME_SIGN_NO_CERT_ID = 2; - public const STATUS_SMIME_SIGN_CERT = 3; - public const STATUS_SMIME_SIGN_FAIL = 4; - public const STATUS_SMIME_ENCRYPT_NO_CERT_ID = 5; - public const STATUS_SMIME_ENCRYPT_CERT = 6; - public const STATUS_SMIME_ENCRYT_FAIL = 7; - public const STATUS_TOO_MANY_RECIPIENTS = 8; - public const STATUS_RATELIMIT = 9; - public const STATUS_SMPT_SEND_FAIL = 10; - public const STATUS_IMAP_SENT_MAILBOX_FAIL = 11; - public const STATUS_PROCESSED = 12; /** - * @var int<1,12> + * @var int * @psalm-var self::TYPE_* */ protected $type; @@ -133,15 +116,6 @@ class LocalMessage extends Entity implements JsonSerializable { /** @var bool|null */ protected $smimeEncrypt; - /** - * @var int|null - * @psalm-var int-mask-of - */ - protected $status; - - /** @var string|null */ - protected $raw; - public function __construct() { $this->addType('type', 'integer'); $this->addType('accountId', 'integer'); @@ -153,7 +127,6 @@ public function __construct() { $this->addType('smimeSign', 'boolean'); $this->addType('smimeCertificateId', 'integer'); $this->addType('smimeEncrypt', 'boolean'); - $this->addType('status', 'integer'); } #[ReturnTypeWillChange] @@ -195,8 +168,6 @@ public function jsonSerialize() { 'smimeCertificateId' => $this->getSmimeCertificateId(), 'smimeSign' => $this->getSmimeSign() === true, 'smimeEncrypt' => $this->getSmimeEncrypt() === true, - 'status' => $this->getStatus(), - 'raw' => $this->getRaw(), ]; } diff --git a/lib/Db/LocalMessageMapper.php b/lib/Db/LocalMessageMapper.php index 0eaf469542..54fcc67ae5 100644 --- a/lib/Db/LocalMessageMapper.php +++ b/lib/Db/LocalMessageMapper.php @@ -64,8 +64,7 @@ public function getAllForUser(string $userId, int $type = LocalMessage::TYPE_OUT ->join('a', $this->getTableName(), 'm', $qb->expr()->eq('m.account_id', 'a.id')) ->where( $qb->expr()->eq('a.user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR), - $qb->expr()->eq('m.type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), - $qb->expr()->neq('m.status', $qb->createNamedParameter(LocalMessage::STATUS_PROCESSED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT) + $qb->expr()->eq('m.type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT) ); $rows = $qb->executeQuery(); @@ -135,6 +134,10 @@ public function findDue(int $time, int $type = LocalMessage::TYPE_OUTGOING): arr $qb->expr()->isNotNull('send_at'), $qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), $qb->expr()->lte('send_at', $qb->createNamedParameter($time, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $qb->expr()->orX( + $qb->expr()->isNull('failed'), + $qb->expr()->eq('failed', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL), IQueryBuilder::PARAM_BOOL), + ) ) ->orderBy('send_at', 'asc'); $messages = $this->findEntities($select); diff --git a/lib/Events/BeforeMessageSentEvent.php b/lib/Events/BeforeMessageSentEvent.php new file mode 100644 index 0000000000..76d233baa6 --- /dev/null +++ b/lib/Events/BeforeMessageSentEvent.php @@ -0,0 +1,95 @@ + + * + * @author 2021 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Events; + +use Horde_Mime_Mail; +use OCA\Mail\Account; +use OCA\Mail\Db\Message; +use OCA\Mail\Model\IMessage; +use OCA\Mail\Model\NewMessageData; +use OCP\EventDispatcher\Event; + +/** + * @psalm-immutable + */ +class BeforeMessageSentEvent extends Event { + /** @var Account */ + private $account; + + /** @var NewMessageData */ + private $newMessageData; + + /** @var Message|null */ + private $draft; + + /** @var IMessage */ + private $message; + + /** @var Horde_Mime_Mail */ + private $mail; + + /** @var string|null */ + private $repliedToMessageId; + + public function __construct(Account $account, + NewMessageData $newMessageData, + ?string $repliedToMessageId, + ?Message $draft, + IMessage $message, + Horde_Mime_Mail $mail) { + parent::__construct(); + $this->account = $account; + $this->newMessageData = $newMessageData; + $this->repliedToMessageId = $repliedToMessageId; + $this->draft = $draft; + $this->message = $message; + $this->mail = $mail; + } + + public function getAccount(): Account { + return $this->account; + } + + public function getNewMessageData(): NewMessageData { + return $this->newMessageData; + } + + public function getRepliedToMessageId(): ?string { + return $this->repliedToMessageId; + } + + public function getDraft(): ?Message { + return $this->draft; + } + + public function getMessage(): IMessage { + return $this->message; + } + + public function getMail(): Horde_Mime_Mail { + return $this->mail; + } +} diff --git a/lib/Events/DraftSavedEvent.php b/lib/Events/DraftSavedEvent.php index 53626508e8..ed0072b60a 100644 --- a/lib/Events/DraftSavedEvent.php +++ b/lib/Events/DraftSavedEvent.php @@ -34,15 +34,15 @@ class DraftSavedEvent extends Event { /** @var Account */ private $account; - /** @var NewMessageData|null */ + /** @var NewMessageData */ private $newMessageData; /** @var Message|null */ private $draft; public function __construct(Account $account, - ?NewMessageData $newMessageData = null, - ?Message $draft = null) { + NewMessageData $newMessageData, + ?Message $draft) { parent::__construct(); $this->account = $account; $this->newMessageData = $newMessageData; @@ -53,7 +53,7 @@ public function getAccount(): Account { return $this->account; } - public function getNewMessageData(): ?NewMessageData { + public function getNewMessageData(): NewMessageData { return $this->newMessageData; } diff --git a/lib/Events/MessageSentEvent.php b/lib/Events/MessageSentEvent.php index 2bd8e73404..05f0d82a6f 100644 --- a/lib/Events/MessageSentEvent.php +++ b/lib/Events/MessageSentEvent.php @@ -25,8 +25,11 @@ namespace OCA\Mail\Events; +use Horde_Mime_Mail; use OCA\Mail\Account; -use OCA\Mail\Db\LocalMessage; +use OCA\Mail\Db\Message; +use OCA\Mail\Model\IMessage; +use OCA\Mail\Model\NewMessageData; use OCP\EventDispatcher\Event; /** @@ -36,17 +39,57 @@ class MessageSentEvent extends Event { /** @var Account */ private $account; + /** @var NewMessageData */ + private $newMessageData; + + /** @var null|string */ + private $repliedToMessageId; + + /** @var Message|null */ + private $draft; + + /** @var IMessage */ + private $message; + + /** @var Horde_Mime_Mail */ + private $mail; + public function __construct(Account $account, - private LocalMessage $localMessage) { + NewMessageData $newMessageData, + ?string $repliedToMessageId, + ?Message $draft, + IMessage $message, + Horde_Mime_Mail $mail) { parent::__construct(); $this->account = $account; + $this->newMessageData = $newMessageData; + $this->repliedToMessageId = $repliedToMessageId; + $this->draft = $draft; + $this->message = $message; + $this->mail = $mail; } public function getAccount(): Account { return $this->account; } - public function getLocalMessage(): LocalMessage { - return $this->localMessage; + public function getNewMessageData(): NewMessageData { + return $this->newMessageData; + } + + public function getRepliedToMessageId(): ?string { + return $this->repliedToMessageId; + } + + public function getDraft(): ?Message { + return $this->draft; + } + + public function getMessage(): IMessage { + return $this->message; + } + + public function getMail(): Horde_Mime_Mail { + return $this->mail; } } diff --git a/lib/IMAP/MessageMapper.php b/lib/IMAP/MessageMapper.php index 8503ed2738..e685399591 100644 --- a/lib/IMAP/MessageMapper.php +++ b/lib/IMAP/MessageMapper.php @@ -35,6 +35,7 @@ use Horde_Imap_Client_Socket; use Horde_Mime_Exception; use Horde_Mime_Headers; +use Horde_Mime_Mail; use Horde_Mime_Part; use Html2Text\Html2Text; use OCA\Mail\Attachment; @@ -397,7 +398,7 @@ public function expunge(Horde_Imap_Client_Base $client, */ public function save(Horde_Imap_Client_Socket $client, Mailbox $mailbox, - string $mail, + Horde_Mime_Mail $mail, array $flags = []): int { $flags = array_merge([ Horde_Imap_Client::FLAG_SEEN, @@ -407,7 +408,7 @@ public function save(Horde_Imap_Client_Socket $client, $mailbox->getName(), [ [ - 'data' => $mail, + 'data' => $mail->getRaw(), 'flags' => $flags, ] ] diff --git a/lib/Listener/AddressCollectionListener.php b/lib/Listener/AddressCollectionListener.php index 950f745bde..a6d2a0a373 100644 --- a/lib/Listener/AddressCollectionListener.php +++ b/lib/Listener/AddressCollectionListener.php @@ -26,10 +26,8 @@ namespace OCA\Mail\Listener; use OCA\Mail\Contracts\IUserPreferences; -use OCA\Mail\Db\Recipient; use OCA\Mail\Events\MessageSentEvent; use OCA\Mail\Service\AutoCompletion\AddressCollector; -use OCA\Mail\Service\TransmissionService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use Psr\Log\LoggerInterface; @@ -50,8 +48,7 @@ class AddressCollectionListener implements IEventListener { public function __construct(IUserPreferences $preferences, AddressCollector $collector, - LoggerInterface $logger, - private TransmissionService $transmissionService) { + LoggerInterface $logger) { $this->collector = $collector; $this->logger = $logger; $this->preferences = $preferences; @@ -68,12 +65,10 @@ public function handle(Event $event): void { // Non-essential feature, hence we catch all possible errors try { - $message = $event->getLocalMessage(); - $to = $this->transmissionService->getAddressList($message, Recipient::TYPE_TO); - $cc = $this->transmissionService->getAddressList($message, Recipient::TYPE_CC); - $bcc = $this->transmissionService->getAddressList($message, Recipient::TYPE_BCC); - - $addresses = $to->merge($cc)->merge($bcc); + $message = $event->getMessage(); + $addresses = $message->getTo() + ->merge($message->getCC()) + ->merge($message->getBCC()); $this->collector->addAddresses($event->getAccount()->getUserId(), $addresses); } catch (Throwable $e) { diff --git a/lib/Listener/AntiAbuseListener.php b/lib/Listener/AntiAbuseListener.php new file mode 100644 index 0000000000..a06d0f7f00 --- /dev/null +++ b/lib/Listener/AntiAbuseListener.php @@ -0,0 +1,75 @@ + + * + * @author 2021 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Listener; + +use OCA\Mail\Events\BeforeMessageSentEvent; +use OCA\Mail\Service\AntiAbuseService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +/** + * @template-implements IEventListener + */ +class AntiAbuseListener implements IEventListener { + /** @var IUserManager */ + private $userManager; + + /** @var AntiAbuseService */ + private $service; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(IUserManager $userManager, + AntiAbuseService $service, + LoggerInterface $logger) { + $this->service = $service; + $this->userManager = $userManager; + $this->logger = $logger; + } + + public function handle(Event $event): void { + if (!($event instanceof BeforeMessageSentEvent)) { + return; + } + + $user = $this->userManager->get($event->getAccount()->getUserId()); + if ($user === null) { + $this->logger->error('User {user} for mail account {id} does not exist', [ + 'user' => $event->getAccount()->getUserId(), + 'id' => $event->getAccount()->getId(), + ]); + return; + } + + $this->service->onBeforeMessageSent( + $user, + $event->getNewMessageData(), + ); + } +} diff --git a/lib/Listener/FlagRepliedMessageListener.php b/lib/Listener/FlagRepliedMessageListener.php new file mode 100644 index 0000000000..f53d07408b --- /dev/null +++ b/lib/Listener/FlagRepliedMessageListener.php @@ -0,0 +1,114 @@ + + * + * @author 2019 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Listener; + +use Horde_Imap_Client; +use Horde_Imap_Client_Exception; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Db\MessageMapper as DbMessageMapper; +use OCA\Mail\Events\MessageSentEvent; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\MessageMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +/** + * @template-implements IEventListener + */ +class FlagRepliedMessageListener implements IEventListener { + /** @var IMAPClientFactory */ + private $imapClientFactory; + + /** @var MailboxMapper */ + private $mailboxMapper; + + /** @var MessageMapper */ + private $messageMapper; + + /** @var LoggerInterface */ + private $logger; + + /** @var DbMessageMapper */ + private $dbMessageMapper; + + public function __construct(IMAPClientFactory $imapClientFactory, + MailboxMapper $mailboxMapper, + DbMessageMapper $dbMessageMapper, + MessageMapper $mapper, + LoggerInterface $logger) { + $this->imapClientFactory = $imapClientFactory; + $this->mailboxMapper = $mailboxMapper; + $this->dbMessageMapper = $dbMessageMapper; + $this->messageMapper = $mapper; + $this->logger = $logger; + } + + public function handle(Event $event): void { + if (!($event instanceof MessageSentEvent) || $event->getRepliedToMessageId() === null) { + return; + } + + $messages = $this->dbMessageMapper->findByMessageId($event->getAccount(), $event->getRepliedToMessageId()); + if ($messages === []) { + return; + } + + try { + $client = $this->imapClientFactory->getClient($event->getAccount()); + foreach ($messages as $message) { + try { + $mailbox = $this->mailboxMapper->findById($message->getMailboxId()); + //ignore read-only mailboxes + if ($mailbox->getMyAcls() !== null && !strpos($mailbox->getMyAcls(), "w")) { + continue; + } + // ignore drafts and sent + if ($mailbox->isSpecialUse('sent') || $mailbox->isSpecialUse('drafts')) { + continue; + } + // Mark all other mailboxes that contain the message with the same imap message id as replied + $this->messageMapper->addFlag( + $client, + $mailbox, + [$message->getUid()], + Horde_Imap_Client::FLAG_ANSWERED + ); + } catch (DoesNotExistException | Horde_Imap_Client_Exception $e) { + $this->logger->warning('Could not flag replied message: ' . $e, [ + 'exception' => $e, + ]); + } + + $message->setFlagAnswered(true); + $this->dbMessageMapper->update($message); + } + } finally { + $client->logout(); + } + } +} diff --git a/lib/Listener/InteractionListener.php b/lib/Listener/InteractionListener.php index 8f09586f8d..66c3ab0049 100644 --- a/lib/Listener/InteractionListener.php +++ b/lib/Listener/InteractionListener.php @@ -70,19 +70,16 @@ public function handle(Event $event): void { $this->logger->debug('no user object found'); return; } - $message = $event->getLocalMessage(); - $emails = []; - foreach ($message->getRecipients() as $recipient) { - if (in_array($recipient->getEmail(), $emails)) { - continue; - } + $recipients = $event->getMessage()->getTo() + ->merge($event->getMessage()->getCC()) + ->merge($event->getMessage()->getBCC()); + foreach ($recipients->iterate() as $recipient) { $interactionEvent = new ContactInteractedWithEvent($user); $email = $recipient->getEmail(); if ($email === null) { // Weird, bot ok continue; } - $emails[] = $email; $interactionEvent->setEmail($email); $this->dispatcher->dispatch(ContactInteractedWithEvent::class, $interactionEvent); } diff --git a/lib/Listener/SaveSentMessageListener.php b/lib/Listener/SaveSentMessageListener.php new file mode 100644 index 0000000000..95df328b63 --- /dev/null +++ b/lib/Listener/SaveSentMessageListener.php @@ -0,0 +1,101 @@ + + * + * @author 2019 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Listener; + +use Horde_Imap_Client_Exception; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Events\MessageSentEvent; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\MessageMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +/** + * @template-implements IEventListener + */ +class SaveSentMessageListener implements IEventListener { + /** @var MailboxMapper */ + private $mailboxMapper; + + /** @var IMAPClientFactory */ + private $imapClientFactory; + + /** @var MessageMapper */ + private $messageMapper; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(MailboxMapper $mailboxMapper, + IMAPClientFactory $imapClientFactory, + MessageMapper $messageMapper, + LoggerInterface $logger) { + $this->mailboxMapper = $mailboxMapper; + $this->imapClientFactory = $imapClientFactory; + $this->messageMapper = $messageMapper; + $this->logger = $logger; + } + + public function handle(Event $event): void { + if (!($event instanceof MessageSentEvent)) { + return; + } + + $sentMailboxId = $event->getAccount()->getMailAccount()->getSentMailboxId(); + if ($sentMailboxId === null) { + $this->logger->warning("No sent mailbox exists, can't save sent message"); + return; + } + + // Save the message in the sent mailbox + try { + $sentMailbox = $this->mailboxMapper->findById( + $sentMailboxId + ); + } catch (DoesNotExistException $e) { + $this->logger->error("Sent mailbox could not be found", [ + 'exception' => $e, + ]); + return; + } + + $client = $this->imapClientFactory->getClient($event->getAccount()); + try { + $this->messageMapper->save( + $client, + $sentMailbox, + $event->getMail() + ); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException('Could not save sent message on IMAP', 0, $e); + } finally { + $client->logout(); + } + } +} diff --git a/lib/Migration/Version3600Date20240220134813.php b/lib/Migration/Version3600Date20240220134813.php index 7fcb91d366..c0d9b39c4a 100644 --- a/lib/Migration/Version3600Date20240220134813.php +++ b/lib/Migration/Version3600Date20240220134813.php @@ -44,6 +44,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); + /* REVERTED in Version3600Date20240220134814 $localMessagesTable = $schema->getTable('mail_local_messages'); if (!$localMessagesTable->hasColumn('status')) { $localMessagesTable->addColumn('status', Types::INTEGER, [ @@ -57,6 +58,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt 'default' => null, ]); } + */ return $schema; } diff --git a/lib/Migration/Version3600Date20240220134814.php b/lib/Migration/Version3600Date20240220134814.php new file mode 100644 index 0000000000..0a45560c37 --- /dev/null +++ b/lib/Migration/Version3600Date20240220134814.php @@ -0,0 +1,57 @@ + + * + * @author Anna Larch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version3600Date20240220134814 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $localMessagesTable = $schema->getTable('mail_local_messages'); + if ($localMessagesTable->hasColumn('status')) { + $localMessagesTable->dropColumn('status'); + } + if ($localMessagesTable->hasColumn('raw')) { + $localMessagesTable->dropColumn('raw'); + } + + return $schema; + } + +} diff --git a/lib/Send/AHandler.php b/lib/Send/AHandler.php deleted file mode 100644 index 1de4254c40..0000000000 --- a/lib/Send/AHandler.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * @author Anna Larch - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU AFFERO GENERAL PUBLIC LICENSE for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with this library. If not, see . - */ -namespace OCA\Mail\Send; - -use OCA\Mail\Account; -use OCA\Mail\Db\LocalMessage; - -abstract class AHandler { - - protected AHandler|null $next = null; - public function setNext(AHandler $next): AHandler { - $this->next = $next; - return $next; - } - - abstract public function process(Account $account, LocalMessage $localMessage): LocalMessage; - - protected function processNext(Account $account, LocalMessage $localMessage): LocalMessage { - if ($this->next !== null) { - return $this->next->process($account, $localMessage); - } - return $localMessage; - } -} diff --git a/lib/Send/AntiAbuseHandler.php b/lib/Send/AntiAbuseHandler.php deleted file mode 100644 index 72f0f80dce..0000000000 --- a/lib/Send/AntiAbuseHandler.php +++ /dev/null @@ -1,63 +0,0 @@ - - * - * @author Anna Larch - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU AFFERO GENERAL PUBLIC LICENSE for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with this library. If not, see . - */ - -namespace OCA\Mail\Send; - -use OCA\Mail\Account; -use OCA\Mail\Db\LocalMessage; -use OCA\Mail\Service\AntiAbuseService; -use OCP\IUserManager; -use Psr\Log\LoggerInterface; - -class AntiAbuseHandler extends AHandler { - - public function __construct(private IUserManager $userManager, - private AntiAbuseService $service, - private LoggerInterface $logger) { - } - public function process(Account $account, LocalMessage $localMessage): LocalMessage { - if ($localMessage->getStatus() === LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL - || $localMessage->getStatus() === LocalMessage::STATUS_PROCESSED) { - return $this->processNext($account, $localMessage); - } - - $user = $this->userManager->get($account->getUserId()); - if ($user === null) { - $this->logger->error('User {user} for mail account {id} does not exist', [ - 'user' => $account->getUserId(), - 'id' => $account->getId(), - ]); - // What to do here? - return $localMessage; - } - - $this->service->onBeforeMessageSent( - $user, - $localMessage, - ); - // We don't react to a ratelimited message / a message that has too many recipients - // at this point. - // Any future improvement from https://github.com/nextcloud/mail/issues/6461 - // should refactor the chain to stop at this point unless the force send option is true - return $this->processNext($account, $localMessage); - } -} diff --git a/lib/Send/Chain.php b/lib/Send/Chain.php deleted file mode 100644 index 94e122efab..0000000000 --- a/lib/Send/Chain.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * @author Anna Larch - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU AFFERO GENERAL PUBLIC LICENSE for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with this library. If not, see . - */ - -namespace OCA\Mail\Send; - -use OCA\Mail\Account; -use OCA\Mail\Db\LocalMessage; -use OCA\Mail\Db\LocalMessageMapper; -use OCA\Mail\Service\Attachment\AttachmentService; - -class Chain { - public function __construct(private SentMailboxHandler $sentMailboxHandler, - private AntiAbuseHandler $antiAbuseHandler, - private SendHandler $sendHandler, - private CopySentMessageHandler $copySentMessageHandler, - private FlagRepliedMessageHandler $flagRepliedMessageHandler, - private AttachmentService $attachmentService, - private LocalMessageMapper $localMessageMapper, - ) { - } - - public function process(Account $account, LocalMessage $localMessage): void { - $handlers = $this->sentMailboxHandler; - $handlers->setNext($this->antiAbuseHandler) - ->setNext($this->sendHandler) - ->setNext($this->copySentMessageHandler) - ->setNext($this->flagRepliedMessageHandler); - - $result = $handlers->process($account, $localMessage); - if ($result->getStatus() === LocalMessage::STATUS_PROCESSED) { - $this->attachmentService->deleteLocalMessageAttachments($account->getUserId(), $result->getId()); - $this->localMessageMapper->deleteWithRecipients($result); - return; - } - $this->localMessageMapper->update($result); - } -} diff --git a/lib/Send/CopySentMessageHandler.php b/lib/Send/CopySentMessageHandler.php deleted file mode 100644 index ba98dddaaa..0000000000 --- a/lib/Send/CopySentMessageHandler.php +++ /dev/null @@ -1,91 +0,0 @@ - - * - * @author Anna Larch - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU AFFERO GENERAL PUBLIC LICENSE for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with this library. If not, see . - */ - -namespace OCA\Mail\Send; - -use Horde_Imap_Client_Exception; -use OCA\Mail\Account; -use OCA\Mail\Db\LocalMessage; -use OCA\Mail\Db\MailboxMapper; -use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\IMAP\MessageMapper; -use OCP\AppFramework\Db\DoesNotExistException; -use Psr\Log\LoggerInterface; - -class CopySentMessageHandler extends AHandler { - public function __construct(private IMAPClientFactory $imapClientFactory, - private MailboxMapper $mailboxMapper, - private LoggerInterface $logger, - private MessageMapper $messageMapper - ) { - } - public function process(Account $account, LocalMessage $localMessage): LocalMessage { - if ($localMessage->getStatus() === LocalMessage::STATUS_PROCESSED) { - return $this->processNext($account, $localMessage); - } - - $sentMailboxId = $account->getMailAccount()->getSentMailboxId(); - if ($sentMailboxId === null) { - // We can't write the "sent mailbox" status here bc that would trigger an additional send. - // Thus, we leave the "imap copy to sent mailbox" status. - $localMessage->setStatus(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); - $this->logger->warning("No sent mailbox exists, can't save sent message"); - return $localMessage; - } - - // Save the message in the sent mailbox - try { - $sentMailbox = $this->mailboxMapper->findById( - $sentMailboxId - ); - } catch (DoesNotExistException $e) { - // We can't write the "sent mailbox" status here bc that would trigger an additional send. - // Thus, we leave the "imap copy to sent mailbox" status. - $localMessage->setStatus(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); - $this->logger->error('Sent mailbox could not be found', [ - 'exception' => $e, - ]); - - return $localMessage; - } - - $client = $this->imapClientFactory->getClient($account); - try { - $this->messageMapper->save( - $client, - $sentMailbox, - $localMessage->getRaw() - ); - $localMessage->setStatus(LocalMessage::STATUS_PROCESSED); - } catch (Horde_Imap_Client_Exception $e) { - $localMessage->setStatus(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); - $this->logger->error('Could not copy message to sent mailbox', [ - 'exception' => $e, - ]); - return $localMessage; - } finally { - $client->logout(); - } - - return $this->processNext($account, $localMessage); - } -} diff --git a/lib/Send/FlagRepliedMessageHandler.php b/lib/Send/FlagRepliedMessageHandler.php deleted file mode 100644 index 9745f65db2..0000000000 --- a/lib/Send/FlagRepliedMessageHandler.php +++ /dev/null @@ -1,94 +0,0 @@ - - * - * @author Anna Larch - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU AFFERO GENERAL PUBLIC LICENSE for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with this library. If not, see . - */ - -namespace OCA\Mail\Send; - -use Horde_Imap_Client; -use Horde_Imap_Client_Exception; -use OCA\Mail\Account; -use OCA\Mail\Db\LocalMessage; -use OCA\Mail\Db\MailboxMapper; -use OCA\Mail\Db\MessageMapper as DbMessageMapper; -use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\IMAP\MessageMapper; -use OCP\AppFramework\Db\DoesNotExistException; -use Psr\Log\LoggerInterface; - -class FlagRepliedMessageHandler extends AHandler { - public function __construct(private IMAPClientFactory $imapClientFactory, - private MailboxMapper $mailboxMapper, - private LoggerInterface $logger, - private MessageMapper $messageMapper, - private DbMessageMapper $dbMessageMapper, - ) { - } - - public function process(Account $account, LocalMessage $localMessage): LocalMessage { - if ($localMessage->getStatus() !== LocalMessage::STATUS_PROCESSED) { - return $localMessage; - } - - if ($localMessage->getInReplyToMessageId() === null) { - return $this->processNext($account, $localMessage); - } - - $messages = $this->dbMessageMapper->findByMessageId($account, $localMessage->getInReplyToMessageId()); - if ($messages === []) { - return $this->processNext($account, $localMessage); - } - - try { - $client = $this->imapClientFactory->getClient($account); - foreach ($messages as $message) { - try { - $mailbox = $this->mailboxMapper->findById($message->getMailboxId()); - //ignore read-only mailboxes - if ($mailbox->getMyAcls() !== null && !strpos($mailbox->getMyAcls(), 'w')) { - continue; - } - // ignore drafts and sent - if ($mailbox->isSpecialUse('sent') || $mailbox->isSpecialUse('drafts')) { - continue; - } - // Mark all other mailboxes that contain the message with the same imap message id as replied - $this->messageMapper->addFlag( - $client, - $mailbox, - [$message->getUid()], - Horde_Imap_Client::FLAG_ANSWERED - ); - $message->setFlagAnswered(true); - $this->dbMessageMapper->update($message); - } catch (DoesNotExistException|Horde_Imap_Client_Exception $e) { - $this->logger->warning('Could not flag replied message: ' . $e, [ - 'exception' => $e, - ]); - } - - } - } finally { - $client->logout(); - } - - return $this->processNext($account, $localMessage); - } -} diff --git a/lib/Send/SendHandler.php b/lib/Send/SendHandler.php deleted file mode 100644 index c000830bd7..0000000000 --- a/lib/Send/SendHandler.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * @author Anna Larch - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU AFFERO GENERAL PUBLIC LICENSE for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with this library. If not, see . - */ - -namespace OCA\Mail\Send; - -use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailTransmission; -use OCA\Mail\Db\LocalMessage; - -class SendHandler extends AHandler { - public function __construct(private IMailTransmission $transmission, - ) { - } - - public function process(Account $account, LocalMessage $localMessage): LocalMessage { - if ($localMessage->getStatus() === LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL - || $localMessage->getStatus() === LocalMessage::STATUS_PROCESSED) { - return $this->processNext($account, $localMessage); - } - - $this->transmission->sendMessage($account, $localMessage); - - if ($localMessage->getStatus() === LocalMessage::STATUS_RAW || $localMessage->getStatus() === null) { - return $this->processNext($account, $localMessage); - } - // Something went wrong during the sending - return $localMessage; - } -} diff --git a/lib/Send/SentMailboxHandler.php b/lib/Send/SentMailboxHandler.php deleted file mode 100644 index 7b5ca8de10..0000000000 --- a/lib/Send/SentMailboxHandler.php +++ /dev/null @@ -1,36 +0,0 @@ - - * - * @author Anna Larch - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU AFFERO GENERAL PUBLIC LICENSE for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with this library. If not, see . - */ - -namespace OCA\Mail\Send; - -use OCA\Mail\Account; -use OCA\Mail\Db\LocalMessage; - -class SentMailboxHandler extends AHandler { - public function process(Account $account, LocalMessage $localMessage): LocalMessage { - if ($account->getMailAccount()->getSentMailboxId() === null) { - $localMessage->setStatus(LocalMessage::STATUS_NO_SENT_MAILBOX); - return $localMessage; - } - return $this->processNext($account, $localMessage); - } -} diff --git a/lib/Service/AntiAbuseService.php b/lib/Service/AntiAbuseService.php index 22368bf358..c470325bef 100644 --- a/lib/Service/AntiAbuseService.php +++ b/lib/Service/AntiAbuseService.php @@ -26,7 +26,7 @@ namespace OCA\Mail\Service; use OCA\Mail\AppInfo\Application; -use OCA\Mail\Db\LocalMessage; +use OCA\Mail\Model\NewMessageData; use OCP\AppFramework\Utility\ITimeFactory; use OCP\ICacheFactory; use OCP\IConfig; @@ -58,7 +58,8 @@ public function __construct(IConfig $config, $this->logger = $logger; } - public function onBeforeMessageSent(IUser $user, LocalMessage $localMessage): void { + public function onBeforeMessageSent(IUser $user, + NewMessageData $messageData): void { $abuseDetection = $this->config->getAppValue( Application::APP_ID, 'abuse_detection', @@ -69,11 +70,12 @@ public function onBeforeMessageSent(IUser $user, LocalMessage $localMessage): vo return; } - $this->checkNumberOfRecipients($user, $localMessage); - $this->checkRateLimits($user, $localMessage); + $this->checkNumberOfRecipients($user, $messageData); + $this->checkRateLimits($user, $messageData); } - private function checkNumberOfRecipients(IUser $user, LocalMessage $message): void { + private function checkNumberOfRecipients(IUser $user, + NewMessageData $messageData): void { $numberOfRecipientsThreshold = (int)$this->config->getAppValue( Application::APP_ID, 'abuse_number_of_recipients_per_message_threshold', @@ -83,10 +85,11 @@ private function checkNumberOfRecipients(IUser $user, LocalMessage $message): vo return; } - $actualNumberOfRecipients = count($message->getRecipients()); + $actualNumberOfRecipients = count($messageData->getTo()) + + count($messageData->getCc()) + + count($messageData->getBcc()); if ($actualNumberOfRecipients >= $numberOfRecipientsThreshold) { - $message->setStatus(LocalMessage::STATUS_TOO_MANY_RECIPIENTS); $this->logger->alert('User {user} sends to a suspicious number of recipients. {expected} are allowed. {actual} are used', [ 'user' => $user->getUID(), 'expected' => $numberOfRecipientsThreshold, @@ -95,7 +98,8 @@ private function checkNumberOfRecipients(IUser $user, LocalMessage $message): vo } } - private function checkRateLimits(IUser $user, LocalMessage $message): void { + private function checkRateLimits(IUser $user, + NewMessageData $messageData): void { if (!$this->cacheFactory->isAvailable()) { // No cache, no rate limits return; @@ -106,21 +110,16 @@ private function checkRateLimits(IUser $user, LocalMessage $message): void { return; } - $ratelimited = ( - $this->checkRateLimitsForPeriod($user, $cache, '15m', 15 * 60, $message) || - $this->checkRateLimitsForPeriod($user, $cache, '1h', 60 * 60, $message) || - $this->checkRateLimitsForPeriod($user, $cache, '1d', 24 * 60 * 60, $message) - ); - if ($ratelimited) { - $message->setStatus(LocalMessage::STATUS_RATELIMIT); - } + $this->checkRateLimitsForPeriod($user, $messageData, $cache, '15m', 15 * 60); + $this->checkRateLimitsForPeriod($user, $messageData, $cache, '1h', 60 * 60); + $this->checkRateLimitsForPeriod($user, $messageData, $cache, '1d', 24 * 60 * 60); } private function checkRateLimitsForPeriod(IUser $user, + NewMessageData $messageData, IMemcache $cache, string $id, - int $period, - LocalMessage $message): bool { + int $period): void { $maxNumberOfMessages = (int)$this->config->getAppValue( Application::APP_ID, 'abuse_number_of_messages_per_' . $id, @@ -128,7 +127,7 @@ private function checkRateLimitsForPeriod(IUser $user, ); if ($maxNumberOfMessages === 0) { // No limit set - return false; + return; } $now = $this->timeFactory->getTime(); @@ -137,7 +136,7 @@ private function checkRateLimitsForPeriod(IUser $user, $periodStart = ((int)($now / $period)) * $period; $cacheKey = implode('_', ['counter', $id, $periodStart]); $cache->add($cacheKey, 0); - $counter = $cache->inc($cacheKey, count($message->getRecipients())); + $counter = $cache->inc($cacheKey, count($messageData->getTo()) + count($messageData->getCc()) + count($messageData->getBcc())); if ($counter >= $maxNumberOfMessages) { $this->logger->alert('User {user} sends a supcious number of messages within {period}. {expected} are allowed. {actual} have been sent', [ @@ -146,8 +145,6 @@ private function checkRateLimitsForPeriod(IUser $user, 'expected' => $maxNumberOfMessages, 'actual' => $counter, ]); - return true; } - return false; } } diff --git a/lib/Service/AntiSpamService.php b/lib/Service/AntiSpamService.php index 47a0e50de8..e3f1b665ea 100644 --- a/lib/Service/AntiSpamService.php +++ b/lib/Service/AntiSpamService.php @@ -25,36 +25,34 @@ namespace OCA\Mail\Service; -use Horde_Imap_Client_Exception; -use Horde_Mime_Exception; -use Horde_Mime_Mail; use OCA\Mail\Account; -use OCA\Mail\Address; -use OCA\Mail\AddressList; +use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MessageMapper; -use OCA\Mail\Exception\ClientException; +use OCA\Mail\Exception\SentMailboxNotSetException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; -use OCA\Mail\Service\DataUri\DataUriParser; -use OCA\Mail\SMTP\SmtpClientFactory; -use OCP\AppFramework\Db\DoesNotExistException; +use OCA\Mail\Model\NewMessageData; use OCP\IConfig; -use Psr\Log\LoggerInterface; class AntiSpamService { private const NAME = 'antispam_reporting'; private const MESSAGE_TYPE = 'message/rfc822'; - public function __construct(private IConfig $config, - private MessageMapper $dbMessageMapper, - private MailManager $mailManager, - private IMAPClientFactory $imapClientFactory, - private SmtpClientFactory $smtpClientFactory, - private ImapMessageMapper $messageMapper, - private LoggerInterface $logger, - ) { + /** @var IConfig */ + private $config; + + /** @var MessageMapper */ + private $messageMapper; + + /** @var IMailTransmission */ + private $transmission; + + public function __construct(IConfig $config, + MessageMapper $messageMapper, + IMailTransmission $transmission) { + $this->config = $config; + $this->messageMapper = $messageMapper; + $this->transmission = $transmission; } public function getSpamEmail(): string { @@ -101,126 +99,25 @@ public function sendReportEmail(Account $account, Mailbox $mailbox, int $uid, st $subject = ($flag === '$junk') ? $this->getSpamSubject() : $this->getHamSubject(); // Message to attach not found - $messageId = $this->dbMessageMapper->getIdForUid($mailbox, $uid); + $messageId = $this->messageMapper->getIdForUid($mailbox, $uid); if ($messageId === null) { throw new ServiceException('Could not find reported message'); } - if ($account->getMailAccount()->getSentMailboxId() === null) { - throw new ServiceException('Could not find sent mailbox'); - } - - $message = $account->newMessage(); - $from = new AddressList([ - Address::fromRaw($account->getName(), $account->getEMailAddress()), - ]); - $to = new AddressList([ - Address::fromRaw($reportEmail, $reportEmail), - ]); - $message->setTo($to); - $message->setSubject($subject); - $message->setFrom($from); - $message->setContent($subject); - - // Gets original of other message - $userId = $account->getMailAccount()->getUserId(); - try { - $attachmentMessage = $this->mailManager->getMessage($userId, $messageId); - } catch (DoesNotExistException $e) { - $this->logger->error('Could not find reported email with message ID #' . $messageId, ['exception' => $e]); - return; - } - - $mailbox = $this->mailManager->getMailbox($userId, $attachmentMessage->getMailboxId()); - - $client = $this->imapClientFactory->getClient($account); - try { - $fullText = $this->messageMapper->getFullText( - $client, - $mailbox->getName(), - $attachmentMessage->getUid(), - $userId - ); - } finally { - $client->logout(); - } - - $message->addEmbeddedMessageAttachment( - $attachmentMessage->getSubject() . '.eml', - $fullText + $messageData = NewMessageData::fromRequest( + $account, + $reportEmail, + null, + null, + $subject, + $subject, // add any message body - not all IMAP servers accept empty emails + [['id' => $messageId, 'type' => self::MESSAGE_TYPE]] ); - $transport = $this->smtpClientFactory->create($account); - // build mime body - $headers = [ - 'From' => $message->getFrom()->first()->toHorde(), - 'To' => $message->getTo()->toHorde(), - 'Cc' => $message->getCC()->toHorde(), - 'Bcc' => $message->getBCC()->toHorde(), - 'Subject' => $message->getSubject(), - ]; - - if (($inReplyTo = $message->getInReplyTo()) !== null) { - $headers['References'] = $inReplyTo; - $headers['In-Reply-To'] = $inReplyTo; - } - - $mail = new Horde_Mime_Mail(); - $mail->addHeaders($headers); - - $mimeMessage = new MimeMessage( - new DataUriParser() - ); - $mimePart = $mimeMessage->build( - true, - $message->getContent(), - $message->getAttachments() - ); - - $mail->setBasePart($mimePart); - - // Send the message try { - $mail->send($transport, false, false); - } catch (Horde_Mime_Exception $e) { - throw new ServiceException( - 'Could not send message: ' . $e->getMessage(), - $e->getCode(), - $e - ); - } - - $sentMailboxId = $account->getMailAccount()->getSentMailboxId(); - if ($sentMailboxId === null) { - $this->logger->warning("No sent mailbox exists, can't save sent message"); - return; - } - - // Save the message in the sent mailbox - try { - $sentMailbox = $this->mailManager->getMailbox( - $account->getUserId(), - $sentMailboxId - ); - } catch (ClientException $e) { - $this->logger->error('Sent mailbox could not be found', [ - 'exception' => $e, - ]); - return; - } - - $client = $this->imapClientFactory->getClient($account); - try { - $this->messageMapper->save( - $client, - $sentMailbox, - $mail->getRaw(false) - ); - } catch (Horde_Imap_Client_Exception $e) { - $this->logger->error('Could not move report email to sent mailbox, but the report email was sent. Reported email was id: #' . $messageId, ['exception' => $e]); - } finally { - $client->logout(); + $this->transmission->sendMessage($messageData); + } catch (SentMailboxNotSetException | ServiceException $e) { + throw new ServiceException('Could not send report email from anti spam email service', 0, $e); } } - } diff --git a/lib/Service/DraftsService.php b/lib/Service/DraftsService.php index 9fb4eee8a5..f3c9d596b4 100644 --- a/lib/Service/DraftsService.php +++ b/lib/Service/DraftsService.php @@ -119,10 +119,6 @@ public function saveMessage(Account $account, LocalMessage $message, array $to, throw new ClientException('Cannot convert message to outbox message without at least one recipient'); } - // Explicitly reset the status, so we can try sending from scratch again - // in case the user has updated a failing component - $message->setStatus(LocalMessage::STATUS_RAW); - $message = $this->mapper->saveWithRecipients($message, $toRecipients, $ccRecipients, $bccRecipients); if ($attachments === []) { diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index c3122e9a97..ca7d09d913 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -41,79 +41,156 @@ use OCA\Mail\Account; use OCA\Mail\Address; use OCA\Mail\AddressList; +use OCA\Mail\Contracts\IAttachmentService; +use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailTransmission; +use OCA\Mail\Db\Alias; +use OCA\Mail\Db\LocalAttachment; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; use OCA\Mail\Db\Recipient; +use OCA\Mail\Events\BeforeMessageSentEvent; use OCA\Mail\Events\DraftSavedEvent; use OCA\Mail\Events\MessageSentEvent; use OCA\Mail\Events\SaveDraftEvent; +use OCA\Mail\Exception\AttachmentNotFoundException; use OCA\Mail\Exception\ClientException; +use OCA\Mail\Exception\SentMailboxNotSetException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Exception\SmimeEncryptException; +use OCA\Mail\Exception\SmimeSignException; use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Model\IMessage; use OCA\Mail\Model\NewMessageData; use OCA\Mail\Service\DataUri\DataUriParser; use OCA\Mail\SMTP\SmtpClientFactory; use OCA\Mail\Support\PerformanceLogger; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\File; +use OCP\Files\Folder; use Psr\Log\LoggerInterface; +use function array_filter; +use function array_map; class MailTransmission implements IMailTransmission { - public function __construct( - private IMAPClientFactory $imapClientFactory, - private SmtpClientFactory $smtpClientFactory, - private IEventDispatcher $eventDispatcher, - private MailboxMapper $mailboxMapper, - private MessageMapper $messageMapper, - private LoggerInterface $logger, - private PerformanceLogger $performanceLogger, - private AliasesService $aliasesService, - private TransmissionService $transmissionService - ) { + private SmimeService $smimeService; + + /** @var Folder */ + private $userFolder; + + /** @var IAttachmentService */ + private $attachmentService; + + /** @var IMailManager */ + private $mailManager; + + /** @var IMAPClientFactory */ + private $imapClientFactory; + + /** @var SmtpClientFactory */ + private $smtpClientFactory; + + /** @var IEventDispatcher */ + private $eventDispatcher; + + /** @var MailboxMapper */ + private $mailboxMapper; + + /** @var MessageMapper */ + private $messageMapper; + + /** @var LoggerInterface */ + private $logger; + + /** @var PerformanceLogger */ + private $performanceLogger; + + /** @var AliasesService */ + private $aliasesService; + + /** @var GroupsIntegration */ + private $groupsIntegration; + + /** + * @param Folder $userFolder + */ + public function __construct($userFolder, + IAttachmentService $attachmentService, + IMailManager $mailManager, + IMAPClientFactory $imapClientFactory, + SmtpClientFactory $smtpClientFactory, + IEventDispatcher $eventDispatcher, + MailboxMapper $mailboxMapper, + MessageMapper $messageMapper, + LoggerInterface $logger, + PerformanceLogger $performanceLogger, + AliasesService $aliasesService, + GroupsIntegration $groupsIntegration, + SmimeService $smimeService) { + $this->userFolder = $userFolder; + $this->attachmentService = $attachmentService; + $this->mailManager = $mailManager; + $this->imapClientFactory = $imapClientFactory; + $this->smtpClientFactory = $smtpClientFactory; + $this->eventDispatcher = $eventDispatcher; + $this->mailboxMapper = $mailboxMapper; + $this->messageMapper = $messageMapper; + $this->logger = $logger; + $this->performanceLogger = $performanceLogger; + $this->aliasesService = $aliasesService; + $this->groupsIntegration = $groupsIntegration; + $this->smimeService = $smimeService; } - public function sendMessage(Account $account, LocalMessage $localMessage): void { - $to = $this->transmissionService->getAddressList($localMessage, Recipient::TYPE_TO); - $cc = $this->transmissionService->getAddressList($localMessage, Recipient::TYPE_CC); - $bcc = $this->transmissionService->getAddressList($localMessage, Recipient::TYPE_BCC); - $attachments = $this->transmissionService->getAttachments($localMessage); + public function sendMessage(NewMessageData $messageData, + ?string $repliedToMessageId = null, + ?Alias $alias = null, + ?Message $draft = null): void { + $account = $messageData->getAccount(); + if ($account->getMailAccount()->getSentMailboxId() === null) { + throw new SentMailboxNotSetException(); + } - $alias = null; - if ($localMessage->getAliasId() !== null) { - $alias = $this->aliasesService->find($localMessage->getAliasId(), $account->getUserId()); + if ($repliedToMessageId !== null) { + $message = $this->buildReplyMessage($account, $messageData, $repliedToMessageId); + } else { + $message = $this->buildNewMessage($account, $messageData); } + + $account->setAlias($alias); $fromEmail = $alias ? $alias->getAlias() : $account->getEMailAddress(); $from = new AddressList([ Address::fromRaw($account->getName(), $fromEmail), ]); - - $attachmentParts = []; - foreach ($attachments as $attachment) { - $part = $this->transmissionService->handleAttachment($account, $attachment); - if ($part !== null) { - $attachmentParts[] = $part; - } - } + $message->setFrom($from); + $message->setCC($messageData->getCc()); + $message->setBcc($messageData->getBcc()); + $message->setContent($messageData->getBody()); + $this->handleAttachments($account, $messageData, $message); // only ever going to be local attachments $transport = $this->smtpClientFactory->create($account); // build mime body $headers = [ - 'From' => $from->first()->toHorde(), - 'To' => $to->toHorde(), - 'Cc' => $cc->toHorde(), - 'Bcc' => $bcc->toHorde(), - 'Subject' => $localMessage->getSubject(), + 'From' => $message->getFrom()->first()->toHorde(), + 'To' => $message->getTo()->toHorde(), + 'Cc' => $message->getCC()->toHorde(), + 'Bcc' => $message->getBCC()->toHorde(), + 'Subject' => $message->getSubject(), ]; - if (($inReplyTo = $localMessage->getInReplyToMessageId()) !== null) { + if (($inReplyTo = $message->getInReplyTo()) !== null) { $headers['References'] = $inReplyTo; $headers['In-Reply-To'] = $inReplyTo; } + if ($messageData->isMdnRequested()) { + $headers[Horde_Mime_Mdn::MDN_HEADER] = $message->getFrom()->first()->toHorde(); + } + $mail = new Horde_Mime_Mail(); $mail->addHeaders($headers); @@ -121,59 +198,170 @@ public function sendMessage(Account $account, LocalMessage $localMessage): void new DataUriParser() ); $mimePart = $mimeMessage->build( - $localMessage->isHtml(), - $localMessage->getBody(), - $attachmentParts + $messageData->isHtml(), + $message->getContent(), + $message->getAttachments() ); // TODO: add smimeEncrypt check if implemented - try { - $mimePart = $this->transmissionService->getSignMimePart($localMessage, $account, $mimePart); - $mimePart = $this->transmissionService->getEncryptMimePart($localMessage, $to, $cc, $bcc, $account, $mimePart); - } catch (ServiceException $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - return; + if ($messageData->getSmimeSign()) { + if ($messageData->getSmimeCertificateId() === null) { + throw new ServiceException('Could not send message: Requested S/MIME signature without certificate id'); + } + + try { + $certificate = $this->smimeService->findCertificate( + $messageData->getSmimeCertificateId(), + $account->getUserId(), + ); + $mimePart = $this->smimeService->signMimePart($mimePart, $certificate); + } catch (DoesNotExistException $e) { + throw new ServiceException( + 'Could not send message: Certificate does not exist: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } catch (SmimeSignException | ServiceException $e) { + throw new ServiceException( + 'Could not send message: Failed to sign MIME part: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } + } + + if ($messageData->getSmimeEncrypt()) { + if ($messageData->getSmimeCertificateId() === null) { + throw new ServiceException('Could not send message: Requested S/MIME signature without certificate id'); + } + + try { + $addressList = $messageData->getTo() + ->merge($messageData->getCc()) + ->merge($messageData->getBcc()); + $certificates = $this->smimeService->findCertificatesByAddressList($addressList, $account->getUserId()); + + $senderCertificate = $this->smimeService->findCertificate($messageData->getSmimeCertificateId(), $account->getUserId()); + $certificates[] = $senderCertificate; + + $mimePart = $this->smimeService->encryptMimePart($mimePart, $certificates); + } catch (DoesNotExistException $e) { + throw new ServiceException( + 'Could not send message: Certificate does not exist: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } catch (SmimeEncryptException | ServiceException $e) { + throw new ServiceException( + 'Could not send message: Failed to encrypt MIME part: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } } $mail->setBasePart($mimePart); + $this->eventDispatcher->dispatchTyped( + new BeforeMessageSentEvent($account, $messageData, $repliedToMessageId, $draft, $message, $mail) + ); + // Send the message try { $mail->send($transport, false, false); - $localMessage->setRaw($mail->getRaw(false)); } catch (Horde_Mime_Exception $e) { - $localMessage->setStatus(LocalMessage::STATUS_SMPT_SEND_FAIL); - $this->logger->error($e->getMessage(), ['exception' => $e]); - return; + throw new ServiceException( + 'Could not send message: ' . $e->getMessage(), + $e->getCode(), + $e + ); } $this->eventDispatcher->dispatchTyped( - new MessageSentEvent($account, $localMessage) + new MessageSentEvent($account, $messageData, $repliedToMessageId, $draft, $message, $mail) + ); + } + + public function sendLocalMessage(Account $account, LocalMessage $message): void { + $to = new AddressList( + array_map( + static function ($recipient) { + return Address::fromRaw($recipient->getLabel() ?? $recipient->getEmail(), $recipient->getEmail()); + }, + $this->groupsIntegration->expand(array_filter($message->getRecipients(), static function (Recipient $recipient) { + return $recipient->getType() === Recipient::TYPE_TO; + })) + ) + ); + $cc = new AddressList( + array_map( + static function ($recipient) { + return Address::fromRaw($recipient->getLabel() ?? $recipient->getEmail(), $recipient->getEmail()); + }, + $this->groupsIntegration->expand(array_filter($message->getRecipients(), static function (Recipient $recipient) { + return $recipient->getType() === Recipient::TYPE_CC; + })) + ) ); + $bcc = new AddressList( + array_map( + static function ($recipient) { + return Address::fromRaw($recipient->getLabel() ?? $recipient->getEmail(), $recipient->getEmail()); + }, + $this->groupsIntegration->expand(array_filter($message->getRecipients(), static function (Recipient $recipient) { + return $recipient->getType() === Recipient::TYPE_BCC; + })) + ) + ); + $attachments = array_map(static function (LocalAttachment $attachment) { + // Convert to the untyped nested array used in \OCA\Mail\Controller\AccountsController::send + return [ + 'type' => 'local', + 'id' => $attachment->getId(), + ]; + }, $message->getAttachments()); + $messageData = new NewMessageData( + $account, + $to, + $cc, + $bcc, + $message->getSubject(), + $message->getBody(), + $attachments, + $message->isHtml(), + false, + $message->getSmimeCertificateId(), + $message->getSmimeSign() ?? false, + $message->getSmimeEncrypt() ?? false, + ); + + if ($message->getAliasId() !== null) { + $alias = $this->aliasesService->find($message->getAliasId(), $account->getUserId()); + } + + try { + $this->sendMessage($messageData, $message->getInReplyToMessageId(), $alias ?? null); + } catch (SentMailboxNotSetException $e) { + throw new ClientException('Could not send message: ' . $e->getMessage(), $e->getCode(), $e); + } } public function saveLocalDraft(Account $account, LocalMessage $message): void { - $to = $this->transmissionService->getAddressList($message, Recipient::TYPE_TO); - $cc = $this->transmissionService->getAddressList($message, Recipient::TYPE_CC); - $bcc = $this->transmissionService->getAddressList($message, Recipient::TYPE_BCC); - $attachments = $this->transmissionService->getAttachments($message); + $messageData = $this->getNewMessageData($message, $account); $perfLogger = $this->performanceLogger->start('save local draft'); + $account = $messageData->getAccount(); $imapMessage = $account->newMessage(); - $imapMessage->setTo($to); - $imapMessage->setSubject($message->getSubject()); + $imapMessage->setTo($messageData->getTo()); + $imapMessage->setSubject($messageData->getSubject()); $from = new AddressList([ Address::fromRaw($account->getName(), $account->getEMailAddress()), ]); $imapMessage->setFrom($from); - $imapMessage->setCC($cc); - $imapMessage->setBcc($bcc); - $imapMessage->setContent($message->getBody()); - - foreach ($attachments as $attachment) { - $this->transmissionService->handleAttachment($account, $attachment); - } + $imapMessage->setCC($messageData->getCc()); + $imapMessage->setBcc($messageData->getBcc()); + $imapMessage->setContent($messageData->getBody()); // build mime body $headers = [ @@ -210,7 +398,7 @@ public function saveLocalDraft(Account $account, LocalMessage $message): void { $this->messageMapper->save( $client, $draftsMailbox, - $mail->getRaw(false), + $mail, [Horde_Imap_Client::FLAG_DRAFT] ); $perfLogger->step('save local draft message on IMAP'); @@ -222,7 +410,7 @@ public function saveLocalDraft(Account $account, LocalMessage $message): void { $client->logout(); } - $this->eventDispatcher->dispatchTyped(new DraftSavedEvent($account, null)); + $this->eventDispatcher->dispatchTyped(new DraftSavedEvent($account, $messageData, null)); $perfLogger->step('emit post local draft save event'); $perfLogger->end(); @@ -292,7 +480,7 @@ public function saveDraft(NewMessageData $message, ?Message $previousDraft = nul $newUid = $this->messageMapper->save( $client, $draftsMailbox, - $mail->getRaw(false), + $mail, [Horde_Imap_Client::FLAG_DRAFT] ); $perfLogger->step('save message on IMAP'); @@ -314,6 +502,201 @@ public function saveDraft(NewMessageData $message, ?Message $previousDraft = nul return [$account, $draftsMailbox, $newUid]; } + private function buildReplyMessage(Account $account, + NewMessageData $messageData, + string $repliedToMessageId): IMessage { + // Reply + $message = $account->newMessage(); + $message->setSubject($messageData->getSubject()); + $message->setTo($messageData->getTo()); + $message->setInReplyTo($repliedToMessageId); + + return $message; + } + + private function buildNewMessage(Account $account, NewMessageData $messageData): IMessage { + // New message + $message = $account->newMessage(); + $message->setTo($messageData->getTo()); + $message->setSubject($messageData->getSubject()); + + return $message; + } + + /** + * @param Account $account + * @param NewMessageData $messageData + * @param IMessage $message + * + * @return void + */ + private function handleAttachments(Account $account, NewMessageData $messageData, IMessage $message): void { + foreach ($messageData->getAttachments() as $attachment) { + if (isset($attachment['type']) && $attachment['type'] === 'local') { + // Adds an uploaded attachment + $this->handleLocalAttachment($account, $attachment, $message); + } elseif (isset($attachment['type']) && $attachment['type'] === 'message') { + // Adds another message as attachment + $this->handleForwardedMessageAttachment($account, $attachment, $message); + } elseif (isset($attachment['type']) && $attachment['type'] === 'message/rfc822') { + // Adds another message as attachment with mime type 'message/rfc822 + $this->handleEmbeddedMessageAttachments($account, $attachment, $message); + } elseif (isset($attachment['type']) && $attachment['type'] === 'message-attachment') { + // Adds an attachment from another email (use case is, eg., a mail forward) + $this->handleForwardedAttachment($account, $attachment, $message); + } else { + // Adds an attachment from Files + $this->handleCloudAttachment($attachment, $message); + } + } + } + + /** + * @param Account $account + * @param array $attachment + * @param IMessage $message + * + * @return int|null + */ + private function handleLocalAttachment(Account $account, array $attachment, IMessage $message) { + if (!isset($attachment['id'])) { + $this->logger->warning('ignoring local attachment because its id is unknown'); + return null; + } + + $id = (int)$attachment['id']; + + try { + [$localAttachment, $file] = $this->attachmentService->getAttachment($account->getMailAccount()->getUserId(), $id); + $message->addLocalAttachment($localAttachment, $file); + } catch (AttachmentNotFoundException $ex) { + $this->logger->warning('ignoring local attachment because it does not exist'); + // TODO: rethrow? + return null; + } + } + + /** + * Adds an attachment that's coming from another message's attachment (typical use case: email forwarding) + * + * @param Account $account + * @param mixed[] $attachment + * @param IMessage $message + */ + private function handleForwardedMessageAttachment(Account $account, array $attachment, IMessage $message): void { + // Gets original of other message + $userId = $account->getMailAccount()->getUserId(); + $attachmentMessage = $this->mailManager->getMessage($userId, (int)$attachment['id']); + $mailbox = $this->mailManager->getMailbox($userId, $attachmentMessage->getMailboxId()); + + $client = $this->imapClientFactory->getClient($account); + try { + $fullText = $this->messageMapper->getFullText( + $client, + $mailbox->getName(), + $attachmentMessage->getUid(), + $userId + ); + } finally { + $client->logout(); + } + + $message->addRawAttachment( + $attachment['displayName'] ?? $attachmentMessage->getSubject() . '.eml', + $fullText + ); + } + + /** + * Adds an email as attachment + * + * @param Account $account + * @param mixed[] $attachment + * @param IMessage $message + */ + private function handleEmbeddedMessageAttachments(Account $account, array $attachment, IMessage $message): void { + // Gets original of other message + $userId = $account->getMailAccount()->getUserId(); + $attachmentMessage = $this->mailManager->getMessage($userId, (int)$attachment['id']); + $mailbox = $this->mailManager->getMailbox($userId, $attachmentMessage->getMailboxId()); + + $client = $this->imapClientFactory->getClient($account); + try { + $fullText = $this->messageMapper->getFullText( + $client, + $mailbox->getName(), + $attachmentMessage->getUid(), + $userId + ); + } finally { + $client->logout(); + } + + $message->addEmbeddedMessageAttachment( + $attachment['displayName'] ?? $attachmentMessage->getSubject() . '.eml', + $fullText + ); + } + + + /** + * Adds an attachment that's coming from another message's attachment (typical use case: email forwarding) + * + * @param Account $account + * @param mixed[] $attachment + * @param IMessage $message + */ + private function handleForwardedAttachment(Account $account, array $attachment, IMessage $message): void { + // Gets attachment from other message + $userId = $account->getMailAccount()->getUserId(); + $attachmentMessage = $this->mailManager->getMessage($userId, (int)$attachment['messageId']); + $mailbox = $this->mailManager->getMailbox($userId, $attachmentMessage->getMailboxId()); + $client = $this->imapClientFactory->getClient($account); + try { + $attachments = $this->messageMapper->getRawAttachments( + $client, + $mailbox->getName(), + $attachmentMessage->getUid(), + $userId, + [ + $attachment['id'] + ] + ); + } finally { + $client->logout(); + } + + // Attaches attachment to new message + $message->addRawAttachment($attachment['fileName'], $attachments[0]); + } + + /** + * @param array $attachment + * @param IMessage $message + * + * @return File|null + */ + private function handleCloudAttachment(array $attachment, IMessage $message) { + if (!isset($attachment['fileName'])) { + $this->logger->warning('ignoring cloud attachment because its fileName is unknown'); + return null; + } + + $fileName = $attachment['fileName']; + if (!$this->userFolder->nodeExists($fileName)) { + $this->logger->warning('ignoring cloud attachment because the node does not exist'); + return null; + } + + $file = $this->userFolder->get($fileName); + if (!$file instanceof File) { + $this->logger->warning('ignoring cloud attachment because the node is not a file'); + return null; + } + + $message->addAttachmentFromFiles($file); + } + public function sendMdn(Account $account, Mailbox $mailbox, Message $message): void { $query = new Horde_Imap_Client_Fetch_Query(); $query->flags(); @@ -383,4 +766,59 @@ public function sendMdn(Account $account, Mailbox $mailbox, Message $message): v } } + /** + * @param LocalMessage $message + * @param Account $account + * @return NewMessageData + */ + private function getNewMessageData(LocalMessage $message, Account $account): NewMessageData { + $to = new AddressList( + array_map( + static function ($recipient) { + return Address::fromRaw($recipient->getLabel() ?? $recipient->getEmail(), $recipient->getEmail()); + }, + $this->groupsIntegration->expand(array_filter($message->getRecipients(), static function (Recipient $recipient) { + return $recipient->getType() === Recipient::TYPE_TO; + })) + ) + ); + + $cc = new AddressList( + array_map( + static function ($recipient) { + return Address::fromRaw($recipient->getLabel() ?? $recipient->getEmail(), $recipient->getEmail()); + }, + $this->groupsIntegration->expand(array_filter($message->getRecipients(), static function (Recipient $recipient) { + return $recipient->getType() === Recipient::TYPE_CC; + })) + ) + ); + $bcc = new AddressList( + array_map( + static function ($recipient) { + return Address::fromRaw($recipient->getLabel() ?? $recipient->getEmail(), $recipient->getEmail()); + }, + $this->groupsIntegration->expand(array_filter($message->getRecipients(), static function (Recipient $recipient) { + return $recipient->getType() === Recipient::TYPE_BCC; + })) + ) + ); + $attachments = array_map(function (LocalAttachment $attachment) { + // Convert to the untyped nested array used in \OCA\Mail\Controller\AccountsController::send + return [ + 'type' => 'local', + 'id' => $attachment->getId(), + ]; + }, $message->getAttachments()); + return new NewMessageData( + $account, + $to, + $cc, + $bcc, + $message->getSubject(), + $message->getBody(), + $attachments, + $message->isHtml() + ); + } } diff --git a/lib/Service/OutboxService.php b/lib/Service/OutboxService.php index a39b6d761e..8afc6b1309 100644 --- a/lib/Service/OutboxService.php +++ b/lib/Service/OutboxService.php @@ -34,8 +34,8 @@ use OCA\Mail\Db\Recipient; use OCA\Mail\Events\OutboxMessageCreatedEvent; use OCA\Mail\Exception\ClientException; +use OCA\Mail\Exception\ServiceException; use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\Send\Chain; use OCA\Mail\Service\Attachment\AttachmentService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; @@ -79,9 +79,7 @@ public function __construct(IMailTransmission $transmission, IMailManager $mailManager, AccountService $accountService, ITimeFactory $timeFactory, - LoggerInterface $logger, - private Chain $sendChain, - ) { + LoggerInterface $logger) { $this->transmission = $transmission; $this->mapper = $mapper; $this->attachmentService = $attachmentService; @@ -132,9 +130,24 @@ public function deleteMessage(string $userId, LocalMessage $message): void { $this->mapper->deleteWithRecipients($message); } - public function sendMessage(LocalMessage $message, Account $account): LocalMessage { - $this->sendChain->process($account, $message); - return $message; + /** + * @param LocalMessage $message + * @param Account $account + * @return void + * @throws ClientException + * @throws ServiceException + */ + public function sendMessage(LocalMessage $message, Account $account): void { + try { + $this->transmission->sendLocalMessage($account, $message); + } catch (ClientException|ServiceException $e) { + // Mark as failed so the message is not sent repeatedly in background + $message->setFailed(true); + $this->mapper->update($message); + throw $e; + } + $this->attachmentService->deleteLocalMessageAttachments($account->getUserId(), $message->getId()); + $this->mapper->deleteWithRecipients($message); } /** @@ -181,7 +194,6 @@ public function updateMessage(Account $account, LocalMessage $message, array $to $toRecipients = self::convertToRecipient($to, Recipient::TYPE_TO); $ccRecipients = self::convertToRecipient($cc, Recipient::TYPE_CC); $bccRecipients = self::convertToRecipient($bcc, Recipient::TYPE_BCC); - $message = $this->mapper->updateWithRecipients($message, $toRecipients, $ccRecipients, $bccRecipients); if ($attachments === []) { @@ -239,13 +251,16 @@ public function flush(): void { }, $accountIds)); foreach ($messages as $message) { - $account = $accounts[$message->getAccountId()]; - if ($account === null) { - // Ignore message of non-existent account - continue; - } try { - $this->sendChain->process($account, $message); + $account = $accounts[$message->getAccountId()]; + if ($account === null) { + // Ignore message of non-existent account + continue; + } + $this->sendMessage( + $message, + $account, + ); $this->logger->debug('Outbox message {id} sent', [ 'id' => $message->getId(), ]); diff --git a/lib/Service/TransmissionService.php b/lib/Service/TransmissionService.php deleted file mode 100644 index 4f3a95b9af..0000000000 --- a/lib/Service/TransmissionService.php +++ /dev/null @@ -1,196 +0,0 @@ - - * - * @author Anna Larch - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU AFFERO GENERAL PUBLIC LICENSE for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with this library. If not, see . - */ - -namespace OCA\Mail\Service; - -use OCA\Mail\Account; -use OCA\Mail\Address; -use OCA\Mail\AddressList; -use OCA\Mail\Db\LocalAttachment; -use OCA\Mail\Db\LocalMessage; -use OCA\Mail\Db\Recipient; -use OCA\Mail\Exception\AttachmentNotFoundException; -use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Exception\SmimeEncryptException; -use OCA\Mail\Exception\SmimeSignException; -use OCA\Mail\Service\Attachment\AttachmentService; -use OCP\AppFramework\Db\DoesNotExistException; -use Psr\Log\LoggerInterface; - -class TransmissionService { - - public function __construct(private GroupsIntegration $groupsIntegration, - private AttachmentService $attachmentService, - private LoggerInterface $logger, - private SmimeService $smimeService, - ) { - } - - /** - * @param LocalMessage $message - * @param int $type - * @return AddressList - */ - public function getAddressList(LocalMessage $message, int $type): AddressList { - return new AddressList( - array_map( - static function ($recipient) use ($type) { - return Address::fromRaw($recipient->getLabel() ?? $recipient->getEmail(), $recipient->getEmail()); - }, - $this->groupsIntegration->expand( - array_filter($message->getRecipients(), static function (Recipient $recipient) use ($type) { - return $recipient->getType() === $type; - }) - ) - ) - ); - } - - /** - * @param LocalMessage $message - * @return array|array[] - */ - public function getAttachments(LocalMessage $message): array { - if(empty($message->getAttachments())) { - return []; - } - return array_map(static function (LocalAttachment $attachment) { - // Convert to the untyped nested array used in \OCA\Mail\Controller\AccountsController::send - return [ - 'type' => 'local', - 'id' => $attachment->getId(), - ]; - }, $message->getAttachments()); - } - - /** - * @param Account $account - * @param array $attachment - * @return \Horde_Mime_Part|null - */ - public function handleAttachment(Account $account, array $attachment): ?\Horde_Mime_Part { - if (!isset($attachment['id'])) { - $this->logger->warning('ignoring local attachment because its id is unknown'); - return null; - } - - try { - [$localAttachment, $file] = $this->attachmentService->getAttachment($account->getMailAccount()->getUserId(), (int)$attachment['id']); - $part = new \Horde_Mime_Part(); - $part->setCharset('us-ascii'); - $part->setDisposition('attachment'); - $part->setName($localAttachment->getFileName()); - $part->setContents($file->getContent()); - $part->setType($localAttachment->getMimeType()); - return $part; - } catch (AttachmentNotFoundException $e) { - $this->logger->warning('Ignoring local attachment because it does not exist', ['exception' => $e]); - return null; - } - } - - /** - * @param LocalMessage $localMessage - * @param Account $account - * @param \Horde_Mime_Part $mimePart - * @return \Horde_Mime_Part - * @throws ServiceException - */ - public function getSignMimePart(LocalMessage $localMessage, Account $account, \Horde_Mime_Part $mimePart): \Horde_Mime_Part { - if ($localMessage->getSmimeSign()) { - if ($localMessage->getSmimeCertificateId() === null) { - $localMessage->setStatus(LocalMessage::STATUS_SMIME_SIGN_NO_CERT_ID); - throw new ServiceException('Could not send message: Requested S/MIME signature without certificate id'); - } - - try { - $certificate = $this->smimeService->findCertificate( - $localMessage->getSmimeCertificateId(), - $account->getUserId(), - ); - $mimePart = $this->smimeService->signMimePart($mimePart, $certificate); - } catch (DoesNotExistException $e) { - $localMessage->setStatus(LocalMessage::STATUS_SMIME_SIGN_CERT); - throw new ServiceException( - 'Could not send message: Certificate does not exist: ' . $e->getMessage(), - $e->getCode(), - $e, - ); - } catch (SmimeSignException|ServiceException $e) { - $localMessage->setStatus(LocalMessage::STATUS_SMIME_SIGN_FAIL); - throw new ServiceException( - 'Could not send message: Failed to sign MIME part: ' . $e->getMessage(), - $e->getCode(), - $e, - ); - } - } - return $mimePart; - } - - /** - * @param LocalMessage $localMessage - * @param AddressList $to - * @param AddressList $cc - * @param AddressList $bcc - * @param Account $account - * @param \Horde_Mime_Part $mimePart - * @return \Horde_Mime_Part - * @throws ServiceException - */ - public function getEncryptMimePart(LocalMessage $localMessage, AddressList $to, AddressList $cc, AddressList $bcc, Account $account, \Horde_Mime_Part $mimePart): \Horde_Mime_Part { - if ($localMessage->getSmimeEncrypt()) { - if ($localMessage->getSmimeCertificateId() === null) { - $localMessage->setStatus(LocalMessage::STATUS_SMIME_ENCRYPT_NO_CERT_ID); - throw new ServiceException('Could not send message: Requested S/MIME signature without certificate id'); - } - - try { - $addressList = $to - ->merge($cc) - ->merge($bcc); - $certificates = $this->smimeService->findCertificatesByAddressList($addressList, $account->getUserId()); - - $senderCertificate = $this->smimeService->findCertificate($localMessage->getSmimeCertificateId(), $account->getUserId()); - $certificates[] = $senderCertificate; - - $mimePart = $this->smimeService->encryptMimePart($mimePart, $certificates); - } catch (DoesNotExistException $e) { - $localMessage->setStatus(LocalMessage::STATUS_SMIME_ENCRYPT_CERT); - throw new ServiceException( - 'Could not send message: Certificate does not exist: ' . $e->getMessage(), - $e->getCode(), - $e, - ); - } catch (SmimeEncryptException|ServiceException $e) { - $localMessage->setStatus(LocalMessage::STATUS_SMIME_ENCRYT_FAIL); - throw new ServiceException( - 'Could not send message: Failed to encrypt MIME part: ' . $e->getMessage(), - $e->getCode(), - $e, - ); - } - } - return $mimePart; - } - -} diff --git a/package-lock.json b/package-lock.json index cc0580b35b..efa2734e9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nextcloud-mail", - "version": "3.6.0-rc2", + "version": "3.6.0-rc3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "nextcloud-mail", - "version": "3.6.0-rc2", + "version": "3.6.0-rc3", "license": "agpl", "dependencies": { "@ckeditor/ckeditor5-alignment": "37.1.0", diff --git a/package.json b/package.json index 4c1ac626ed..a81bed5fa5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nextcloud-mail", "description": "Nextcloud Mail", - "version": "3.6.0-rc2", + "version": "3.6.0-rc3", "author": "Christoph Wurst ", "license": "agpl", "private": true, diff --git a/src/components/NewMessageModal.vue b/src/components/NewMessageModal.vue index 50dde5e7e9..c304e9a295 100644 --- a/src/components/NewMessageModal.vue +++ b/src/components/NewMessageModal.vue @@ -396,9 +396,7 @@ export default { if (!data.sendAt || data.sendAt < Math.floor((now + UNDO_DELAY) / 1000)) { // Awaiting here would keep the modal open for a long time and thus block the user - this.$store.dispatch('outbox/sendMessageWithUndo', { id: dataForServer.id }).catch((error) => { - logger.debug('Could not send message', { error }) - }) + this.$store.dispatch('outbox/sendMessageWithUndo', { id: dataForServer.id }) } if (dataForServer.id) { // Remove old draft envelope diff --git a/src/components/OutboxMessageListItem.vue b/src/components/OutboxMessageListItem.vue index 839c8f6607..e614b69591 100644 --- a/src/components/OutboxMessageListItem.vue +++ b/src/components/OutboxMessageListItem.vue @@ -21,8 +21,7 @@ --> - - - - -