diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 2dac019913..db0c84a8c8 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -40,7 +40,6 @@ 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; @@ -49,15 +48,14 @@ use OCA\Mail\Events\MessageSentEvent; use OCA\Mail\Events\NewMessagesSynchronized; use OCA\Mail\Events\OutboxMessageCreatedEvent; +use OCA\Mail\Events\OutboxMessageStatusChangeEvent; use OCA\Mail\Events\SynchronizationEvent; use OCA\Mail\HordeTranslationHandler; use OCA\Mail\Http\Middleware\ErrorMiddleware; 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; @@ -67,8 +65,8 @@ use OCA\Mail\Listener\NewMessageClassificationListener; use OCA\Mail\Listener\OauthTokenRefreshListener; use OCA\Mail\Listener\OptionalIndicesListener; +use OCA\Mail\Listener\OutboxStatusChangeListener; 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; @@ -132,10 +130,10 @@ 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); + $context->registerEventListener(OutboxMessageStatusChangeEvent::class, OutboxStatusChangeListener::class); $context->registerEventListener(MailboxesSynchronizedEvent::class, MailboxesSynchronizedSpecialMailboxesUpdater::class); $context->registerEventListener(MessageFlaggedEvent::class, MessageCacheUpdaterListener::class); $context->registerEventListener(MessageFlaggedEvent::class, SpamReportListener::class); @@ -143,9 +141,7 @@ 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 05f394ea99..c7ab218f39 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 82732d1e63..c4f6004136 100644 --- a/lib/Contracts/IMailTransmission.php +++ b/lib/Contracts/IMailTransmission.php @@ -24,7 +24,6 @@ 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; @@ -37,27 +36,12 @@ interface IMailTransmission { /** * Send a new message or reply to an existing one * - * @param NewMessageData $messageData - * @param string|null $repliedToMessageId - * @param Alias|null $alias - * @param Message|null $draft - * - * @throws SentMailboxNotSetException - * @throws ServiceException - */ - public function sendMessage(NewMessageData $messageData, - ?string $repliedToMessageId = null, - ?Alias $alias = null, - ?Message $draft = null): void; - - /** * @param Account $account - * @param LocalMessage $message - * @throws ClientException + * @param LocalMessage $localMessage + * @throws SentMailboxNotSetException * @throws ServiceException - * @return void */ - public function sendLocalMessage(Account $account, LocalMessage $message): void; + public function sendMessage(Account $account, LocalMessage $localMessage): void; /** * @param Account $account diff --git a/lib/Controller/DraftsController.php b/lib/Controller/DraftsController.php index 47a7f558c7..7a96ee801f 100644 --- a/lib/Controller/DraftsController.php +++ b/lib/Controller/DraftsController.php @@ -172,7 +172,6 @@ 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 e62fccb224..7613f24dea 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,6 +209,9 @@ 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); @@ -217,7 +220,6 @@ public function update( $message->setBody($body); $message->setEditorBody($editorBody); $message->setHtml($isHtml); - $message->setFailed($failed); $message->setInReplyToMessageId($inReplyToMessageId); $message->setSendAt($sendAt); $message->setSmimeSign($smimeSign); @@ -244,8 +246,12 @@ public function send(int $id): JsonResponse { $message = $this->service->getMessage($id, $this->userId); $account = $this->accountService->find($this->userId, $message->getAccountId()); - $this->service->sendMessage($message, $account); - return JsonResponse::success( + $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( 'Message sent', Http::STATUS_ACCEPTED ); } diff --git a/lib/Db/LocalMessage.php b/lib/Db/LocalMessage.php index db3e239eb2..c71e910c25 100644 --- a/lib/Db/LocalMessage.php +++ b/lib/Db/LocalMessage.php @@ -60,13 +60,30 @@ * @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 + * @var int<1,12> * @psalm-var self::TYPE_* */ protected $type; @@ -116,6 +133,15 @@ 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'); @@ -127,6 +153,7 @@ public function __construct() { $this->addType('smimeSign', 'boolean'); $this->addType('smimeCertificateId', 'integer'); $this->addType('smimeEncrypt', 'boolean'); + $this->addType('status', 'integer'); } #[ReturnTypeWillChange] @@ -168,6 +195,8 @@ 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 54fcc67ae5..0eaf469542 100644 --- a/lib/Db/LocalMessageMapper.php +++ b/lib/Db/LocalMessageMapper.php @@ -64,7 +64,8 @@ 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()->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) ); $rows = $qb->executeQuery(); @@ -134,10 +135,6 @@ 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 deleted file mode 100644 index 76d233baa6..0000000000 --- a/lib/Events/BeforeMessageSentEvent.php +++ /dev/null @@ -1,95 +0,0 @@ - - * - * @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 ed0072b60a..53626508e8 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 */ + /** @var NewMessageData|null */ private $newMessageData; /** @var Message|null */ private $draft; public function __construct(Account $account, - NewMessageData $newMessageData, - ?Message $draft) { + ?NewMessageData $newMessageData = null, + ?Message $draft = null) { 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 05f0d82a6f..2bd8e73404 100644 --- a/lib/Events/MessageSentEvent.php +++ b/lib/Events/MessageSentEvent.php @@ -25,11 +25,8 @@ 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 OCA\Mail\Db\LocalMessage; use OCP\EventDispatcher\Event; /** @@ -39,57 +36,17 @@ 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, - NewMessageData $newMessageData, - ?string $repliedToMessageId, - ?Message $draft, - IMessage $message, - Horde_Mime_Mail $mail) { + private LocalMessage $localMessage) { 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; + public function getLocalMessage(): LocalMessage { + return $this->localMessage; } } diff --git a/lib/IMAP/MessageMapper.php b/lib/IMAP/MessageMapper.php index e685399591..8503ed2738 100644 --- a/lib/IMAP/MessageMapper.php +++ b/lib/IMAP/MessageMapper.php @@ -35,7 +35,6 @@ 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; @@ -398,7 +397,7 @@ public function expunge(Horde_Imap_Client_Base $client, */ public function save(Horde_Imap_Client_Socket $client, Mailbox $mailbox, - Horde_Mime_Mail $mail, + string $mail, array $flags = []): int { $flags = array_merge([ Horde_Imap_Client::FLAG_SEEN, @@ -408,7 +407,7 @@ public function save(Horde_Imap_Client_Socket $client, $mailbox->getName(), [ [ - 'data' => $mail->getRaw(), + 'data' => $mail, 'flags' => $flags, ] ] diff --git a/lib/Listener/AddressCollectionListener.php b/lib/Listener/AddressCollectionListener.php index a6d2a0a373..950f745bde 100644 --- a/lib/Listener/AddressCollectionListener.php +++ b/lib/Listener/AddressCollectionListener.php @@ -26,8 +26,10 @@ 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; @@ -48,7 +50,8 @@ class AddressCollectionListener implements IEventListener { public function __construct(IUserPreferences $preferences, AddressCollector $collector, - LoggerInterface $logger) { + LoggerInterface $logger, + private TransmissionService $transmissionService) { $this->collector = $collector; $this->logger = $logger; $this->preferences = $preferences; @@ -65,10 +68,12 @@ public function handle(Event $event): void { // Non-essential feature, hence we catch all possible errors try { - $message = $event->getMessage(); - $addresses = $message->getTo() - ->merge($message->getCC()) - ->merge($message->getBCC()); + $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); $this->collector->addAddresses($event->getAccount()->getUserId(), $addresses); } catch (Throwable $e) { diff --git a/lib/Listener/AntiAbuseListener.php b/lib/Listener/AntiAbuseListener.php deleted file mode 100644 index a06d0f7f00..0000000000 --- a/lib/Listener/AntiAbuseListener.php +++ /dev/null @@ -1,75 +0,0 @@ - - * - * @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 deleted file mode 100644 index f53d07408b..0000000000 --- a/lib/Listener/FlagRepliedMessageListener.php +++ /dev/null @@ -1,114 +0,0 @@ - - * - * @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 66c3ab0049..8f09586f8d 100644 --- a/lib/Listener/InteractionListener.php +++ b/lib/Listener/InteractionListener.php @@ -70,16 +70,19 @@ public function handle(Event $event): void { $this->logger->debug('no user object found'); return; } - $recipients = $event->getMessage()->getTo() - ->merge($event->getMessage()->getCC()) - ->merge($event->getMessage()->getBCC()); - foreach ($recipients->iterate() as $recipient) { + $message = $event->getLocalMessage(); + $emails = []; + foreach ($message->getRecipients() as $recipient) { + if (in_array($recipient->getEmail(), $emails)) { + continue; + } $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 deleted file mode 100644 index 95df328b63..0000000000 --- a/lib/Listener/SaveSentMessageListener.php +++ /dev/null @@ -1,101 +0,0 @@ - - * - * @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 new file mode 100644 index 0000000000..5576d9cdb9 --- /dev/null +++ b/lib/Migration/Version3600Date20240220134813.php @@ -0,0 +1,64 @@ + + * + * @author Your name + * + * @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\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version3600Date20240220134813 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->addColumn('status', Types::INTEGER, [ + 'notnull' => false, + 'default' => 0, + ]); + } + if (!$localMessagesTable->hasColumn('raw')) { + $localMessagesTable->addColumn('raw', Types::TEXT, [ + 'notnull' => false, + 'default' => null, + ]); + } + + return $schema; + } + +} diff --git a/lib/Send/AHandler.php b/lib/Send/AHandler.php new file mode 100644 index 0000000000..1de4254c40 --- /dev/null +++ b/lib/Send/AHandler.php @@ -0,0 +1,43 @@ + + * + * @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 new file mode 100644 index 0000000000..72f0f80dce --- /dev/null +++ b/lib/Send/AntiAbuseHandler.php @@ -0,0 +1,63 @@ + + * + * @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 new file mode 100644 index 0000000000..94e122efab --- /dev/null +++ b/lib/Send/Chain.php @@ -0,0 +1,56 @@ + + * + * @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 new file mode 100644 index 0000000000..ba98dddaaa --- /dev/null +++ b/lib/Send/CopySentMessageHandler.php @@ -0,0 +1,91 @@ + + * + * @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 new file mode 100644 index 0000000000..9745f65db2 --- /dev/null +++ b/lib/Send/FlagRepliedMessageHandler.php @@ -0,0 +1,94 @@ + + * + * @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 new file mode 100644 index 0000000000..433173e151 --- /dev/null +++ b/lib/Send/SendHandler.php @@ -0,0 +1,48 @@ + + * + * @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); + // Something went wrong during the sending + if ($localMessage->getStatus() !== LocalMessage::STATUS_RAW) { + return $localMessage; + } + + return $this->processNext($account, $localMessage); + } +} diff --git a/lib/Send/SentMailboxHandler.php b/lib/Send/SentMailboxHandler.php new file mode 100644 index 0000000000..7b5ca8de10 --- /dev/null +++ b/lib/Send/SentMailboxHandler.php @@ -0,0 +1,36 @@ + + * + * @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 c470325bef..22368bf358 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\Model\NewMessageData; +use OCA\Mail\Db\LocalMessage; use OCP\AppFramework\Utility\ITimeFactory; use OCP\ICacheFactory; use OCP\IConfig; @@ -58,8 +58,7 @@ public function __construct(IConfig $config, $this->logger = $logger; } - public function onBeforeMessageSent(IUser $user, - NewMessageData $messageData): void { + public function onBeforeMessageSent(IUser $user, LocalMessage $localMessage): void { $abuseDetection = $this->config->getAppValue( Application::APP_ID, 'abuse_detection', @@ -70,12 +69,11 @@ public function onBeforeMessageSent(IUser $user, return; } - $this->checkNumberOfRecipients($user, $messageData); - $this->checkRateLimits($user, $messageData); + $this->checkNumberOfRecipients($user, $localMessage); + $this->checkRateLimits($user, $localMessage); } - private function checkNumberOfRecipients(IUser $user, - NewMessageData $messageData): void { + private function checkNumberOfRecipients(IUser $user, LocalMessage $message): void { $numberOfRecipientsThreshold = (int)$this->config->getAppValue( Application::APP_ID, 'abuse_number_of_recipients_per_message_threshold', @@ -85,11 +83,10 @@ private function checkNumberOfRecipients(IUser $user, return; } - $actualNumberOfRecipients = count($messageData->getTo()) - + count($messageData->getCc()) - + count($messageData->getBcc()); + $actualNumberOfRecipients = count($message->getRecipients()); 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, @@ -98,8 +95,7 @@ private function checkNumberOfRecipients(IUser $user, } } - private function checkRateLimits(IUser $user, - NewMessageData $messageData): void { + private function checkRateLimits(IUser $user, LocalMessage $message): void { if (!$this->cacheFactory->isAvailable()) { // No cache, no rate limits return; @@ -110,16 +106,21 @@ private function checkRateLimits(IUser $user, return; } - $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); + $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); + } } private function checkRateLimitsForPeriod(IUser $user, - NewMessageData $messageData, IMemcache $cache, string $id, - int $period): void { + int $period, + LocalMessage $message): bool { $maxNumberOfMessages = (int)$this->config->getAppValue( Application::APP_ID, 'abuse_number_of_messages_per_' . $id, @@ -127,7 +128,7 @@ private function checkRateLimitsForPeriod(IUser $user, ); if ($maxNumberOfMessages === 0) { // No limit set - return; + return false; } $now = $this->timeFactory->getTime(); @@ -136,7 +137,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($messageData->getTo()) + count($messageData->getCc()) + count($messageData->getBcc())); + $counter = $cache->inc($cacheKey, count($message->getRecipients())); if ($counter >= $maxNumberOfMessages) { $this->logger->alert('User {user} sends a supcious number of messages within {period}. {expected} are allowed. {actual} have been sent', [ @@ -145,6 +146,8 @@ 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 e3f1b665ea..303c2be120 100644 --- a/lib/Service/AntiSpamService.php +++ b/lib/Service/AntiSpamService.php @@ -25,34 +25,36 @@ namespace OCA\Mail\Service; +use Horde_Imap_Client_Exception; +use Horde_Mime_Exception; +use Horde_Mime_Mail; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailTransmission; +use OCA\Mail\Address; +use OCA\Mail\AddressList; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MessageMapper; -use OCA\Mail\Exception\SentMailboxNotSetException; +use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Model\NewMessageData; +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 OCP\IConfig; +use Psr\Log\LoggerInterface; class AntiSpamService { private const NAME = 'antispam_reporting'; private const MESSAGE_TYPE = 'message/rfc822'; - /** @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 __construct(private IConfig $config, + private MessageMapper $dbMessageMapper, + private MailManager $mailManager, + private IMAPClientFactory $imapClientFactory, + private SmtpClientFactory $smtpClientFactory, + private ImapMessageMapper $messageMapper, + private LoggerInterface $logger, + ) { } public function getSpamEmail(): string { @@ -99,25 +101,128 @@ public function sendReportEmail(Account $account, Mailbox $mailbox, int $uid, st $subject = ($flag === '$junk') ? $this->getSpamSubject() : $this->getHamSubject(); // Message to attach not found - $messageId = $this->messageMapper->getIdForUid($mailbox, $uid); + $messageId = $this->dbMessageMapper->getIdForUid($mailbox, $uid); if ($messageId === null) { throw new ServiceException('Could not find reported message'); } - $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]] + 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); + return; + } + + $mailbox = $this->mailManager->getMailbox($userId, $attachmentMessage->getMailboxId()); + + $client = $this->imapClientFactory->getClient($account); + try { + $fullText = $this->messageMapper->getFullText( + $client, + $mailbox->getName(), + $attachmentMessage->getUid(), + $userId + ); + } catch (ServiceException $e) { + throw new ServiceException($e); + } finally { + $client->logout(); + } + + $message->addEmbeddedMessageAttachment( + $attachmentMessage->getSubject() . '.eml', + $fullText ); + $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 { - $this->transmission->sendMessage($messageData); - } catch (SentMailboxNotSetException | ServiceException $e) { - throw new ServiceException('Could not send report email from anti spam email service', 0, $e); + $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); + } finally { + $client->logout(); } } + } diff --git a/lib/Service/DraftsService.php b/lib/Service/DraftsService.php index f3c9d596b4..9fb4eee8a5 100644 --- a/lib/Service/DraftsService.php +++ b/lib/Service/DraftsService.php @@ -119,6 +119,10 @@ 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 ca7d09d913..f198e772c8 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -41,156 +41,79 @@ 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 { - 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 __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 + ) { } - 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(); - } + 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); - if ($repliedToMessageId !== null) { - $message = $this->buildReplyMessage($account, $messageData, $repliedToMessageId); - } else { - $message = $this->buildNewMessage($account, $messageData); + $alias = null; + if ($localMessage->getAliasId() !== null) { + $alias = $this->aliasesService->find($localMessage->getAliasId(), $account->getUserId()); } - - $account->setAlias($alias); $fromEmail = $alias ? $alias->getAlias() : $account->getEMailAddress(); $from = new AddressList([ Address::fromRaw($account->getName(), $fromEmail), ]); - $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 + + $attachmentParts = []; + foreach ($attachments as $attachment) { + $part = $this->transmissionService->handleAttachment($account, $attachment); + if ($part !== null) { + $attachmentParts[] = $part; + } + } $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(), + 'From' => $from->first()->toHorde(), + 'To' => $to->toHorde(), + 'Cc' => $cc->toHorde(), + 'Bcc' => $bcc->toHorde(), + 'Subject' => $localMessage->getSubject(), ]; - if (($inReplyTo = $message->getInReplyTo()) !== null) { + if (($inReplyTo = $localMessage->getInReplyToMessageId()) !== 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); @@ -198,170 +121,59 @@ public function sendMessage(NewMessageData $messageData, new DataUriParser() ); $mimePart = $mimeMessage->build( - $messageData->isHtml(), - $message->getContent(), - $message->getAttachments() + $localMessage->isHtml(), + $localMessage->getBody(), + $attachmentParts ); // TODO: add smimeEncrypt check if implemented - 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, - ); - } + 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; } $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) { - throw new ServiceException( - 'Could not send message: ' . $e->getMessage(), - $e->getCode(), - $e - ); + $localMessage->setStatus(LocalMessage::STATUS_SMPT_SEND_FAIL); + $this->logger->error($e->getMessage(), ['exception' => $e]); + return; } $this->eventDispatcher->dispatchTyped( - 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; - })) - ) + new MessageSentEvent($account, $localMessage) ); - $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 { - $messageData = $this->getNewMessageData($message, $account); + $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); $perfLogger = $this->performanceLogger->start('save local draft'); - $account = $messageData->getAccount(); $imapMessage = $account->newMessage(); - $imapMessage->setTo($messageData->getTo()); - $imapMessage->setSubject($messageData->getSubject()); + $imapMessage->setTo($to); + $imapMessage->setSubject($message->getSubject()); $from = new AddressList([ Address::fromRaw($account->getName(), $account->getEMailAddress()), ]); $imapMessage->setFrom($from); - $imapMessage->setCC($messageData->getCc()); - $imapMessage->setBcc($messageData->getBcc()); - $imapMessage->setContent($messageData->getBody()); + $imapMessage->setCC($cc); + $imapMessage->setBcc($bcc); + $imapMessage->setContent($message->getBody()); + + foreach ($attachments as $attachment) { + $this->transmissionService->handleAttachment($account, $attachment, $imapMessage); + } // build mime body $headers = [ @@ -398,7 +210,7 @@ public function saveLocalDraft(Account $account, LocalMessage $message): void { $this->messageMapper->save( $client, $draftsMailbox, - $mail, + $mail->getRaw(false), [Horde_Imap_Client::FLAG_DRAFT] ); $perfLogger->step('save local draft message on IMAP'); @@ -410,7 +222,7 @@ public function saveLocalDraft(Account $account, LocalMessage $message): void { $client->logout(); } - $this->eventDispatcher->dispatchTyped(new DraftSavedEvent($account, $messageData, null)); + $this->eventDispatcher->dispatchTyped(new DraftSavedEvent($account, null)); $perfLogger->step('emit post local draft save event'); $perfLogger->end(); @@ -480,7 +292,7 @@ public function saveDraft(NewMessageData $message, ?Message $previousDraft = nul $newUid = $this->messageMapper->save( $client, $draftsMailbox, - $mail, + $mail->getRaw(false), [Horde_Imap_Client::FLAG_DRAFT] ); $perfLogger->step('save message on IMAP'); @@ -494,7 +306,7 @@ public function saveDraft(NewMessageData $message, ?Message $previousDraft = nul $this->eventDispatcher->dispatch( DraftSavedEvent::class, - new DraftSavedEvent($account, $message, $previousDraft) + new DraftSavedEvent($account, $message) ); $perfLogger->step('emit post event'); @@ -502,201 +314,6 @@ 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(); @@ -766,59 +383,4 @@ 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 8afc6b1309..7e5080acb6 100644 --- a/lib/Service/OutboxService.php +++ b/lib/Service/OutboxService.php @@ -36,6 +36,7 @@ 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,7 +80,9 @@ public function __construct(IMailTransmission $transmission, IMailManager $mailManager, AccountService $accountService, ITimeFactory $timeFactory, - LoggerInterface $logger) { + LoggerInterface $logger, + private Chain $sendChain, + ) { $this->transmission = $transmission; $this->mapper = $mapper; $this->attachmentService = $attachmentService; @@ -134,20 +137,11 @@ public function deleteMessage(string $userId, LocalMessage $message): void { * @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); + public function sendMessage(LocalMessage $message, Account $account): LocalMessage { + $this->sendChain->process($account, $message); + return $message; } /** @@ -194,6 +188,7 @@ 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 === []) { @@ -251,16 +246,13 @@ public function flush(): void { }, $accountIds)); foreach ($messages as $message) { + $account = $accounts[$message->getAccountId()]; + if ($account === null) { + // Ignore message of non-existent account + continue; + } try { - $account = $accounts[$message->getAccountId()]; - if ($account === null) { - // Ignore message of non-existent account - continue; - } - $this->sendMessage( - $message, - $account, - ); + $this->sendChain->process($account, $message); $this->logger->debug('Outbox message {id} sent', [ 'id' => $message->getId(), ]); diff --git a/lib/Service/TransmissionService.php b/lib/Service/TransmissionService.php new file mode 100644 index 0000000000..3a1026363e --- /dev/null +++ b/lib/Service/TransmissionService.php @@ -0,0 +1,193 @@ + + * + * @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 { + 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/src/components/NewMessageModal.vue b/src/components/NewMessageModal.vue index c6489f575c..5a56b1bfbf 100644 --- a/src/components/NewMessageModal.vue +++ b/src/components/NewMessageModal.vue @@ -392,7 +392,9 @@ 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 }) + this.$store.dispatch('outbox/sendMessageWithUndo', { id: dataForServer.id }).catch((error) => { + logger.debug('Could not send message', { error }) + }) } if (dataForServer.id) { // Remove old draft envelope diff --git a/src/components/OutboxMessageListItem.vue b/src/components/OutboxMessageListItem.vue index e614b69591..461ea59577 100644 --- a/src/components/OutboxMessageListItem.vue +++ b/src/components/OutboxMessageListItem.vue @@ -21,7 +21,8 @@ --> + + + + {{ t('mail', 'Delete') }} + + + + + + +