diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1deb51fdec..9b52abe161 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -17,7 +17,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; @@ -61,7 +60,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; @@ -119,7 +117,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 = $logger; $this->l10n = $l10n; $this->aliasesService = $aliasesService; - $this->mailTransmission = $mailTransmission; $this->setup = $setup; $this->mailManager = $mailManager; - $this->syncService = $syncService; $this->config = $config; $this->hostValidator = $hostValidator; $this->mailboxSync = $mailboxSync; @@ -398,59 +388,6 @@ public function create(string $accountName, ); } - /** - * @NoAdminRequired - * - * @return JSONResponse - * - * @throws ClientException - */ - #[TrapError] - public function draft(int $id, - string $subject, - string $body, - string $to, - string $cc, - string $bcc, - bool $isHtml = true, - ?int $draftId = null): JSONResponse { - if ($draftId === null) { - $this->logger->info("Saving a new draft in account <$id>"); - } else { - $this->logger->info("Updating draft <$draftId> in account <$id>"); - } - - $account = $this->accountService->find($this->currentUserId, $id); - $previousDraft = null; - if ($draftId !== null) { - try { - $previousDraft = $this->mailManager->getMessage($this->currentUserId, $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, - [] - ); - 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 c1341b4c69..9ba35baa62 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -15,8 +15,8 @@ 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\Protocol\ProtocolFactory; use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Db\Message; use OCA\Mail\Exception\ClientException; @@ -67,7 +67,7 @@ class MessagesController extends Controller { private IURLGenerator $urlGenerator; private ContentSecurityPolicyNonceManager $nonceManager; private ITrustedSenderService $trustedSenderService; - private IMailTransmission $mailTransmission; + private ProtocolFactory $protocolFactory; private SmimeService $smimeService; private IDkimService $dkimService; private IUserPreferences $preferences; @@ -89,7 +89,7 @@ public function __construct( IURLGenerator $urlGenerator, ContentSecurityPolicyNonceManager $nonceManager, ITrustedSenderService $trustedSenderService, - IMailTransmission $mailTransmission, + ProtocolFactory $protocolFactory, SmimeService $smimeService, IDkimService $dkimService, IUserPreferences $preferences, @@ -110,7 +110,7 @@ public function __construct( $this->urlGenerator = $urlGenerator; $this->nonceManager = $nonceManager; $this->trustedSenderService = $trustedSenderService; - $this->mailTransmission = $mailTransmission; + $this->protocolFactory = $protocolFactory; $this->smimeService = $smimeService; $this->dkimService = $dkimService; $this->preferences = $preferences; @@ -495,7 +495,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()); @@ -617,18 +617,14 @@ public function getHtmlBody(int $id, bool $plain = false): Response { $html = $cacheInstance->get($imapMessageCacheKey); if ($html === null) { - $client = $this->clientFactory->getClient($account); - try { - $html = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), - true - )->getHtmlBody($id); - } finally { - $client->logout(); - } + $html = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message, + true + )->getHtmlBody( + $id + ); } diff --git a/lib/IMAP/ImapTransmissionConnector.php b/lib/IMAP/ImapTransmissionConnector.php new file mode 100644 index 0000000000..fda68f46e6 --- /dev/null +++ b/lib/IMAP/ImapTransmissionConnector.php @@ -0,0 +1,360 @@ +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 = $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); + + $name = $account->getName(); + $emailAddress = $account->getEMailAddress(); + + if ($message->getAliasId() !== null) { + try { + $alias = $this->aliasesService->find($message->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' => $message->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); + + $headers = $this->buildHeaders($from, $to, $cc, $bcc, $message->getSubject()); + + if (($inReplyTo = $message->getInReplyToMessageId()) !== null) { + $headers['References'] = $inReplyTo; + $headers['In-Reply-To'] = $inReplyTo; + } + + if ($message->getRequestMdn()) { + $headers[\Horde_Mime_Mdn::MDN_HEADER] = $from->toHorde(); + } + + $mail = new Horde_Mime_Mail(); + $mail->addHeaders($headers); + + $mimePart = $this->mimeMessage->build( + $message->getBodyPlain(), + $message->getBodyHtml(), + $message->isPgpMime() === true, + $attachmentParts, + ); + + try { + $mimePart = $this->transmissionService->getSignMimePart($message, $account, $mimePart); + $mimePart = $this->transmissionService->getEncryptMimePart($message, $to, $cc, $bcc, $account, $mimePart); + } catch (ServiceException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return; + } + + $mail->setBasePart($mimePart); + + try { + $mail->send($transport, false, false); + $message->setRaw($mail->getRaw(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($mail->getRaw(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 = $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 message to IMAP mailbox'); + + $from = Address::fromRaw($account->getName(), $account->getEMailAddress()); + + foreach ($attachments as $attachment) { + $this->transmissionService->handleAttachment($account, $attachment); + } + + $headers = $this->buildHeaders($from, $to, $cc, $bcc, $message->getSubject()); + + $mail = new Horde_Mime_Mail(); + $mail->addHeaders($headers); + 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{ + * From: \Horde_Mail_Rfc822_Address, + * To: \Horde_Mail_Rfc822_List, + * Subject: string|null, + * Cc?: \Horde_Mail_Rfc822_List, + * Bcc?: \Horde_Mail_Rfc822_List, + * } + */ + private function buildHeaders(Address $from, \OCA\Mail\AddressList $to, \OCA\Mail\AddressList $cc, \OCA\Mail\AddressList $bcc, ?string $subject): array { + $headers = [ + 'From' => $from->toHorde(), + 'To' => $to->toHorde(), + 'Subject' => $subject, + ]; + + if (count($cc) > 0) { + $headers['Cc'] = $cc->toHorde(); + } + if (count($bcc) > 0) { + $headers['Bcc'] = $bcc->toHorde(); + } + + return $headers; + } +} diff --git a/lib/JMAP/JmapTransmissionConnector.php b/lib/JMAP/JmapTransmissionConnector.php new file mode 100644 index 0000000000..7a1158b505 --- /dev/null +++ b/lib/JMAP/JmapTransmissionConnector.php @@ -0,0 +1,270 @@ +transmissionService->getAddressList($message, Recipient::TYPE_TO); + $cc = $this->transmissionService->getAddressList($message, Recipient::TYPE_CC); + $bcc = $this->transmissionService->getAddressList($message, Recipient::TYPE_BCC); + + $name = $account->getName(); + $emailAddress = $account->getEMailAddress(); + + if ($message->getAliasId() !== null) { + try { + $alias = $this->aliasesService->find($message->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.', [ + 'aliasId' => $message->getAliasId(), + 'accountId' => $account->getId(), + ]); + } + } + + $from = Address::fromRaw($name, $emailAddress); + + $sentMailboxRid = $sentMailbox->getRemoteId(); + if ($sentMailboxRid === null) { + $this->logger->error('Sent mailbox does not have a JMAP remote ID', ['mailboxId' => $sentMailbox->getId()]); + $message->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()]); + $message->setStatus(LocalMessage::STATUS_ERROR); + return; + } + + try { + $identityId = $this->resolveIdentityId($emailAddress); + } catch (Exception $e) { + $this->logger->error('Could not resolve JMAP identity for send: ' . $e->getMessage(), ['exception' => $e]); + $message->setStatus(LocalMessage::STATUS_ERROR); + return; + } + + $rcptTo = $this->collectEnvelopeRecipients($to, $cc, $bcc); + $emailParams = $this->buildEmailParams($from, $to, $cc, $bcc, $message); + + try { + $this->jmapOperationsService->entitySend( + $identityId, + $emailParams, + $draftsMailboxRid, + $sentMailboxRid, + $from->getEmail() ?? $emailAddress, + $rcptTo, + ); + $message->setStatus(LocalMessage::STATUS_PROCESSED); + } catch (Exception $e) { + $status = $this->classifyJmapError($e->getMessage()); + $this->logger->error('JMAP send failed: ' . $e->getMessage(), ['exception' => $e]); + $message->setStatus($status); + } + } + + #[\Override] + public function saveMessage(Account $account, Mailbox $mailbox, LocalMessage $message, 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($message, Recipient::TYPE_TO); + $cc = $this->transmissionService->getAddressList($message, Recipient::TYPE_CC); + $bcc = $this->transmissionService->getAddressList($message, Recipient::TYPE_BCC); + $from = Address::fromRaw($account->getName(), $account->getEMailAddress()); + + $emailParams = $this->buildEmailParams($from, $to, $cc, $bcc, $message); + + // Apply mailbox location and keyword flags + $keywords = []; + foreach ($flags as $flag) { + $keywords[$flag] = true; + } + $emailParams->in($remoteId); + if ($keywords !== []) { + $emailParams->keywords($keywords); + } + + $this->jmapOperationsService->connect($account); + try { + $this->jmapOperationsService->entitySave($emailParams); + } 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 buildEmailParams( + Address $from, + AddressList $to, + AddressList $cc, + AddressList $bcc, + LocalMessage $message, + ): MailParametersRequest { + $emailParams = new MailParametersRequest(); + $emailParams->from($from->getEmail() ?? '', $from->getLabel() ?? ''); + + foreach ($to->iterate() as $address) { + $emailParams->to($address->getEmail() ?? '', $address->getLabel() ?? ''); + } + + foreach ($cc->iterate() as $address) { + $emailParams->cc($address->getEmail() ?? '', $address->getLabel() ?? ''); + } + + foreach ($bcc->iterate() as $address) { + $emailParams->bcc($address->getEmail() ?? '', $address->getLabel() ?? ''); + } + + $emailParams->subject($message->getSubject() ?? ''); + + if (($inReplyTo = $message->getInReplyToMessageId()) !== null) { + $emailParams->inReplyTo($inReplyTo); + $emailParams->references($inReplyTo); + } + + $bodyPlain = $message->getBodyPlain() ?? ''; + $bodyHtml = $message->getBodyHtml(); + + if ($bodyHtml !== null && $bodyHtml !== '') { + $bodyStructure = $emailParams->bodyPartStructure(); + $bodyStructure->type('multipart/alternative'); + $bodyStructure->addPart()->id('text')->type('text/plain'); + $bodyStructure->addPart()->id('html')->type('text/html'); + + $emailParams->bodyPartValue('text', $bodyPlain); + $emailParams->bodyPartValue('html', $bodyHtml); + } else { + $emailParams->bodyTextPlain($bodyPlain, 'text'); + } + + return $emailParams; + } + + /** + * 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/Protocol/ProtocolFactory.php b/lib/Protocol/ProtocolFactory.php index 331052297c..b1b6cee935 100644 --- a/lib/Protocol/ProtocolFactory.php +++ b/lib/Protocol/ProtocolFactory.php @@ -24,6 +24,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 { @@ -35,12 +36,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 b1aa900cc7..2391a47b07 100644 --- a/lib/Send/AHandler.php +++ b/lib/Send/AHandler.php @@ -7,7 +7,6 @@ */ namespace OCA\Mail\Send; -use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\Db\LocalMessage; @@ -22,16 +21,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 17d84aaf8d..31709f3f14 100644 --- a/lib/Send/AntiAbuseHandler.php +++ b/lib/Send/AntiAbuseHandler.php @@ -7,7 +7,6 @@ */ namespace OCA\Mail\Send; -use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Service\AntiAbuseService; @@ -27,11 +26,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()); @@ -52,6 +50,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 43add7b222..cd6a5eee59 100644 --- a/lib/Send/Chain.php +++ b/lib/Send/Chain.php @@ -11,20 +11,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, ) { } @@ -34,12 +30,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 @@ -49,12 +39,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 dffe3b08b9..0000000000 --- a/lib/Send/CopySentMessageHandler.php +++ /dev/null @@ -1,85 +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 d0982579ce..ae7000ed6b 100644 --- a/lib/Send/FlagRepliedMessageHandler.php +++ b/lib/Send/FlagRepliedMessageHandler.php @@ -8,13 +8,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; @@ -22,7 +20,7 @@ class FlagRepliedMessageHandler extends AHandler { public function __construct( private MailboxMapper $mailboxMapper, private LoggerInterface $logger, - private MessageMapper $messageMapper, + private MailManager $mailManager, private DbMessageMapper $dbMessageMapper, ) { } @@ -31,19 +29,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) { @@ -58,21 +55,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 c3df8f6aa9..b83c917f1e 100644 --- a/lib/Send/SendHandler.php +++ b/lib/Send/SendHandler.php @@ -7,14 +7,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, ) { } @@ -22,17 +29,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 c86b4fcb61..0000000000 --- a/lib/Send/SentMailboxHandler.php +++ /dev/null @@ -1,27 +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/DraftsService.php b/lib/Service/DraftsService.php index c2052ee364..e26523a61c 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; @@ -26,31 +30,31 @@ use Throwable; class DraftsService { - private IMailTransmission $transmission; private LocalMessageMapper $mapper; private AttachmentService $attachmentService; private IEventDispatcher $eventDispatcher; private ProtocolFactory $protocolFactory; private MailManager $mailManager; + private MailboxMapper $mailboxMapper; private LoggerInterface $logger; private AccountService $accountService; private ITimeFactory $time; - public function __construct(IMailTransmission $transmission, - LocalMessageMapper $mapper, + public function __construct(LocalMessageMapper $mapper, AttachmentService $attachmentService, IEventDispatcher $eventDispatcher, ProtocolFactory $protocolFactory, MailManager $mailManager, + MailboxMapper $mailboxMapper, LoggerInterface $logger, AccountService $accountService, ITimeFactory $time) { - $this->transmission = $transmission; $this->mapper = $mapper; $this->attachmentService = $attachmentService; $this->eventDispatcher = $eventDispatcher; $this->protocolFactory = $protocolFactory; $this->mailManager = $mailManager; + $this->mailboxMapper = $mailboxMapper; $this->logger = $logger; $this->accountService = $accountService; $this->time = $time; @@ -160,23 +164,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); } @@ -236,4 +235,26 @@ public function flush() { } } } + + /** + * "Send" the message + * + * @param LocalMessage $message + * @param Account $account + * @return void + */ + 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 7d18609918..28e33930e1 100644 --- a/lib/Service/JMAP/JmapOperationsService.php +++ b/lib/Service/JMAP/JmapOperationsService.php @@ -913,57 +913,107 @@ public function entityMove(string $target, string ...$identifiers): array { } /** - * send entity + * 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 ID to send from + * @param MailParametersRequest $email Pre-built email parameters (mailboxIds set to $preSendLocation by this method) + * @param string $preSendLocation JMAP remote ID of the Drafts mailbox (staging area) + * @param string $postSentLocation JMAP remote ID of the Sent mailbox + * @param string $from Envelope From address (bare email) + * @param string[] $rcptTo Envelope recipient addresses (bare emails, To + Cc + Bcc combined) + * @throws Exception on server error or submission failure */ - public function entitySend(string $identity, MailMessageObject $message, ?string $presendLocation = null, ?string $postsendLocation = null): string { - // determine if pre-send location is present - if ($presendLocation === null || empty($presendLocation)) { + public function entitySend( + string $identity, + MailParametersRequest $email, + string $preSendLocation, + string $postSentLocation, + string $from, + array $rcptTo, + ): void { + if ($preSendLocation === '') { throw new Exception('Pre-Send Location is missing', 1); } - // determine if post-send location is present - if ($postsendLocation === null || empty($postsendLocation)) { - throw new Exception('Post-Send Location is missing', 1); - } - // determine if we have the basic required data and fail otherwise - if (empty($message->getFrom())) { - throw new Exception('Missing Requirements: Message MUST have a From address', 1); + if ($postSentLocation === '') { + throw new Exception('Post-Sent Location is missing', 1); } - if (empty($message->getTo())) { - throw new Exception('Missing Requirements: Message MUST have a To address(es)', 1); + if ($from === '') { + throw new Exception('Envelope From address is missing', 1); } - // determine if message has attachments - if (count($message->getAttachments()) > 0) { - // process attachments first - $message = $this->depositAttachmentsFromMessage($message); + if ($rcptTo === []) { + throw new Exception('At least one envelope recipient is required', 1); } - // convert from address object to string - $from = $message->getFrom()->getAddress(); - // convert to, cc and bcc address object arrays to single strings array - $to = array_map( - function ($entry) { return $entry->getAddress(); }, - array_merge($message->getTo(), $message->getCc(), $message->getBcc()) - ); - unset($cc, $bcc); - // construct set request + // Stage the message in the pre-send (Drafts) mailbox $r0 = new MailSet($this->dataAccount); - $r0->create('1', $message)->in($presendLocation); - // construct set request + $r0->create('1', $email)->in($preSendLocation); + // Submit via EmailSubmission/set; on success move the staged email to Sent $r1 = new MailSubmissionSet($this->dataAccount); - // construct envelope $e1 = $r1->create('2'); $e1->identity($identity); $e1->message('#1'); $e1->from($from); - $e1->to($to); - // transceive + $e1->to($rcptTo); + $r1->completionUpdate('#2', [ + 'mailboxIds/' . $postSentLocation => true, + 'mailboxIds/' . $preSendLocation => null, + 'keywords/$draft' => null, + ]); + // Perform both requests in a single HTTP round-trip $bundle = $this->dataStore->perform([$r0, $r1]); - // extract response - $response = $bundle->response(1); - // return collection information - return (string)$response->created()['2']['id']; + // Check Email/set create + $emailResponse = $bundle->response(0); + if ($emailResponse instanceof ResponseException) { + throw new Exception('Email/set failed: ' . $emailResponse->type() . ': ' . $emailResponse->description(), 1); + } + $emailFailure = $emailResponse->createFailure('1'); + if ($emailFailure !== null) { + throw new Exception('Email/set create failed: ' . ($emailFailure['type'] ?? 'unknownError'), 1); + } + // Check EmailSubmission/set create + $submissionResponse = $bundle->response(1); + if ($submissionResponse instanceof ResponseException) { + throw new Exception('EmailSubmission/set failed: ' . $submissionResponse->type() . ': ' . $submissionResponse->description(), 1); + } + $submissionFailure = $submissionResponse->createFailure('2'); + if ($submissionFailure !== null) { + throw new Exception('EmailSubmission/set create failed: ' . ($submissionFailure['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. + * @return string Remote ID of the created email. + * @throws Exception on server error. + */ + public function entitySave(MailParametersRequest $email): string { + $id = uniqid(); + $r0 = new MailSet($this->dataAccount); + $r0->create($id, $email); + $bundle = $this->dataStore->perform([$r0]); + $response = $bundle->first(); + 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 { + public function attachmentFetch(string $entityId, string ...$blobIds): array { $entities = $this->entityFetchNative($entityId); $entity = $entities[$entityId] ?? null; if (!$entity instanceof MailParametersResponse) { @@ -972,8 +1022,7 @@ public function attachmentFetch(string $entityId, string ...$blobId): array { $attachments = []; foreach (($entity->attachments() ?? []) as $key => $attachment) { - if ($blobIds === null || in_array($attachment->blob(), $blobIds, true)) { - $content = null; + if ($blobIds === [] || in_array($attachment->blob(), $blobIds, true)) { $this->dataStore->download($this->dataAccount, $attachment->blob(), $content); $attachment = new Attachment( @@ -991,45 +1040,6 @@ public function attachmentFetch(string $entityId, string ...$blobId): array { return $attachments; } - /** - * retrieve collection entity attachment from remote storage - */ - public function depositAttachmentsFromMessage(MailMessageObject $message): MailMessageObject { - - $parameters = $message->toJmap(); - $attachments = $message->getAttachments(); - $matches = []; - - $this->findAttachmentParts($parameters['bodyStructure'], $matches); - - foreach ($attachments as $attachment) { - $part = $attachment->toJmap(); - if (isset($matches[$part->getId()])) { - // deposit attachment in data store - $response = $this->blobDeposit($account, $part->getType(), $attachment->getContents()); - // transfer blobId and size to mail part - $matches[$part->getId()]->blobId = $response['blobId']; - $matches[$part->getId()]->size = $response['size']; - unset($matches[$part->getId()]->partId); - } - } - - return (new MailMessageObject())->fromJmap($parameters); - - } - - protected function findAttachmentParts(object &$part, array &$matches) { - - if ($part->disposition === 'attachment' || $part->disposition === 'inline') { - $matches[$part->partId] = $part; - } - - foreach ($part->subParts as $entry) { - $this->findAttachmentParts($entry, $matches); - } - - } - /** * retrieve identity from remote storage * @@ -1049,4 +1059,5 @@ public function identityFetch(?string $account = null): array { return $response->objects(); } + } diff --git a/tests/Integration/Service/MailTransmissionIntegrationTest.php b/tests/Integration/Service/MailTransmissionIntegrationTest.php index ca124f63d4..05fea6f153 100644 --- a/tests/Integration/Service/MailTransmissionIntegrationTest.php +++ b/tests/Integration/Service/MailTransmissionIntegrationTest.php @@ -13,8 +13,6 @@ use OC; use OCA\Mail\Account; use OCA\Mail\Contracts\IAttachmentService; -use OCA\Mail\Contracts\IMailManager; -use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\MailAccount; @@ -23,29 +21,14 @@ use OCA\Mail\Db\Message; use OCA\Mail\Db\Recipient; use OCA\Mail\Db\RecipientMapper; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MailboxSync; -use OCA\Mail\IMAP\MessageMapper; -use OCA\Mail\Model\NewMessageData; -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\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,8 @@ 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(IMAPClientFactory::class), - ); - - $this->transmission = new MailTransmission(Server::get(IMAPClientFactory::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(IMailManager::class) - ); + $this->chain = Server::get(Chain::class); + } public function testSendMail() { @@ -242,26 +203,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/Unit/Send/ChainTest.php b/tests/Unit/Send/ChainTest.php index 681f5e823b..f8e42240ca 100644 --- a/tests/Unit/Send/ChainTest.php +++ b/tests/Unit/Send/ChainTest.php @@ -9,51 +9,37 @@ 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\IMAP\IMAPClientFactory; 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&IMAPClientFactory $clientFactory; 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->clientFactory = $this->createMock(IMAPClientFactory::class); - $this->chain = new Chain($this->sentMailboxHandler, + $this->chain = new Chain( $this->antiAbuseHandler, $this->sendHandler, - $this->copySentMessageHandler, $this->flagRepliedMessageHandler, $this->attachmentService, $this->localMessageMapper, - $this->clientFactory, ); } @@ -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->clientFactory->expects(self::once()) - ->method('getClient') - ->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->clientFactory->expects(self::once()) - ->method('getClient') - ->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 9e193bbf60..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/SendHandlerTest.php b/tests/Unit/Send/SendHandlerTest.php index 013c086745..74575c6b2f 100644 --- a/tests/Unit/Send/SendHandlerTest.php +++ b/tests/Unit/Send/SendHandlerTest.php @@ -8,87 +8,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 8f52ac238e..0000000000 --- a/tests/Unit/Send/SentMailboxHandlerTest.php +++ /dev/null @@ -1,62 +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); - } -}