diff --git a/appinfo/routes.php b/appinfo/routes.php
index 26e7e17776..0e116e1349 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -210,6 +210,11 @@
'url' => '/api/messages/{id}/attachment/{attachmentId}',
'verb' => 'POST'
],
+ [
+ 'name' => 'messages#saveFile',
+ 'url' => '/api/messages/{id}/file',
+ 'verb' => 'POST'
+ ],
[
'name' => 'messages#getBody',
'url' => '/api/messages/{id}/body',
diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php
index eb076c9f24..e015113c69 100755
--- a/lib/Controller/MessagesController.php
+++ b/lib/Controller/MessagesController.php
@@ -44,6 +44,7 @@
use OCP\AppFramework\Http\ZipResponse;
use OCP\Files\Folder;
use OCP\Files\GenericFileException;
+use OCP\Files\IFilenameValidator;
use OCP\Files\IMimeTypeDetector;
use OCP\Files\NotPermittedException;
use OCP\ICache;
@@ -71,6 +72,7 @@ public function __construct(
private ItineraryService $itineraryService,
private ?string $userId,
private ?Folder $userFolder,
+ private IFilenameValidator $filenameValidator,
private LoggerInterface $logger,
IL10N $l10n,
IMimeTypeDetector $mimeTypeDetector,
@@ -585,6 +587,72 @@ public function export(int $id): Response {
);
}
+ /**
+ * Save a whole message as an .eml file in the local storage
+ *
+ * @NoAdminRequired
+ *
+ * @param int $id
+ * @param string $targetPath
+ *
+ * @return Response
+ *
+ * @throws ClientException
+ * @throws GenericFileException
+ * @throws NotPermittedException
+ * @throws LockedException
+ * @throws ServiceException
+ */
+ #[TrapError]
+ public function saveFile(int $id, string $targetPath): Response {
+ if ($this->userId === null) {
+ return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
+ }
+ if ($this->userFolder === null) {
+ return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ if (!$this->userFolder->nodeExists($targetPath)) {
+ return new JSONResponse([], Http::STATUS_BAD_REQUEST);
+ }
+ if (!($this->userFolder->get($targetPath) instanceof Folder)) {
+ return new JSONResponse([], Http::STATUS_BAD_REQUEST);
+ }
+ try {
+ $effectiveUserId = $this->delegationService->resolveMessageUserId($id, $this->userId);
+ $message = $this->mailManager->getMessage($effectiveUserId, $id);
+ $mailbox = $this->mailManager->getMailbox($effectiveUserId, $message->getMailboxId());
+ $account = $this->accountService->find($effectiveUserId, $mailbox->getAccountId());
+ } catch (DoesNotExistException $e) {
+ return new JSONResponse([], Http::STATUS_FORBIDDEN);
+ }
+
+ $client = $this->clientFactory->getClient($account);
+ try {
+ $source = $this->mailManager->getSource(
+ $client,
+ $account,
+ $mailbox->getName(),
+ $message->getUid()
+ );
+ } finally {
+ $client->logout();
+ }
+
+ $fileName = $this->filenameValidator->sanitizeFilename($message->getSubject());
+ $fileExtension = 'eml';
+ $fullPath = "$targetPath/$fileName.$fileExtension";
+ $counter = 2;
+ while ($this->userFolder->nodeExists($fullPath)) {
+ $fullPath = "$targetPath/$fileName ($counter).$fileExtension";
+ $counter++;
+ }
+
+ $newFile = $this->userFolder->newFile($fullPath);
+ $newFile->putContent($source ?? '');
+
+ return new JSONResponse();
+ }
+
/**
* @NoAdminRequired
* @NoCSRFRequired
diff --git a/src/components/Envelope.vue b/src/components/Envelope.vue
index a61d100d6b..d4b39ee410 100644
--- a/src/components/Envelope.vue
+++ b/src/components/Envelope.vue
@@ -189,6 +189,14 @@
fill-color="var(--color-primary-element)" />
+ isFilePickerOpen = false" />
{{ t('mail', 'Download message') }}
+ isFilePickerOpen = true">
+
+
+
+
+ {{ t('mail', 'Save message to Files') }}
+
import { showError, showSuccess, showWarning } from '@nextcloud/dialogs'
+import { FilePickerVue as FilePicker } from '@nextcloud/dialogs/filepicker.js'
import { isRTL } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
import { generateUrl } from '@nextcloud/router'
@@ -520,6 +540,7 @@ import {
NcActionButton as ActionButton,
NcActionLink as ActionLink,
NcActionText as ActionText,
+ NcLoadingIcon as IconLoading,
NcActionInput,
NcActionSeparator,
NcAssistantIcon,
@@ -542,6 +563,7 @@ import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
import IconEmailFast from 'vue-material-design-icons/EmailFastOutline.vue'
import EmailRead from 'vue-material-design-icons/EmailOpenOutline.vue'
import EmailUnread from 'vue-material-design-icons/EmailOutline.vue'
+import IconSave from 'vue-material-design-icons/FolderOutline.vue'
import ImportantIcon from 'vue-material-design-icons/LabelVariant.vue'
import ImportantOutlineIcon from 'vue-material-design-icons/LabelVariantOutline.vue'
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
@@ -571,6 +593,7 @@ import NoTrashMailboxConfiguredError
import logger from '../logger.js'
import AttachmentMixin from '../mixins/AttachmentMixin.js'
import { buildRecipients as buildReplyRecipients } from '../ReplyBuilder.js'
+import { saveMessage } from '../service/MessageService.js'
import { FOLLOW_UP_TAG_LABEL } from '../store/constants.js'
import useMainStore from '../store/mainStore.js'
import { mailboxHasRights } from '../util/acl.js'
@@ -593,6 +616,9 @@ export default {
DotsHorizontalIcon,
EnvelopePrimaryActions,
EventModal,
+ IconLoading,
+ IconSave,
+ FilePicker,
ImportantIcon,
ImportantOutlineIcon,
TaskModal,
@@ -686,6 +712,15 @@ export default {
hoveringAvatar: false,
quickActionLoading: false,
possibleAttachmentsCount: 0,
+ savingToCloud: false,
+ isFilePickerOpen: false,
+ saveMessageButtons: [
+ {
+ label: t('mail', 'Choose'),
+ callback: this.saveToCloud,
+ type: 'primary',
+ },
+ ],
}
},
@@ -1419,6 +1454,23 @@ export default {
this.showTagModal = false
},
+ async saveToCloud(dest) {
+ const path = dest[0].path
+ this.savingToCloud = true
+ const id = this.data.databaseId
+
+ try {
+ await saveMessage(id, path)
+ logger.info('saved')
+ showSuccess(t('mail', 'Message saved to Files'))
+ } catch (e) {
+ logger.error('not saved', { error: e })
+ showError(t('mail', 'Message could not be saved'))
+ } finally {
+ this.savingToCloud = false
+ }
+ },
+
getTimestamp(momentObject) {
return momentObject?.minute(0).second(0).millisecond(0).valueOf() || null
},
diff --git a/src/components/MenuEnvelope.vue b/src/components/MenuEnvelope.vue
index 5f989e5903..6d29b816e2 100644
--- a/src/components/MenuEnvelope.vue
+++ b/src/components/MenuEnvelope.vue
@@ -5,6 +5,14 @@
+
isFilePickerOpen = false" />
{{ t('mail', 'Download message') }}
+ isFilePickerOpen = true">
+
+
+
+
+ {{ t('mail', 'Save message to Files') }}
+
import { showError, showSuccess } from '@nextcloud/dialogs'
+import { FilePickerVue as FilePicker } from '@nextcloud/dialogs/filepicker.js'
import moment from '@nextcloud/moment'
import { generateUrl } from '@nextcloud/router'
import {
NcActionButton as ActionButton,
NcActionLink as ActionLink,
+ NcLoadingIcon as IconLoading,
NcActionButton,
} from '@nextcloud/vue'
import { Base64 } from 'js-base64'
@@ -295,6 +316,7 @@ import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue'
import ContentCopyIcon from 'vue-material-design-icons/ContentCopy.vue'
import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
import FilterIcon from 'vue-material-design-icons/FilterOutline.vue'
+import IconSave from 'vue-material-design-icons/FolderOutline.vue'
import InformationIcon from 'vue-material-design-icons/InformationOutline.vue'
import ImportantIcon from 'vue-material-design-icons/LabelVariant.vue'
import ImportantOutlineIcon from 'vue-material-design-icons/LabelVariantOutline.vue'
@@ -307,6 +329,7 @@ import TranslationIcon from 'vue-material-design-icons/Translate.vue'
import DownloadIcon from 'vue-material-design-icons/TrayArrowDown.vue'
import logger from '../logger.js'
import { buildRecipients as buildReplyRecipients } from '../ReplyBuilder.js'
+import { saveMessage } from '../service/MessageService.js'
import useMainStore from '../store/mainStore.js'
import { mailboxHasRights } from '../util/acl.js'
@@ -326,6 +349,9 @@ export default {
DotsHorizontalIcon,
TranslationIcon,
DownloadIcon,
+ IconLoading,
+ IconSave,
+ FilePicker,
InformationIcon,
OpenInNewIcon,
PlusIcon,
@@ -386,6 +412,15 @@ export default {
customSnoozeDateTime: new Date(moment().add(2, 'hours').minute(0).second(0).valueOf()),
copied: false,
copyResetTimer: null,
+ savingToCloud: false,
+ isFilePickerOpen: false,
+ saveMessageButtons: [
+ {
+ label: t('mail', 'Choose'),
+ callback: this.saveToCloud,
+ type: 'primary',
+ },
+ ],
}
},
@@ -682,6 +717,23 @@ export default {
}
},
+ async saveToCloud(dest) {
+ const path = dest[0].path
+ this.savingToCloud = true
+ const id = this.envelope.databaseId
+
+ try {
+ await saveMessage(id, path)
+ logger.info('saved')
+ showSuccess(t('mail', 'Message saved to Files'))
+ } catch (e) {
+ logger.error('not saved', { error: e })
+ showError(t('mail', 'Message could not be saved'))
+ } finally {
+ this.savingToCloud = false
+ }
+ },
+
isSieveEnabled() {
return this.account.sieveEnabled
},
diff --git a/src/service/MessageService.js b/src/service/MessageService.js
index 2af9dd1a28..ac55668181 100644
--- a/src/service/MessageService.js
+++ b/src/service/MessageService.js
@@ -276,6 +276,16 @@ export function moveMessage(id, destFolderId) {
})
}
+export async function saveMessage(id, directory) {
+ const url = generateUrl('/apps/mail/api/messages/{id}/file', {
+ id,
+ })
+
+ return await axios.post(url, {
+ targetPath: directory,
+ })
+}
+
export function snoozeMessage(id, unixTimestamp, destMailboxId) {
const url = generateUrl('/apps/mail/api/messages/{id}/snooze', {
id,
diff --git a/tests/Unit/Controller/MessagesControllerTest.php b/tests/Unit/Controller/MessagesControllerTest.php
index d0a774702c..ff20cd3aa1 100644
--- a/tests/Unit/Controller/MessagesControllerTest.php
+++ b/tests/Unit/Controller/MessagesControllerTest.php
@@ -49,6 +49,7 @@
use OCP\AppFramework\Http\ZipResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\Folder;
+use OCP\Files\IFilenameValidator;
use OCP\Files\IMimeTypeDetector;
use OCP\ICacheFactory;
use OCP\IL10N;
@@ -171,6 +172,10 @@ protected function setUp(): void {
$this->delegationService->method('resolveMessageUserId')->willReturn($this->userId);
$this->delegationService->method('resolveMailboxUserId')->willReturn($this->userId);
+ $this->filenameValidator = $this->createMock(IFilenameValidator::class);
+ $this->filenameValidator->method('sanitizeFilename')
+ ->willReturn('core_master has new results');
+
$timeFactory = $this->createMocK(ITimeFactory::class);
$timeFactory->expects($this->any())
->method('getTime')
@@ -187,6 +192,7 @@ protected function setUp(): void {
$this->itineraryService,
$this->userId,
$this->userFolder,
+ $this->filenameValidator,
$this->logger,
$this->l10n,
$this->mimeTypeDetector,
@@ -1191,6 +1197,66 @@ public function testExport() {
$this->assertEquals($expectedResponse, $actualResponse);
}
+ public function testSaveFile() {
+ $accountId = 17;
+ $mailboxId = 13;
+ $folderId = 'testfolder';
+ $messageId = 4321;
+ $targetPath = 'Downloads';
+ $this->account
+ ->method('getId')
+ ->willReturn($accountId);
+ $mailbox = new \OCA\Mail\Db\Mailbox();
+ $message = new \OCA\Mail\Db\Message();
+ $message->setMailboxId($mailboxId);
+ $message->setUid(123);
+ $message->setSubject('core/master has new results');
+ $mailbox->setAccountId($accountId);
+ $mailbox->setName($folderId);
+ $this->mailManager->expects($this->exactly(1))
+ ->method('getMessage')
+ ->with($this->userId, $messageId)
+ ->willReturn($message);
+ $this->mailManager->expects($this->exactly(1))
+ ->method('getMailbox')
+ ->with($this->userId, $mailboxId)
+ ->willReturn($mailbox);
+ $this->accountService->expects($this->exactly(1))
+ ->method('find')
+ ->with($this->equalTo($this->userId), $this->equalTo($accountId))
+ ->will($this->returnValue($this->account));
+ $source = file_get_contents(__DIR__ . '/../../data/mail-message-123.txt');
+ $client = $this->createStub(Horde_Imap_Client_Socket::class);
+ $this->mailManager->expects($this->exactly(1))
+ ->method('getSource')
+ ->with($client, $this->account, $folderId, 123)
+ ->willReturn($source);
+ $folderNode = $this->createStub(Folder::class);
+ $this->userFolder->expects($this->once())
+ ->method('get')
+ ->with('Downloads')
+ ->willReturn($folderNode);
+ $this->userFolder->expects($this->exactly(2))
+ ->method('nodeExists')
+ ->withConsecutive(['Downloads'], ['Downloads/core_master has new results.eml'])
+ ->willReturnOnConsecutiveCalls(true, false);
+ $file = $this->getMockBuilder('\OCP\Files\File')
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->userFolder->expects($this->once())
+ ->method('newFile')
+ ->with('Downloads/core_master has new results.eml')
+ ->will($this->returnValue($file));
+ $this->clientFactory->expects($this->once())
+ ->method('getClient')
+ ->willReturn($client);
+
+ $expectedResponse = new JSONResponse();
+ $actualResponse = $this->controller->saveFile($messageId, $targetPath);
+
+ $this->assertEquals($expectedResponse, $actualResponse);
+ }
+
public function testGetDkim() {
$mailAccount = new MailAccount();
$mailAccount->setId(100);
@@ -1298,6 +1364,7 @@ public function testNeedsTranslationNoUser() {
$this->itineraryService,
null,
$this->userFolder,
+ $this->filenameValidator,
$this->logger,
$this->l10n,
$this->mimeTypeDetector,
@@ -1471,6 +1538,7 @@ public function testSmartReplyNoUser(): void {
$this->itineraryService,
null,
$this->userFolder,
+ $this->filenameValidator,
$this->logger,
$this->l10n,
$this->mimeTypeDetector,