diff --git a/lib/Service/IMipService.php b/lib/Service/IMipService.php index 1c8d3f991c..aab0dda744 100644 --- a/lib/Service/IMipService.php +++ b/lib/Service/IMipService.php @@ -10,12 +10,18 @@ namespace OCA\Mail\Service; use OCA\Mail\Account; +use OCA\Mail\Contracts\IMailTransmission; +use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; use OCA\Mail\Db\MessageMapper; +use OCA\Mail\Db\Recipient; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\Model\IMAPMessage; +use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Util\ServerVersion; use OCP\AppFramework\Db\DoesNotExistException; use OCP\Calendar\IManager; @@ -32,6 +38,10 @@ class IMipService { private MailManager $mailManager; private MessageMapper $messageMapper; private ServerVersion $serverVersion; + private IMAPClientFactory $imapClientFactory; + private ImapMessageMapper $imapMessageMapper; + private IMailTransmission $mailTransmission; + private AttachmentService $attachmentService; public function __construct( AccountService $accountService, @@ -41,6 +51,10 @@ public function __construct( MailManager $mailManager, MessageMapper $messageMapper, ServerVersion $serverVersion, + IMAPClientFactory $imapClientFactory, + ImapMessageMapper $imapMessageMapper, + IMailTransmission $mailTransmission, + AttachmentService $attachmentService, ) { $this->accountService = $accountService; $this->calendarManager = $manager; @@ -49,6 +63,10 @@ public function __construct( $this->mailManager = $mailManager; $this->messageMapper = $messageMapper; $this->serverVersion = $serverVersion; + $this->imapClientFactory = $imapClientFactory; + $this->imapMessageMapper = $imapMessageMapper; + $this->mailTransmission = $mailTransmission; + $this->attachmentService = $attachmentService; } public function process(): void { @@ -182,9 +200,171 @@ public function process(): void { ]); $message->setImipProcessed(true); $message->setImipError(true); + + $affectedUsers = [$account->getEmail()]; + // add original sender ($replyTo) and admin email to affected users? + /** + $replyTo = $imapMessage->getReplyTo()->first()?->getEmail(); + if ($replyTo !== null && $replyTo !== $account->getEmail()) { + $affectedUsers[] = $replyTo; + } + */ + + // Send error notification via email + try { + $this->sendErrorNotification($account, $mailbox, $message, $imapMessage, $sender, $affectedUsers, $e); + } catch (Throwable $notificationException) { + $this->logger->error('Failed to send error notification', [ + 'exception' => $notificationException, + 'messageId' => $message->getId(), + 'affectedUsers' => $affectedUsers, + ]); + } } } $this->messageMapper->updateImipData(...$filteredMessages); } } + + /** + * Send error notification email when iMIP processing fails + */ + private function sendErrorNotification( + Account $account, + Mailbox $mailbox, + Message $message, + IMAPMessage $imapMessage, + string $sender, + array $affectedUsers, + Throwable $exception, + ): void { + // Fetch the raw message content from IMAP + $client = $this->imapClientFactory->getClient($account); + try { + $rawMessage = $this->imapMessageMapper->getFullText( + $client, + $mailbox->getName(), + $message->getUid(), + $account->getUserId(), + false // Don't decrypt, send raw message + ); + + if ($rawMessage === null) { + throw new ServiceException('Could not fetch raw message content'); + } + } finally { + $client->logout(); + } + + $localMessage = new LocalMessage(); + $localMessage->setType(LocalMessage::TYPE_OUTGOING); + $localMessage->setAccountId($account->getId()); + $localMessage->setSubject('[ERROR] Calendar invitation processing failed: ' . ($imapMessage->getSubject() ?: 'No Subject')); + $localMessage->setBodyHtml(null); + $localMessage->setBodyPlain($this->buildErrorNotificationBody($account, $message, $imapMessage, $sender, $affectedUsers, $exception)); + $localMessage->setHtml(false); + + // Build recipient list - include all affected users + $recipients = []; + foreach ($affectedUsers as $userEmail) { + $recipient = new Recipient(); + $recipient->setType(Recipient::TYPE_TO); + $recipient->setEmail($userEmail); + $recipient->setLabel($userEmail); + $recipients[] = $recipient; + } + $localMessage->setRecipients($recipients); + + // Create attachment from the raw message + $attachment = $this->attachmentService->addFileFromString( + $account->getUserId(), + $this->sanitizeFilename($imapMessage->getSubject() ?? 'original-message') . '.eml', + 'message/rfc822', + $rawMessage + ); + $localMessage->setAttachments([$attachment]); + + // Send using the account's SMTP settings + $this->mailTransmission->sendMessage($account, $localMessage); + + $this->logger->info('Error notification sent for failed iMIP message', [ + 'messageId' => $message->getId(), + 'from' => $account->getEmail(), + 'recipients' => $affectedUsers, + 'subject' => $imapMessage->getSubject(), + ]); + } + + /** + * Sanitize a filename by removing invalid characters + * + * @param string $filename The filename to sanitize + * @return string The sanitized filename + */ + private function sanitizeFilename(string $filename): string { + // Remove or replace characters that are invalid in filenames + $filename = preg_replace('/[^\w\s\-_.]/u', '_', $filename); + $filename = trim($filename, '._'); + return empty($filename) ? 'original-message' : $filename; + } + + /** + * Build the body of the error notification email + * + * @param Account $account The account that received the message + * @param Message $message The message entity + * @param IMAPMessage $imapMessage The IMAP message + * @param string $sender The sender email address + * @param array $affectedUsers List of affected user emails + * @param Throwable $exception The exception that caused the error + * @return string The notification body + */ + private function buildErrorNotificationBody(Account $account, Message $message, IMAPMessage $imapMessage, string $sender, array $affectedUsers, Throwable $exception): string { + $schedulingMethods = []; + foreach ($imapMessage->scheduling as $schedulingInfo) { + $schedulingMethods[] = $schedulingInfo['method'] ?? 'UNKNOWN'; + } + + $lines = [ + 'Calendar Invitation Processing Error', + '====================================', + '', + 'We were unable to automatically process a calendar invitation in your email.', + '', + 'WHAT YOU NEED TO DO:', + 'Please manually add this event to your calendar.', + '', + 'The original email with the calendar invitation is attached to this message.', + 'You can open the attached .eml file and add the event to your calendar from there.', + '', + 'If you have any questions, please contact the system administrator.', + '', + '====================================', + '', + 'Email Details:', + '- Subject: ' . ($imapMessage->getSubject() ?? '(no subject)'), + '- From: ' . $sender, + '- To: ' . $account->getEmail(), + '- Date: ' . $imapMessage->getSentDate()->format('Y-m-d H:i:s'), + '', + 'Calendar Event Type:', + '- Method: ' . implode(', ', $schedulingMethods), + '', + 'Timestamp: ' . date('Y-m-d H:i:s'), + '', + 'Technical Details:', + '- Message ID: ' . $message->getId(), + '- Account: ' . $account->getEmail(), + '- Recipients notified: ' . implode(', ', $affectedUsers), + '- Error: ' . $exception->getMessage(), + '', + '', + '====================================', + '', + 'This is an automated message please do not reply.', + '', + ]; + + return implode("\n", $lines); + } } diff --git a/tests/Unit/Service/IMipServiceTest.php b/tests/Unit/Service/IMipServiceTest.php index 3496749322..a55dbb0f7e 100644 --- a/tests/Unit/Service/IMipServiceTest.php +++ b/tests/Unit/Service/IMipServiceTest.php @@ -13,14 +13,19 @@ use OCA\Mail\Account; use OCA\Mail\Address; use OCA\Mail\AddressList; +use OCA\Mail\Contracts\IMailTransmission; +use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; use OCA\Mail\Db\MessageMapper; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\IMipService; use OCA\Mail\Service\MailManager; use OCA\Mail\Util\ServerVersion; @@ -44,9 +49,21 @@ class IMipServiceTest extends TestCase { /** @var MailManager|MockObject */ private $mailManager; - /** @var MockObject|LoggerInterface */ + /** @var LoggerInterface|MockObject */ private $logger; + /** @var IMailTransmission|MockObject */ + private $mailTransmission; + + /** @var ImapMessageMapper|MockObject */ + private $imapMessageMapper; + + /** @var IMAPClientFactory|MockObject */ + private $imapClientFactory; + + /** @var AttachmentService|MockObject */ + private $attachmentService; + private IMipService $service; private ServerVersion|MockObject $serverVersion; @@ -64,6 +81,10 @@ protected function setUp(): void { $this->messageMapper = $this->createMock(MessageMapper::class); $this->serverVersion = $this->createMock(ServerVersion::class); $this->OCPServerVersion = new OCPServerVersion(); + $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->imapMessageMapper = $this->createMock(ImapMessageMapper::class); + $this->mailTransmission = $this->createMock(IMailTransmission::class); + $this->attachmentService = $this->createMock(AttachmentService::class); $this->service = new IMipService( $this->accountService, @@ -72,7 +93,11 @@ protected function setUp(): void { $this->mailboxMapper, $this->mailManager, $this->messageMapper, - $this->serverVersion + $this->serverVersion, + $this->imapClientFactory, + $this->imapMessageMapper, + $this->mailTransmission, + $this->attachmentService ); } @@ -641,6 +666,7 @@ public function testHandleImipRequestThrowsException(): void { $mailbox = new Mailbox(); $mailbox->setId(100); $mailbox->setAccountId(200); + $mailbox->setName('INBOX'); $mailAccount = new MailAccount(); $mailAccount->setId(200); $mailAccount->setEmail('vincent@stardew-valley.edu'); @@ -651,6 +677,9 @@ public function testHandleImipRequestThrowsException(): void { $addressList = $this->createMock(AddressList::class); $address = $this->createMock(Address::class); + // Create a mock IMAP client that will be returned by the factory + $imapClient = $this->createMock(\Horde_Imap_Client_Socket::class); + $this->messageMapper->expects(self::once()) ->method('findIMipMessagesAscending') ->willReturn([$message]); @@ -679,18 +708,127 @@ public function testHandleImipRequestThrowsException(): void { $this->calendarManager->expects(self::once()) ->method('handleIMipRequest') ->willThrowException(new \Exception('Calendar error')); - $this->logger->expects(self::once()) - ->method('error') - ->with( - 'iMIP message processing failed', - self::callback(fn ($context) => isset($context['exception']) - && $context['messageId'] === $message->getId() - && $context['mailboxId'] === $mailbox->getId()) - ); + $this->logger->expects(self::exactly(2)) + ->method('error'); $this->messageMapper->expects(self::once()) ->method('updateImipData') ->with(self::callback(fn (Message $msg) => $msg->isImipProcessed() === true && $msg->isImipError() === true)); + $this->imapClientFactory->expects(self::once()) + ->method('getClient') + ->with($account) + ->willReturn($imapClient); + + // The error notification should fail to send due to missing raw message + $this->imapMessageMapper->expects(self::once()) + ->method('getFullText') + ->willReturn(null); + + $imapClient->expects(self::once()) + ->method('logout'); + + $this->service->process(); + } + + public function testHandleImipRequestThrowsExceptionAndSendsNotification(): void { + $message = new Message(); + $message->setImipMessage(true); + $message->setUid(1); + $message->setMailboxId(100); + $mailbox = new Mailbox(); + $mailbox->setId(100); + $mailbox->setAccountId(200); + $mailbox->setName('INBOX'); + $mailAccount = new MailAccount(); + $mailAccount->setId(200); + $mailAccount->setEmail('vincent@stardew-valley.edu'); + $mailAccount->setUserId('vincent'); + $account = new Account($mailAccount); + $imapMessage = $this->createMock(IMAPMessage::class); + $imapMessage->scheduling[] = ['method' => 'REQUEST', 'contents' => 'VCALENDAR']; + $addressList = $this->createMock(AddressList::class); + $address = $this->createMock(Address::class); + + // Create a mock IMAP client that will be returned by the factory + $imapClient = $this->createMock(\Horde_Imap_Client_Socket::class); + + $this->messageMapper->expects(self::once()) + ->method('findIMipMessagesAscending') + ->willReturn([$message]); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->willReturn($mailbox); + $this->accountService->expects(self::once()) + ->method('findById') + ->willReturn($account); + $this->mailManager->expects(self::once()) + ->method('getImapMessagesForScheduleProcessing') + ->with($account, $mailbox, [$message->getUid()]) + ->willReturn([$imapMessage]); + $imapMessage->expects(self::once()) + ->method('getUid') + ->willReturn(1); + $imapMessage->expects(self::once()) + ->method('getFrom') + ->willReturn($addressList); + $addressList->expects(self::once()) + ->method('first') + ->willReturn($address); + $address->expects(self::once()) + ->method('getEmail') + ->willReturn('pam@stardew-bus-service.com'); + $this->calendarManager->expects(self::once()) + ->method('handleIMipRequest') + ->willThrowException(new \Exception('Calendar error')); + $this->logger->expects(self::once()) + ->method('error'); + $this->messageMapper->expects(self::once()) + ->method('updateImipData') + ->with(self::callback(function (Message $msg) { + return $msg->isImipProcessed() === true && $msg->isImipError() === true; + })); + + $this->imapClientFactory->expects(self::once()) + ->method('getClient') + ->with($account) + ->willReturn($imapClient); + + $rawMessage = 'Raw message content'; + $this->imapMessageMapper->expects(self::once()) + ->method('getFullText') + ->with($imapClient, 'INBOX', 1, 'vincent', false) + ->willReturn($rawMessage); + + $imapClient->expects(self::once()) + ->method('logout'); + + // Mock the attachment service + $attachment = $this->createMock(\OCA\Mail\Db\LocalAttachment::class); + $this->attachmentService->expects(self::once()) + ->method('addFileFromString') + ->with( + 'vincent', + 'original-message.eml', + 'message/rfc822', + $rawMessage + ) + ->willReturn($attachment); + + // Mock the mail transmission + $this->mailTransmission->expects(self::once()) + ->method('sendMessage') + ->with( + $account, + self::callback(function (LocalMessage $localMessage) use ($account) { + return $localMessage->getType() === LocalMessage::TYPE_OUTGOING + && $localMessage->getAccountId() === $account->getId() + && $localMessage->getSubject() === '[ERROR] Calendar invitation processing failed: No Subject' + && $localMessage->getHtml() === false + && count($localMessage->getRecipients()) === 1 + && $localMessage->getRecipients()[0]->getEmail() === $account->getEmail(); + }) + ); + $this->service->process(); } }