* * Mail * * This code is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, version 3, * as published by the Free Software Foundation. * * 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, version 3, * along with this program. If not, see * */ namespace OCA\Mail\Service; use Horde_Exception; use Horde_Imap_Client; use Horde_Imap_Client_Data_Fetch; use Horde_Imap_Client_DateTime; use Horde_Imap_Client_Fetch_Query; use Horde_Imap_Client_Ids; use Horde_Mail_Transport_Null; use Horde_Mime_Exception; use Horde_Mime_Headers; use Horde_Mime_Headers_Addresses; use Horde_Mime_Headers_Date; use Horde_Mime_Headers_MessageId; use Horde_Mime_Headers_Subject; use Horde_Mime_Mail; use Horde_Mime_Mdn; 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\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 { /** @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) { $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; } 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(); } 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), ]); $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' => $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; } if ($messageData->isMdnRequested()) { $headers[Horde_Mime_Mdn::MDN_HEADER] = $message->getFrom()->first()->toHorde(); } $mail = new Horde_Mime_Mail(); $mail->addHeaders($headers); $mimeMessage = new MimeMessage( new DataUriParser() ); $mail->setBasePart($mimeMessage->build( $messageData->isHtml(), $message->getContent(), $message->getAttachments() )); $this->eventDispatcher->dispatchTyped( new BeforeMessageSentEvent($account, $messageData, $repliedToMessageId, $draft, $message, $mail) ); // Send the message try { $mail->send($transport, false, false); } catch (Horde_Mime_Exception $e) { throw new ServiceException( 'Could not send message: ' . $e->getMessage(), (int) $e->getCode(), $e ); } $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; })) ) ); $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()); $messageData = new NewMessageData( $account, $to, $cc, $bcc, $message->getSubject(), $message->getBody(), $attachments, $message->isHtml() ); 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(), (int)$e->getCode(), $e); } } /** * @param NewMessageData $message * @param Message|null $previousDraft * * @return array * * @throws ClientException * @throws ServiceException */ public function saveDraft(NewMessageData $message, Message $previousDraft = null): array { $perfLogger = $this->performanceLogger->start('save draft'); $this->eventDispatcher->dispatch( SaveDraftEvent::class, new SaveDraftEvent($message->getAccount(), $message, $previousDraft) ); $perfLogger->step('emit pre event'); $account = $message->getAccount(); $imapMessage = $account->newMessage(); $imapMessage->setTo($message->getTo()); $imapMessage->setSubject($message->getSubject()); $from = new AddressList([ Address::fromRaw($account->getName(), $account->getEMailAddress()), ]); $imapMessage->setFrom($from); $imapMessage->setCC($message->getCc()); $imapMessage->setBcc($message->getBcc()); $imapMessage->setContent($message->getBody()); // build mime body $headers = [ 'From' => $imapMessage->getFrom()->first()->toHorde(), 'To' => $imapMessage->getTo()->toHorde(), 'Cc' => $imapMessage->getCC()->toHorde(), 'Bcc' => $imapMessage->getBCC()->toHorde(), 'Subject' => $imapMessage->getSubject(), 'Date' => Horde_Mime_Headers_Date::create(), ]; $mail = new Horde_Mime_Mail(); $mail->addHeaders($headers); if ($message->isHtml()) { $mail->setHtmlBody($imapMessage->getContent()); } else { $mail->setBody($imapMessage->getContent()); } $mail->addHeaderOb(Horde_Mime_Headers_MessageId::create()); $perfLogger->step('build draft message'); // 'Send' the message $client = $this->imapClientFactory->getClient($account); try { $transport = new Horde_Mail_Transport_Null(); $mail->send($transport, false, false); $perfLogger->step('create IMAP message'); // save the message in the drafts folder $draftsMailboxId = $account->getMailAccount()->getDraftsMailboxId(); if ($draftsMailboxId === null) { throw new ClientException("No drafts mailbox configured"); } $draftsMailbox = $this->mailboxMapper->findById($draftsMailboxId); $newUid = $this->messageMapper->save( $client, $draftsMailbox, $mail, [Horde_Imap_Client::FLAG_DRAFT] ); $perfLogger->step('save message on IMAP'); } catch (DoesNotExistException $e) { throw new ServiceException('Drafts mailbox does not exist', 0, $e); } catch (Horde_Exception $e) { throw new ServiceException('Could not save draft message', 0, $e); } finally { $client->logout(); } $this->eventDispatcher->dispatch( DraftSavedEvent::class, new DraftSavedEvent($account, $message, $previousDraft) ); $perfLogger->step('emit post event'); $perfLogger->end(); 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() ); } 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() ); } 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(), [ $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(); $query->uid(); $query->imapDate(); $query->headerText([ 'cache' => true, 'peek' => true, ]); $imapClient = $this->imapClientFactory->getClient($account); try { /** @var Horde_Imap_Client_Data_Fetch[] $fetchResults */ $fetchResults = iterator_to_array($imapClient->fetch($mailbox->getName(), $query, [ 'ids' => new Horde_Imap_Client_Ids([$message->getUid()]), ]), false); } finally { $imapClient->logout(); } if (count($fetchResults) < 1) { throw new ServiceException('Message "' .$message->getId() . '" not found.'); } /** @var Horde_Imap_Client_DateTime $imapDate */ $imapDate = $fetchResults[0]->getImapDate(); /** @var Horde_Mime_Headers $headers */ $mdnHeaders = $fetchResults[0]->getHeaderText('0', Horde_Imap_Client_Data_Fetch::HEADER_PARSE); /** @var Horde_Mime_Headers_Addresses|null $dispositionNotificationTo */ $dispositionNotificationTo = $mdnHeaders->getHeader('disposition-notification-to'); /** @var Horde_Mime_Headers_Addresses|null $originalRecipient */ $originalRecipient = $mdnHeaders->getHeader('original-recipient'); if ($dispositionNotificationTo === null) { throw new ServiceException('Message "' .$message->getId() . '" has no disposition-notification-to header.'); } $headers = new Horde_Mime_Headers(); $headers->addHeaderOb($dispositionNotificationTo); if ($originalRecipient instanceof Horde_Mime_Headers_Addresses) { $headers->addHeaderOb($originalRecipient); } $headers->addHeaderOb(new Horde_Mime_Headers_Subject(null, $message->getSubject())); $headers->addHeaderOb(new Horde_Mime_Headers_Addresses('From', $message->getFrom()->toHorde())); $headers->addHeaderOb(new Horde_Mime_Headers_Addresses('To', $message->getTo()->toHorde())); $headers->addHeaderOb(new Horde_Mime_Headers_MessageId(null, $message->getMessageId())); $headers->addHeaderOb(new Horde_Mime_Headers_Date(null, $imapDate->format('r'))); $smtpClient = $this->smtpClientFactory->create($account); $mdn = new Horde_Mime_Mdn($headers); try { $mdn->generate( true, true, 'displayed', $account->getMailAccount()->getOutboundHost(), $smtpClient, [ 'from_addr' => $account->getEMailAddress(), 'charset' => 'UTF-8', ] ); } catch (Horde_Mime_Exception $e) { throw new ServiceException('Unable to send mdn for message "' . $message->getId() . '" caused by: ' . $e->getMessage(), 0, $e); } } }