userId = $userId; } /** * Send an email though a mail account that has been configured with F7cloud Mail * * @param int $accountId The mail account to use for SMTP * @param string $fromEmail The "From" email address or alias email address * @param string $subject The subject * @param string $body The message body * @param bool $isHtml If the message body contains HTML * @param list $to An array of "To" recipients in the format ['label' => 'Name', 'email' => 'Email Address'] or ['email' => 'Email Address'] * @param list $cc An optional array of 'CC' recipients in the format ['label' => 'Name', 'email' => 'Email Address'] or ['email' => 'Email Address'] * @param list $bcc An optional array of 'BCC' recipients in the format ['label' => 'Name', 'email' => 'Email Address'] or ['email' => 'Email Address'] * @param ?string $references An optional string of an RFC2392 "message-id" to set the "Reply-To" and "References" header on sending * @return DataResponse * * 200: The email was sent * 202: The email was sent but could not be copied to the 'Sent' mailbox * 202: The email was accepted but not sent by the SMTP server and will be automatically retried * 400: No recipients * 400: Recipient fromat invalid * 400: A recipient array contained no email addresse * 400: Recipient email address malformed * 400: Message could not be processed * 403: No "Sent" mailbox set for account * 404: User was not logged in * 404: Account not found * 404: Alias email not found * 500: Attachments could not be processed * 500: SMTP error */ #[ApiRoute(verb: 'POST', url: '/message/send')] #[UserRateLimit(limit: 5, period: 100)] #[NoAdminRequired] #[NoCSRFRequired] public function send( int $accountId, string $fromEmail, string $subject, string $body, bool $isHtml, array $to, array $cc = [], array $bcc = [], ?string $references = null, ): DataResponse { if ($this->userId === null) { return new DataResponse('Account not found.', Http::STATUS_NOT_FOUND); } try { $mailAccount = $this->accountService->find($this->userId, $accountId); } catch (ClientException $e) { $this->logger->error("Mail account #$accountId not found", ['exception' => $e]); return new DataResponse('Account not found.', Http::STATUS_NOT_FOUND); } if ($fromEmail !== $mailAccount->getEmail()) { try { $alias = $this->aliasesService->findByAliasAndUserId($fromEmail, $this->userId); } catch (DoesNotExistException $e) { $this->logger->error("Alias $fromEmail for mail account $accountId not found", ['exception' => $e]); // Cannot send from this email as it is not configured as an alias return new DataResponse("Could not find alias $fromEmail. Please check the logs.", Http::STATUS_NOT_FOUND); } } if (empty($to)) { return new DataResponse('Recipients cannot be empty.', Http::STATUS_BAD_REQUEST); } try { $messageAttachments = $this->handleAttachments(); } catch (UploadException $e) { return new DataResponse('Could not convert attachment(s) to local attachment(s). Please check the logs.', Http::STATUS_INTERNAL_SERVER_ERROR); } $message = new LocalMessage(); $message->setType(LocalMessage::TYPE_OUTGOING); $message->setAccountId($accountId); $message->setSubject($subject); if ($isHtml) { $message->setBodyPlain(null); $message->setBodyHtml($body); $message->setHtml(true); } else { $message->setBodyPlain($body); $message->setBodyHtml(null); $message->setHtml(false); } $message->setEditorBody($body); $message->setSendAt($this->time->getTime()); $message->setType(LocalMessage::TYPE_OUTGOING); if (isset($alias)) { $message->setAliasId($alias->getId()); } if (!empty($references)) { $message->setInReplyToMessageId($references); } if (!empty($messageAttachments)) { $message->setAttachments($messageAttachments); } $recipients = array_merge($to, $cc, $bcc); foreach ($recipients as $recipient) { if (!is_array($recipient)) { return new DataResponse('Recipient address must be an array.', Http::STATUS_BAD_REQUEST); } if (!isset($recipient['email'])) { return new DataResponse('Recipient address must contain an email address.', Http::STATUS_BAD_REQUEST); } $mightBeValidEmail = filter_var($recipient['email'], FILTER_VALIDATE_EMAIL); if ($mightBeValidEmail === false) { $email = $recipient['email']; return new DataResponse("Email address $email not valid.", Http::STATUS_BAD_REQUEST); } } $localAttachments = array_map(static fn ($messageAttachment) => ['type' => 'local', 'id' => $messageAttachment->getId()], $messageAttachments); $localMessage = $this->outboxService->saveMessage($mailAccount, $message, $to, $cc, $bcc, $localAttachments); try { $localMessage = $this->outboxService->sendMessage($localMessage, $mailAccount); } catch (ServiceException $e) { $this->logger->error('Processing error: could not send message', ['exception' => $e]); return new DataResponse('Processing error: could not send message. Please check the logs', Http::STATUS_BAD_REQUEST); } catch (Throwable $e) { $this->logger->error('SMTP error: could not send message', ['exception' => $e]); return new DataResponse('Fatal SMTP error: could not send message, and no resending is possible. Please check the mail server logs.', Http::STATUS_INTERNAL_SERVER_ERROR); } return match ($localMessage->getStatus()) { LocalMessage::STATUS_PROCESSED => new DataResponse('', Http::STATUS_OK), LocalMessage::STATUS_NO_SENT_MAILBOX => new DataResponse('Configuration error: Cannot send message without sent mailbox.', Http::STATUS_FORBIDDEN), LocalMessage::STATUS_SMPT_SEND_FAIL => new DataResponse('SMTP error: could not send message. Message sending will be retried. Please check the logs.', Http::STATUS_INTERNAL_SERVER_ERROR), LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL => new DataResponse('Email was sent but could not be copied to sent mailbox. Copying will be retried. Please check the logs.', Http::STATUS_ACCEPTED), default => new DataResponse('An error occured. Please check the logs.', Http::STATUS_INTERNAL_SERVER_ERROR), }; } /** * Get a mail message with its metadata * * @param int $id the message id * @return DataResponse|DataResponse * * 200: Message found * 206: Message could not be decrypted, no "body" data returned * 404: User was not logged in * 404: Message, Account or Mailbox not found * 500: Could not connect to IMAP server */ #[BruteForceProtection('mailGetMessage')] #[NoAdminRequired] #[NoCSRFRequired] public function get(int $id): DataResponse { if ($this->userId === null) { return new DataResponse('Account not found.', Http::STATUS_NOT_FOUND); } try { $message = $this->mailManager->getMessage($this->userId, $id); $mailbox = $this->mailManager->getMailbox($this->userId, $message->getMailboxId()); $account = $this->accountService->find($this->userId, $mailbox->getAccountId()); } catch (ClientException|DoesNotExistException $e) { $this->logger->error('Message, Account or Mailbox not found', ['exception' => $e->getMessage()]); return new DataResponse('Account not found.', Http::STATUS_NOT_FOUND); } $loadBody = true; $client = $this->clientFactory->getClient($account); try { $imapMessage = $this->mailManager->getImapMessage( $client, $account, $mailbox, $message->getUid(), true ); } catch (ServiceException $e) { $this->logger->error('Could not connect to IMAP server', ['exception' => $e->getMessage()]); return new DataResponse('Could not connect to IMAP server. Please check your logs.', Http::STATUS_INTERNAL_SERVER_ERROR); } catch (SmimeDecryptException $e) { $this->logger->warning('Message could not be decrypted', ['exception' => $e->getMessage()]); $loadBody = false; $imapMessage = $this->mailManager->getImapMessage( $client, $account, $mailbox, $message->getUid() ); } finally { $client->logout(); } $json = $imapMessage->getFullMessage($id, $loadBody); $itineraries = $this->itineraryService->getCached($account, $mailbox, $message->getUid()); if ($itineraries) { $json['itineraries'] = $itineraries->jsonSerialize(); } $json['attachments'] = array_map(fn ($a) => $this->enrichDownloadUrl( $id, $a ), $json['attachments']); $json['id'] = $message->getId(); $json['isSenderTrusted'] = $this->trustedSenderService->isSenderTrusted($this->userId, $message); $smimeData = new SmimeData(); $smimeData->setIsEncrypted($message->isEncrypted() || $imapMessage->isEncrypted()); if ($imapMessage->isSigned()) { $smimeData->setIsSigned(true); $smimeData->setSignatureIsValid($imapMessage->isSignatureValid()); } $json['smime'] = $smimeData->jsonSerialize(); $dkimResult = $this->dkimService->getCached($account, $mailbox, $message->getUid()); if (is_bool($dkimResult)) { $json['dkimValid'] = $dkimResult; } $json['rawUrl'] = $this->urlGenerator->linkToOCSRouteAbsolute('mail.messageApi.getRaw', ['id' => $id]); if (!$loadBody) { return new DataResponse($json, Http::STATUS_PARTIAL_CONTENT); } return new DataResponse($json, Http::STATUS_OK); } /** * Get the raw rfc2822 email * * @param int $id the id of the message * @return DataResponse|DataResponse * * 200: Message found * 404: User was not logged in * 404: Message, Account or Mailbox not found * 404: Could not find message on IMAP */ #[BruteForceProtection('mailGetRawMessage')] #[NoAdminRequired] #[NoCSRFRequired] public function getRaw(int $id): DataResponse { if ($this->userId === null) { return new DataResponse('Account not found.', Http::STATUS_NOT_FOUND); } try { $message = $this->mailManager->getMessage($this->userId, $id); $mailbox = $this->mailManager->getMailbox($this->userId, $message->getMailboxId()); $account = $this->accountService->find($this->userId, $mailbox->getAccountId()); } catch (ClientException|DoesNotExistException $e) { $this->logger->error('Message, Account or Mailbox not found', ['exception' => $e->getMessage()]); return new DataResponse('Message, Account or Mailbox not found', Http::STATUS_NOT_FOUND); } $client = $this->clientFactory->getClient($account); try { $source = $this->mailManager->getSource( $client, $account, $mailbox->getName(), $message->getUid() ); } catch (ServiceException $e) { $this->logger->error('Message not found on IMAP, or mail server went away', ['exception' => $e->getMessage()]); return new DataResponse('Message not found', Http::STATUS_NOT_FOUND); } finally { $client->logout(); } return new DataResponse($source, Http::STATUS_OK); } /** * @param int $id the id of the message * @param array $attachment * * @return array */ private function enrichDownloadUrl(int $id, array $attachment): array { $downloadUrl = $this->urlGenerator->linkToOCSRouteAbsolute('mail.messageApi.getAttachment', [ 'id' => $id, 'attachmentId' => $attachment['id'], ]); $attachment['downloadUrl'] = $downloadUrl; return $attachment; } /** * Get a mail message's attachments * * @param int $id the mail id * @param string $attachmentId the attachment id * @return DataResponse|DataResponse * * 200: Message found * 404: User was not logged in * 404: Message, Account or Mailbox not found * 404: Could not find attachment * 500: Could not process attachment * */ #[NoCSRFRequired] #[NoAdminRequired] #[TrapError] public function getAttachment(int $id, string $attachmentId): DataResponse { try { $message = $this->mailManager->getMessage($this->userId, $id); $mailbox = $this->mailManager->getMailbox($this->userId, $message->getMailboxId()); $account = $this->accountService->find($this->userId, $mailbox->getAccountId()); } catch (DoesNotExistException|ClientException $e) { return new DataResponse('Message, Account or Mailbox not found', Http::STATUS_NOT_FOUND); } try { $attachment = $this->mailManager->getMailAttachment( $account, $mailbox, $message, $attachmentId, ); } catch (\Horde_Imap_Client_Exception_NoSupportExtension|\Horde_Imap_Client_Exception|\Horde_Mime_Exception $e) { $this->logger->error('Error when trying to process the attachment', ['exception' => $e]); return new DataResponse('Error when trying to process the attachment', Http::STATUS_INTERNAL_SERVER_ERROR); } catch (ServiceException|DoesNotExistException $e) { $this->logger->error('Could not find attachment', ['exception' => $e]); return new DataResponse('Could not find attachment', Http::STATUS_NOT_FOUND); } // Body party and embedded messages do not have a name if ($attachment->getName() === null) { return new DataResponse([ 'name' => $attachmentId . '.eml', 'mime' => $attachment->getType(), 'size' => $attachment->getSize(), 'content' => $attachment->getContent() ]); } return new DataResponse([ 'name' => $attachment->getName(), 'mime' => $attachment->getType(), 'size' => $attachment->getSize(), 'content' => $attachment->getContent() ]); } /** * @return array * @throws UploadException */ private function handleAttachments(): array { $fileAttachments = $this->request->getUploadedFile('attachments'); $hasAttachments = isset($fileAttachments['name']); if (!$hasAttachments) { return []; } $messageAttachments = []; foreach ($fileAttachments['name'] as $attachmentKey => $attachmentName) { $filedata = [ 'name' => $attachmentName, 'type' => $fileAttachments['type'][$attachmentKey], 'size' => $fileAttachments['size'][$attachmentKey], 'tmp_name' => $fileAttachments['tmp_name'][$attachmentKey], ]; $file = new UploadedFile($filedata); try { $attachment = $this->attachmentService->addFile($this->userId, $file); $messageAttachments[] = $attachment; } catch (UploadException $e) { $this->logger->error('Could not convert attachment to local attachment.', ['exception' => $e]); foreach ($messageAttachments as $attachment) { // Handle possible dangling local attachments $this->attachmentService->deleteAttachment($this->userId, $attachment->getId()); } throw $e; } } return $messageAttachments; } }