diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 4f7c58ce61..a52c9432e6 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -18,7 +18,6 @@ use OCA\Mail\Contracts\IDkimService; use OCA\Mail\Contracts\IDkimValidator; use OCA\Mail\Contracts\IMailSearch; -use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Contracts\ITrustedSenderService; use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Dashboard\ImportantMailWidget; @@ -62,7 +61,6 @@ use OCA\Mail\Service\AvatarService; use OCA\Mail\Service\DkimService; use OCA\Mail\Service\DkimValidator; -use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\Search\MailSearch; use OCA\Mail\Service\TrustedSenderService; use OCA\Mail\Service\UserPreferenceService; @@ -120,7 +118,6 @@ public function register(IRegistrationContext $context): void { $context->registerServiceAlias(IAvatarService::class, AvatarService::class); $context->registerServiceAlias(IAttachmentService::class, AttachmentService::class); $context->registerServiceAlias(IMailSearch::class, MailSearch::class); - $context->registerServiceAlias(IMailTransmission::class, MailTransmission::class); $context->registerServiceAlias(ITrustedSenderService::class, TrustedSenderService::class); $context->registerServiceAlias(IUserPreferences::class, UserPreferenceService::class); $context->registerServiceAlias(IDkimService::class, DkimService::class); diff --git a/lib/Contracts/IMailTransmission.php b/lib/Contracts/IMailTransmission.php deleted file mode 100644 index 98a0a932ea..0000000000 --- a/lib/Contracts/IMailTransmission.php +++ /dev/null @@ -1,63 +0,0 @@ -logger->info("Saving a new draft in account <$id>"); - } else { - $this->logger->info("Updating draft <$draftId> in account <$id>"); - } - - $effectiveUserId = $this->delegationService->resolveAccountUserId($id, $this->userId); - $account = $this->accountService->find($effectiveUserId, $id); - $previousDraft = null; - if ($draftId !== null) { - try { - $this->delegationService->assertMessageAccess($draftId, $this->userId); - } catch (DoesNotExistException $e) { - // Nothing to authorise, loading the draft below will fail and be logged - } - try { - $previousDraft = $this->mailManager->getMessage($effectiveUserId, $draftId); - } catch (ClientException $e) { - $this->logger->info("Draft {$draftId} could not be loaded: {$e->getMessage()}"); - } - } - $messageData = NewMessageData::fromRequest($account, $subject, $body, $to, $cc, $bcc, [], $isHtml); - - try { - /** @var Mailbox $draftsMailbox */ - [, $draftsMailbox, $newUID] = $this->mailTransmission->saveDraft($messageData, $previousDraft); - $this->syncService->syncMailbox( - $account, - $draftsMailbox, - Horde_Imap_Client::SYNC_NEWMSGSUIDS, - false, - null, - [] - ); - $this->delegationService->logDelegatedAction($this->userId, $effectiveUserId, "$this->userId saved draft in account <$id> on behalf of $effectiveUserId"); - return new JSONResponse([ - 'id' => $this->mailManager->getMessageIdForUid($draftsMailbox, $newUID) - ]); - } catch (ClientException|ServiceException $ex) { - $this->logger->error('Saving draft failed: ' . $ex->getMessage()); - throw $ex; - } - } - /** * @NoAdminRequired * diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php index 8ba48a59a6..68c6d8c641 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -15,7 +15,6 @@ use OCA\Mail\Attachment; use OCA\Mail\Contracts\IDkimService; use OCA\Mail\Contracts\IMailSearch; -use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Contracts\ITrustedSenderService; use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Db\Message; @@ -25,6 +24,7 @@ use OCA\Mail\Http\HtmlResponse; use OCA\Mail\Http\TrapError; use OCA\Mail\Model\SmimeData; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\DelegationService; @@ -78,7 +78,7 @@ public function __construct( IURLGenerator $urlGenerator, ContentSecurityPolicyNonceManager $nonceManager, private ITrustedSenderService $trustedSenderService, - private IMailTransmission $mailTransmission, + private ProtocolFactory $protocolFactory, private SmimeService $smimeService, private IDkimService $dkimService, private IUserPreferences $preferences, @@ -490,7 +490,7 @@ public function mdn(int $id): JSONResponse { } try { - $this->mailTransmission->sendMdn($account, $mailbox, $message); + $this->protocolFactory->transmissionConnector($account)->sendMdn($account, $mailbox, $message); $this->mailManager->flagMessages($account, $mailbox, '$mdnsent', true, $message); } catch (ServiceException $ex) { $this->logger->error('Sending mdn failed: ' . $ex->getMessage()); diff --git a/lib/Db/MailboxMapper.php b/lib/Db/MailboxMapper.php index 11a8f15232..6009b10fe1 100644 --- a/lib/Db/MailboxMapper.php +++ b/lib/Db/MailboxMapper.php @@ -179,6 +179,28 @@ public function findSpecialUseMailbox(Account $account, string $specialUse): ?Ma return null; } + /** + * @throws DoesNotExistException + * @throws ServiceException + */ + public function findByName(Account $account, string $name): Mailbox { + $qb = $this->db->getQueryBuilder(); + + $select = $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('account_id', $qb->createNamedParameter($account->getId())), + $qb->expr()->eq('name', $qb->createNamedParameter($name)) + ); + + try { + return $this->findEntity($select); + } catch (MultipleObjectsReturnedException $e) { + // Not possible due to DB constraints + throw new ServiceException('The impossible has happened', 42, $e); + } + } + /** * @throws MailboxLockedException */ diff --git a/lib/IMAP/ImapMessageConnector.php b/lib/IMAP/ImapMessageConnector.php index 27380a2ddb..8ef2d8da04 100644 --- a/lib/IMAP/ImapMessageConnector.php +++ b/lib/IMAP/ImapMessageConnector.php @@ -119,7 +119,7 @@ public function findMessages(Account $account, Mailbox $mailbox, SearchQuery $se } #[\Override] - public function fetchMessageRaw(Account $account, Mailbox $mailbox, Message $message): ?string { + public function fetchMessageRaw(Account $account, Mailbox $mailbox, Message $message, bool $decrypt = false): ?string { $client = $this->protocolFactory->imapClient($account); try { return $this->imapMessageMapper->getFullText( @@ -127,7 +127,7 @@ public function fetchMessageRaw(Account $account, Mailbox $mailbox, Message $mes $mailbox->getName(), $message->getUid(), $account->getUserId(), - false, + $decrypt, ); } finally { $client->logout(); diff --git a/lib/IMAP/ImapTransmissionConnector.php b/lib/IMAP/ImapTransmissionConnector.php new file mode 100644 index 0000000000..f0e1556063 --- /dev/null +++ b/lib/IMAP/ImapTransmissionConnector.php @@ -0,0 +1,523 @@ +getStatus() === LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL) { + $raw = $message->getRaw(); + if ($raw !== null) { + $client = $this->protocolFactory->imapClient($account); + try { + $this->messageMapper->save($client, $sentMailbox, $raw, []); + $message->setStatus(LocalMessage::STATUS_PROCESSED); + } catch (\Throwable $e) { + $this->logger->error('Retry copy-to-sent failed: ' . $e->getMessage(), ['exception' => $e]); + $message->setStatus(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); + } finally { + $client->logout(); + } + } else { + $message->setStatus(LocalMessage::STATUS_ERROR); + } + return; + } + [$to, $cc, $bcc, $attachments] = $this->getRecipientsAndAttachments($message); + + $name = $account->getName(); + $emailAddress = $account->getEMailAddress(); + + $aliasId = $message->getAliasId(); + if ($aliasId !== null) { + try { + $alias = $this->aliasesService->find($aliasId, $account->getUserId()); + $name = ($alias->getName() ?? $name); + $emailAddress = $alias->getAlias(); + } catch (DoesNotExistException) { + $this->logger->debug('The assigned alias no longer exists. Falling back to the default name and email address. It is likely that the alias was deleted or deprovisioned in the meantime.', [ + 'aliasId' => $message->getAliasId(), + 'accountId' => $account->getId(), + ]); + } + } + + $from = Address::fromRaw($name, $emailAddress); + + $attachmentParts = []; + foreach ($attachments as $attachment) { + $part = $this->buildAttachmentMimePart($account, $attachment); + if ($part !== null) { + $attachmentParts[] = $part; + } + } + + $transport = $this->smtpClientFactory->create($account); + + $fromHorde = $from->toHorde(); + $toHorde = $to->toHorde(); + $ccHorde = $cc->toHorde(); + $bccHorde = $bcc->toHorde(); + + // Build full headers for the Sent-folder copy (FCC), including Bcc so the + // sender can see who was blind-copied when reviewing sent mail — the same + // approach used by Horde IMP and other clients (Evolution, Thunderbird). + $fccHeaders = new Horde_Mime_Headers(); + $fccHeaders->addHeaderOb(Horde_Mime_Headers_Date::create()); + $fccHeaders->addHeaderOb(Horde_Mime_Headers_MessageId::create()); + $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('From', $fromHorde)); + $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('To', $toHorde)); + if (count($cc) > 0) { + $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('Cc', $ccHorde)); + } + if (count($bcc) > 0) { + $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('Bcc', $bccHorde)); + } + if ($message->getSubject() !== null) { + $fccHeaders->addHeader('Subject', $message->getSubject()); + } + // The table (oc_local_messages) currently only allows for a single reply to message id + // but we already set the 'references' header for an email so we could support multiple references + // Get the previous message and then concatenate all its "References" message ids with this one + if (($inReplyTo = $message->getInReplyToMessageId()) !== null) { + $fccHeaders->addHeader('References', $inReplyTo); + $fccHeaders->addHeader('In-Reply-To', $inReplyTo); + } + if ($message->getRequestMdn()) { + $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses(Horde_Mime_Mdn::MDN_HEADER, $fromHorde)); + } + + // For SMTP delivery: strip Bcc so it never appears in the transmitted + // message (RFC 5321). All three recipient lists are passed as SMTP + // envelope recipients so every addressee still receives the mail. + $sendHeaders = clone $fccHeaders; + $sendHeaders->removeHeader('Bcc'); + + $smtpRecipients = new Horde_Mail_Rfc822_List(); + $smtpRecipients->add($toHorde); + $smtpRecipients->add($ccHorde); + $smtpRecipients->add($bccHorde); + $smtpRecipients->unique(); + + $mimePart = $this->mimeMessage->build( + $message->getBodyPlain(), + $message->getBodyHtml(), + $message->isPgpMime() === true, + $attachmentParts, + ); + + try { + $mimePart = $this->applySmimeSignature($message, $account, $mimePart); + $mimePart = $this->applySmimeEncryption($message, $to, $cc, $bcc, $account, $mimePart); + } catch (ServiceException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return; + } + + // Send the message + try { + $mimePart->send($smtpRecipients->writeAddress(), $sendHeaders, $transport); + $message->setRaw($mimePart->toString([ + 'encode' => Horde_Mime_Part::ENCODE_7BIT | Horde_Mime_Part::ENCODE_8BIT | Horde_Mime_Part::ENCODE_BINARY, + 'headers' => $fccHeaders, + 'stream' => false, + ])); + $message->setStatus(LocalMessage::STATUS_RAW); + } catch (Horde_Mime_Exception $e) { + if ($e->getPrevious() instanceof Horde_Smtp_Exception) { + /** @var Horde_Smtp_Exception $previousException */ + $previousException = $e->getPrevious(); + $this->logger->error('SMTP error: ' . $e->getMessage(), [ + 'exception' => $e, + 'smtpErrorCode' => $previousException->getSmtpCode(), + ]); + } else { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + + if (in_array($e->getCode(), self::RETRIABLE_CODES, true)) { + $message->setStatus(LocalMessage::STATUS_SMPT_SEND_FAIL); + return; + } + + try { + $message->setRaw($mimePart->toString([ + 'encode' => Horde_Mime_Part::ENCODE_7BIT | Horde_Mime_Part::ENCODE_8BIT | Horde_Mime_Part::ENCODE_BINARY, + 'headers' => $fccHeaders, + 'stream' => false, + ])); + } catch (Throwable) { + // Having the raw message is nice for troubleshooting, but should not fail hard. + } + $message->setStatus(LocalMessage::STATUS_ERROR); + return; + } finally { + if ($transport instanceof Horde_Mail_Transport_Smtphorde) { + try { + $transport->getSMTPObject()->logout(); + } catch (Throwable) { + // Handle silently as this is a resource usage optimization + } + } + } + + // Copy to Sent mailbox after successful SMTP send + $raw = $message->getRaw(); + if ($raw !== null) { + $client = $this->protocolFactory->imapClient($account); + try { + $this->messageMapper->save($client, $sentMailbox, $raw, []); + $message->setStatus(LocalMessage::STATUS_PROCESSED); + } catch (\Throwable $e) { + $this->logger->error('Copy to sent mailbox failed: ' . $e->getMessage(), ['exception' => $e]); + $message->setStatus(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); + } finally { + $client->logout(); + } + } + } + + #[\Override] + public function saveMessage(Account $account, Mailbox $mailbox, LocalMessage $message, array $flags = []): void { + [$to, $cc, $bcc, $attachments] = $this->getRecipientsAndAttachments($message); + + $perfLogger = $this->performanceLogger->start('save message to IMAP mailbox'); + + $from = Address::fromRaw($account->getName(), $account->getEMailAddress()); + + $headers = [ + 'From' => $from->toHorde(), + 'To' => $to->toHorde(), + 'Subject' => $message->getSubject(), + ]; + if (count($cc) > 0) { + $headers['Cc'] = $cc->toHorde(); + } + if (count($bcc) > 0) { + $headers['Bcc'] = $bcc->toHorde(); + } + + $mail = new Horde_Mime_Mail(); + $mail->addHeaders($headers); + foreach ($attachments as $attachment) { + $part = $this->buildAttachmentMimePart($account, $attachment); + if ($part !== null) { + $mail->addMimePart($part); + } + } + if ($message->isHtml()) { + $mail->setHtmlBody($message->getBodyHtml()); + } else { + $mail->setBody($message->getBodyPlain()); + } + $mail->addHeaderOb(Horde_Mime_Headers_MessageId::create()); + $perfLogger->step('build MIME message'); + + // Map JMAP-style keyword flags to IMAP flags + $imapFlags = []; + foreach ($flags as $flag) { + $imapFlag = match (strtolower($flag)) { + '$draft' => Horde_Imap_Client::FLAG_DRAFT, + '$seen' => Horde_Imap_Client::FLAG_SEEN, + '$flagged' => Horde_Imap_Client::FLAG_FLAGGED, + '$answered' => Horde_Imap_Client::FLAG_ANSWERED, + '$deleted' => Horde_Imap_Client::FLAG_DELETED, + default => null, + }; + if ($imapFlag !== null) { + $imapFlags[] = $imapFlag; + } + } + + $client = $this->protocolFactory->imapClient($account); + try { + $transport = new Horde_Mail_Transport_Null(); + $mail->send($transport, false, false); + $perfLogger->step('encode MIME message'); + $this->messageMapper->save($client, $mailbox, $mail->getRaw(false), $imapFlags); + $perfLogger->step('save message on IMAP'); + } catch (Horde_Exception $e) { + throw new ServiceException('Could not save message to IMAP mailbox', 0, $e); + } finally { + $client->logout(); + } + + $perfLogger->end(); + } + + #[\Override] + 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->protocolFactory->imapClient($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."); + } + + $imapDate = $fetchResults[0]->getImapDate(); + /** @var Horde_Mime_Headers $mdnHeaders */ + $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); + } + } + + /** + * @return array{0: AddressList, 1: AddressList, 2: AddressList, 3: array} + */ + private function getRecipientsAndAttachments(LocalMessage $message): array { + return [ + $this->transmissionService->getAddressList($message, Recipient::TYPE_TO), + $this->transmissionService->getAddressList($message, Recipient::TYPE_CC), + $this->transmissionService->getAddressList($message, Recipient::TYPE_BCC), + $this->transmissionService->getAttachments($message), + ]; + } + + private function buildAttachmentMimePart(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'); + + if ($localAttachment->isDispositionAttachmentOrInline()) { + $part->setDisposition($localAttachment->getDisposition()); + /* + * Setting a name implicitly adds a Content-Disposition header in Horde, + * which would override the intentional omission. Only set it for attachment/inline dispositions. + */ + $part->setName($localAttachment->getFileName()); + } + + if ($localAttachment->getContentId() !== null) { + $part->setContentId($localAttachment->getContentId()); + } + + $part->setContents($file->getContent()); + /* + * Horde_Mime_Part.setType takes the mimetype (e.g. text/calendar) + * and discards additional parameters (like method=REQUEST). + * + * $part->setType('text/calendar; method=REQUEST') + * $part->getType() => text/calendar + */ + $contentTypeHeader = Horde_Mime_Headers_ContentParam_ContentType::create(); + $contentTypeHeader->decode($localAttachment->getMimeType()); + + $part->setType($contentTypeHeader->value); + foreach ($contentTypeHeader->params as $label => $data) { + $part->setContentTypeParameter($label, $data); + } + + return $part; + } catch (AttachmentNotFoundException $e) { + $this->logger->warning('Ignoring local attachment because it does not exist', ['exception' => $e]); + return null; + } + } + + /** + * @throws ServiceException + */ + private function applySmimeSignature(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; + } + + /** + * @throws ServiceException + */ + private function applySmimeEncryption(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/lib/JMAP/JmapMessageConnector.php b/lib/JMAP/JmapMessageConnector.php index d32c80b6d5..8b2b09d825 100644 --- a/lib/JMAP/JmapMessageConnector.php +++ b/lib/JMAP/JmapMessageConnector.php @@ -167,7 +167,7 @@ public function findMessages(Account $account, Mailbox $mailbox, SearchQuery $se } #[\Override] - public function fetchMessageRaw(Account $account, Mailbox $mailbox, Message $message): ?string { + public function fetchMessageRaw(Account $account, Mailbox $mailbox, Message $message, bool $decrypt = false): ?string { $remoteId = $message->getRemoteId(); if ($remoteId === null) { throw new ServiceException("Message {$message->getId()} does not have a remote id"); diff --git a/lib/JMAP/JmapTransmissionConnector.php b/lib/JMAP/JmapTransmissionConnector.php new file mode 100644 index 0000000000..8776824d2e --- /dev/null +++ b/lib/JMAP/JmapTransmissionConnector.php @@ -0,0 +1,304 @@ +transmissionService->getAddressList($lMessage, Recipient::TYPE_TO); + $cc = $this->transmissionService->getAddressList($lMessage, Recipient::TYPE_CC); + $bcc = $this->transmissionService->getAddressList($lMessage, Recipient::TYPE_BCC); + + $senderName = $account->getName(); + $senderAddress = $account->getEMailAddress(); + + $aliasId = $lMessage->getAliasId(); + if ($aliasId !== null) { + try { + $alias = $this->aliasesService->find($aliasId, $account->getUserId()); + $senderName = ($alias->getName() ?? $senderName); + $senderAddress = $alias->getAlias(); + } catch (DoesNotExistException) { + $this->logger->debug('The assigned alias no longer exists. Falling back to the default name and email address.', [ + 'aliasId' => $lMessage->getAliasId(), + 'accountId' => $account->getId(), + ]); + } + } + + $from = Address::fromRaw($senderName, $senderAddress); + + $sentMailboxRid = $sentMailbox->getRemoteId(); + if ($sentMailboxRid === null) { + $this->logger->error('Sent mailbox does not have a JMAP remote ID', ['mailboxId' => $sentMailbox->getId()]); + $lMessage->setStatus(LocalMessage::STATUS_ERROR); + return; + } + + $this->jmapOperationsService->connect($account); + + $draftsMailboxRid = $this->resolveDraftsMailboxRid($account); + if ($draftsMailboxRid === null) { + $this->logger->error('No Drafts mailbox configured for JMAP send staging', ['accountId' => $account->getId()]); + $lMessage->setStatus(LocalMessage::STATUS_ERROR); + return; + } + + try { + $identityId = $this->resolveIdentityId($senderAddress); + } catch (Exception $e) { + $this->logger->error('Could not resolve JMAP identity for send: ' . $e->getMessage(), ['exception' => $e]); + $lMessage->setStatus(LocalMessage::STATUS_ERROR); + return; + } + + $rcptTo = $this->collectEnvelopeRecipients($to, $cc, $bcc); + $attachments = $this->collectAttachments($account, $lMessage); + $jMessage = $this->convertLocalMessage($from, $to, $cc, $bcc, $lMessage); + $jMessage->draft(true)->seen(true); + + try { + $this->jmapOperationsService->entitySend( + $identityId, + $jMessage, + $draftsMailboxRid, + $sentMailboxRid, + $from->getEmail() ?? $senderAddress, + $rcptTo, + $attachments, + ); + $lMessage->setStatus(LocalMessage::STATUS_PROCESSED); + } catch (Exception $e) { + $status = $this->classifyJmapError($e->getMessage()); + $this->logger->error('JMAP send failed: ' . $e->getMessage(), ['exception' => $e]); + $lMessage->setStatus($status); + } + } + + #[\Override] + public function saveMessage(Account $account, Mailbox $mailbox, LocalMessage $lMessage, array $flags = []): void { + $remoteId = $mailbox->getRemoteId(); + if ($remoteId === null) { + throw new ServiceException("Mailbox {$mailbox->getId()} does not have a JMAP remote ID"); + } + + $to = $this->transmissionService->getAddressList($lMessage, Recipient::TYPE_TO); + $cc = $this->transmissionService->getAddressList($lMessage, Recipient::TYPE_CC); + $bcc = $this->transmissionService->getAddressList($lMessage, Recipient::TYPE_BCC); + $from = Address::fromRaw($account->getName(), $account->getEMailAddress()); + + $attachments = $this->collectAttachments($account, $lMessage); + $jMessage = $this->convertLocalMessage($from, $to, $cc, $bcc, $lMessage); + $jMessage->draft(true)->seen(true); + + // Apply mailbox location and keyword flags + $keywords = []; + foreach ($flags as $flag) { + $keywords[$flag] = true; + } + $jMessage->in($remoteId); + if ($keywords !== []) { + $jMessage->keywords($keywords); + } + + $this->jmapOperationsService->connect($account); + try { + $this->jmapOperationsService->entitySave($jMessage, $attachments); + } catch (Exception $e) { + throw new ServiceException('Could not save message to JMAP mailbox: ' . $e->getMessage(), 0, $e); + } + } + + #[\Override] + public function sendMdn(Account $account, Mailbox $mailbox, Message $message): void { + throw new ServiceException('MDN is not supported for JMAP accounts'); + } + + /** + * Build a MailParametersRequest from a LocalMessage. + * + * Uses the JMAP client parameter builders so the generated payload stays aligned + * with the request classes used elsewhere in the integration. + */ + private function convertLocalMessage(Address $from, AddressList $to, AddressList $cc, AddressList $bcc, LocalMessage $lMessage): MailParametersRequest { + $jMessage = new MailParametersRequest(); + $jMessage->from($from->getEmail() ?? '', $from->getLabel() ?? ''); + + foreach ($to->iterate() as $address) { + $jMessage->to($address->getEmail() ?? '', $address->getLabel() ?? ''); + } + + foreach ($cc->iterate() as $address) { + $jMessage->cc($address->getEmail() ?? '', $address->getLabel() ?? ''); + } + + foreach ($bcc->iterate() as $address) { + $jMessage->bcc($address->getEmail() ?? '', $address->getLabel() ?? ''); + } + + if (($inReplyTo = $lMessage->getInReplyToMessageId()) !== null) { + $jMessage->inReplyTo($inReplyTo); + $jMessage->references($inReplyTo); + } + + $jMessage->subject($lMessage->getSubject() ?? ''); + + $bodyPlain = $lMessage->getBodyPlain(); + $bodyHtml = $lMessage->getBodyHtml(); + + $bodyPart = $jMessage->bodyPartStructure(); + $bodyPart->type('multipart/mixed'); + + if (!empty($bodyPlain) && !empty($bodyHtml)) { + $textPart = $bodyPart->addPart(); + $textPart->type('multipart/alternative'); + + $textPart->addPart() + ->id('text-plain') + ->type('text/plain'); + $textPart->addPart() + ->id('text-html') + ->type('text/html'); + $jMessage->bodyPartValue('text-plain', $bodyPlain); + $jMessage->bodyPartValue('text-html', $bodyHtml); + } elseif (!empty($bodyHtml)) { + $bodyPart->addPart() + ->id('text-html') + ->type('text/html'); + $jMessage->bodyPartValue('text-html', $bodyHtml); + } else { + $bodyPart->addPart() + ->id('text-plain') + ->type('text/plain'); + $jMessage->bodyPartValue('text-plain', $bodyPlain ?? ''); + } + + return $jMessage; + } + + /** + * Resolve a message's local attachments into their raw content, skipping any + * that no longer exist. JmapOperationsService uploads this content and wires + * the resulting blob into the email. + * + * @return array + */ + private function collectAttachments(Account $account, LocalMessage $message): array { + $attachments = []; + foreach ($this->transmissionService->getAttachments($message) as $attachmentRef) { + $content = $this->transmissionService->getAttachmentContent($account, $attachmentRef); + if ($content === null) { + continue; + } + $attachments[] = $content; + } + return $attachments; + } + + /** + * Collect bare email addresses for the SMTP envelope from To, Cc, Bcc lists. + * + * @return string[] + */ + private function collectEnvelopeRecipients(AddressList $to, AddressList $cc, AddressList $bcc): array { + $recipients = []; + foreach ([$to, $cc, $bcc] as $list) { + foreach ($list->iterate() as $address) { + $email = $address->getEmail(); + if ($email !== null) { + $recipients[] = $email; + } + } + } + return array_unique($recipients); + } + + /** + * Find the JMAP identity ID whose email address matches the given address. + * + * Falls back to the first available identity if no exact match. + * + * @throws Exception when no identities are found on the server + */ + private function resolveIdentityId(string $emailAddress): string { + $identities = $this->jmapOperationsService->identityFetch(); + if ($identities === []) { + throw new Exception('No JMAP identities found on server'); + } + foreach ($identities as $identity) { + if (strtolower($identity->address() ?? '') === strtolower($emailAddress)) { + return $identity->id(); + } + } + // fall back to first identity + return $identities[0]->id(); + } + + /** + * Get the JMAP remote ID of the configured Drafts mailbox. + */ + private function resolveDraftsMailboxRid(Account $account): ?string { + $draftsMailboxId = $account->getMailAccount()->getDraftsMailboxId(); + if ($draftsMailboxId === null) { + return null; + } + try { + $mailbox = $this->mailboxMapper->findById($draftsMailboxId); + return $mailbox->getRemoteId(); + } catch (DoesNotExistException) { + return null; + } + } + + /** + * Classify a JMAP server error string into a LocalMessage status constant. + * + * @return LocalMessage::STATUS_* + */ + private function classifyJmapError(string $errorMessage): int { + $lower = strtolower($errorMessage); + if (str_contains($lower, 'toomanyrecipients')) { + return LocalMessage::STATUS_TOO_MANY_RECIPIENTS; + } + if (str_contains($lower, 'forbiddenfrom') || str_contains($lower, 'notpermitted')) { + return LocalMessage::STATUS_ERROR; + } + // Network / HTTP errors and other transient failures are retriable + return LocalMessage::STATUS_SMPT_SEND_FAIL; + } +} diff --git a/lib/Listener/DeleteDraftListener.php b/lib/Listener/DeleteDraftListener.php index a15cd4d9a2..0cdf122287 100644 --- a/lib/Listener/DeleteDraftListener.php +++ b/lib/Listener/DeleteDraftListener.php @@ -9,8 +9,6 @@ namespace OCA\Mail\Listener; -use Horde_Imap_Client; -use Horde_Imap_Client_Exception; use OCA\Mail\Account; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; @@ -19,7 +17,6 @@ use OCA\Mail\Events\DraftSavedEvent; use OCA\Mail\Events\MessageDeletedEvent; use OCA\Mail\Events\OutboxMessageCreatedEvent; -use OCA\Mail\IMAP\MessageMapper; use OCA\Mail\Protocol\ProtocolFactory; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\Event; @@ -37,7 +34,6 @@ class DeleteDraftListener implements IEventListener { public function __construct( private ProtocolFactory $protocolFactory, private MailboxMapper $mailboxMapper, - private MessageMapper $messageMapper, private LoggerInterface $logger, IEventDispatcher $eventDispatcher, ) { @@ -56,36 +52,15 @@ public function handle(Event $event): void { * @param Message $draft */ private function deleteDraft(Account $account, Message $draft): void { - $client = $this->protocolFactory->imapClient($account); try { $draftsMailbox = $this->getDraftsMailbox($account); } catch (DoesNotExistException $e) { $this->logger->warning("Account has no draft mailbox set, can't delete the draft"); return; - } finally { - $client->logout(); } - try { - $this->messageMapper->addFlag( - $client, - $draftsMailbox, - [$draft->getUid()], // TODO: the UID could be from another mailbox - Horde_Imap_Client::FLAG_DELETED - ); - } catch (Horde_Imap_Client_Exception $e) { - $this->logger->error('Could not flag draft as deleted', [ - 'exception' => $e, - ]); - } - - try { - $client->expunge($draftsMailbox->getName()); - } catch (Horde_Imap_Client_Exception $e) { - $this->logger->error('Could not expunge drafts folder', [ - 'exception' => $e, - ]); - } + // TODO: the UID could be from another mailbox + $this->protocolFactory->messageConnector($account)->deleteMessages($account, $draftsMailbox, $draft); $this->eventDispatcher->dispatchTyped( new MessageDeletedEvent($account, $draftsMailbox, $draft->getUid()) diff --git a/lib/Protocol/ProtocolFactory.php b/lib/Protocol/ProtocolFactory.php index e973172565..cfa2c9ad2e 100644 --- a/lib/Protocol/ProtocolFactory.php +++ b/lib/Protocol/ProtocolFactory.php @@ -26,6 +26,7 @@ use OCA\Mail\JMAP\JmapClientFactory; use OCA\Mail\JMAP\JmapMailboxConnector; use OCA\Mail\JMAP\JmapMessageConnector; +use OCA\Mail\JMAP\JmapTransmissionConnector; use Psr\Container\ContainerInterface; class ProtocolFactory { @@ -37,12 +38,12 @@ class ProtocolFactory { MailAccount::PROTOCOL_IMAP => [ IMailboxConnector::class => ImapMailboxConnector::class, IMessageConnector::class => ImapMessageConnector::class, - //ITransmissionConnector::class => ImapTransmissionConnector::class, + ITransmissionConnector::class => ImapTransmissionConnector::class, ], MailAccount::PROTOCOL_JMAP => [ IMailboxConnector::class => JmapMailboxConnector::class, IMessageConnector::class => JmapMessageConnector::class, - //ITransmissionConnector::class => JmapTransmissionConnector::class, + ITransmissionConnector::class => JmapTransmissionConnector::class, ], ]; diff --git a/lib/Send/AHandler.php b/lib/Send/AHandler.php index 85e283dce4..4db9d09c2b 100644 --- a/lib/Send/AHandler.php +++ b/lib/Send/AHandler.php @@ -8,7 +8,6 @@ namespace OCA\Mail\Send; -use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\Db\LocalMessage; @@ -23,16 +22,14 @@ public function setNext(AHandler $next): AHandler { abstract public function process( Account $account, LocalMessage $localMessage, - Horde_Imap_Client_Socket $client, ): LocalMessage; protected function processNext( Account $account, LocalMessage $localMessage, - Horde_Imap_Client_Socket $client, ): LocalMessage { if ($this->next !== null) { - return $this->next->process($account, $localMessage, $client); + return $this->next->process($account, $localMessage); } return $localMessage; } diff --git a/lib/Send/AntiAbuseHandler.php b/lib/Send/AntiAbuseHandler.php index d6a19cf9f6..63ea1cf8e3 100644 --- a/lib/Send/AntiAbuseHandler.php +++ b/lib/Send/AntiAbuseHandler.php @@ -8,7 +8,6 @@ namespace OCA\Mail\Send; -use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Service\AntiAbuseService; @@ -28,11 +27,10 @@ public function __construct( public function process( Account $account, LocalMessage $localMessage, - Horde_Imap_Client_Socket $client, ): LocalMessage { if ($localMessage->getStatus() === LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL || $localMessage->getStatus() === LocalMessage::STATUS_PROCESSED) { - return $this->processNext($account, $localMessage, $client); + return $this->processNext($account, $localMessage); } $user = $this->userManager->get($account->getUserId()); @@ -53,6 +51,6 @@ public function process( // 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, $client); + return $this->processNext($account, $localMessage); } } diff --git a/lib/Send/Chain.php b/lib/Send/Chain.php index bc485d1948..6f81c64083 100644 --- a/lib/Send/Chain.php +++ b/lib/Send/Chain.php @@ -12,20 +12,16 @@ use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Attachment\AttachmentService; use OCP\DB\Exception; 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, - private ProtocolFactory $protocolFactory, ) { } @@ -35,12 +31,6 @@ public function __construct( * @throws ServiceException */ public function process(Account $account, LocalMessage $localMessage): LocalMessage { - $handlers = $this->sentMailboxHandler; - $handlers->setNext($this->antiAbuseHandler) - ->setNext($this->sendHandler) - ->setNext($this->copySentMessageHandler) - ->setNext($this->flagRepliedMessageHandler); - /** * Skip all messages that errored out indeterminedly in the SMTP send. * @see \Horde_Smtp_Exception for the error codes that are inderminate @@ -50,12 +40,11 @@ public function process(Account $account, LocalMessage $localMessage): LocalMess throw new ServiceException('Could not send message because a previous send operation produced an unclear sent state.'); } - $client = $this->protocolFactory->imapClient($account); - try { - $result = $handlers->process($account, $localMessage, $client); - } finally { - $client->logout(); - } + $head = $this->antiAbuseHandler; + $head->setNext($this->sendHandler) + ->setNext($this->flagRepliedMessageHandler); + + $result = $head->process($account, $localMessage); if ($result->getStatus() === LocalMessage::STATUS_PROCESSED) { $this->attachmentService->deleteLocalMessageAttachments($account->getUserId(), $result->getId()); diff --git a/lib/Send/CopySentMessageHandler.php b/lib/Send/CopySentMessageHandler.php deleted file mode 100644 index 068e6254ff..0000000000 --- a/lib/Send/CopySentMessageHandler.php +++ /dev/null @@ -1,86 +0,0 @@ -getStatus() === LocalMessage::STATUS_PROCESSED) { - return $this->processNext($account, $localMessage, $client); - } - - $rawMessage = $localMessage->getRaw(); - if ($rawMessage === null) { - $localMessage->setStatus(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); - return $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; - } - - try { - $this->messageMapper->save( - $client, - $sentMailbox, - $rawMessage, - ); - $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; - } - - return $this->processNext($account, $localMessage, $client); - } -} diff --git a/lib/Send/FlagRepliedMessageHandler.php b/lib/Send/FlagRepliedMessageHandler.php index 7aa46ebe51..4bc97e469b 100644 --- a/lib/Send/FlagRepliedMessageHandler.php +++ b/lib/Send/FlagRepliedMessageHandler.php @@ -9,13 +9,11 @@ namespace OCA\Mail\Send; use Horde_Imap_Client; -use Horde_Imap_Client_Exception; -use Horde_Imap_Client_Socket; 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\MessageMapper; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use Psr\Log\LoggerInterface; @@ -23,7 +21,7 @@ class FlagRepliedMessageHandler extends AHandler { public function __construct( private MailboxMapper $mailboxMapper, private LoggerInterface $logger, - private MessageMapper $messageMapper, + private MailManager $mailManager, private DbMessageMapper $dbMessageMapper, ) { } @@ -32,19 +30,18 @@ public function __construct( public function process( Account $account, LocalMessage $localMessage, - Horde_Imap_Client_Socket $client, ): LocalMessage { if ($localMessage->getStatus() !== LocalMessage::STATUS_PROCESSED) { return $localMessage; } if ($localMessage->getInReplyToMessageId() === null) { - return $this->processNext($account, $localMessage, $client); + return $this->processNext($account, $localMessage); } $messages = $this->dbMessageMapper->findByMessageId($account, $localMessage->getInReplyToMessageId()); if ($messages === []) { - return $this->processNext($account, $localMessage, $client); + return $this->processNext($account, $localMessage); } foreach ($messages as $message) { @@ -59,21 +56,22 @@ public function process( continue; } // Mark all other mailboxes that contain the message with the same imap message id as replied - $this->messageMapper->addFlag( - $client, + $this->mailManager->flagMessages( + $account, $mailbox, - [$message->getUid()], - Horde_Imap_Client::FLAG_ANSWERED + Horde_Imap_Client::FLAG_ANSWERED, + true, + $message ); $message->setFlagAnswered(true); $this->dbMessageMapper->update($message); - } catch (DoesNotExistException|Horde_Imap_Client_Exception $e) { + } catch (DoesNotExistException $e) { $this->logger->warning('Could not flag replied message: ' . $e->getMessage(), [ 'exception' => $e, ]); } } - return $this->processNext($account, $localMessage, $client); + return $this->processNext($account, $localMessage); } } diff --git a/lib/Send/SendHandler.php b/lib/Send/SendHandler.php index 38e1d47690..d8d9b6cf34 100644 --- a/lib/Send/SendHandler.php +++ b/lib/Send/SendHandler.php @@ -8,14 +8,21 @@ namespace OCA\Mail\Send; -use Horde_Imap_Client_Socket; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\LocalMessage; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Events\MessageSentEvent; +use OCA\Mail\Protocol\ProtocolFactory; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\IEventDispatcher; +use Psr\Log\LoggerInterface; class SendHandler extends AHandler { public function __construct( - private IMailTransmission $transmission, + private ProtocolFactory $protocolFactory, + private IEventDispatcher $eventDispatcher, + private MailboxMapper $mailboxMapper, + private LoggerInterface $logger, ) { } @@ -23,17 +30,32 @@ public function __construct( public function process( Account $account, LocalMessage $localMessage, - Horde_Imap_Client_Socket $client, ): LocalMessage { - if ($localMessage->getStatus() === LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL - || $localMessage->getStatus() === LocalMessage::STATUS_PROCESSED) { - return $this->processNext($account, $localMessage, $client); + if ($localMessage->getStatus() === LocalMessage::STATUS_PROCESSED) { + return $this->processNext($account, $localMessage); } - $this->transmission->sendMessage($account, $localMessage); + // Resolve the Sent mailbox before calling the connector + $sentMailboxId = $account->getMailAccount()->getSentMailboxId(); + if ($sentMailboxId === null) { + $localMessage->setStatus(LocalMessage::STATUS_NO_SENT_MAILBOX); + return $localMessage; + } + try { + $sentMailbox = $this->mailboxMapper->findById($sentMailboxId); + } catch (DoesNotExistException $e) { + $this->logger->error('Sent mailbox not found', ['exception' => $e]); + $localMessage->setStatus(LocalMessage::STATUS_NO_SENT_MAILBOX); + return $localMessage; + } - if ($localMessage->getStatus() === LocalMessage::STATUS_RAW || $localMessage->getStatus() === null) { - return $this->processNext($account, $localMessage, $client); + $this->protocolFactory->transmissionConnector($account)->sendMessage($account, $localMessage, $sentMailbox); + + if ($localMessage->getStatus() === LocalMessage::STATUS_RAW + || $localMessage->getStatus() === null + || $localMessage->getStatus() === LocalMessage::STATUS_PROCESSED) { + $this->eventDispatcher->dispatchTyped(new MessageSentEvent($account, $localMessage)); + return $this->processNext($account, $localMessage); } // Something went wrong during the sending return $localMessage; diff --git a/lib/Send/SentMailboxHandler.php b/lib/Send/SentMailboxHandler.php deleted file mode 100644 index 298fffd0b5..0000000000 --- a/lib/Send/SentMailboxHandler.php +++ /dev/null @@ -1,28 +0,0 @@ -getMailAccount()->getSentMailboxId() === null) { - $localMessage->setStatus(LocalMessage::STATUS_NO_SENT_MAILBOX); - return $localMessage; - } - return $this->processNext($account, $localMessage, $client); - } -} diff --git a/lib/Service/Attachment/AttachmentService.php b/lib/Service/Attachment/AttachmentService.php index 149a1ef1d1..fcd1ea2b42 100644 --- a/lib/Service/Attachment/AttachmentService.php +++ b/lib/Service/Attachment/AttachmentService.php @@ -23,7 +23,6 @@ use OCA\Mail\Exception\ServiceException; use OCA\Mail\Exception\SmimeDecryptException; use OCA\Mail\Exception\UploadException; -use OCA\Mail\IMAP\MessageMapper; use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; @@ -51,7 +50,6 @@ public function __construct( private LocalAttachmentMapper $mapper, private AttachmentStorage $storage, private MailManager $mailManager, - private MessageMapper $messageMapper, private ICacheFactory $cacheFactory, private IURLGenerator $urlGenerator, private IMimeTypeDetector $mimeTypeDetector, @@ -216,7 +214,7 @@ public function updateLocalMessageAttachments(string $userId, LocalMessage $mess * @param array $attachments * @return int[] */ - public function handleAttachments(Account $account, array $attachments, \Horde_Imap_Client_Socket $client): array { + public function handleAttachments(Account $account, array $attachments): array { $attachmentIds = []; if ($attachments === []) { @@ -235,12 +233,12 @@ public function handleAttachments(Account $account, array $attachments, \Horde_I } if ($attachment['type'] === 'message' || $attachment['type'] === 'message/rfc822') { // Adds another message as attachment - $attachmentIds[] = $this->handleForwardedMessageAttachment($account, $attachment, $client); + $attachmentIds[] = $this->handleForwardedMessageAttachment($account, $attachment); continue; } if ($attachment['type'] === 'message-attachment' || $attachment['type'] === 'message-attachment-inline') { // Adds an attachment from another email (use case is, eg., a mail forward) - $attachmentIds[] = $this->handleForwardedAttachment($account, $attachment, $client); + $attachmentIds[] = $this->handleForwardedAttachment($account, $attachment); continue; } @@ -306,18 +304,12 @@ public function getAttachmentNames(Account $account, Mailbox $mailbox, Message $ * * @param Account $account * @param mixed[] $attachment - * @param \Horde_Imap_Client_Socket $client * @return int|null */ - private function handleForwardedMessageAttachment(Account $account, array $attachment, \Horde_Imap_Client_Socket $client): ?int { + private function handleForwardedMessageAttachment(Account $account, array $attachment): ?int { $attachmentMessage = $this->mailManager->getMessage($account->getUserId(), (int)$attachment['id']); $mailbox = $this->mailManager->getMailbox($account->getUserId(), $attachmentMessage->getMailboxId()); - $fullText = $this->messageMapper->getFullText( - $client, - $mailbox->getName(), - $attachmentMessage->getUid(), - $account->getUserId() - ); + $fullText = $this->mailManager->getRawMessage($account, $mailbox, $attachmentMessage, true); // detect mime type $mime = 'application/octet-stream'; @@ -343,19 +335,23 @@ private function handleForwardedMessageAttachment(Account $account, array $attac * * @param Account $account * @param mixed[] $attachment - * @param \Horde_Imap_Client_Socket $client * @return int * @throws DoesNotExistException */ - private function handleForwardedAttachment(Account $account, array $attachment, \Horde_Imap_Client_Socket $client): ?int { + private function handleForwardedAttachment(Account $account, array $attachment): ?int { $mailbox = $this->mailManager->getMailbox($account->getUserId(), $attachment['mailboxId']); - $imapAttachment = $this->messageMapper->getAttachment( - $client, - $mailbox->getName(), - (int)$attachment['uid'], + $messageId = $this->mailManager->getMessageIdForUid($mailbox, (int)$attachment['uid']); + if ($messageId === null) { + throw new DoesNotExistException('Unable to load the attachment.'); + } + $message = $this->mailManager->getMessage($account->getUserId(), $messageId); + + $imapAttachment = $this->mailManager->getMailAttachment( + $account, + $mailbox, + $message, $attachment['id'], - $account->getUserId(), ); try { diff --git a/lib/Service/DraftsService.php b/lib/Service/DraftsService.php index de8053d7c1..1fd4326b7e 100644 --- a/lib/Service/DraftsService.php +++ b/lib/Service/DraftsService.php @@ -9,12 +9,16 @@ namespace OCA\Mail\Service; +use Horde_Imap_Client; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Recipient; use OCA\Mail\Events\DraftMessageCreatedEvent; +use OCA\Mail\Events\DraftSavedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Protocol\ProtocolFactory; @@ -30,12 +34,12 @@ class DraftsService { private ITimeFactory $time; public function __construct( - private IMailTransmission $transmission, private LocalMessageMapper $mapper, private AttachmentService $attachmentService, IEventDispatcher $eventDispatcher, private ProtocolFactory $protocolFactory, private MailManager $mailManager, + private MailboxMapper $mailboxMapper, private LoggerInterface $logger, private AccountService $accountService, ITimeFactory $time, @@ -100,12 +104,7 @@ public function saveMessage(Account $account, LocalMessage $message, array $to, return $message; } - $client = $this->protocolFactory->imapClient($account); - try { - $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); - } finally { - $client->logout(); - } + $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments); $message->setAttachments($this->attachmentService->saveLocalMessageAttachments($account->getUserId(), $message->getId(), $attachmentIds)); return $message; @@ -132,12 +131,7 @@ public function updateMessage(Account $account, LocalMessage $message, array $to return $message; } - $client = $this->protocolFactory->imapClient($account); - try { - $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); - } finally { - $client->logout(); - } + $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments); $message->setAttachments($this->attachmentService->updateLocalMessageAttachments($account->getUserId(), $message, $attachmentIds)); return $message; } @@ -147,23 +141,18 @@ public function handleDraft(Account $account, int $draftId): void { $this->eventDispatcher->dispatchTyped(new DraftMessageCreatedEvent($account, $message)); } - /** - * "Send" the message - * - * @param LocalMessage $message - * @param Account $account - * @return void - */ public function sendMessage(LocalMessage $message, Account $account): void { + $draftsMailbox = $this->findOrCreateDraftsMailbox($account); try { - $this->transmission->saveLocalDraft($account, $message); + $this->protocolFactory->transmissionConnector($account)->saveMessage($account, $draftsMailbox, $message, ['$draft']); } catch (ClientException|ServiceException $e) { - $this->logger->error('Could not move draft to IMAP', ['exception' => $e]); + $this->logger->error('Could not save draft', ['exception' => $e]); // Mark as failed so the message is not moved repeatedly in background $message->setFailed(true); $this->mapper->update($message); throw $e; } + $this->eventDispatcher->dispatchTyped(new DraftSavedEvent($account, null)); $this->attachmentService->deleteLocalMessageAttachments($account->getUserId(), $message->getId()); $this->mapper->deleteWithRecipients($message); } @@ -223,4 +212,26 @@ public function flush() { } } } + + /** + * Find the account's drafts mailbox, creating one for IMAP accounts if none is configured. + * + * @param Account $account + * @return Mailbox + * @throws ServiceException + */ + private function findOrCreateDraftsMailbox(Account $account): Mailbox { + $draftsMailboxId = $account->getMailAccount()->getDraftsMailboxId(); + if ($draftsMailboxId === null) { + if ($account->getMailAccount()->getProtocol() !== MailAccount::PROTOCOL_IMAP) { + throw new ServiceException('No drafts mailbox configured for JMAP account ' . $account->getId()); + } + return $this->mailManager->createMailbox( + $account, + 'Drafts', + [Horde_Imap_Client::SPECIALUSE_DRAFTS] + ); + } + return $this->mailboxMapper->findById($draftsMailboxId); + } } diff --git a/lib/Service/JMAP/JmapOperationsService.php b/lib/Service/JMAP/JmapOperationsService.php index 913923e83d..76460133c2 100644 --- a/lib/Service/JMAP/JmapOperationsService.php +++ b/lib/Service/JMAP/JmapOperationsService.php @@ -20,6 +20,7 @@ use JmapClient\Requests\Mail\MailQuery; use JmapClient\Requests\Mail\MailQueryChanges; use JmapClient\Requests\Mail\MailSet; +use JmapClient\Requests\Mail\MailSubmissionSet; use JmapClient\Responses\Mail\MailboxParameters as MailboxParametersResponse; use JmapClient\Responses\Mail\MailParameters as MailParametersResponse; use JmapClient\Responses\ResponseBundle; @@ -988,6 +989,116 @@ public function entityMove(string $target, string ...$identifiers): array { return $results; } + /** + * Send an email via JMAP, atomically staging it in Drafts and filing the sent copy in the Sent mailbox. + * + * Batches an Email/set create with an EmailSubmission/set create in a single HTTP request. + * On successful delivery, the server updates the staged email's mailboxIds to point to + * the Sent mailbox and removes the $draft keyword (RFC 8621 §7.5 onSuccessUpdateEmail). + * + * @param string $identity JMAP identity to send from + * @param MailParametersRequest $message Pre-built email parameters + * @param string $preSendLocation JMAP remote ID of the staging mailbox + * @param string $postSentLocation JMAP remote ID of the sent mailbox + * @param string $from Envelope From address (bare email) + * @param string[] $rcptTo Envelope recipient addresses + * + * @throws Exception on server error or submission failure + */ + public function entitySend(string $identity, MailParametersRequest $message, string $preSendLocation, string $postSentLocation, string $from, array $rcptTo, array $attachments = []): void { + if ($preSendLocation === '') { + throw new Exception('Pre-Send Location is missing', 1); + } + if ($postSentLocation === '') { + throw new Exception('Post-Sent Location is missing', 1); + } + if ($from === '') { + throw new Exception('Envelope From address is missing', 1); + } + if ($rcptTo === []) { + throw new Exception('At least one envelope recipient is required', 1); + } + // stage attachments and wire them onto the message + if ($attachments !== []) { + $this->attachAttachments($message, $attachments); + } + // construct save request + $r0 = new MailSet($this->dataAccount); + $r0->create('1', $message) + ->in($preSendLocation); + // construct submission request + $r1 = new MailSubmissionSet($this->dataAccount); + $r1->create('2') + ->identity($identity) + ->message('#1') + ->from($from) + ->to($rcptTo); + $r1->completionUpdate('#2', [ + 'mailboxIds/' . $postSentLocation => true, + 'mailboxIds/' . $preSendLocation => null, + 'keywords/$draft' => null, + ]); + // transceive + $bundle = $this->dataStore->perform([$r0, $r1]); + // extract responses + $response = $bundle->response(0); + // check for save command error + if ($response instanceof ResponseException) { + throw new Exception('Email saving failed: ' . $response->type() . ': ' . $response->description(), 1); + } + $failure = $response->createFailure('1'); + if ($failure !== null) { + throw new Exception('Email saving failed: ' . ($failure['type'] ?? 'unknownError'), 1); + } + // check for submission command error + $response = $bundle->response(1); + if ($response instanceof ResponseException) { + throw new Exception('Email sending failed: ' . $response->type() . ': ' . $response->description(), 1); + } + $failure = $response->createFailure('2'); + if ($failure !== null) { + throw new Exception('Email sending failed: ' . ($failure['type'] ?? 'unknownError'), 1); + } + } + + /** + * Save/stage an email in a mailbox (e.g., save as draft). + * + * @param MailParametersRequest $email Pre-built email parameters including mailboxIds and keywords. + * @param array $attachments + * @return string Remote ID of the created email. + * @throws Exception on server error. + */ + public function entitySave(MailParametersRequest $email, array $attachments = []): string { + // Stage attachments and wire them onto the email + if ($attachments !== []) { + $this->attachAttachments($email, $attachments); + } + // construct save request + $id = uniqid(); + $r0 = new MailSet($this->dataAccount); + $r0->create($id, $email); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response(s) + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + $result = $response->createSuccess($id); + if ($result !== null) { + return (string)($result['id'] ?? ''); + } + $failure = $response->createFailure($id); + if ($failure !== null) { + $type = $failure['type'] ?? 'unknownError'; + $description = $failure['description'] ?? 'An unknown error occurred.'; + throw new Exception("$type: $description", 1); + } + throw new Exception('Email/set create returned no result', 1); + } + public function attachmentFetch(string $entityId, string ...$blobId): array { $entities = $this->entityFetchNative($entityId); $entity = $entities[$entityId] ?? null; @@ -1017,9 +1128,20 @@ public function attachmentFetch(string $entityId, string ...$blobId): array { } /** - * retrieve identity from remote storage - * + * Upload a blob (e.g. attachment content) to the remote storage and return its blob id. * + * @throws Exception if the upload does not return a blob id + */ + public function attachmentUpload(string $mimeType, string $content): string { + $response = json_decode($this->dataStore->upload($this->account(), $mimeType, $content), true, 512, JSON_THROW_ON_ERROR); + if (!isset($response['blobId']) || !is_string($response['blobId'])) { + throw new Exception('Blob upload did not return a blob id', 1); + } + return $response['blobId']; + } + + /** + * retrieve identity from remote storage */ public function identityFetch(?string $account = null): array { if ($account === null) { @@ -1035,4 +1157,31 @@ public function identityFetch(?string $account = null): array { return $response->objects(); } + /** + * Upload attachments individually via the plain upload endpoint and wire each + * one into the email's `bodyStructure` as a sibling MIME part referencing the + * real blob id. + * + * @param array $attachments + */ + private function attachAttachments(MailParametersRequest $message, array $attachments): void { + foreach ($attachments as $attachment) { + $blobId = $this->attachmentUpload($attachment['type'], $attachment['content']); + $this->addAttachmentPart($message, $attachment, $blobId); + } + } + + /** + * @param array{content: string, type: string, name: string, disposition: ?string, contentId: ?string} $attachment + */ + private function addAttachmentPart(MailParametersRequest $message, array $attachment, string $blobId): void { + $part = $message->bodyPartStructure()->addPart(); + $part->blob($blobId) + ->type($attachment['type']) + ->name($attachment['name']) + ->disposition($attachment['disposition'] ?? 'attachment'); + if ($attachment['contentId'] !== null) { + $part->cid($attachment['contentId']); + } + } } diff --git a/lib/Service/MailManager.php b/lib/Service/MailManager.php index cf8f68e043..386d68eaae 100644 --- a/lib/Service/MailManager.php +++ b/lib/Service/MailManager.php @@ -182,10 +182,10 @@ public function getImapMessages(Account $account, Mailbox $mailbox, bool $loadBo * @throws ClientException * @throws ServiceException */ - public function getRawMessage(Account $account, Mailbox $mailbox, Message $message): ?string { + public function getRawMessage(Account $account, Mailbox $mailbox, Message $message, bool $decrypt = false): ?string { $raw = $this->protocolFactory ->messageConnector($account) - ->fetchMessageRaw($account, $mailbox, $message); + ->fetchMessageRaw($account, $mailbox, $message, $decrypt); if ($raw === null) { throw new ClientException('Message not found on remote server'); } diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php deleted file mode 100644 index d67155942b..0000000000 --- a/lib/Service/MailTransmission.php +++ /dev/null @@ -1,463 +0,0 @@ -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); - - $name = $account->getName(); - $emailAddress = $account->getEMailAddress(); - - if ($localMessage->getAliasId() !== null) { - try { - $alias = $this->aliasesService->find($localMessage->getAliasId(), $account->getUserId()); - $name = ($alias->getName() ?? $name); - $emailAddress = $alias->getAlias(); - } catch (DoesNotExistException) { - $this->logger->debug('The assigned alias no longer exists. Falling back to the default name and email address. It is likely that the alias was deleted or deprovisioned in the meantime.', [ - 'aliasId' => $localMessage->getAliasId(), - 'accountId' => $account->getId(), - ]); - } - } - - $from = Address::fromRaw($name, $emailAddress); - - $attachmentParts = []; - foreach ($attachments as $attachment) { - $part = $this->transmissionService->handleAttachment($account, $attachment); - if ($part !== null) { - $attachmentParts[] = $part; - } - } - - $transport = $this->smtpClientFactory->create($account); - - // Build full headers for the Sent-folder copy (FCC), including Bcc so the - // sender can see who was blind-copied when reviewing sent mail — the same - // approach used by Horde IMP and other clients (Evolution, Thunderbird). - $fccHeaders = new Horde_Mime_Headers(); - $fccHeaders->addHeaderOb(Horde_Mime_Headers_Date::create()); - $fccHeaders->addHeaderOb(Horde_Mime_Headers_MessageId::create()); - $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('From', $from->toHorde())); - $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('To', $to->toHorde())); - if (count($cc) > 0) { - $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('Cc', $cc->toHorde())); - } - if (count($bcc) > 0) { - $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('Bcc', $bcc->toHorde())); - } - if ($localMessage->getSubject() !== null) { - $fccHeaders->addHeader('Subject', $localMessage->getSubject()); - } - // The table (oc_local_messages) currently only allows for a single reply to message id - // but we already set the 'references' header for an email so we could support multiple references - // Get the previous message and then concatenate all its "References" message ids with this one - if (($inReplyTo = $localMessage->getInReplyToMessageId()) !== null) { - $fccHeaders->addHeader('References', $inReplyTo); - $fccHeaders->addHeader('In-Reply-To', $inReplyTo); - } - if ($localMessage->getRequestMdn()) { - $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses(Horde_Mime_Mdn::MDN_HEADER, $from->toHorde())); - } - - if ($localMessage->isAiGenerated()) { - $fccHeaders->addHeader(LocalMessage::HEADER_AI_GENERATED, '1'); - } - - // For SMTP delivery: strip Bcc so it never appears in the transmitted - // message (RFC 5321). All three recipient lists are passed as SMTP - // envelope recipients so every addressee still receives the mail. - $sendHeaders = clone $fccHeaders; - $sendHeaders->removeHeader('Bcc'); - - $smtpRecipients = new Horde_Mail_Rfc822_List(); - $smtpRecipients->add($to->toHorde()); - $smtpRecipients->add($cc->toHorde()); - $smtpRecipients->add($bcc->toHorde()); - $smtpRecipients->unique(); - - $mimeMessage = new MimeMessage( - new DataUriParser() - ); - $mimePart = $mimeMessage->build( - $localMessage->getBodyPlain(), - $localMessage->getBodyHtml(), - $localMessage->isPgpMime() === true, - $attachmentParts, - ); - - // TODO: add smimeEncrypt check if implemented - try { - $mimePart = $this->transmissionService->getSignMimePart($localMessage, $account, $mimePart); - $mimePart = $this->transmissionService->getEncryptMimePart($localMessage, $to, $cc, $bcc, $account, $mimePart); - } catch (ServiceException $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - return; - } - - // Send the message - try { - $mimePart->send($smtpRecipients->writeAddress(), $sendHeaders, $transport); - $localMessage->setRaw($mimePart->toString([ - 'encode' => Horde_Mime_Part::ENCODE_7BIT | Horde_Mime_Part::ENCODE_8BIT | Horde_Mime_Part::ENCODE_BINARY, - 'headers' => $fccHeaders, - 'stream' => false, - ])); - $localMessage->setStatus(LocalMessage::STATUS_RAW); - } catch (Horde_Mime_Exception $e) { - if ($e->getPrevious() instanceof Horde_Smtp_Exception) { - /** @var Horde_Smtp_Exception $previousException */ - $previousException = $e->getPrevious(); - $this->logger->error('SMTP error: ' . $e->getMessage(), [ - 'exception' => $e, - 'smtpErrorCode' => $previousException->getSmtpCode(), - ]); - } else { - $this->logger->error($e->getMessage(), ['exception' => $e]); - } - if (in_array($e->getCode(), self::RETRIABLE_CODES, true)) { - $localMessage->setStatus(LocalMessage::STATUS_SMPT_SEND_FAIL); - return; - } - - try { - $localMessage->setRaw($mimePart->toString([ - 'encode' => Horde_Mime_Part::ENCODE_7BIT | Horde_Mime_Part::ENCODE_8BIT | Horde_Mime_Part::ENCODE_BINARY, - 'headers' => $fccHeaders, - 'stream' => false, - ])); - } catch (Throwable) { - // Having the raw message is nice for troubleshooting, but should not fail hard. - } - $localMessage->setStatus(LocalMessage::STATUS_ERROR); - return; - } finally { - if ($transport instanceof Horde_Mail_Transport_Smtphorde) { - try { - $transport->getSMTPObject()->logout(); - } catch (Throwable) { - // Handle silently as this is a resource usage optimization - } - } - } - - $this->eventDispatcher->dispatchTyped( - new MessageSentEvent($account, $localMessage) - ); - } - - #[\Override] - public function saveLocalDraft(Account $account, LocalMessage $message): void { - $to = $this->transmissionService->getAddressList($message, Recipient::TYPE_TO); - $cc = $this->transmissionService->getAddressList($message, Recipient::TYPE_CC); - $bcc = $this->transmissionService->getAddressList($message, Recipient::TYPE_BCC); - $attachments = $this->transmissionService->getAttachments($message); - - $perfLogger = $this->performanceLogger->start('save local draft'); - - $from = Address::fromRaw($account->getName(), $account->getEMailAddress()); - - foreach ($attachments as $attachment) { - $this->transmissionService->handleAttachment($account, $attachment); - } - - $draftHeaders = $this->buildMimeHeaders($from, $to, $cc, $bcc, $message->getSubject()); - if ($message->isAiGenerated()) { - $draftHeaders->addHeader(LocalMessage::HEADER_AI_GENERATED, '1'); - } - - $mail = new Horde_Mime_Mail(); - if ($message->isHtml()) { - $mail->setHtmlBody($message->getBodyHtml()); - } else { - $mail->setBody($message->getBodyPlain()); - } - $perfLogger->step('build local draft message'); - - // Use a null transport to trigger MIME body encoding without sending - $client = $this->protocolFactory->imapClient($account); - try { - $mail->send(new Horde_Mail_Transport_Null(), false, false); - $perfLogger->step('create IMAP draft message'); - $draftsMailbox = $this->findOrCreateDraftsMailbox($account); - $this->messageMapper->save( - $client, - $draftsMailbox, - $mail->getBasePart()->toString([ - 'encode' => Horde_Mime_Part::ENCODE_7BIT | Horde_Mime_Part::ENCODE_8BIT | Horde_Mime_Part::ENCODE_BINARY, - 'headers' => $draftHeaders, - 'stream' => false, - ]), - [Horde_Imap_Client::FLAG_DRAFT] - ); - $perfLogger->step('save local draft 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->dispatchTyped(new DraftSavedEvent($account, null)); - $perfLogger->step('emit post local draft save event'); - - $perfLogger->end(); - } - - private function findOrCreateDraftsMailbox(Account $account): Mailbox { - $draftsMailboxId = $account->getMailAccount()->getDraftsMailboxId(); - - if ($draftsMailboxId === null) { - return $this->mailManager->createMailbox( - $account, - 'Drafts', - [Horde_Imap_Client::SPECIALUSE_DRAFTS] - ); - } - - return $this->mailboxMapper->findById($draftsMailboxId); - } - - /** - * @param NewMessageData $message - * @param Message|null $previousDraft - * - * @return array - * - * @throws ClientException - * @throws ServiceException - */ - #[\Override] - 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(); - $from = Address::fromRaw($account->getName(), $account->getEMailAddress()); - - $draftHeaders = $this->buildMimeHeaders( - $from, - $message->getTo(), - $message->getCc(), - $message->getBcc(), - $message->getSubject() - ); - - $mail = new Horde_Mime_Mail(); - if ($message->isHtml()) { - $mail->setHtmlBody($message->getBody()); - } else { - $mail->setBody($message->getBody()); - } - $perfLogger->step('build draft message'); - - // Use a null transport to trigger MIME body encoding without sending - $client = $this->protocolFactory->imapClient($account); - try { - $mail->send(new Horde_Mail_Transport_Null(), false, false); - $perfLogger->step('create IMAP message'); - // save the message in the drafts mailbox - $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->getBasePart()->toString([ - 'encode' => Horde_Mime_Part::ENCODE_7BIT | Horde_Mime_Part::ENCODE_8BIT | Horde_Mime_Part::ENCODE_BINARY, - 'headers' => $draftHeaders, - 'stream' => false, - ]), - [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 buildMimeHeaders(Address $from, AddressList $to, AddressList $cc, AddressList $bcc, ?string $subject): Horde_Mime_Headers { - $headers = new Horde_Mime_Headers(); - $headers->addHeaderOb(Horde_Mime_Headers_Date::create()); - $headers->addHeaderOb(Horde_Mime_Headers_MessageId::create()); - $headers->addHeaderOb(new Horde_Mime_Headers_Addresses('From', $from->toHorde())); - $headers->addHeaderOb(new Horde_Mime_Headers_Addresses('To', $to->toHorde())); - if (count($cc) > 0) { - $headers->addHeaderOb(new Horde_Mime_Headers_Addresses('Cc', $cc->toHorde())); - } - if (count($bcc) > 0) { - $headers->addHeaderOb(new Horde_Mime_Headers_Addresses('Bcc', $bcc->toHorde())); - } - if ($subject !== null) { - $headers->addHeader('Subject', $subject); - } - return $headers; - } - - #[\Override] - 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->protocolFactory->imapClient($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."); - } - - $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); - } - } - -} diff --git a/lib/Service/OutboxService.php b/lib/Service/OutboxService.php index 530f3218ab..39661f6213 100644 --- a/lib/Service/OutboxService.php +++ b/lib/Service/OutboxService.php @@ -16,7 +16,6 @@ use OCA\Mail\Events\OutboxMessageCreatedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Send\Chain; use OCA\Mail\Service\Attachment\AttachmentService; use OCP\AppFramework\Db\DoesNotExistException; @@ -38,7 +37,6 @@ public function __construct( private LocalMessageMapper $mapper, private AttachmentService $attachmentService, IEventDispatcher $eventDispatcher, - private ProtocolFactory $protocolFactory, private MailManager $mailManager, private AccountService $accountService, ITimeFactory $timeFactory, @@ -117,12 +115,7 @@ public function saveMessage(Account $account, LocalMessage $message, array $to, return $message; } - $client = $this->protocolFactory->imapClient($account); - try { - $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); - } finally { - $client->logout(); - } + $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments); $message->setAttachments($this->attachmentService->saveLocalMessageAttachments($account->getUserId(), $message->getId(), $attachmentIds)); return $message; @@ -149,12 +142,7 @@ public function updateMessage(Account $account, LocalMessage $message, array $to return $message; } - $client = $this->protocolFactory->imapClient($account); - try { - $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); - } finally { - $client->logout(); - } + $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments); $message->setAttachments($this->attachmentService->updateLocalMessageAttachments($account->getUserId(), $message, $attachmentIds)); return $message; } diff --git a/lib/Service/TransmissionService.php b/lib/Service/TransmissionService.php index 6832e920d3..a033e300af 100644 --- a/lib/Service/TransmissionService.php +++ b/lib/Service/TransmissionService.php @@ -8,7 +8,6 @@ namespace OCA\Mail\Service; -use Horde_Mime_Part; use OCA\Mail\Account; use OCA\Mail\Address; use OCA\Mail\AddressList; @@ -16,11 +15,7 @@ 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 { @@ -29,7 +24,6 @@ public function __construct( private GroupsIntegration $groupsIntegration, private AttachmentService $attachmentService, private LoggerInterface $logger, - private SmimeService $smimeService, ) { } @@ -62,140 +56,28 @@ public function getAttachments(LocalMessage $message): array { } /** - * @param Account $account - * @param array $attachment - * @return \Horde_Mime_Part|null + * @return array{content: string, type: string, name: string, disposition: ?string, contentId: ?string}|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'); + public function getAttachmentContent(Account $account, array $attachmentRef): ?array { + if (!isset($attachmentRef['id'])) { + $this->logger->warning('Ignoring local attachment reference without an id', ['ref' => $attachmentRef]); return null; } try { - [$localAttachment, $file] = $this->attachmentService->getAttachment($account->getMailAccount()->getUserId(), (int)$attachment['id']); - $part = new Horde_Mime_Part(); - $part->setCharset('us-ascii'); - - if ($localAttachment->isDispositionAttachmentOrInline()) { - $part->setDisposition($localAttachment->getDisposition()); - /* - * Setting a name implicitly adds a Content-Disposition header in Horde, - * which would override the intentional omission. Only set it for attachment/inline dispositions. - */ - $part->setName($localAttachment->getFileName()); - } - - if ($localAttachment->getContentId() !== null) { - $part->setContentId($localAttachment->getContentId()); - } - - $part->setContents($file->getContent()); - /* - * Horde_Mime_Part.setType takes the mimetype (e.g. text/calendar) - * and discards additional parameters (like method=REQUEST). - * - * $part->setType('text/calendar; method=REQUEST') - * $part->getType() => text/calendar - */ - $contentTypeHeader = \Horde_Mime_Headers_ContentParam_ContentType::create(); - $contentTypeHeader->decode($localAttachment->getMimeType()); - - $part->setType($contentTypeHeader->value); - foreach ($contentTypeHeader->params as $label => $data) { - $part->setContentTypeParameter($label, $data); - } - - return $part; + [$attachment, $file] = $this->attachmentService->getAttachment($account->getMailAccount()->getUserId(), (int)$attachmentRef['id']); } 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; + return [ + 'content' => $file->getContent(), + 'type' => $attachment->getMimeType(), + 'name' => $attachment->getFileName(), + 'disposition' => $attachment->getDisposition(), + 'contentId' => $attachment->getContentId(), + ]; } } diff --git a/tests/Integration/Service/DraftServiceIntegrationTest.php b/tests/Integration/Service/DraftServiceIntegrationTest.php index 5f9e9d564e..c7f0854cf1 100644 --- a/tests/Integration/Service/DraftServiceIntegrationTest.php +++ b/tests/Integration/Service/DraftServiceIntegrationTest.php @@ -12,12 +12,11 @@ use ChristophWurst\Nextcloud\Testing\TestUser; use OCA\Mail\Account; use OCA\Mail\Contracts\IAttachmentService; -use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\LocalAttachmentMapper; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\MailAccount; -use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\Attachment\AttachmentService; @@ -57,9 +56,6 @@ class DraftServiceIntegrationTest extends TestCase { /** @var IAttachmentService */ private $attachmentService; - /** @var IMailTransmission */ - private $transmission; - /** @var OutboxService */ private $service; @@ -98,7 +94,6 @@ protected function setUp(): void { Server::get(LocalAttachmentMapper::class), Server::get(AttachmentStorage::class), $mailManager, - Server::get(MessageMapper::class), Server::get(ICacheFactory::class), Server::get(IURLGenerator::class), Server::get(IMimeTypeDetector::class), @@ -107,7 +102,6 @@ protected function setUp(): void { ); $this->client = $this->getClient($this->account); $this->mapper = Server::get(LocalMessageMapper::class); - $this->transmission = Server::get(IMailTransmission::class); $this->eventDispatcher = Server::get(IEventDispatcher::class); $this->clientFactory = Server::get(ProtocolFactory::class); $this->accountService = $this->createMock(AccountService::class); @@ -119,12 +113,12 @@ protected function setUp(): void { $delete->executeStatement(); $this->service = new DraftsService( - $this->transmission, $this->mapper, $this->attachmentService, $this->eventDispatcher, $this->clientFactory, $mailManager, + Server::get(MailboxMapper::class), $this->createMock(LoggerInterface::class), $this->accountService, $this->timeFactory diff --git a/tests/Integration/Service/MailTransmissionIntegrationTest.php b/tests/Integration/Service/MailTransmissionIntegrationTest.php index 70013d9cac..8860255fb8 100644 --- a/tests/Integration/Service/MailTransmissionIntegrationTest.php +++ b/tests/Integration/Service/MailTransmissionIntegrationTest.php @@ -13,7 +13,6 @@ use OC; use OCA\Mail\Account; use OCA\Mail\Contracts\IAttachmentService; -use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\MailAccount; @@ -23,29 +22,13 @@ use OCA\Mail\Db\Recipient; use OCA\Mail\Db\RecipientMapper; use OCA\Mail\IMAP\MailboxSync; -use OCA\Mail\IMAP\MessageMapper; -use OCA\Mail\Model\NewMessageData; -use OCA\Mail\Protocol\ProtocolFactory; -use OCA\Mail\Send\AntiAbuseHandler; use OCA\Mail\Send\Chain; -use OCA\Mail\Send\CopySentMessageHandler; -use OCA\Mail\Send\FlagRepliedMessageHandler; -use OCA\Mail\Send\SendHandler; -use OCA\Mail\Send\SentMailboxHandler; -use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Attachment\UploadedFile; -use OCA\Mail\Service\MailManager; -use OCA\Mail\Service\MailTransmission; -use OCA\Mail\Service\TransmissionService; -use OCA\Mail\SMTP\SmtpClientFactory; -use OCA\Mail\Support\PerformanceLogger; use OCA\Mail\Tests\Integration\Framework\ImapTest; use OCA\Mail\Tests\Integration\TestCase; -use OCP\EventDispatcher\IEventDispatcher; use OCP\IUser; use OCP\Security\ICrypto; use OCP\Server; -use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; class MailTransmissionIntegrationTest extends TestCase { @@ -61,8 +44,6 @@ class MailTransmissionIntegrationTest extends TestCase { /** @var IAttachmentService */ private $attachmentService; - /** @var IMailTransmission */ - private $transmission; private Chain $chain; private LocalMessageMapper $localMessageMapper; @@ -121,28 +102,7 @@ protected function setUp(): void { $mbSync = Server::get(MailboxSync::class); $mbSync->sync($this->account, new NullLogger(), true); - $this->chain = new Chain( - Server::get(SentMailboxHandler::class), - Server::get(AntiAbuseHandler::class), - Server::get(SendHandler::class), - Server::get(CopySentMessageHandler::class), - Server::get(FlagRepliedMessageHandler::class), - $this->attachmentService, - $this->localMessageMapper, - Server::get(ProtocolFactory::class), - ); - - $this->transmission = new MailTransmission(Server::get(ProtocolFactory::class), - Server::get(SmtpClientFactory::class), - Server::get(IEventDispatcher::class), - Server::get(MailboxMapper::class), - Server::get(MessageMapper::class), - Server::get(LoggerInterface::class), - Server::get(PerformanceLogger::class), - Server::get(AliasesService::class), - Server::get(TransmissionService::class), - Server::get(MailManager::class) - ); + $this->chain = Server::get(Chain::class); } public function testSendMail() { @@ -242,26 +202,4 @@ public function testSendReplyWithoutReplySubject() { $this->assertMailboxExists('Sent'); $this->assertMessageCount(1, 'Sent'); } - - public function testSaveNewDraft() { - $message = NewMessageData::fromRequest($this->account, 'greetings', 'hello there', 'recipient@domain.com', null, null, [], false); - [,,$uid] = $this->transmission->saveDraft($message); - // There should be a new mailbox … - $this->assertMailboxExists('Drafts'); - // … and it should have exactly one message … - $this->assertMessageCount(1, 'Drafts'); - // … and the correct content - $this->assertMessageContent('Drafts', $uid, 'hello there'); - } - - public function testReplaceDraft() { - $message1 = NewMessageData::fromRequest($this->account, 'greetings', 'hello t', 'recipient@domain.com', null, null, []); - [,,$uid] = $this->transmission->saveDraft($message1); - $message2 = NewMessageData::fromRequest($this->account, 'greetings', 'hello there', 'recipient@domain.com', null, null, []); - $previous = new Message(); - $previous->setUid($uid); - $this->transmission->saveDraft($message2, $previous); - - $this->assertMessageCount(1, 'Drafts'); - } } diff --git a/tests/Integration/Service/OutboxServiceIntegrationTest.php b/tests/Integration/Service/OutboxServiceIntegrationTest.php index 0bef06dc14..5218479504 100644 --- a/tests/Integration/Service/OutboxServiceIntegrationTest.php +++ b/tests/Integration/Service/OutboxServiceIntegrationTest.php @@ -19,7 +19,6 @@ use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\MessageMapper; -use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Send\Chain; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\Attachment\AttachmentService; @@ -65,9 +64,6 @@ class OutboxServiceIntegrationTest extends TestCase { /** @var IEventDispatcher */ private $eventDispatcher; - /** @var ProtocolFactory */ - private $clientFactory; - /** @var LocalMessageMapper */ private $mapper; @@ -99,7 +95,6 @@ protected function setUp(): void { Server::get(LocalAttachmentMapper::class), Server::get(AttachmentStorage::class), $mailManager, - Server::get(\OCA\Mail\IMAP\MessageMapper::class), Server::get(ICacheFactory::class), Server::get(IURLGenerator::class), Server::get(IMimeTypeDetector::class), @@ -109,7 +104,6 @@ protected function setUp(): void { $this->client = $this->getClient($this->account); $this->mapper = Server::get(LocalMessageMapper::class); $this->eventDispatcher = Server::get(IEventDispatcher::class); - $this->clientFactory = Server::get(ProtocolFactory::class); $this->accountService = Server::get(AccountService::class); $this->timeFactory = Server::get(ITimeFactory::class); $this->chain = Server::get(Chain::class); @@ -123,7 +117,6 @@ protected function setUp(): void { $this->mapper, $this->attachmentService, $this->eventDispatcher, - $this->clientFactory, $mailManager, $this->accountService, $this->timeFactory, diff --git a/tests/Unit/Controller/AccountsControllerTest.php b/tests/Unit/Controller/AccountsControllerTest.php index 873a4777eb..3e3df4af23 100644 --- a/tests/Unit/Controller/AccountsControllerTest.php +++ b/tests/Unit/Controller/AccountsControllerTest.php @@ -12,20 +12,16 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Controller\AccountsController; use OCA\Mail\Db\MailAccount; -use OCA\Mail\Db\Mailbox; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\DelegationForbiddenException; use OCA\Mail\IMAP\MailboxSync; -use OCA\Mail\IMAP\Sync\Response; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\DelegationService; use OCA\Mail\Service\MailManager; use OCA\Mail\Service\SetupService; -use OCA\Mail\Service\Sync\SyncService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; @@ -69,18 +65,12 @@ class AccountsControllerTest extends TestCase { /** @var AliasesService|MockObject */ private $aliasesService; - /** @var IMailTransmission|MockObject */ - private $transmission; - /** @var SetupService|MockObject */ private $setupService; /** @var MailManager|MockObject */ private $mailManager; - /** @var SyncService|MockObject */ - private $syncService; - /** @var MailboxSync|MockObject */ private $mailboxSync; @@ -108,10 +98,8 @@ protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); $this->l10n = $this->createMock(IL10N::class); $this->aliasesService = $this->createMock(AliasesService::class); - $this->transmission = $this->createMock(IMailTransmission::class); $this->setupService = $this->createMock(SetupService::class); $this->mailManager = $this->createMock(MailManager::class); - $this->syncService = $this->createMock(SyncService::class); $this->mailboxSync = $this->createMock(mailboxSync::class); $this->config = $this->createMock(IConfig::class); $this->appConfig = $this->createMock(IAppConfig::class); @@ -130,10 +118,8 @@ protected function setUp(): void { $this->logger, $this->l10n, $this->aliasesService, - $this->transmission, $this->setupService, $this->mailManager, - $this->syncService, $this->config, $this->hostValidator, $this->mailboxSync, @@ -393,42 +379,6 @@ public function draftDataProvider(): array { ]; } - public function testDraft(): void { - $subject = 'Hello'; - $body = 'Hi!'; - $to = 'user1@example.com'; - $cc = '"user2" , user3@example.com'; - $bcc = 'user4@example.com'; - $id = 123; - $newId = 1245; - $newUid = 124; - $account = $this->createStub(Account::class); - $mailbox = new Mailbox(); - $this->accountService->expects(self::once()) - ->method('find') - ->with($this->userId, $this->accountId) - ->will(self::returnValue($this->account)); - $this->transmission->expects(self::once()) - ->method('saveDraft') - ->willReturn([$account, $mailbox, $newUid]); - $this->mailManager->expects(self::once()) - ->method('getMessageIdForUid') - ->willReturn($newId); - $this->syncService->expects(self::once()) - ->method('syncMailbox') - ->willReturn(new Response([], [], [])); - $this->delegationService->expects(self::once()) - ->method('logDelegatedAction') - ->with($this->userId, $this->userId, "$this->userId saved draft in account <$this->accountId> on behalf of $this->userId"); - - $actual = $this->controller->draft($this->accountId, $subject, $body, $to, $cc, $bcc, true, $id); - - $expected = new JSONResponse([ - 'id' => $newId, - ]); - self::assertEquals($expected, $actual); - } - public function testPatchAccountLogsDelegatedAction(): void { $mailAccount = new MailAccount(); $mailAccount->setId($this->accountId); diff --git a/tests/Unit/Controller/MessagesControllerTest.php b/tests/Unit/Controller/MessagesControllerTest.php index 770a7e6ab7..c716945e4f 100644 --- a/tests/Unit/Controller/MessagesControllerTest.php +++ b/tests/Unit/Controller/MessagesControllerTest.php @@ -19,7 +19,6 @@ use OCA\Mail\Attachment; use OCA\Mail\Contracts\IDkimService; use OCA\Mail\Contracts\IMailSearch; -use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Contracts\ITrustedSenderService; use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Controller\MessagesController; @@ -34,6 +33,7 @@ use OCA\Mail\Http\HtmlResponse; use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Model\Message; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\DelegationService; @@ -113,8 +113,8 @@ class MessagesControllerTest extends TestCase { /** @var MockObject|ITrustedSenderService */ private $trustedSenderService; - /** @var MockObject|IMailTransmission */ - private $mailTransmission; + /** @var MockObject|ProtocolFactory */ + private $protocolFactory; /** @var ITimeFactory */ private $oldFactory; @@ -153,7 +153,7 @@ protected function setUp(): void { $this->urlGenerator = $this->createMock(IURLGenerator::class); $this->nonceManager = $this->createMock(ContentSecurityPolicyNonceManager::class); $this->trustedSenderService = $this->createMock(ITrustedSenderService::class); - $this->mailTransmission = $this->createMock(IMailTransmission::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->smimeService = $this->createMock(SmimeService::class); $this->dkimService = $this->createMock(IDkimService::class); $this->userPreferences = $this->createMock(IUserPreferences::class); @@ -195,7 +195,7 @@ protected function setUp(): void { $this->urlGenerator, $this->nonceManager, $this->trustedSenderService, - $this->mailTransmission, + $this->protocolFactory, $this->smimeService, $this->dkimService, $this->userPreferences, @@ -1392,7 +1392,7 @@ public function testSmartReplyNoUser(): void { $this->urlGenerator, $this->nonceManager, $this->trustedSenderService, - $this->mailTransmission, + $this->protocolFactory, $this->smimeService, $this->dkimService, $this->userPreferences, diff --git a/tests/Unit/IMAP/ImapTransmissionConnectorTest.php b/tests/Unit/IMAP/ImapTransmissionConnectorTest.php new file mode 100644 index 0000000000..98d03678bf --- /dev/null +++ b/tests/Unit/IMAP/ImapTransmissionConnectorTest.php @@ -0,0 +1,492 @@ +protocolFactory = $this->createMock(ProtocolFactory::class); + $this->transmissionService = $this->createMock(TransmissionService::class); + $this->aliasesService = $this->createMock(AliasesService::class); + $this->smtpClientFactory = $this->createMock(SmtpClientFactory::class); + $this->mimeMessage = $this->createMock(MimeMessage::class); + $this->messageMapper = $this->createMock(MessageMapper::class); + $this->mailboxMapper = $this->createMock(MailboxMapper::class); + $this->performanceLogger = $this->createMock(PerformanceLogger::class); + $this->smimeService = $this->createMock(SmimeService::class); + $this->attachmentService = $this->createMock(AttachmentService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->connector = new ImapTransmissionConnector( + $this->protocolFactory, + $this->transmissionService, + $this->aliasesService, + $this->smtpClientFactory, + $this->mimeMessage, + $this->messageMapper, + $this->mailboxMapper, + $this->performanceLogger, + $this->smimeService, + $this->attachmentService, + $this->logger, + ); + } + + /** + * @return mixed + */ + private function callPrivate(string $method, array $args) { + $reflection = new ReflectionMethod(ImapTransmissionConnector::class, $method); + $reflection->setAccessible(true); + return $reflection->invoke($this->connector, ...$args); + } + + public function testBuildAttachmentMimePart(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + + $attachment = new LocalAttachment(); + $attachment->setFileName('test.txt'); + $attachment->setMimeType('text/plain'); + $attachment->setDisposition(LocalAttachment::DISPOSITION_ATTACHMENT); + + $file = new InMemoryFile( + 'test.txt', + "Hello, I'm a test file." + ); + + $this->attachmentService->expects(self::once()) + ->method('getAttachment') + ->willReturn([$attachment, $file]); + $this->logger->expects(self::never()) + ->method('warning'); + + $part = $this->callPrivate('buildAttachmentMimePart', [$account, ['id' => 1, 'type' => 'local']]); + + $this->assertEquals('test.txt', $part->getContentTypeParameter('name')); + } + + public function testBuildAttachmentMimePartInlineWithContentId(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + + $attachment = new LocalAttachment(); + $attachment->setFileName('logo.png'); + $attachment->setMimeType('image/png'); + $attachment->setDisposition(LocalAttachment::DISPOSITION_INLINE); + $attachment->setContentId('img001@example.com'); + + $file = new InMemoryFile('logo.png', 'fake png content'); + + $this->attachmentService->expects(self::once()) + ->method('getAttachment') + ->willReturn([$attachment, $file]); + + $part = $this->callPrivate('buildAttachmentMimePart', [$account, ['id' => 1, 'type' => 'local']]); + + $this->assertEquals('inline', $part->getDisposition()); + $this->assertEquals('img001@example.com', $part->getContentId()); + $this->assertEquals('logo.png', $part->getContentTypeParameter('name')); + } + + public function testBuildAttachmentMimePartNoId(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + + $this->logger->expects(self::once()) + ->method('warning'); + + $this->callPrivate('buildAttachmentMimePart', [$account, ['type' => 'local']]); + } + + public function testBuildAttachmentMimePartNotFound(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + + $this->attachmentService->expects(self::once()) + ->method('getAttachment') + ->willThrowException(new AttachmentNotFoundException()); + $this->logger->expects(self::once()) + ->method('warning'); + + $this->callPrivate('buildAttachmentMimePart', [$account, ['id' => 1, 'type' => 'local']]); + } + + public function testBuildAttachmentMimePartKeepAdditionalContentTypeParameters(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + $attachment = new LocalAttachment(); + $attachment->setFileName(null); + $attachment->setMimeType('text/calendar; method=REQUEST; charset="utf-8"; name=event.ics'); + // iMIP attachments must not carry a Content-Disposition header. + // See https://github.com/nextcloud/mail/issues/10416 + $attachment->setDisposition(LocalAttachment::DISPOSITION_OMIT); + $file = new InMemoryFile( + 'event.ics', + "BEGIN:VCALENDAR\nEND:VCALENDAR" + ); + $this->attachmentService->expects(self::once()) + ->method('getAttachment') + ->willReturn([$attachment, $file]); + + $part = $this->callPrivate('buildAttachmentMimePart', [$account, ['id' => 1, 'type' => 'local']]); + + $this->assertEquals('text/calendar', $part->getType()); + $this->assertEquals('REQUEST', $part->getContentTypeParameter('method')); + $this->assertEquals('utf-8', $part->getContentTypeParameter('charset')); + $this->assertEquals('event.ics', $part->getContentTypeParameter('name')); + } + + public function testBuildAttachmentMimePartImipOmitsContentDisposition(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + $attachment = new LocalAttachment(); + $attachment->setFileName(null); + $attachment->setMimeType('text/calendar; method=REQUEST; charset="utf-8"; name=event.ics'); + // iMIP attachments must not carry a Content-Disposition header. + // See https://github.com/nextcloud/mail/issues/10416 + $attachment->setDisposition(LocalAttachment::DISPOSITION_OMIT); + $file = new InMemoryFile( + 'event.ics', + "BEGIN:VCALENDAR\nEND:VCALENDAR" + ); + $this->attachmentService->expects(self::once()) + ->method('getAttachment') + ->willReturn([$attachment, $file]); + + $part = $this->callPrivate('buildAttachmentMimePart', [$account, ['id' => 1, 'type' => 'local']]); + + $this->assertEquals('', $part->getDisposition()); + } + + public function testApplySmimeSignature() { + $send = new \Horde_Mime_Part(); + $send->setContents('Test'); + $localMessage = new LocalMessage(); + $localMessage->setSmimeSign(true); + $localMessage->setSmimeCertificateId(1); + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + $smimeCertificate = new SmimeCertificate(); + $smimeCertificate->setCertificate('123'); + + $this->smimeService->expects(self::once()) + ->method('findCertificate') + ->willReturn($smimeCertificate); + $this->smimeService->expects(self::once()) + ->method('signMimePart'); + + $this->callPrivate('applySmimeSignature', [$localMessage, $account, $send]); + $this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus()); + } + + public function testApplySmimeSignatureNoCertId() { + $send = new \Horde_Mime_Part(); + $send->setContents('Test'); + $localMessage = new LocalMessage(); + $localMessage->setSmimeSign(true); + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + + $this->smimeService->expects(self::never()) + ->method('findCertificate'); + $this->smimeService->expects(self::never()) + ->method('signMimePart'); + + $this->expectException(ServiceException::class); + $this->callPrivate('applySmimeSignature', [$localMessage, $account, $send]); + $this->assertEquals(LocalMessage::STATUS_SMIME_SIGN_NO_CERT_ID, $localMessage->getStatus()); + } + + public function testApplySmimeSignatureNoCertFound() { + $send = new \Horde_Mime_Part(); + $send->setContents('Test'); + $localMessage = new LocalMessage(); + $localMessage->setSmimeSign(true); + $localMessage->setSmimeCertificateId(1); + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + + $this->smimeService->expects(self::once()) + ->method('findCertificate') + ->willThrowException(new DoesNotExistException('')); + $this->smimeService->expects(self::never()) + ->method('signMimePart'); + + $this->expectException(ServiceException::class); + $this->callPrivate('applySmimeSignature', [$localMessage, $account, $send]); + $this->assertEquals(LocalMessage::STATUS_SMIME_SIGN_CERT, $localMessage->getStatus()); + } + + public function testApplySmimeSignatureFailedSigning() { + $send = new \Horde_Mime_Part(); + $send->setContents('Test'); + $localMessage = new LocalMessage(); + $localMessage->setSmimeSign(true); + $localMessage->setSmimeCertificateId(1); + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + $smimeCertificate = new SmimeCertificate(); + $smimeCertificate->setCertificate('123'); + + $this->smimeService->expects(self::once()) + ->method('findCertificate') + ->willReturn($smimeCertificate); + $this->smimeService->expects(self::once()) + ->method('signMimePart') + ->willThrowException(new SmimeSignException()); + + $this->expectException(ServiceException::class); + $this->callPrivate('applySmimeSignature', [$localMessage, $account, $send]); + $this->assertEquals(LocalMessage::STATUS_SMIME_SIGN_FAIL, $localMessage->getStatus()); + } + + public function testApplySmimeEncryption() { + $send = new \Horde_Mime_Part(); + $send->setContents('Test'); + $localMessage = new LocalMessage(); + $localMessage->setSmimeEncrypt(true); + $localMessage->setSmimeCertificateId(1); + $to = new AddressList([Address::fromRaw('Bob', 'bob@test.com')]); + $cc = new AddressList([]); + $bcc = new AddressList([]); + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + $smimeCertificate = new SmimeCertificate(); + $smimeCertificate->setCertificate('123'); + + $this->smimeService->expects(self::once()) + ->method('findCertificatesByAddressList') + ->willReturn([$smimeCertificate]); + $this->smimeService->expects(self::once()) + ->method('findCertificate') + ->willReturn($smimeCertificate); + $this->smimeService->expects(self::once()) + ->method('encryptMimePart'); + + $this->callPrivate('applySmimeEncryption', [$localMessage, $to, $cc, $bcc, $account, $send]); + $this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus()); + } + + public function testApplySmimeEncryptionNoCertId() { + $send = new \Horde_Mime_Part(); + $send->setContents('Test'); + $localMessage = new LocalMessage(); + $localMessage->setSmimeEncrypt(true); + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + $to = new AddressList([Address::fromRaw('Bob', 'bob@test.com')]); + $cc = new AddressList([]); + $bcc = new AddressList([]); + + $this->expectException(ServiceException::class); + $this->callPrivate('applySmimeEncryption', [$localMessage, $to, $cc, $bcc, $account, $send]); + $this->assertEquals(LocalMessage::STATUS_SMIME_ENCRYPT_NO_CERT_ID, $localMessage->getStatus()); + } + + public function testApplySmimeEncryptionNoAddressCerts() { + $send = new \Horde_Mime_Part(); + $send->setContents('Test'); + $localMessage = new LocalMessage(); + $localMessage->setSmimeEncrypt(true); + $localMessage->setSmimeCertificateId(1); + $to = new AddressList([Address::fromRaw('Bob', 'bob@test.com')]); + $cc = new AddressList([]); + $bcc = new AddressList([]); + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + + $this->smimeService->expects(self::once()) + ->method('findCertificatesByAddressList') + ->willThrowException(new ServiceException()); + $this->smimeService->expects(self::never()) + ->method('findCertificate'); + $this->smimeService->expects(self::never()) + ->method('encryptMimePart'); + + $this->expectException(ServiceException::class); + $this->callPrivate('applySmimeEncryption', [$localMessage, $to, $cc, $bcc, $account, $send]); + $this->assertEquals(LocalMessage::STATUS_SMIME_ENCRYT_FAIL, $localMessage->getStatus()); + } + + public function testApplySmimeEncryptionNoCert() { + $send = new \Horde_Mime_Part(); + $send->setContents('Test'); + $localMessage = new LocalMessage(); + $localMessage->setSmimeEncrypt(true); + $localMessage->setSmimeCertificateId(1); + $to = new AddressList([Address::fromRaw('Bob', 'bob@test.com')]); + $cc = new AddressList([]); + $bcc = new AddressList([]); + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + $smimeCertificate = new SmimeCertificate(); + $smimeCertificate->setCertificate('123'); + + $this->smimeService->expects(self::once()) + ->method('findCertificatesByAddressList') + ->willReturn([$smimeCertificate]); + $this->smimeService->expects(self::once()) + ->method('findCertificate') + ->willThrowException(new DoesNotExistException('')); + $this->smimeService->expects(self::never()) + ->method('encryptMimePart'); + + $this->expectException(ServiceException::class); + $this->callPrivate('applySmimeEncryption', [$localMessage, $to, $cc, $bcc, $account, $send]); + $this->assertEquals(LocalMessage::STATUS_SMIME_ENCRYPT_CERT, $localMessage->getStatus()); + } + + public function testApplySmimeEncryptionEncryptFail() { + $send = new \Horde_Mime_Part(); + $send->setContents('Test'); + $localMessage = new LocalMessage(); + $localMessage->setSmimeEncrypt(true); + $localMessage->setSmimeCertificateId(1); + $to = new AddressList([Address::fromRaw('Bob', 'bob@test.com')]); + $cc = new AddressList([]); + $bcc = new AddressList([]); + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + $smimeCertificate = new SmimeCertificate(); + $smimeCertificate->setCertificate('123'); + + $this->smimeService->expects(self::once()) + ->method('findCertificatesByAddressList') + ->willReturn([$smimeCertificate]); + $this->smimeService->expects(self::once()) + ->method('findCertificate') + ->willReturn($smimeCertificate); + $this->smimeService->expects(self::once()) + ->method('encryptMimePart') + ->willThrowException(new ServiceException()); + + $this->expectException(ServiceException::class); + $this->callPrivate('applySmimeEncryption', [$localMessage, $to, $cc, $bcc, $account, $send]); + $this->assertEquals(LocalMessage::STATUS_SMIME_ENCRYT_FAIL, $localMessage->getStatus()); + } + + public function testSaveMessageIncludesAttachments(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + $mailbox = new Mailbox(); + $mailbox->setName('Drafts'); + + $localMessage = new LocalMessage(); + $localMessage->setSubject('Hello'); + $localMessage->setBodyPlain('Body'); + $localMessage->setHtml(false); + + $recipient = new Recipient(); + $recipient->setLabel('Bob'); + $recipient->setEmail('bob@test.com'); + $recipient->setType(Recipient::TYPE_TO); + + $this->transmissionService->method('getAddressList') + ->willReturnCallback(function (LocalMessage $message, int $type) use ($recipient) { + if ($type === Recipient::TYPE_TO) { + return new AddressList([Address::fromRaw($recipient->getLabel(), $recipient->getEmail())]); + } + return new AddressList([]); + }); + $this->transmissionService->method('getAttachments') + ->willReturn([['type' => 'local', 'id' => 1]]); + + $attachment = new LocalAttachment(); + $attachment->setFileName('test.txt'); + $attachment->setMimeType('text/plain'); + $attachment->setDisposition(LocalAttachment::DISPOSITION_ATTACHMENT); + $file = new InMemoryFile('test.txt', 'Attachment contents'); + $this->attachmentService->expects(self::once()) + ->method('getAttachment') + ->willReturn([$attachment, $file]); + + $this->performanceLogger->method('start') + ->willReturn($this->createMock(PerformanceLoggerTask::class)); + $this->protocolFactory->method('imapClient') + ->willReturn($this->createMock(Horde_Imap_Client_Socket::class)); + + $capturedRaw = null; + $this->messageMapper->expects(self::once()) + ->method('save') + ->willReturnCallback(function ($client, $mailboxArg, $raw) use (&$capturedRaw) { + $capturedRaw = $raw; + return null; + }); + + $this->connector->saveMessage($account, $mailbox, $localMessage); + + $this->assertNotNull($capturedRaw); + $this->assertStringContainsString('test.txt', $capturedRaw); + $this->assertStringContainsString('Attachment contents', $capturedRaw); + } +} diff --git a/tests/Unit/JMAP/JmapTransmissionConnectorTest.php b/tests/Unit/JMAP/JmapTransmissionConnectorTest.php new file mode 100644 index 0000000000..e29ae863e4 --- /dev/null +++ b/tests/Unit/JMAP/JmapTransmissionConnectorTest.php @@ -0,0 +1,140 @@ +jmapOperationsService = $this->createMock(JmapOperationsService::class); + $this->transmissionService = $this->createMock(TransmissionService::class); + $this->aliasesService = $this->createMock(AliasesService::class); + $this->mailboxMapper = $this->createMock(MailboxMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->connector = new JmapTransmissionConnector( + $this->jmapOperationsService, + $this->transmissionService, + $this->aliasesService, + $this->mailboxMapper, + $this->logger, + ); + } + + public function testSaveMessageIncludesAttachment(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + + $mailbox = new Mailbox(); + $mailbox->setId(9); + $mailbox->setRemoteId('mbox-remote-1'); + + $message = new LocalMessage(); + $message->setSubject('Test'); + $message->setBodyPlain('body'); + $message->setRecipients([]); + + $attachmentRef = ['type' => 'local', 'id' => 1]; + $attachmentContent = [ + 'content' => 'file content', + 'type' => 'text/plain', + 'name' => 'test.txt', + 'disposition' => 'attachment', + 'contentId' => null, + ]; + + $this->transmissionService->method('getAddressList')->willReturn(new AddressList([])); + $this->transmissionService->expects(self::once()) + ->method('getAttachments') + ->with($message) + ->willReturn([$attachmentRef]); + $this->transmissionService->expects(self::once()) + ->method('getAttachmentContent') + ->with($account, $attachmentRef) + ->willReturn($attachmentContent); + + $this->jmapOperationsService->expects(self::once()) + ->method('connect') + ->with($account); + + $savedAttachments = null; + $this->jmapOperationsService->expects(self::once()) + ->method('entitySave') + ->willReturnCallback(function (MailParametersRequest $emailParams, array $attachments) use (&$savedAttachments) { + $savedAttachments = $attachments; + return 'remote-id'; + }); + + $this->connector->saveMessage($account, $mailbox, $message, ['$draft']); + + $this->assertEquals([$attachmentContent], $savedAttachments); + } + + public function testSaveMessageSkipsMissingAttachment(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + + $mailbox = new Mailbox(); + $mailbox->setId(9); + $mailbox->setRemoteId('mbox-remote-1'); + + $message = new LocalMessage(); + $message->setSubject('Test'); + $message->setBodyPlain('body'); + $message->setRecipients([]); + + $attachmentRef = ['type' => 'local', 'id' => 1]; + + $this->transmissionService->method('getAddressList')->willReturn(new AddressList([])); + $this->transmissionService->expects(self::once()) + ->method('getAttachments') + ->willReturn([$attachmentRef]); + $this->transmissionService->expects(self::once()) + ->method('getAttachmentContent') + ->willReturn(null); + + $savedAttachments = null; + $this->jmapOperationsService->expects(self::once()) + ->method('entitySave') + ->willReturnCallback(function (MailParametersRequest $emailParams, array $attachments) use (&$savedAttachments) { + $savedAttachments = $attachments; + return 'remote-id'; + }); + + $this->connector->saveMessage($account, $mailbox, $message, ['$draft']); + + $this->assertEquals([], $savedAttachments); + } +} diff --git a/tests/Unit/Listener/DeleteDraftListenerTest.php b/tests/Unit/Listener/DeleteDraftListenerTest.php index 7f205423b4..209003d6e2 100644 --- a/tests/Unit/Listener/DeleteDraftListenerTest.php +++ b/tests/Unit/Listener/DeleteDraftListenerTest.php @@ -11,12 +11,13 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; +use OCA\Mail\Contracts\IMessageConnector; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; use OCA\Mail\Events\DraftSavedEvent; -use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Events\MessageDeletedEvent; use OCA\Mail\Listener\DeleteDraftListener; use OCA\Mail\Model\NewMessageData; use OCA\Mail\Protocol\ProtocolFactory; @@ -34,9 +35,6 @@ class DeleteDraftListenerTest extends TestCase { /** @var MailboxMapper|MockObject */ private $mailboxMapper; - /** @var MessageMapper|MockObject */ - private $messageMapper; - /** @var LoggerInterface|MockObject */ private $logger; @@ -51,14 +49,12 @@ protected function setUp(): void { $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->mailboxMapper = $this->createMock(MailboxMapper::class); - $this->messageMapper = $this->createMock(MessageMapper::class); $this->logger = $this->createMock(LoggerInterface::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->listener = new DeleteDraftListener( $this->protocolFactory, $this->mailboxMapper, - $this->messageMapper, $this->logger, $this->eventDispatcher ); @@ -82,8 +78,8 @@ public function testHandleDraftSavedEventNoUid(): void { $newMessageData, null ); - $this->messageMapper->expects($this->never()) - ->method('addFlag'); + $this->protocolFactory->expects($this->never()) + ->method('messageConnector'); $this->logger->expects($this->never()) ->method('error'); $this->eventDispatcher->expects($this->never()) @@ -107,16 +103,10 @@ public function testHandleDraftSavedEventNoDraftMailboxSet(): void { $newMessageData, $draft ); - /** @var \Horde_Imap_Client_Socket|MockObject $client */ - $client = $this->createStub(\Horde_Imap_Client_Socket::class); - $this->protocolFactory - ->method('imapClient') - ->with($account) - ->willReturn($client); - $mailbox = new Mailbox(); - $mailbox->setName('Drafts'); $this->mailboxMapper->expects($this->never()) ->method('findById'); + $this->protocolFactory->expects($this->never()) + ->method('messageConnector'); $this->logger->expects($this->once())->method('warning'); $this->listener->handle($event); @@ -138,23 +128,62 @@ public function testHandleDraftSavedEventDraftMailboxNotFound(): void { $newMessageData, $draft ); - /** @var \Horde_Imap_Client_Socket|MockObject $client */ - $client = $this->createStub(\Horde_Imap_Client_Socket::class); - $this->protocolFactory - ->method('imapClient') - ->with($account) - ->willReturn($client); - $mailbox = new Mailbox(); - $mailbox->setName('Drafts'); $this->mailboxMapper->expects($this->once()) ->method('findById') ->with(123) ->willThrowException(new DoesNotExistException('')); + $this->protocolFactory->expects($this->never()) + ->method('messageConnector'); $this->logger->expects($this->once())->method('warning'); $this->listener->handle($event); } + public function testHandleDraftSavedEventDeletesDraftViaMessageConnector(): void { + /** @var Account|MockObject $account */ + $account = $this->createMock(Account::class); + $mailAccount = new MailAccount(); + $mailAccount->setDraftsMailboxId(123); + $account->method('getMailAccount')->willReturn($mailAccount); + /** @var NewMessageData|MockObject $newMessageData */ + $newMessageData = $this->createStub(NewMessageData::class); + $draft = new Message(); + $uid = 123; + $draft->setUid($uid); + $event = new DraftSavedEvent( + $account, + $newMessageData, + $draft + ); + $mailbox = new Mailbox(); + $mailbox->setName('Drafts'); + $this->mailboxMapper->expects($this->once()) + ->method('findById') + ->with(123) + ->willReturn($mailbox); + + /** @var IMessageConnector|MockObject $messageConnector */ + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory->expects($this->once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector->expects($this->once()) + ->method('deleteMessages') + ->with($account, $mailbox, $draft) + ->willReturn([$draft]); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(static function (MessageDeletedEvent $deletedEvent) use ($account, $mailbox, $uid): bool { + return $deletedEvent->getAccount() === $account + && $deletedEvent->getMailbox() === $mailbox + && $deletedEvent->getMessageId() === $uid; + })); + + $this->listener->handle($event); + } + public function testHandleMessageSentEventNoUid(): void { /** @var Account|MockObject $account */ $account = $this->createStub(Account::class); @@ -165,8 +194,8 @@ public function testHandleMessageSentEventNoUid(): void { $newMessageData, null ); - $this->messageMapper->expects($this->never()) - ->method('addFlag'); + $this->protocolFactory->expects($this->never()) + ->method('messageConnector'); $this->logger->expects($this->never()) ->method('error'); $this->eventDispatcher->expects($this->never()) diff --git a/tests/Unit/Send/ChainTest.php b/tests/Unit/Send/ChainTest.php index 233a270ec5..89c1bbcfad 100644 --- a/tests/Unit/Send/ChainTest.php +++ b/tests/Unit/Send/ChainTest.php @@ -9,51 +9,37 @@ namespace Unit\Send; use ChristophWurst\Nextcloud\Testing\TestCase; -use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\MailAccount; -use OCA\Mail\Db\MessageMapper; -use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Send\AntiAbuseHandler; use OCA\Mail\Send\Chain; -use OCA\Mail\Send\CopySentMessageHandler; use OCA\Mail\Send\FlagRepliedMessageHandler; use OCA\Mail\Send\SendHandler; -use OCA\Mail\Send\SentMailboxHandler; use OCA\Mail\Service\Attachment\AttachmentService; use PHPUnit\Framework\MockObject\MockObject; class ChainTest extends TestCase { private Chain $chain; - private SentMailboxHandler|MockObject $sentMailboxHandler; private MockObject|AntiAbuseHandler $antiAbuseHandler; private SendHandler|MockObject $sendHandler; - private MockObject|CopySentMessageHandler $copySentMessageHandler; private MockObject|FlagRepliedMessageHandler $flagRepliedMessageHandler; - private MockObject|MessageMapper $messageMapper; private AttachmentService|MockObject $attachmentService; private MockObject|LocalMessageMapper $localMessageMapper; - private MockObject&ProtocolFactory $protocolFactory; protected function setUp(): void { - $this->sentMailboxHandler = $this->createMock(SentMailboxHandler::class); $this->antiAbuseHandler = $this->createMock(AntiAbuseHandler::class); $this->sendHandler = $this->createMock(SendHandler::class); - $this->copySentMessageHandler = $this->createMock(CopySentMessageHandler::class); $this->flagRepliedMessageHandler = $this->createMock(FlagRepliedMessageHandler::class); $this->attachmentService = $this->createMock(AttachmentService::class); $this->localMessageMapper = $this->createMock(LocalMessageMapper::class); - $this->protocolFactory = $this->createMock(ProtocolFactory::class); - $this->chain = new Chain($this->sentMailboxHandler, + $this->chain = new Chain( $this->antiAbuseHandler, $this->sendHandler, - $this->copySentMessageHandler, $this->flagRepliedMessageHandler, $this->attachmentService, $this->localMessageMapper, - $this->protocolFactory, ); } @@ -68,16 +54,14 @@ public function testProcess(): void { $expected = new LocalMessage(); $expected->setStatus(LocalMessage::STATUS_PROCESSED); $expected->setId(100); - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $client->expects(self::once()) - ->method('logout'); - $this->sentMailboxHandler->expects(self::once()) - ->method('setNext'); - $this->protocolFactory->expects(self::once()) - ->method('imapClient') - ->willReturn($client); - $this->sentMailboxHandler->expects(self::once()) + $this->antiAbuseHandler->expects(self::once()) + ->method('setNext') + ->willReturn($this->sendHandler); + $this->sendHandler->expects(self::once()) + ->method('setNext') + ->willReturn($this->flagRepliedMessageHandler); + $this->antiAbuseHandler->expects(self::once()) ->method('process') ->with($account, $localMessage) ->willReturn($expected); @@ -93,7 +77,7 @@ public function testProcess(): void { $this->chain->process($account, $localMessage); } - public function testProcessNotProcessed() { + public function testProcessNotProcessed(): void { $mailAccount = new MailAccount(); $mailAccount->setSentMailboxId(1); $mailAccount->setUserId('bob'); @@ -104,16 +88,14 @@ public function testProcessNotProcessed() { $expected = new LocalMessage(); $expected->setStatus(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); $expected->setId(100); - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $client->expects(self::once()) - ->method('logout'); - $this->sentMailboxHandler->expects(self::once()) - ->method('setNext'); - $this->protocolFactory->expects(self::once()) - ->method('imapClient') - ->willReturn($client); - $this->sentMailboxHandler->expects(self::once()) + $this->antiAbuseHandler->expects(self::once()) + ->method('setNext') + ->willReturn($this->sendHandler); + $this->sendHandler->expects(self::once()) + ->method('setNext') + ->willReturn($this->flagRepliedMessageHandler); + $this->antiAbuseHandler->expects(self::once()) ->method('process') ->with($account, $localMessage) ->willReturn($expected); diff --git a/tests/Unit/Send/CopySendMessageHandlerTest.php b/tests/Unit/Send/CopySendMessageHandlerTest.php deleted file mode 100644 index d947b49c70..0000000000 --- a/tests/Unit/Send/CopySendMessageHandlerTest.php +++ /dev/null @@ -1,240 +0,0 @@ -mailboxMapper = $this->createMock(MailboxMapper::class); - $this->loggerInterface = $this->createMock(LoggerInterface::class); - $this->messageMapper = $this->createMock(MessageMapper::class); - $this->flagRepliedMessageHandler = $this->createMock(FlagRepliedMessageHandler::class); - $this->handler = new CopySentMessageHandler( - $this->mailboxMapper, - $this->loggerInterface, - $this->messageMapper, - ); - $this->handler->setNext($this->flagRepliedMessageHandler); - } - - public function testProcess(): void { - $mailAccount = new MailAccount(); - $mailAccount->setSentMailboxId(1); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $localMessage = $this->getMockBuilder(LocalMessage::class); - $localMessage->addMethods(['getStatus','setStatus', 'getRaw']); - $mock = $localMessage->getMock(); - $mailbox = new Mailbox(); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - - $mock->expects(self::once()) - ->method('getStatus') - ->willReturn(LocalMessage::STATUS_RAW); - $this->loggerInterface->expects(self::never()) - ->method('warning'); - $this->loggerInterface->expects(self::never()) - ->method('error'); - $this->mailboxMapper->expects(self::once()) - ->method('findById') - ->willReturn($mailbox); - $mock->expects(self::once()) - ->method('getRaw') - ->willReturn('Test'); - $this->messageMapper->expects(self::once()) - ->method('save'); - $mock->expects(self::once()) - ->method('setStatus') - ->willReturn(LocalMessage::STATUS_PROCESSED); - $this->flagRepliedMessageHandler->expects(self::once()) - ->method('process') - ->with($account, $mock); - - $this->handler->process($account, $mock, $client); - } - - public function testProcessNoSentMailbox(): void { - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $localMessage = $this->getMockBuilder(LocalMessage::class); - $localMessage->addMethods(['getStatus', 'setStatus', 'getRaw']); - $mock = $localMessage->getMock(); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - - $this->loggerInterface->expects(self::once()) - ->method('warning'); - $mock->expects(self::once()) - ->method('getStatus') - ->willReturn(LocalMessage::STATUS_RAW); - $mock->expects(self::once()) - ->method('getRaw') - ->willReturn('Test'); - $mock->expects(self::once()) - ->method('setStatus') - ->with(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); - $this->loggerInterface->expects(self::never()) - ->method('error'); - $this->mailboxMapper->expects(self::never()) - ->method('findById'); - $this->messageMapper->expects(self::never()) - ->method('save'); - $this->flagRepliedMessageHandler->expects(self::never()) - ->method('process'); - - $this->handler->process($account, $mock, $client); - } - - public function testProcessNoSentMailboxFound(): void { - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $mailAccount->setSentMailboxId(1); - $account = new Account($mailAccount); - $localMessage = $this->getMockBuilder(LocalMessage::class); - $localMessage->addMethods(['getStatus', 'setStatus', 'getRaw']); - $mock = $localMessage->getMock(); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - - $this->loggerInterface->expects(self::never()) - ->method('warning'); - $mock->expects(self::once()) - ->method('getStatus') - ->willReturn(LocalMessage::STATUS_RAW); - $mock->expects(self::once()) - ->method('getRaw') - ->willReturn('Test'); - $mock->expects(self::once()) - ->method('setStatus') - ->with(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); - $this->mailboxMapper->expects(self::once()) - ->method('findById') - ->willThrowException(new DoesNotExistException('')); - $this->loggerInterface->expects(self::once()) - ->method('error'); - $this->messageMapper->expects(self::never()) - ->method('save'); - $this->flagRepliedMessageHandler->expects(self::never()) - ->method('process'); - - $this->handler->process($account, $mock, $client); - } - - public function testProcessCouldNotCopy(): void { - $mailAccount = new MailAccount(); - $mailAccount->setSentMailboxId(1); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $localMessage = $this->getMockBuilder(LocalMessage::class); - $localMessage->addMethods(['getStatus','setStatus', 'getRaw']); - $mock = $localMessage->getMock(); - $mailbox = new Mailbox(); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - - $mock->expects(self::once()) - ->method('getStatus') - ->willReturn(LocalMessage::STATUS_RAW); - $this->loggerInterface->expects(self::never()) - ->method('warning'); - $this->mailboxMapper->expects(self::once()) - ->method('findById') - ->willReturn($mailbox); - $mock->expects(self::once()) - ->method('getRaw') - ->willReturn('123 Content'); - $this->messageMapper->expects(self::once()) - ->method('save') - ->willThrowException(new Horde_Imap_Client_Exception()); - $mock->expects(self::once()) - ->method('setStatus') - ->with(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); - $this->loggerInterface->expects(self::once()) - ->method('error'); - $this->flagRepliedMessageHandler->expects(self::never()) - ->method('process'); - - $this->handler->process($account, $mock, $client); - } - - public function testProcessAlreadyProcessed(): void { - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $localMessage = $this->getMockBuilder(LocalMessage::class); - $localMessage->addMethods(['getStatus']); - $mock = $localMessage->getMock(); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - - $this->loggerInterface->expects(self::never()) - ->method('warning'); - $mock->expects(self::once()) - ->method('getStatus') - ->willReturn(LocalMessage::STATUS_PROCESSED); - $this->loggerInterface->expects(self::never()) - ->method('error'); - $this->mailboxMapper->expects(self::never()) - ->method('findById'); - $this->messageMapper->expects(self::never()) - ->method('save'); - $this->flagRepliedMessageHandler->expects(self::once()) - ->method('process'); - - $this->handler->process($account, $mock, $client); - } - - public function testProcessNoRawMessage(): void { - $mailAccount = new MailAccount(); - $mailAccount->setSentMailboxId(1); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $localMessage = $this->getMockBuilder(LocalMessage::class); - $localMessage->addMethods(['getStatus','setStatus', 'getRaw']); - $mock = $localMessage->getMock(); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - - $mock->expects(self::once()) - ->method('getStatus') - ->willReturn(LocalMessage::STATUS_RAW); - $mock->expects(self::once()) - ->method('getRaw') - ->willReturn(null); - $mock->expects(self::once()) - ->method('setStatus') - ->willReturn(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); - $this->mailboxMapper->expects(self::never()) - ->method('findById'); - $this->messageMapper->expects(self::never()) - ->method('save'); - $this->flagRepliedMessageHandler->expects(self::never()) - ->method('process'); - - $result = $this->handler->process($account, $mock, $client); - $this->assertEquals($mock, $result); - } -} diff --git a/tests/Unit/Send/FlagRepliedMessageHandlerTest.php b/tests/Unit/Send/FlagRepliedMessageHandlerTest.php index 1f0ba61d0f..5df8b97565 100644 --- a/tests/Unit/Send/FlagRepliedMessageHandlerTest.php +++ b/tests/Unit/Send/FlagRepliedMessageHandlerTest.php @@ -9,7 +9,6 @@ namespace Unit\Send; use ChristophWurst\Nextcloud\Testing\TestCase; -use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\MailAccount; @@ -17,8 +16,8 @@ use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; use OCA\Mail\Db\MessageMapper as DbMessageMapper; -use OCA\Mail\IMAP\MessageMapper; use OCA\Mail\Send\FlagRepliedMessageHandler; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -26,7 +25,7 @@ class FlagRepliedMessageHandlerTest extends TestCase { private MailboxMapper|MockObject $mailboxMapper; private LoggerInterface|MockObject $loggerInterface; - private MockObject|MessageMapper $messageMapper; + private MockObject|MailManager $mailManager; private FlagRepliedMessageHandler $handler; private MockObject|DbMessageMapper $dbMessageMapper; @@ -34,12 +33,12 @@ protected function setUp(): void { $this->mailboxMapper = $this->createMock(MailboxMapper::class); $this->loggerInterface = $this->createMock(LoggerInterface::class); - $this->messageMapper = $this->createMock(MessageMapper::class); + $this->mailManager = $this->createMock(MailManager::class); $this->dbMessageMapper = $this->createMock(DbMessageMapper::class); $this->handler = new FlagRepliedMessageHandler( $this->mailboxMapper, $this->loggerInterface, - $this->messageMapper, + $this->mailManager, $this->dbMessageMapper, ); } @@ -54,7 +53,6 @@ public function testProcess(): void { $dbMessage->setMailboxId(1); $mailbox = new Mailbox(); $mailbox->setMyAcls('rw'); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $this->dbMessageMapper->expects(self::once()) ->method('findByMessageId') @@ -64,12 +62,12 @@ public function testProcess(): void { ->willReturn($mailbox); $this->loggerInterface->expects(self::never()) ->method('warning'); - $this->messageMapper->expects(self::once()) - ->method('addFlag'); + $this->mailManager->expects(self::once()) + ->method('flagMessages'); $this->dbMessageMapper->expects(self::once()) ->method('update'); - $this->handler->process($account, $localMessage, $client); + $this->handler->process($account, $localMessage); } public function testProcessError(): void { @@ -82,7 +80,6 @@ public function testProcessError(): void { $dbMessage->setMailboxId(1); $mailbox = new Mailbox(); $mailbox->setMyAcls('rw'); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $this->dbMessageMapper->expects(self::once()) ->method('findByMessageId') @@ -90,15 +87,15 @@ public function testProcessError(): void { $this->mailboxMapper->expects(self::once()) ->method('findById') ->willReturn($mailbox); - $this->messageMapper->expects(self::once()) - ->method('addFlag') + $this->mailManager->expects(self::once()) + ->method('flagMessages') ->willThrowException(new DoesNotExistException('')); $this->loggerInterface->expects(self::once()) ->method('warning'); $this->dbMessageMapper->expects(self::never()) ->method('update'); - $this->handler->process($account, $localMessage, $client); + $this->handler->process($account, $localMessage); } public function testProcessReadOnly(): void { @@ -111,7 +108,6 @@ public function testProcessReadOnly(): void { $dbMessage->setMailboxId(1); $mailbox = new Mailbox(); $mailbox->setMyAcls('r'); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $this->dbMessageMapper->expects(self::once()) ->method('findByMessageId') @@ -121,12 +117,12 @@ public function testProcessReadOnly(): void { ->willReturn($mailbox); $this->loggerInterface->expects(self::never()) ->method('warning'); - $this->messageMapper->expects(self::never()) - ->method('addFlag'); + $this->mailManager->expects(self::never()) + ->method('flagMessages'); $this->dbMessageMapper->expects(self::never()) ->method('update'); - $this->handler->process($account, $localMessage, $client); + $this->handler->process($account, $localMessage); } public function testProcessNotFound(): void { @@ -134,7 +130,6 @@ public function testProcessNotFound(): void { $localMessage = new LocalMessage(); $localMessage->setInReplyToMessageId('ab123'); $localMessage->setStatus(LocalMessage::STATUS_PROCESSED); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $this->dbMessageMapper->expects(self::once()) ->method('findByMessageId') @@ -143,19 +138,18 @@ public function testProcessNotFound(): void { ->method('findById'); $this->loggerInterface->expects(self::never()) ->method('warning'); - $this->messageMapper->expects(self::never()) - ->method('addFlag'); + $this->mailManager->expects(self::never()) + ->method('flagMessages'); $this->dbMessageMapper->expects(self::never()) ->method('update'); - $this->handler->process($account, $localMessage, $client); + $this->handler->process($account, $localMessage); } public function testProcessNoRepliedMessageId(): void { $account = new Account(new MailAccount()); $localMessage = new LocalMessage(); $localMessage->setStatus(LocalMessage::STATUS_PROCESSED); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $this->dbMessageMapper->expects(self::never()) ->method('findByMessageId'); @@ -163,11 +157,11 @@ public function testProcessNoRepliedMessageId(): void { ->method('findById'); $this->loggerInterface->expects(self::never()) ->method('warning'); - $this->messageMapper->expects(self::never()) - ->method('addFlag'); + $this->mailManager->expects(self::never()) + ->method('flagMessages'); $this->dbMessageMapper->expects(self::never()) ->method('update'); - $this->handler->process($account, $localMessage, $client); + $this->handler->process($account, $localMessage); } } diff --git a/tests/Unit/Send/SendHandlerTest.php b/tests/Unit/Send/SendHandlerTest.php index ddaf96f335..6e2d42bde3 100644 --- a/tests/Unit/Send/SendHandlerTest.php +++ b/tests/Unit/Send/SendHandlerTest.php @@ -9,87 +9,177 @@ namespace Unit\Send; use ChristophWurst\Nextcloud\Testing\TestCase; -use Horde_Imap_Client_Socket; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailTransmission; +use OCA\Mail\Contracts\ITransmissionConnector; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\MailAccount; -use OCA\Mail\Send\CopySentMessageHandler; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Events\MessageSentEvent; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Send\FlagRepliedMessageHandler; use OCA\Mail\Send\SendHandler; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\IEventDispatcher; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; class SendHandlerTest extends TestCase { - private MockObject|IMailTransmission $transmission; - private MockObject|CopySentMessageHandler $copySentMessageHandler; - private MockObject|FlagRepliedMessageHandler $flagRepliedMessageHandler; + private MockObject|ProtocolFactory $protocolFactory; + private MockObject|IEventDispatcher $eventDispatcher; + private MockObject|MailboxMapper $mailboxMapper; + private MockObject|LoggerInterface $logger; + private MockObject|FlagRepliedMessageHandler $nextHandler; private SendHandler $handler; protected function setUp(): void { - $this->transmission = $this->createMock(IMailTransmission::class); - $this->copySentMessageHandler = $this->createMock(CopySentMessageHandler::class); - $this->flagRepliedMessageHandler = $this->createMock(FlagRepliedMessageHandler::class); - $this->handler = new SendHandler($this->transmission); - $this->handler->setNext($this->copySentMessageHandler) - ->setNext($this->flagRepliedMessageHandler); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->mailboxMapper = $this->createMock(MailboxMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->nextHandler = $this->createMock(FlagRepliedMessageHandler::class); + $this->handler = new SendHandler( + $this->protocolFactory, + $this->eventDispatcher, + $this->mailboxMapper, + $this->logger, + ); + $this->handler->setNext($this->nextHandler); } - public function testProcess(): void { + public function testProcessSkipsIfAlreadyProcessed(): void { $mailAccount = new MailAccount(); $mailAccount->setSentMailboxId(1); $mailAccount->setUserId('bob'); $account = new Account($mailAccount); $localMessage = new LocalMessage(); $localMessage->setId(100); + $localMessage->setStatus(LocalMessage::STATUS_PROCESSED); + + $this->protocolFactory->expects(self::never()) + ->method('transmissionConnector'); + $this->nextHandler->expects(self::once()) + ->method('process') + ->with($account, $localMessage) + ->willReturn($localMessage); + + $this->handler->process($account, $localMessage); + } + + public function testProcessNoSentMailbox(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + // sentMailboxId is null — no sent mailbox configured + $account = new Account($mailAccount); + $localMessage = new LocalMessage(); + $localMessage->setId(100); $localMessage->setStatus(LocalMessage::STATUS_RAW); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - $this->transmission->expects(self::once()) - ->method('sendMessage') - ->with($account, $localMessage); - $this->copySentMessageHandler->expects(self::once()) + $this->protocolFactory->expects(self::never()) + ->method('transmissionConnector'); + $this->eventDispatcher->expects(self::never()) + ->method('dispatchTyped'); + $this->nextHandler->expects(self::never()) ->method('process'); - $this->handler->process($account, $localMessage, $client); + $result = $this->handler->process($account, $localMessage); + + $this->assertEquals(LocalMessage::STATUS_NO_SENT_MAILBOX, $result->getStatus()); } - public function testProcessAlreadyProcessed(): void { + public function testProcessSentMailboxNotFound(): void { $mailAccount = new MailAccount(); - $mailAccount->setSentMailboxId(1); $mailAccount->setUserId('bob'); + $mailAccount->setSentMailboxId(42); $account = new Account($mailAccount); $localMessage = new LocalMessage(); $localMessage->setId(100); - $localMessage->setStatus(LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL); - $client = $this->createStub(Horde_Imap_Client_Socket::class); + $localMessage->setStatus(LocalMessage::STATUS_RAW); - $this->transmission->expects(self::never()) - ->method('sendMessage'); - $this->copySentMessageHandler->expects(self::once()) + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->with(42) + ->willThrowException(new DoesNotExistException('')); + $this->protocolFactory->expects(self::never()) + ->method('transmissionConnector'); + $this->eventDispatcher->expects(self::never()) + ->method('dispatchTyped'); + $this->nextHandler->expects(self::never()) ->method('process'); - $this->handler->process($account, $localMessage, $client); + $result = $this->handler->process($account, $localMessage); + + $this->assertEquals(LocalMessage::STATUS_NO_SENT_MAILBOX, $result->getStatus()); + } + + public function testProcessSendsMessage(): void { + $mailAccount = new MailAccount(); + $mailAccount->setSentMailboxId(1); + $mailAccount->setUserId('bob'); + $account = new Account($mailAccount); + $localMessage = new LocalMessage(); + $localMessage->setId(100); + $localMessage->setStatus(LocalMessage::STATUS_RAW); + $sentMailbox = new Mailbox(); + $sentMailbox->setId(1); + + $connector = $this->createMock(ITransmissionConnector::class); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->with(1) + ->willReturn($sentMailbox); + $this->protocolFactory->expects(self::once()) + ->method('transmissionConnector') + ->with($account) + ->willReturn($connector); + $connector->expects(self::once()) + ->method('sendMessage') + ->with($account, $localMessage, $sentMailbox) + ->willReturnCallback(function ($acct, $msg, $mbx) { + $msg->setStatus(LocalMessage::STATUS_PROCESSED); + }); + $this->eventDispatcher->expects(self::once()) + ->method('dispatchTyped') + ->with(self::isInstanceOf(MessageSentEvent::class)); + $this->nextHandler->expects(self::once()) + ->method('process') + ->willReturn($localMessage); + + $this->handler->process($account, $localMessage); } - public function testProcessError(): void { + public function testProcessSendError(): void { $mailAccount = new MailAccount(); $mailAccount->setSentMailboxId(1); $mailAccount->setUserId('bob'); $account = new Account($mailAccount); - $localMessage = $this->getMockBuilder(LocalMessage::class); - $localMessage->addMethods(['getStatus']); - $mock = $localMessage->getMock(); - $mock->setStatus(10); - $mock->expects(self::any()) - ->method('getStatus') - ->willReturn(LocalMessage::STATUS_SMPT_SEND_FAIL); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - - $this->transmission->expects(self::once()) - ->method('sendMessage'); - $this->copySentMessageHandler->expects(self::never()) + $localMessage = new LocalMessage(); + $localMessage->setId(100); + $localMessage->setStatus(LocalMessage::STATUS_RAW); + $sentMailbox = new Mailbox(); + $sentMailbox->setId(1); + + $connector = $this->createMock(ITransmissionConnector::class); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->with(1) + ->willReturn($sentMailbox); + $this->protocolFactory->expects(self::once()) + ->method('transmissionConnector') + ->with($account) + ->willReturn($connector); + $connector->expects(self::once()) + ->method('sendMessage') + ->willReturnCallback(function ($acct, $msg, $mbx) { + $msg->setStatus(LocalMessage::STATUS_SMPT_SEND_FAIL); + }); + $this->eventDispatcher->expects(self::never()) + ->method('dispatchTyped'); + $this->nextHandler->expects(self::never()) ->method('process'); - $this->handler->process($account, $mock, $client); + $result = $this->handler->process($account, $localMessage); + + $this->assertEquals(LocalMessage::STATUS_SMPT_SEND_FAIL, $result->getStatus()); } } diff --git a/tests/Unit/Send/SentMailboxHandlerTest.php b/tests/Unit/Send/SentMailboxHandlerTest.php deleted file mode 100644 index 12285a42b3..0000000000 --- a/tests/Unit/Send/SentMailboxHandlerTest.php +++ /dev/null @@ -1,63 +0,0 @@ -antiAbuseHandler = $this->createMock(AntiAbuseHandler::class); - $this->handler = new SentMailboxHandler(); - $this->handler->setNext($this->antiAbuseHandler); - } - - public function testProcess(): void { - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $mailAccount->setSentMailboxId(1); - $account = new Account($mailAccount); - $localMessage = new LocalMessage(); - $localMessage->setStatus(LocalMessage::STATUS_RAW); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - - $this->antiAbuseHandler->expects(self::once()) - ->method('process'); - - $this->handler->process($account, $localMessage, $client); - } - - public function testNoSentMailbox(): void { - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $mailAccount->setId(123); - $account = new Account($mailAccount); - $localMessage = $this->getMockBuilder(LocalMessage::class); - $localMessage->addMethods(['setStatus']); - $mock = $localMessage->getMock(); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - - $mock->expects(self::once()) - ->method('setStatus') - ->with(LocalMessage::STATUS_NO_SENT_MAILBOX); - $this->antiAbuseHandler->expects(self::never()) - ->method('process'); - - $this->handler->process($account, $mock, $client); - } -} diff --git a/tests/Unit/Service/AntiSpamServiceTest.php b/tests/Unit/Service/AntiSpamServiceTest.php index 152abbd129..aeeaf2f23a 100644 --- a/tests/Unit/Service/AntiSpamServiceTest.php +++ b/tests/Unit/Service/AntiSpamServiceTest.php @@ -11,7 +11,6 @@ use OCA\Mail\Account; use OCA\Mail\AppInfo\Application; use OCA\Mail\ConfigLexicon; -use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message as DbMessage; @@ -36,7 +35,6 @@ class AntiSpamServiceTest extends TestCase { private SmtpClientFactory|MockObject $smtpClientFactory; private MockObject|ImapMessageMapper $imapMessageMapper; private LoggerInterface|MockObject $logger; - private MockObject|IMailTransmission $transmission; private MailManager|MockObject $mailManager; protected function setUp(): void { @@ -44,7 +42,6 @@ protected function setUp(): void { $this->appConfig = $this->createMock(IAppConfig::class); $this->dbMessageMapper = $this->createMock(DbMessageMapper::class); - $this->transmission = $this->createMock(IMailTransmission::class); $this->mailManager = $this->createMock(MailManager::class); $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->smtpClientFactory = $this->createMock(SmtpClientFactory::class); diff --git a/tests/Unit/Service/Attachment/AttachmentServiceTest.php b/tests/Unit/Service/Attachment/AttachmentServiceTest.php index 960bb9eca6..568c160ee7 100644 --- a/tests/Unit/Service/Attachment/AttachmentServiceTest.php +++ b/tests/Unit/Service/Attachment/AttachmentServiceTest.php @@ -10,7 +10,6 @@ namespace OCA\Mail\Tests\Unit\Service\Attachment; use ChristophWurst\Nextcloud\Testing\TestCase; -use Horde_Imap_Client_Socket; use OC\Files\Node\File; use OCA\Files_Sharing\SharedStorage; use OCA\Mail\Account; @@ -22,7 +21,6 @@ use OCA\Mail\Exception\ServiceException; use OCA\Mail\Exception\SmimeDecryptException; use OCA\Mail\Exception\UploadException; -use OCA\Mail\IMAP\MessageMapper; use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\Attachment\AttachmentStorage; @@ -45,7 +43,6 @@ class AttachmentServiceTest extends TestCase { private LocalAttachmentMapper&MockObject $mapper; private AttachmentStorage&MockObject $storage; private MailManager&MockObject $mailManager; - private MessageMapper&MockObject $messageMapper; private Folder&MockObject $userFolder; private ICache&MockObject $cache; private ICacheFactory&MockObject $cacheFactory; @@ -61,7 +58,6 @@ protected function setUp(): void { $this->mapper = $this->createMock(LocalAttachmentMapper::class); $this->storage = $this->createMock(AttachmentStorage::class); $this->mailManager = $this->createMock(MailManager::class); - $this->messageMapper = $this->createMock(MessageMapper::class); $this->userFolder = $this->createMock(Folder::class); $this->cache = $this->createMock(ICache::class); $this->cacheFactory = $this->createMock(ICacheFactory::class); @@ -77,7 +73,6 @@ protected function setUp(): void { $this->mapper, $this->storage, $this->mailManager, - $this->messageMapper, $this->cacheFactory, $this->urlGenerator, $this->mimeTypeDetector, @@ -295,14 +290,13 @@ public function testSaveLocalMessageAttachmentNoAttachmentIds(): void { public function testhandleLocalMessageAttachment(): void { $account = $this->createStub(Account::class); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $attachments = [ [ 'type' => 'local', 'id' => 1 ] ]; - $result = $this->service->handleAttachments($account, $attachments, $client); + $result = $this->service->handleAttachments($account, $attachments); $this->assertEquals([1], $result); } @@ -329,7 +323,6 @@ public function testHandleAttachmentsForwardedMessageAttachment(): void { $message->setMailboxId(1); $mailbox = new Mailbox(); $mailbox->setName('INBOX'); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $attachments = [ 'type' => 'message', 'id' => 123, @@ -345,9 +338,9 @@ public function testHandleAttachmentsForwardedMessageAttachment(): void { ->method('getMailbox') ->with($account->getUserId()) ->willReturn($mailbox); - $this->messageMapper->expects(self::once()) - ->method('getFullText') - ->with($client, $mailbox->getName(), $message->getUid(), $userId) + $this->mailManager->expects(self::once()) + ->method('getRawMessage') + ->with($account, $mailbox, $message, true) ->willReturn('Lorem ipsum dolor sit amet'); $this->mapper->expects($this->once()) ->method('insert') @@ -356,7 +349,7 @@ public function testHandleAttachmentsForwardedMessageAttachment(): void { $this->storage->expects($this->once()) ->method('saveContent') ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('Lorem ipsum dolor sit amet')); - $this->service->handleAttachments($account, [$attachments], $client); + $this->service->handleAttachments($account, [$attachments]); } public function testHandleAttachmentsForwardedAttachment(): void { @@ -382,7 +375,6 @@ public function testHandleAttachmentsForwardedAttachment(): void { $mailbox = new Mailbox(); $mailbox->setId(9); $mailbox->setName('INBOX'); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $attachments = [ 'type' => 'message-attachment', 'mailboxId' => $mailbox->getId(), @@ -400,14 +392,24 @@ public function testHandleAttachmentsForwardedAttachment(): void { null, 'attachment', ); + $message = new Message(); + $message->setUid(999); $this->mailManager->expects(self::once()) ->method('getMailbox') ->with($account->getUserId(), $mailbox->getId()) ->willReturn($mailbox); - $this->messageMapper->expects(self::once()) - ->method('getAttachment') - ->with($client, $mailbox->getName(), 999, '2', $userId) + $this->mailManager->expects(self::once()) + ->method('getMessageIdForUid') + ->with($mailbox, 999) + ->willReturn(50); + $this->mailManager->expects(self::once()) + ->method('getMessage') + ->with($userId, 50) + ->willReturn($message); + $this->mailManager->expects(self::once()) + ->method('getMailAttachment') + ->with($account, $mailbox, $message, '2') ->willReturn($imapAttachment); $this->mapper->expects($this->once()) ->method('insert') @@ -417,7 +419,7 @@ public function testHandleAttachmentsForwardedAttachment(): void { ->method('saveContent') ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('Lorem ipsum dolor sit amet')); - $this->service->handleAttachments($account, [$attachments], $client); + $this->service->handleAttachments($account, [$attachments]); } public function testHandleAttachmentsForwardedInlineAttachmentPreservesContentId(): void { @@ -443,7 +445,6 @@ public function testHandleAttachmentsForwardedInlineAttachmentPreservesContentId $mailbox = new Mailbox(); $mailbox->setId(9); $mailbox->setName('INBOX'); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $attachments = [ 'type' => 'message-attachment-inline', 'mailboxId' => $mailbox->getId(), @@ -461,14 +462,24 @@ public function testHandleAttachmentsForwardedInlineAttachmentPreservesContentId 'img001@example.com', 'inline', ); + $message = new Message(); + $message->setUid(999); $this->mailManager->expects(self::once()) ->method('getMailbox') ->with($account->getUserId(), $mailbox->getId()) ->willReturn($mailbox); - $this->messageMapper->expects(self::once()) - ->method('getAttachment') - ->with($client, $mailbox->getName(), 999, '3', $userId) + $this->mailManager->expects(self::once()) + ->method('getMessageIdForUid') + ->with($mailbox, 999) + ->willReturn(51); + $this->mailManager->expects(self::once()) + ->method('getMessage') + ->with($userId, 51) + ->willReturn($message); + $this->mailManager->expects(self::once()) + ->method('getMailAttachment') + ->with($account, $mailbox, $message, '3') ->willReturn($imapAttachment); $this->mapper->expects($this->once()) ->method('insert') @@ -478,7 +489,7 @@ public function testHandleAttachmentsForwardedInlineAttachmentPreservesContentId ->method('saveContent') ->with($this->equalTo($userId), $this->equalTo(456), $this->equalTo('fake png content')); - $this->service->handleAttachments($account, [$attachments], $client); + $this->service->handleAttachments($account, [$attachments]); } public function testHandleAttachmentsCloudAttachmentNoDownloadPermission(): void { @@ -510,7 +521,6 @@ public function testHandleAttachmentsCloudAttachmentNoDownloadPermission(): void $account = $this->createConfiguredMock(Account::class, [ 'getUserId' => $userId ]); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $attachments = [ 'type' => 'cloud', 'messageId' => 999, @@ -526,7 +536,7 @@ public function testHandleAttachmentsCloudAttachmentNoDownloadPermission(): void ->with('cat.jpg') ->willReturn($file); - $result = $this->service->handleAttachments($account, [$attachments], $client); + $result = $this->service->handleAttachments($account, [$attachments]); $this->assertEquals([], $result); } @@ -542,7 +552,6 @@ public function testHandleAttachmentsCloudAttachment(): void { $account = $this->createConfiguredMock(Account::class, [ 'getUserId' => $userId ]); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $attachment = LocalAttachment::fromParams([ 'userId' => $userId, 'fileName' => 'cat.jpg', @@ -579,7 +588,7 @@ public function testHandleAttachmentsCloudAttachment(): void { ->method('saveContent') ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('Lorem ipsum dolor sit amet')); - $this->service->handleAttachments($account, [$attachments], $client); + $this->service->handleAttachments($account, [$attachments]); } public function testUpdateLocalMessageAttachments(): void { diff --git a/tests/Unit/Service/DraftsServiceTest.php b/tests/Unit/Service/DraftsServiceTest.php index 0734c5b00d..258f7a6f67 100644 --- a/tests/Unit/Service/DraftsServiceTest.php +++ b/tests/Unit/Service/DraftsServiceTest.php @@ -12,10 +12,13 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OC\EventDispatcher\EventDispatcher; use OCA\Mail\Account; +use OCA\Mail\Contracts\ITransmissionConnector; use OCA\Mail\Db\LocalAttachment; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; use OCA\Mail\Db\Recipient; use OCA\Mail\Events\DraftMessageCreatedEvent; @@ -25,7 +28,6 @@ use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\DraftsService; use OCA\Mail\Service\MailManager; -use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\OutboxService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; @@ -35,8 +37,8 @@ use Psr\Log\LoggerInterface; class DraftsServiceTest extends TestCase { - /** @var MailTransmission|MockObject */ - private $transmission; + /** @var MailboxMapper|MockObject */ + private $mailboxMapper; /** @var LocalMessageMapper|MockObject */ private $mapper; @@ -71,7 +73,7 @@ class DraftsServiceTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->transmission = $this->createMock(MailTransmission::class); + $this->mailboxMapper = $this->createMock(MailboxMapper::class); $this->mapper = $this->createMock(LocalMessageMapper::class); $this->attachmentService = $this->createMock(AttachmentService::class); $this->protocolFactory = $this->createMock(ProtocolFactory::class); @@ -81,12 +83,12 @@ protected function setUp(): void { $this->accountService = $this->createMock(AccountService::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->draftsService = new DraftsService( - $this->transmission, $this->mapper, $this->attachmentService, $this->eventDispatcher, $this->protocolFactory, $this->mailManager, + $this->mailboxMapper, $this->logger, $this->accountService, $this->timeFactory @@ -173,19 +175,14 @@ public function testSaveMessage(): void { $account = $this->createConfiguredMock(Account::class, [ 'getUserId' => $this->userId ]); - $client = $this->createStub(\Horde_Imap_Client_Socket::class); $this->mapper->expects(self::once()) ->method('saveWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->protocolFactory->expects(self::once()) - ->method('imapClient') - ->with($account) - ->willReturn($client); $this->attachmentService->expects(self::once()) ->method('handleAttachments') - ->with($account, $attachments, $client) + ->with($account, $attachments) ->willReturn($attachmentIds); $this->attachmentService->expects(self::once()) ->method('saveLocalMessageAttachments') @@ -228,8 +225,6 @@ public function testSaveMessageNoAttachments(): void { ->method('saveWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->protocolFactory->expects(self::never()) - ->method('imapClient'); $this->attachmentService->expects(self::never()) ->method('handleAttachments'); $this->attachmentService->expects(self::never()) @@ -277,19 +272,14 @@ public function testUpdateMessage(): void { $account = $this->createConfiguredMock(Account::class, [ 'getUserId' => $this->userId ]); - $client = $this->createStub(\Horde_Imap_Client_Socket::class); $this->mapper->expects(self::once()) ->method('updateWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->protocolFactory->expects(self::once()) - ->method('imapClient') - ->with($account) - ->willReturn($client); $this->attachmentService->expects(self::once()) ->method('handleAttachments') - ->with($account, $attachments, $client) + ->with($account, $attachments) ->willReturn($attachmentIds); $this->attachmentService->expects(self::once()) ->method('updateLocalMessageAttachments') @@ -335,19 +325,14 @@ public function testConvertToOutboxMessage(): void { $account = $this->createConfiguredMock(Account::class, [ 'getUserId' => $this->userId ]); - $client = $this->createStub(\Horde_Imap_Client_Socket::class); $this->mapper->expects(self::once()) ->method('updateWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->protocolFactory->expects(self::once()) - ->method('imapClient') - ->with($account) - ->willReturn($client); $this->attachmentService->expects(self::once()) ->method('handleAttachments') - ->with($account, $attachments, $client) + ->with($account, $attachments) ->willReturn($attachmentIds); $this->attachmentService->expects(self::once()) ->method('updateLocalMessageAttachments') @@ -399,8 +384,6 @@ public function testUpdateMessageNoAttachments(): void { $this->attachmentService->expects(self::once()) ->method('updateLocalMessageAttachments') ->with($this->userId, $message2, $attachments); - $this->protocolFactory->expects(self::never()) - ->method('imapClient'); $this->attachmentService->expects(self::never()) ->method('handleAttachments'); $result = $this->draftsService->updateMessage($account, $message, $to, $cc, $bcc, $attachments); @@ -456,13 +439,24 @@ public function testSendMessage(): void { $attachments = [$attachment]; $message->setRecipients($recipients); $message->setAttachments($attachments); - $account = $this->createConfiguredMock(Account::class, [ - 'getUserId' => $this->userId - ]); + $mailAccount = new MailAccount(); + $mailAccount->setUserId($this->userId); + $mailAccount->setDraftsMailboxId(3); + $account = new Account($mailAccount); + $mailbox = new Mailbox(); + $connector = $this->createMock(ITransmissionConnector::class); - $this->transmission->expects(self::once()) - ->method('saveLocalDraft') - ->with($account, $message); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->with(3) + ->willReturn($mailbox); + $this->protocolFactory->expects(self::once()) + ->method('transmissionConnector') + ->with($account) + ->willReturn($connector); + $connector->expects(self::once()) + ->method('saveMessage') + ->with($account, $mailbox, $message, ['$draft']); $this->attachmentService->expects(self::once()) ->method('deleteLocalMessageAttachments') ->with($account->getUserId(), $message->getId()); @@ -488,14 +482,28 @@ public function testSendMessageTransmissionError(): void { $attachments = [$attachment]; $message->setRecipients($recipients); $message->setAttachments($attachments); - $account = $this->createConfiguredMock(Account::class, [ - 'getUserId' => $this->userId - ]); + $mailAccount = new MailAccount(); + $mailAccount->setUserId($this->userId); + $mailAccount->setDraftsMailboxId(3); + $account = new Account($mailAccount); + $mailbox = new Mailbox(); + $connector = $this->createMock(ITransmissionConnector::class); - $this->transmission->expects(self::once()) - ->method('saveLocalDraft') - ->with($account, $message) + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->with(3) + ->willReturn($mailbox); + $this->protocolFactory->expects(self::once()) + ->method('transmissionConnector') + ->with($account) + ->willReturn($connector); + $connector->expects(self::once()) + ->method('saveMessage') + ->with($account, $mailbox, $message, ['$draft']) ->willThrowException(new ClientException()); + $this->mapper->expects(self::once()) + ->method('update') + ->with($message); $this->attachmentService->expects(self::never()) ->method('deleteLocalMessageAttachments'); $this->mapper->expects(self::never()) diff --git a/tests/Unit/Service/JMAP/JmapOperationsServiceTest.php b/tests/Unit/Service/JMAP/JmapOperationsServiceTest.php new file mode 100644 index 0000000000..c492fa5f9f --- /dev/null +++ b/tests/Unit/Service/JMAP/JmapOperationsServiceTest.php @@ -0,0 +1,190 @@ +protocolFactory = $this->createMock(ProtocolFactory::class); + $this->jmapMailboxAdapter = $this->createMock(JmapMailboxAdapter::class); + $this->jmapMessageAdapter = $this->createMock(JmapMessageAdapter::class); + $this->dataStore = $this->createMock(Client::class); + + $this->service = new JmapOperationsService( + $this->protocolFactory, + $this->jmapMailboxAdapter, + $this->jmapMessageAdapter, + ); + + // Bypass connect()'s session handshake and inject an already-connected data store, + // since attachmentUpload() only needs the data store and account id to be set. + $dataStoreProperty = new ReflectionProperty(JmapOperationsService::class, 'dataStore'); + $dataStoreProperty->setAccessible(true); + $dataStoreProperty->setValue($this->service, $this->dataStore); + + $dataAccountProperty = new ReflectionProperty(JmapOperationsService::class, 'dataAccount'); + $dataAccountProperty->setAccessible(true); + $dataAccountProperty->setValue($this->service, 'account1'); + } + + public function testAttachmentUpload(): void { + $content = 'hello world'; + + $this->dataStore->expects(self::once()) + ->method('upload') + ->with('account1', 'text/plain', $content) + ->willReturn(json_encode(['accountId' => 'account1', 'blobId' => 'blob123', 'type' => 'text/plain', 'size' => strlen($content)])); + + $blobId = $this->service->attachmentUpload('text/plain', $content); + + $this->assertEquals('blob123', $blobId); + } + + public function testAttachmentUploadMissingBlobIdThrows(): void { + $this->dataStore->expects(self::once()) + ->method('upload') + ->willReturn(json_encode(['accountId' => 'account1'])); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Blob upload did not return a blob id'); + + $this->service->attachmentUpload('text/plain', 'hello world'); + } + + /** + * Finds the sub-part carrying a blobId in a bound bodyStructure (attachments are + * appended as bodyStructure sibling parts, not via the separate "attachments" + * property, since a server rejects a create that sets both at once). + */ + private function findAttachmentPart(object $bodyStructure): ?object { + foreach ($bodyStructure->subParts ?? [] as $subPart) { + if (isset($subPart->blobId)) { + return $subPart; + } + } + return null; + } + + public function testEntitySaveWithAttachmentsUploadsAndWiresBlobId(): void { + $attachments = [ + [ + 'content' => 'binary-jpeg-bytes', + 'type' => 'image/jpeg', + 'name' => 'photo.jpg', + 'disposition' => 'attachment', + 'contentId' => null, + ], + ]; + + $this->dataStore->expects(self::once()) + ->method('upload') + ->with('account1', 'image/jpeg', 'binary-jpeg-bytes') + ->willReturn(json_encode(['accountId' => 'account1', 'blobId' => 'real-uploaded-blob'])); + + $captured = []; + $this->dataStore->expects(self::once()) + ->method('perform') + ->willReturnCallback(function (array $requests) use (&$captured) { + $captured = $requests; + $mailSetWire = $requests[0]->jsonSerialize(); + $emailId = array_key_first($mailSetWire[1]['create']); + return new ResponseBundle([ + 'methodResponses' => [ + ['Email/set', ['created' => [$emailId => ['id' => 'remote-email-1']]], 'c0'], + ], + ]); + }); + + // Mimics JmapTransmissionConnector::buildEmailParams(), which always builds an + // explicit multipart/mixed bodyStructure before entitySave() wires attachments in. + $email = new MailParametersRequest(); + $body = $email->bodyPartStructure(); + $body->type('multipart/mixed'); + $body->addPart()->id('text-plain')->type('text/plain')->charset('utf-8'); + $email->bodyPartValue('text-plain', 'body'); + + $remoteId = $this->service->entitySave($email, $attachments); + + $this->assertEquals('remote-email-1', $remoteId); + $this->assertCount(1, $captured); + + $mailSetWire = $captured[0]->jsonSerialize(); + $emailId = array_key_first($mailSetWire[1]['create']); + $emailCreateObj = $mailSetWire[1]['create'][$emailId]; + $this->assertFalse(isset($emailCreateObj->attachments), 'must not set the separate "attachments" property alongside bodyStructure'); + $attachmentPart = $this->findAttachmentPart($emailCreateObj->bodyStructure); + $this->assertNotNull($attachmentPart); + $this->assertEquals('real-uploaded-blob', $attachmentPart->blobId); + $this->assertEquals('photo.jpg', $attachmentPart->name); + $this->assertFalse(isset($attachmentPart->partId), 'must not set partId alongside blobId'); + } + + public function testEntitySaveWithoutAttachmentsDoesNotTouchUpload(): void { + $this->dataStore->expects(self::never())->method('upload'); + $this->dataStore->expects(self::once()) + ->method('perform') + ->willReturnCallback(function (array $requests) { + $mailSetWire = $requests[0]->jsonSerialize(); + $emailId = array_key_first($mailSetWire[1]['create']); + return new ResponseBundle([ + 'methodResponses' => [ + ['Email/set', ['created' => [$emailId => ['id' => 'remote-email-3']]], 'c0'], + ], + ]); + }); + + $email = new MailParametersRequest(); + $remoteId = $this->service->entitySave($email, []); + + $this->assertEquals('remote-email-3', $remoteId); + } + + public function testEntitySaveThrowsWhenAttachmentUploadFails(): void { + $attachments = [ + [ + 'content' => 'binary-jpeg-bytes', + 'type' => 'image/jpeg', + 'name' => 'photo.jpg', + 'disposition' => 'attachment', + 'contentId' => null, + ], + ]; + + $this->dataStore->expects(self::once()) + ->method('upload') + ->willReturn(json_encode(['accountId' => 'account1'])); + $this->dataStore->expects(self::never())->method('perform'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Blob upload did not return a blob id'); + + $this->service->entitySave(new MailParametersRequest(), $attachments); + } +} diff --git a/tests/Unit/Service/MailTransmissionTest.php b/tests/Unit/Service/MailTransmissionTest.php deleted file mode 100644 index a17e8aaac2..0000000000 --- a/tests/Unit/Service/MailTransmissionTest.php +++ /dev/null @@ -1,605 +0,0 @@ -protocolFactory = $this->createMock(ProtocolFactory::class); - $this->smtpClientFactory = $this->createMock(SmtpClientFactory::class); - $this->eventDispatcher = $this->createMock(IEventDispatcher::class); - $this->mailboxMapper = $this->createMock(MailboxMapper::class); - $this->messageMapper = $this->createMock(MessageMapper::class); - $this->logger = $this->createMock(LoggerInterface::class); - $this->performanceLogger = $this->createMock(PerformanceLogger::class); - $this->aliasService = $this->createMock(AliasesService::class); - $this->transmissionService = $this->createMock(TransmissionService::class); - $this->mailManager = $this->createMock(MailManager::class); - - $this->transmission = new MailTransmission( - $this->protocolFactory, - $this->smtpClientFactory, - $this->eventDispatcher, - $this->mailboxMapper, - $this->messageMapper, - $this->logger, - $this->performanceLogger, - $this->aliasService, - $this->transmissionService, - $this->mailManager, - ); - } - - public function testSendNewMessage() { - $mailAccount = new MailAccount(); - $mailAccount->setUserId('testuser'); - $mailAccount->setSentMailboxId(123); - /** @var Account|MockObject $account */ - $account = $this->createMock(Account::class); - $account->method('getMailAccount')->willReturn($mailAccount); - $account->method('getName')->willReturn('Test User'); - $account->method('getEMailAddress')->willReturn('test@user'); - $localMessage = new LocalMessage(); - $localMessage->setSubject('Test'); - $localMessage->setBodyPlain('Test'); - $localMessage->setHtml(false); - $transport = $this->createStub(Horde_Mail_Transport::class); - - $this->smtpClientFactory->expects($this->once()) - ->method('create') - ->with($account) - ->willReturn($transport); - $this->transmissionService->expects(self::once()) - ->method('getSignMimePart') - ->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::once()) - ->method('getEncryptMimePart') - ->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart); - - $this->transmission->sendMessage($account, $localMessage); - } - - public function testSendNewMessageSmimeError() { - $mailAccount = new MailAccount(); - $mailAccount->setUserId('testuser'); - $mailAccount->setSentMailboxId(123); - /** @var Account|MockObject $account */ - $account = $this->createMock(Account::class); - $account->method('getMailAccount')->willReturn($mailAccount); - $account->method('getName')->willReturn('Test User'); - $account->method('getEMailAddress')->willReturn('test@user'); - $localMessage = new LocalMessage(); - $localMessage->setSubject('Test'); - $localMessage->setBodyPlain('Test'); - $localMessage->setHtml(false); - $transport = $this->createStub(Horde_Mail_Transport::class); - - $this->transmissionService->expects(self::once()) - ->method('getSignMimePart') - ->willThrowException(new ServiceException()); - $this->smtpClientFactory->expects(self::once()) - ->method('create') - ->with($account) - ->willReturn($transport); - $this->eventDispatcher->expects(self::never()) - ->method('dispatchTyped'); - - $this->transmission->sendMessage($account, $localMessage); - } - - public function testSendMessageFromAlias() { - $mailAccount = new MailAccount(); - $mailAccount->setName('Bob'); - $mailAccount->setEmail('bob@example.org'); - $mailAccount->setUserId('bob'); - $mailAccount->setSentMailboxId(123); - $account = new Account($mailAccount); - $alias = new Alias(); - $alias->setId(1); - $alias->setName('Info'); - $alias->setAlias('info@example.org'); - $localMessage = new LocalMessage(); - $localMessage->setSubject('Test'); - $localMessage->setBodyPlain('Test'); - $localMessage->setHtml(false); - $localMessage->setAliasId(1); - $localMessage->setRequestMdn(true); - $transport = $this->createStub(Horde_Mail_Transport::class); - $this->smtpClientFactory->expects($this->once()) - ->method('create') - ->willReturn($transport); - $this->aliasService->expects(self::once()) - ->method('find') - ->willReturn($alias); - $this->transmissionService->expects(self::once()) - ->method('getSignMimePart') - ->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::once()) - ->method('getEncryptMimePart') - ->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart); - - $this->transmission->sendMessage($account, $localMessage); - - $this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus()); - $this->assertStringContainsString('From: Info getRaw()); - $this->assertStringContainsString('Disposition-Notification-To: Info ', $localMessage->getRaw()); - } - - public function testSendMessageAliasFallbackName() { - $mailAccount = new MailAccount(); - $mailAccount->setName('Bob'); - $mailAccount->setEmail('bob@example.org'); - $mailAccount->setUserId('bob'); - $mailAccount->setSentMailboxId(123); - $account = new Account($mailAccount); - $alias = new Alias(); - $alias->setId(1); - $alias->setAlias('info@example.org'); - $localMessage = new LocalMessage(); - $localMessage->setSubject('Test'); - $localMessage->setBodyPlain('Test'); - $localMessage->setHtml(false); - $localMessage->setAliasId(1); - $localMessage->setRequestMdn(true); - $transport = $this->createStub(Horde_Mail_Transport::class); - $this->smtpClientFactory->expects($this->once()) - ->method('create') - ->willReturn($transport); - $this->aliasService->expects(self::once()) - ->method('find') - ->willReturn($alias); - $this->transmissionService->expects(self::once()) - ->method('getSignMimePart') - ->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::once()) - ->method('getEncryptMimePart') - ->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart); - - $this->transmission->sendMessage($account, $localMessage); - - $this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus()); - $this->assertStringContainsString('From: Bob getRaw()); - $this->assertStringContainsString('Disposition-Notification-To: Bob ', $localMessage->getRaw()); - } - - public function testSendMessageAliasDoesNotExist() { - $mailAccount = new MailAccount(); - $mailAccount->setName('Bob'); - $mailAccount->setEmail('bob@example.org'); - $mailAccount->setUserId('bob'); - $mailAccount->setSentMailboxId(123); - $account = new Account($mailAccount); - $localMessage = new LocalMessage(); - $localMessage->setSubject('Test'); - $localMessage->setBodyPlain('Test'); - $localMessage->setHtml(false); - $localMessage->setAliasId(1); - $localMessage->setRequestMdn(true); - $transport = $this->createStub(Horde_Mail_Transport::class); - $this->smtpClientFactory->expects($this->once()) - ->method('create') - ->willReturn($transport); - $this->aliasService->expects(self::once()) - ->method('find') - ->willThrowException(new DoesNotExistException('Alias does not exist')); - $this->transmissionService->expects(self::once()) - ->method('getSignMimePart') - ->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::once()) - ->method('getEncryptMimePart') - ->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart); - - $this->transmission->sendMessage($account, $localMessage); - - $this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus()); - $this->assertStringContainsString('From: Bob getRaw()); - $this->assertStringContainsString('Disposition-Notification-To: Bob ', $localMessage->getRaw()); - } - - public function testSendNewMessageWithMessageAsAttachment() { - $userId = 'testuser'; - $mailAccount = new MailAccount(); - $mailAccount->setUserId($userId); - $mailAccount->setSentMailboxId(123); - /** @var Account|MockObject $account */ - $account = $this->createMock(Account::class); - $account->method('getMailAccount')->willReturn($mailAccount); - $account->method('getName')->willReturn('Test User'); - $account->method('getEMailAddress')->willReturn('test@user'); - $account->method('getUserId')->willReturn($userId); - $localMessage = new LocalMessage(); - $localMessage->setSubject('Test'); - $localMessage->setBodyPlain('Test'); - $localMessage->setHtml(false); - $attachment = new LocalAttachment(); - $attachment->setId(1); - $localMessage->setAttachments([$attachment]); - $message = new Message(); - $transport = $this->createStub(Horde_Mail_Transport::class); - $attachmentMessage = new DbMessage(); - $attachmentMessage->setMailboxId(1234); - $attachmentMessage->setUid(11); - $mailbox = new DbMailbox(); - $mailbox->setAccountId(22); - $mailbox->setName('mock'); - - $this->smtpClientFactory->expects($this->once()) - ->method('create') - ->with($account) - ->willReturn($transport); - $this->transmissionService->expects(self::once()) - ->method('getAttachments') - ->with($localMessage) - ->willReturn([[ - 'type' => 'local', - 'id' => 1, - ]] - ); - $this->transmissionService->expects(self::once()) - ->method('handleAttachment'); - $this->transmissionService->expects(self::once()) - ->method('getSignMimePart') - ->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::once()) - ->method('getEncryptMimePart') - ->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart); - - $this->transmission->sendMessage($account, $localMessage); - $this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus()); - } - - public function testReplyToAnExistingMessage() { - $mailAccount = new MailAccount(); - $mailAccount->setUserId('testuser'); - $mailAccount->setSentMailboxId(123); - /** @var Account|MockObject $account */ - $account = $this->createMock(Account::class); - $account->method('getMailAccount')->willReturn($mailAccount); - $account->method('getName')->willReturn('Test User'); - $account->method('getEMailAddress')->willReturn('test@user'); - $localMessage = new LocalMessage(); - $localMessage->setSubject('Test'); - $localMessage->setBodyPlain('Test'); - $localMessage->setHtml(false); - $localMessage->setInReplyToMessageId('321'); - $repliedMessageUid = 321; - $messageInReply = new DbMessage(); - $messageInReply->setUid($repliedMessageUid); - $messageInReply->setMessageId('message@server'); - $message = new Message(); - $transport = $this->createStub(Horde_Mail_Transport::class); - - $this->smtpClientFactory->expects($this->once()) - ->method('create') - ->with($account) - ->willReturn($transport); - $this->transmissionService->expects(self::once()) - ->method('getSignMimePart') - ->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::once()) - ->method('getEncryptMimePart') - ->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart); - - $this->transmission->sendMessage($account, $localMessage); - $this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus()); - } - - public function testSaveDraft() { - $mailAccount = new MailAccount(); - $mailAccount->setUserId('testuser'); - $mailAccount->setDraftsMailboxId(123); - /** @var Account|MockObject $account */ - $account = $this->createMock(Account::class); - $account->method('getMailAccount')->willReturn($mailAccount); - $account->method('getName')->willReturn('Test User'); - $account->method('getEMailAddress')->willReturn('test@user'); - $messageData = NewMessageData::fromRequest($account, 'sub', 'bod', 'to@d.com', '', ''); - $message = new Message(); - - $client = $this->createStub(Horde_Imap_Client_Socket::class); - $this->protocolFactory->expects($this->once()) - ->method('imapClient') - ->with($account) - ->willReturn($client); - $draftsMailbox = new DbMailbox(); - $this->mailboxMapper->expects($this->once()) - ->method('findById') - ->with(123) - ->willReturn($draftsMailbox); - $this->messageMapper->expects($this->once()) - ->method('save') - ->with($client, $draftsMailbox, $this->anything()) - ->willReturn(13); - - [, , $newId] = $this->transmission->saveDraft($messageData); - - $this->assertEquals(13, $newId); - } - - public function testSendLocalDraft(): void { - $mailAccount = new MailAccount(); - $mailAccount->setId(10); - $mailAccount->setUserId('gunther'); - $mailAccount->setName('Gunther'); - $mailAccount->setEmail('gunther@stardewvalley-museum.com'); - $mailAccount->setDraftsMailboxId(123); - $localMessage = new LocalMessage(); - $localMessage->setType(LocalMessage::TYPE_DRAFT); - $localMessage->setAccountId($mailAccount->getId()); - $localMessage->setAliasId(2); - $localMessage->setSendAt(123); - $localMessage->setSubject('subject'); - $localMessage->setBodyHtml('message'); - $localMessage->setHtml(true); - $localMessage->setInReplyToMessageId('abc'); - $localMessage->setAttachments([]); - $to = Recipient::fromParams([ - 'email' => 'emily@stardewvalleypub.com', - 'label' => 'Emily', - 'type' => Recipient::TYPE_TO - ]); - $localMessage->setRecipients([$to]); - $replyMessage = new DbMessage(); - $replyMessage->setMessageId('abc'); - - $this->messageMapper->expects(self::once()) - ->method('save'); - - $this->transmission->saveLocalDraft(new Account($mailAccount), $localMessage); - } - - public function testSaveLocalDraftWithAiGeneratedHeader(): void { - $mailAccount = new MailAccount(); - $mailAccount->setId(10); - $mailAccount->setUserId('gunther'); - $mailAccount->setName('Gunther'); - $mailAccount->setEmail('gunther@stardewvalley-museum.com'); - $mailAccount->setDraftsMailboxId(123); - $localMessage = new LocalMessage(); - $localMessage->setType(LocalMessage::TYPE_DRAFT); - $localMessage->setAccountId($mailAccount->getId()); - $localMessage->setSubject('subject'); - $localMessage->setBodyHtml('message'); - $localMessage->setHtml(true); - $localMessage->setAttachments([]); - $localMessage->setAiGenerated(true); - $to = Recipient::fromParams([ - 'email' => 'emily@stardewvalleypub.com', - 'label' => 'Emily', - 'type' => Recipient::TYPE_TO - ]); - $localMessage->setRecipients([$to]); - - $this->messageMapper->expects(self::once()) - ->method('save') - ->with( - self::anything(), - self::anything(), - self::callback(static fn (string $raw) => str_contains($raw, LocalMessage::HEADER_AI_GENERATED . ': 1')), - self::anything(), - ); - - $this->transmission->saveLocalDraft(new Account($mailAccount), $localMessage); - } - - public function testCreateDraftsMailboxAndSave(): void { - $mailAccount = new MailAccount(); - $mailAccount->setId(10); - $mailAccount->setUserId('alice'); - $mailAccount->setName('Alice'); - $mailAccount->setEmail('alice@mail.example'); - $mailAccount->setDraftsMailboxId(null); - $localMessage = new LocalMessage(); - $localMessage->setType(LocalMessage::TYPE_DRAFT); - $localMessage->setAccountId($mailAccount->getId()); - $localMessage->setAliasId(1); - $localMessage->setSendAt(1000); - $localMessage->setSubject('Subject'); - $localMessage->setBodyHtml('

Body

'); - $localMessage->setHtml(true); - $to = new Recipient(); - $to->setLabel('Bob'); - $to->setEmail('bob@mail.example'); - $to->setType(Recipient::TYPE_TO); - $localMessage->setRecipients([$to]); - - $this->mailManager->expects(self::once()) - ->method('createMailbox'); - $this->messageMapper->expects(self::once()) - ->method('save'); - - $this->transmission->saveLocalDraft(new Account($mailAccount), $localMessage); - } - - public function testSendMessageWithAiGeneratedHeader(): void { - $mailAccount = new MailAccount(); - $mailAccount->setName('Bob'); - $mailAccount->setEmail('bob@mail.example'); - $mailAccount->setUserId('bob'); - $mailAccount->setSentMailboxId(123); - $account = new Account($mailAccount); - $localMessage = new LocalMessage(); - $localMessage->setSubject('Test'); - $localMessage->setBodyPlain('Test'); - $localMessage->setHtml(false); - $localMessage->setAiGenerated(true); - $transport = $this->createStub(Horde_Mail_Transport::class); - $this->smtpClientFactory->expects($this->once()) - ->method('create') - ->willReturn($transport); - $this->transmissionService->expects(self::once()) - ->method('getSignMimePart') - ->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::once()) - ->method('getEncryptMimePart') - ->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart); - - $this->transmission->sendMessage($account, $localMessage); - - $this->assertStringContainsString(LocalMessage::HEADER_AI_GENERATED . ': 1', $localMessage->getRaw()); - } - - public function testSendMessageWithoutAiGeneratedHeader(): void { - $mailAccount = new MailAccount(); - $mailAccount->setName('Bob'); - $mailAccount->setEmail('bob@mail.example'); - $mailAccount->setUserId('bob'); - $mailAccount->setSentMailboxId(123); - $account = new Account($mailAccount); - $localMessage = new LocalMessage(); - $localMessage->setSubject('Test'); - $localMessage->setBodyPlain('Test'); - $localMessage->setHtml(false); - $localMessage->setAiGenerated(false); - $transport = $this->createStub(Horde_Mail_Transport::class); - $this->smtpClientFactory->expects($this->once()) - ->method('create') - ->willReturn($transport); - $this->transmissionService->expects(self::once()) - ->method('getSignMimePart') - ->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::once()) - ->method('getEncryptMimePart') - ->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart); - - $this->transmission->sendMessage($account, $localMessage); - - $this->assertStringNotContainsString(LocalMessage::HEADER_AI_GENERATED, $localMessage->getRaw()); - } - - public function testSendMessageCc() { - $mailAccount = new MailAccount(); - $mailAccount->setName('Bob'); - $mailAccount->setEmail('bob@mail.example'); - $mailAccount->setUserId('bob'); - $mailAccount->setSentMailboxId(123); - $account = new Account($mailAccount); - $localMessage = new LocalMessage(); - $localMessage->setSubject('Test'); - $localMessage->setBodyPlain('Test'); - $localMessage->setHtml(false); - $transport = $this->createStub(Horde_Mail_Transport::class); - $this->smtpClientFactory->expects($this->once()) - ->method('create') - ->willReturn($transport); - $this->transmissionService->expects(self::once()) - ->method('getSignMimePart') - ->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::once()) - ->method('getEncryptMimePart') - ->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::exactly(3)) - ->method('getAddressList') - ->willReturnCallback(function ($localMessage, $type) { - $addresses = []; - - if ($type === Recipient::TYPE_CC) { - $addresses[] = Address::fromRaw('Alice', 'alice@mail.example'); - } - - if ($type === Recipient::TYPE_BCC) { - $addresses[] = Address::fromRaw('Jane', 'jane@mail.example'); - } - - return new AddressList($addresses); - }); - - $this->transmission->sendMessage($account, $localMessage); - - $this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus()); - $this->assertStringContainsString('From: Bob ', $localMessage->getRaw()); - $this->assertStringContainsString('Cc: Alice ', $localMessage->getRaw()); - $this->assertStringContainsString('Bcc: Jane ', $localMessage->getRaw()); - } - - public function testSendMessageOmitCc() { - $mailAccount = new MailAccount(); - $mailAccount->setName('Bob'); - $mailAccount->setEmail('bob@example.org'); - $mailAccount->setUserId('bob'); - $mailAccount->setSentMailboxId(123); - $account = new Account($mailAccount); - $localMessage = new LocalMessage(); - $localMessage->setSubject('Test'); - $localMessage->setBodyPlain('Test'); - $localMessage->setHtml(false); - $transport = $this->createStub(Horde_Mail_Transport::class); - $this->smtpClientFactory->expects($this->once()) - ->method('create') - ->willReturn($transport); - $this->transmissionService->expects(self::once()) - ->method('getSignMimePart') - ->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::once()) - ->method('getEncryptMimePart') - ->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart); - $this->transmissionService->expects(self::exactly(3)) - ->method('getAddressList') - ->willReturnCallback(static function ($message, $type) { - $addresses = []; - - if ($type === Recipient::TYPE_TO) { - $addresses[] = Address::fromRaw('Alice', 'alice@mail.example'); - } - - return new AddressList($addresses); - }); - - $this->transmission->sendMessage($account, $localMessage); - - $this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus()); - $this->assertStringContainsString('From: Bob ', $localMessage->getRaw()); - $this->assertStringNotContainsString('Cc:', $localMessage->getRaw()); - $this->assertStringNotContainsString('Bcc:', $localMessage->getRaw()); - } -} diff --git a/tests/Unit/Service/OutboxServiceTest.php b/tests/Unit/Service/OutboxServiceTest.php index 48086feebd..7cf8a6419e 100644 --- a/tests/Unit/Service/OutboxServiceTest.php +++ b/tests/Unit/Service/OutboxServiceTest.php @@ -17,7 +17,6 @@ use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\Recipient; use OCA\Mail\Exception\ClientException; -use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Send\Chain; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\Attachment\AttachmentService; @@ -45,9 +44,6 @@ class OutboxServiceTest extends TestCase { /** @var AttachmentService|MockObject */ private $attachmentService; - /** @var ProtocolFactory|MockObject */ - private $protocolFactory; - /** @var MailManager|MockObject */ private $mailManager; @@ -66,7 +62,6 @@ protected function setUp(): void { $this->mapper = $this->createMock(LocalMessageMapper::class); $this->attachmentService = $this->createMock(AttachmentService::class); - $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->mailManager = $this->createMock(MailManager::class); $this->accountService = $this->createMock(AccountService::class); $this->timeFactory = $this->createMock(ITimeFactory::class); @@ -76,7 +71,6 @@ protected function setUp(): void { $this->mapper, $this->attachmentService, $this->createMock(EventDispatcher::class), - $this->protocolFactory, $this->mailManager, $this->accountService, $this->timeFactory, @@ -211,19 +205,14 @@ public function testSaveMessage(): void { $account = $this->createConfiguredMock(Account::class, [ 'getUserId' => $this->userId ]); - $client = $this->createStub(\Horde_Imap_Client_Socket::class); $this->mapper->expects(self::once()) ->method('saveWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->protocolFactory->expects(self::once()) - ->method('imapClient') - ->with($account) - ->willReturn($client); $this->attachmentService->expects(self::once()) ->method('handleAttachments') - ->with($account, $attachments, $client) + ->with($account, $attachments) ->willReturn($attachmentIds); $this->attachmentService->expects(self::once()) ->method('saveLocalMessageAttachments') @@ -265,8 +254,6 @@ public function testSaveMessageNoAttachments(): void { ->method('saveWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->protocolFactory->expects(self::never()) - ->method('imapClient'); $this->attachmentService->expects(self::never()) ->method('handleAttachments'); $this->attachmentService->expects(self::never()) @@ -313,19 +300,14 @@ public function testUpdateMessage(): void { $account = $this->createConfiguredMock(Account::class, [ 'getUserId' => $this->userId ]); - $client = $this->createStub(\Horde_Imap_Client_Socket::class); $this->mapper->expects(self::once()) ->method('updateWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->protocolFactory->expects(self::once()) - ->method('imapClient') - ->with($account) - ->willReturn($client); $this->attachmentService->expects(self::once()) ->method('handleAttachments') - ->with($account, $attachments, $client) + ->with($account, $attachments) ->willReturn($attachmentIds); $this->attachmentService->expects(self::once()) ->method('updateLocalMessageAttachments') @@ -377,8 +359,6 @@ public function testUpdateMessageNoAttachments(): void { $this->attachmentService->expects(self::once()) ->method('updateLocalMessageAttachments') ->with($this->userId, $message2, $attachments); - $this->protocolFactory->expects(self::never()) - ->method('imapClient'); $this->attachmentService->expects(self::never()) ->method('handleAttachments'); diff --git a/tests/Unit/Service/TransmissionServiceTest.php b/tests/Unit/Service/TransmissionServiceTest.php index 342a83d172..cef3e464da 100644 --- a/tests/Unit/Service/TransmissionServiceTest.php +++ b/tests/Unit/Service/TransmissionServiceTest.php @@ -16,15 +16,10 @@ use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Recipient; -use OCA\Mail\Db\SmimeCertificate; use OCA\Mail\Exception\AttachmentNotFoundException; -use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Exception\SmimeSignException; use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\GroupsIntegration; -use OCA\Mail\Service\SmimeService; use OCA\Mail\Service\TransmissionService; -use OCP\AppFramework\Db\DoesNotExistException; use OCP\Files\SimpleFS\InMemoryFile; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -34,7 +29,6 @@ class TransmissionServiceTest extends TestCase { private GroupsIntegration|MockObject $groupsIntegration; private AttachmentService|MockObject $attachmentService; private LoggerInterface|MockObject $logger; - private SmimeService|MockObject $smimeService; private MockObject|TransmissionService $transmissionService; protected function setUp(): void { @@ -42,13 +36,11 @@ protected function setUp(): void { $this->attachmentService = $this->createMock(AttachmentService::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->smimeService = $this->createMock(SmimeService::class); $this->groupsIntegration = $this->createMock(GroupsIntegration::class); $this->transmissionService = new TransmissionService( $this->groupsIntegration, $this->attachmentService, $this->logger, - $this->smimeService, ); } @@ -84,7 +76,7 @@ public function testGetAttachments() { $this->assertEquals($expected, $actual); } - public function testHandleAttachment(): void { + public function testGetAttachmentContent(): void { $mailAccount = new MailAccount(); $mailAccount->setUserId('bob'); $account = new Account($mailAccount); @@ -105,12 +97,18 @@ public function testHandleAttachment(): void { $this->logger->expects(self::never()) ->method('warning'); - $part = $this->transmissionService->handleAttachment($account, ['id' => 1, 'type' => 'local']); + $content = $this->transmissionService->getAttachmentContent($account, ['id' => 1, 'type' => 'local']); - $this->assertEquals('test.txt', $part->getContentTypeParameter('name')); + $this->assertEquals([ + 'content' => "Hello, I'm a test file.", + 'type' => 'text/plain', + 'name' => 'test.txt', + 'disposition' => 'attachment', + 'contentId' => null, + ], $content); } - public function testHandleAttachmentInlineWithContentId(): void { + public function testGetAttachmentContentInlineWithContentId(): void { $mailAccount = new MailAccount(); $mailAccount->setUserId('bob'); $account = new Account($mailAccount); @@ -127,14 +125,15 @@ public function testHandleAttachmentInlineWithContentId(): void { ->method('getAttachment') ->willReturn([$attachment, $file]); - $part = $this->transmissionService->handleAttachment($account, ['id' => 1, 'type' => 'local']); + $content = $this->transmissionService->getAttachmentContent($account, ['id' => 1, 'type' => 'local']); - $this->assertEquals('inline', $part->getDisposition()); - $this->assertEquals('img001@example.com', $part->getContentId()); - $this->assertEquals('logo.png', $part->getContentTypeParameter('name')); + $this->assertEquals('fake png content', $content['content']); + $this->assertEquals('inline', $content['disposition']); + $this->assertEquals('img001@example.com', $content['contentId']); + $this->assertEquals('logo.png', $content['name']); } - public function testHandleAttachmentNoId(): void { + public function testGetAttachmentContentNoId(): void { $mailAccount = new MailAccount(); $mailAccount->setUserId('bob'); $account = new Account($mailAccount); @@ -142,10 +141,12 @@ public function testHandleAttachmentNoId(): void { $this->logger->expects(self::once()) ->method('warning'); - $this->transmissionService->handleAttachment($account, ['type' => 'local']); + $content = $this->transmissionService->getAttachmentContent($account, ['type' => 'local']); + + $this->assertNull($content); } - public function testHandleAttachmentNotFound(): void { + public function testGetAttachmentContentNotFound(): void { $mailAccount = new MailAccount(); $mailAccount->setUserId('bob'); $account = new Account($mailAccount); @@ -156,273 +157,8 @@ public function testHandleAttachmentNotFound(): void { $this->logger->expects(self::once()) ->method('warning'); - $this->transmissionService->handleAttachment($account, ['id' => 1, 'type' => 'local']); - } - - public function testGetSignMimePart() { - $send = new \Horde_Mime_Part(); - $send->setContents('Test'); - $localMessage = new LocalMessage(); - $localMessage->setSmimeSign(true); - $localMessage->setSmimeCertificateId(1); - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $smimeCertificate = new SmimeCertificate(); - $smimeCertificate->setCertificate('123'); - - $this->smimeService->expects(self::once()) - ->method('findCertificate') - ->willReturn($smimeCertificate); - $this->smimeService->expects(self::once()) - ->method('signMimePart'); - - $this->transmissionService->getSignMimePart($localMessage, $account, $send); - $this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus()); - } - - public function testGetSignMimePartNoCertId() { - $send = new \Horde_Mime_Part(); - $send->setContents('Test'); - $localMessage = new LocalMessage(); - $localMessage->setSmimeSign(true); - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - - $this->smimeService->expects(self::never()) - ->method('findCertificate'); - $this->smimeService->expects(self::never()) - ->method('signMimePart'); - - $this->expectException(ServiceException::class); - $this->transmissionService->getSignMimePart($localMessage, $account, $send); - $this->assertEquals(LocalMessage::STATUS_SMIME_SIGN_NO_CERT_ID, $localMessage->getStatus()); - } - - public function testGetSignMimePartNoCertFound() { - $send = new \Horde_Mime_Part(); - $send->setContents('Test'); - $localMessage = new LocalMessage(); - $localMessage->setSmimeSign(true); - $localMessage->setSmimeCertificateId(1); - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - - $this->smimeService->expects(self::once()) - ->method('findCertificate') - ->willThrowException(new DoesNotExistException('')); - $this->smimeService->expects(self::never()) - ->method('signMimePart'); - - $this->expectException(ServiceException::class); - $this->transmissionService->getSignMimePart($localMessage, $account, $send); - $this->assertEquals(LocalMessage::STATUS_SMIME_SIGN_CERT, $localMessage->getStatus()); - } - - public function testGetSignMimePartFailedSigning() { - $send = new \Horde_Mime_Part(); - $send->setContents('Test'); - $localMessage = new LocalMessage(); - $localMessage->setSmimeSign(true); - $localMessage->setSmimeCertificateId(1); - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $smimeCertificate = new SmimeCertificate(); - $smimeCertificate->setCertificate('123'); - - $this->smimeService->expects(self::once()) - ->method('findCertificate') - ->willReturn($smimeCertificate); - $this->smimeService->expects(self::once()) - ->method('signMimePart') - ->willThrowException(new SmimeSignException()); - - $this->expectException(ServiceException::class); - $this->transmissionService->getSignMimePart($localMessage, $account, $send); - $this->assertEquals(LocalMessage::STATUS_SMIME_SIGN_FAIL, $localMessage->getStatus()); - } - - public function testGetEncryptMimePart() { - $send = new \Horde_Mime_Part(); - $send->setContents('Test'); - $localMessage = new LocalMessage(); - $localMessage->setSmimeEncrypt(true); - $localMessage->setSmimeCertificateId(1); - $to = new AddressList([Address::fromRaw('Bob', 'bob@test.com')]); - $cc = new AddressList([]); - $bcc = new AddressList([]); - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $smimeCertificate = new SmimeCertificate(); - $smimeCertificate->setCertificate('123'); - - $this->smimeService->expects(self::once()) - ->method('findCertificatesByAddressList') - ->willReturn([$smimeCertificate]); - $this->smimeService->expects(self::once()) - ->method('findCertificate') - ->willReturn($smimeCertificate); - $this->smimeService->expects(self::once()) - ->method('encryptMimePart'); - - $this->transmissionService->getEncryptMimePart($localMessage, $to, $cc, $bcc, $account, $send); - $this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus()); - } - - public function testGetEncryptMimePartNoCertId() { - $send = new \Horde_Mime_Part(); - $send->setContents('Test'); - $localMessage = new LocalMessage(); - $localMessage->setSmimeEncrypt(true); - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $to = new AddressList([Address::fromRaw('Bob', 'bob@test.com')]); - $cc = new AddressList([]); - $bcc = new AddressList([]); - - $this->expectException(ServiceException::class); - $this->transmissionService->getEncryptMimePart($localMessage, $to, $cc, $bcc, $account, $send); - $this->assertEquals(LocalMessage::STATUS_SMIME_ENCRYPT_NO_CERT_ID, $localMessage->getStatus()); - } - - public function testGetEncryptMimePartNoAddressCerts() { - $send = new \Horde_Mime_Part(); - $send->setContents('Test'); - $localMessage = new LocalMessage(); - $localMessage->setSmimeEncrypt(true); - $localMessage->setSmimeCertificateId(1); - $to = new AddressList([Address::fromRaw('Bob', 'bob@test.com')]); - $cc = new AddressList([]); - $bcc = new AddressList([]); - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $smimeCertificate = new SmimeCertificate(); - $smimeCertificate->setCertificate('123'); - - $this->smimeService->expects(self::once()) - ->method('findCertificatesByAddressList') - ->willThrowException(new ServiceException()); - $this->smimeService->expects(self::never()) - ->method('findCertificate'); - $this->smimeService->expects(self::never()) - ->method('encryptMimePart'); - - $this->expectException(ServiceException::class); - $this->transmissionService->getEncryptMimePart($localMessage, $to, $cc, $bcc, $account, $send); - $this->assertEquals(LocalMessage::STATUS_SMIME_ENCRYT_FAIL, $localMessage->getStatus()); - } - - public function testGetEncryptMimePartNoCert() { - $send = new \Horde_Mime_Part(); - $send->setContents('Test'); - $localMessage = new LocalMessage(); - $localMessage->setSmimeEncrypt(true); - $localMessage->setSmimeCertificateId(1); - $to = new AddressList([Address::fromRaw('Bob', 'bob@test.com')]); - $cc = new AddressList([]); - $bcc = new AddressList([]); - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $smimeCertificate = new SmimeCertificate(); - $smimeCertificate->setCertificate('123'); - - $this->smimeService->expects(self::once()) - ->method('findCertificatesByAddressList') - ->willReturn([$smimeCertificate]); - $this->smimeService->expects(self::once()) - ->method('findCertificate') - ->willThrowException(new DoesNotExistException('')); - $this->smimeService->expects(self::never()) - ->method('encryptMimePart'); - - $this->expectException(ServiceException::class); - $this->transmissionService->getEncryptMimePart($localMessage, $to, $cc, $bcc, $account, $send); - $this->assertEquals(LocalMessage::STATUS_SMIME_ENCRYPT_CERT, $localMessage->getStatus()); - } - - public function testGetEncryptMimePartEncryptFail() { - $send = new \Horde_Mime_Part(); - $send->setContents('Test'); - $localMessage = new LocalMessage(); - $localMessage->setSmimeEncrypt(true); - $localMessage->setSmimeCertificateId(1); - $to = new AddressList([Address::fromRaw('Bob', 'bob@test.com')]); - $cc = new AddressList([]); - $bcc = new AddressList([]); - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $smimeCertificate = new SmimeCertificate(); - $smimeCertificate->setCertificate('123'); - - $this->smimeService->expects(self::once()) - ->method('findCertificatesByAddressList') - ->willReturn([$smimeCertificate]); - $this->smimeService->expects(self::once()) - ->method('findCertificate') - ->willReturn($smimeCertificate); - $this->smimeService->expects(self::once()) - ->method('encryptMimePart') - ->willThrowException(new ServiceException()); - - $this->expectException(ServiceException::class); - $this->transmissionService->getEncryptMimePart($localMessage, $to, $cc, $bcc, $account, $send); - $this->assertEquals(LocalMessage::STATUS_SMIME_ENCRYT_FAIL, $localMessage->getStatus()); - } - - public function testHandleAttachmentKeepAdditionalContentTypeParameters(): void { - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $attachment = new LocalAttachment(); - $attachment->setFileName(null); - $attachment->setMimeType('text/calendar; method=REQUEST; charset="utf-8"; name=event.ics'); - // iMIP attachments must not carry a Content-Disposition header. - // See https://github.com/nextcloud/mail/issues/10416 - $attachment->setDisposition(LocalAttachment::DISPOSITION_OMIT); - $file = new InMemoryFile( - 'event.ics', - "BEGIN:VCALENDAR\nEND:VCALENDAR" - ); - $this->attachmentService->expects(self::once()) - ->method('getAttachment') - ->willReturn([$attachment, $file]); - - $part = $this->transmissionService->handleAttachment($account, ['id' => 1, 'type' => 'local']); - - $this->assertEquals('text/calendar', $part->getType()); - $this->assertEquals('REQUEST', $part->getContentTypeParameter('method')); - $this->assertEquals('utf-8', $part->getContentTypeParameter('charset')); - $this->assertEquals('event.ics', $part->getContentTypeParameter('name')); - } - - public function testHandleAttachmentImipOmitsContentDisposition(): void { - $mailAccount = new MailAccount(); - $mailAccount->setUserId('bob'); - $account = new Account($mailAccount); - $attachment = new LocalAttachment(); - $attachment->setFileName(null); - $attachment->setMimeType('text/calendar; method=REQUEST; charset="utf-8"; name=event.ics'); - // iMIP attachments must not carry a Content-Disposition header. - // See https://github.com/nextcloud/mail/issues/10416 - $attachment->setDisposition(LocalAttachment::DISPOSITION_OMIT); - $file = new InMemoryFile( - 'event.ics', - "BEGIN:VCALENDAR\nEND:VCALENDAR" - ); - $this->attachmentService->expects(self::once()) - ->method('getAttachment') - ->willReturn([$attachment, $file]); - - $part = $this->transmissionService->handleAttachment($account, ['id' => 1, 'type' => 'local']); + $content = $this->transmissionService->getAttachmentContent($account, ['id' => 1, 'type' => 'local']); - $this->assertEquals('', $part->getDisposition()); + $this->assertNull($content); } }