Skip to content

Commit

Permalink
Merge pull request #9672 from nextcloud/feat/ocs-api-to-send-mails
Browse files Browse the repository at this point in the history
feat(ocs): send a message via api
  • Loading branch information
miaulalala authored Jul 18, 2024
2 parents 2aac28f + 253d0c8 commit d76d7ef
Show file tree
Hide file tree
Showing 7 changed files with 578 additions and 39 deletions.
183 changes: 179 additions & 4 deletions lib/Controller/MessageApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,38 @@
namespace OCA\Mail\Controller;

use OCA\Mail\Contracts\IDkimService;
use OCA\Mail\Db\LocalMessage;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Exception\SmimeDecryptException;
use OCA\Mail\Exception\UploadException;
use OCA\Mail\Http\TrapError;
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\Model\SmimeData;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AliasesService;
use OCA\Mail\Service\Attachment\AttachmentService;
use OCA\Mail\Service\Attachment\UploadedFile;
use OCA\Mail\Service\ItineraryService;
use OCA\Mail\Service\MailManager;
use OCA\Mail\Service\OutboxService;
use OCA\Mail\Service\Search\MailSearch;
use OCA\Mail\Service\TrustedSenderService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\IMimeTypeDetector;
use OCP\IRequest;
use OCP\IURLGenerator;
use Psr\Log\LoggerInterface;
use Throwable;
use function array_map;
use function array_merge;

class MessageApiController extends OCSController {

Expand All @@ -47,13 +53,11 @@ public function __construct(
private AliasesService $aliasesService,
private AttachmentService $attachmentService,
private OutboxService $outboxService,
private MailSearch $mailSearch,
private MailManager $mailManager,
private IMAPClientFactory $clientFactory,
private LoggerInterface $logger,
private ITimeFactory $time,
private IURLGenerator $urlGenerator,
private IMimeTypeDetector $mimeTypeDetector,
private IDkimService $dkimService,
private ItineraryService $itineraryService,
private TrustedSenderService $trustedSenderService,
Expand All @@ -62,6 +66,141 @@ public function __construct(
$this->userId = $userId;
}

/**
* @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 array $to An array of "To" recipients in the format ['label' => 'Name', 'email' => 'Email Address'] or ['email' => 'Email Address']
* @param array $cc An optional array of 'CC' recipients in the format ['label' => 'Name', 'email' => 'Email Address'] or ['email' => 'Email Address']
* @param array $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<Http::STATUS_OK|Http::STATUS_ACCEPTED|Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, string, array{}>
*
* 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);
$message->setBody($body);
$message->setEditorBody($body);
$message->setHtml($isHtml);
$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 function ($messageAttachment) {
return ['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),
};
}

/**
* @param int $id
* @return DataResponse
Expand Down Expand Up @@ -237,4 +376,40 @@ public function getAttachment(int $id,
'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;
}
}
6 changes: 3 additions & 3 deletions lib/Send/Chain.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function __construct(private SentMailboxHandler $sentMailboxHandler,
* @throws Exception
* @throws ServiceException
*/
public function process(Account $account, LocalMessage $localMessage): void {
public function process(Account $account, LocalMessage $localMessage): LocalMessage {
$handlers = $this->sentMailboxHandler;
$handlers->setNext($this->antiAbuseHandler)
->setNext($this->sendHandler)
Expand All @@ -50,8 +50,8 @@ public function process(Account $account, LocalMessage $localMessage): void {
if ($result->getStatus() === LocalMessage::STATUS_PROCESSED) {
$this->attachmentService->deleteLocalMessageAttachments($account->getUserId(), $result->getId());
$this->localMessageMapper->deleteWithRecipients($result);
return;
return $localMessage;
}
$this->localMessageMapper->update($result);
return $this->localMessageMapper->update($result);
}
}
10 changes: 10 additions & 0 deletions lib/Service/AliasesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ public function find(int $aliasId, string $currentUserId): Alias {
return $this->aliasMapper->find($aliasId, $currentUserId);
}

/**
* @param string $aliasEmail
* @param string $userId
* @return Alias
* @throws DoesNotExistException
*/
public function findByAliasAndUserId(string $aliasEmail, string $userId): Alias {
return $this->aliasMapper->findByAlias($aliasEmail, $userId);
}

/**
* @param string $userId
* @param int $accountId
Expand Down
3 changes: 3 additions & 0 deletions lib/Service/MailTransmission.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ public function sendMessage(Account $account, LocalMessage $localMessage): void
'Subject' => $localMessage->getSubject(),
];

// The table (oc_local_messages) currently only allows for a single reply to message id
// but we already set the 'references' header for an email so we could support multiple references
// Get the previous message and then concatenate all its "References" message ids with this one
if (($inReplyTo = $localMessage->getInReplyToMessageId()) !== null) {
$headers['References'] = $inReplyTo;
$headers['In-Reply-To'] = $inReplyTo;
Expand Down
12 changes: 9 additions & 3 deletions lib/Service/OutboxService.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
use OCA\Mail\Db\Recipient;
use OCA\Mail\Events\OutboxMessageCreatedEvent;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\Send\Chain;
use OCA\Mail\Service\Attachment\AttachmentService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\Exception;
use OCP\EventDispatcher\IEventDispatcher;
use Psr\Log\LoggerInterface;
use Throwable;
Expand Down Expand Up @@ -115,9 +117,13 @@ public function deleteMessage(string $userId, LocalMessage $message): void {
$this->mapper->deleteWithRecipients($message);
}

/**
* @throws Throwable
* @throws Exception
* @throws ServiceException
*/
public function sendMessage(LocalMessage $message, Account $account): LocalMessage {
$this->sendChain->process($account, $message);
return $message;
return $this->sendChain->process($account, $message);
}

/**
Expand All @@ -136,7 +142,7 @@ public function saveMessage(Account $account, LocalMessage $message, array $to,
$message = $this->mapper->saveWithRecipients($message, $toRecipients, $ccRecipients, $bccRecipients);

if ($attachments === []) {
$message->setAttachments($attachments);
$message->setAttachments([]);
return $message;
}

Expand Down
Loading

0 comments on commit d76d7ef

Please sign in to comment.