Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
68 changes: 68 additions & 0 deletions lib/Controller/MessagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions src/components/Envelope.vue
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@
fill-color="var(--color-primary-element)" />
</template>
<template #actions>
<FilePicker
v-if="isFilePickerOpen"
:name="t('mail', 'Choose a folder to store the message in')"
:buttons="saveMessageButtons"
:allow-pick-directory="true"
:multiselect="false"
:mimetype-filter="['httpd/unix-directory']"
@close="() => isFilePickerOpen = false" />
<EnvelopePrimaryActions v-if="!moreActionsOpen && !snoozeOptions" id="primary-actions">
<ActionButton
v-if="hasWriteAcl"
Expand Down Expand Up @@ -437,6 +445,17 @@
</template>
{{ t('mail', 'Download message') }}
</ActionLink>
<ActionButton
class="message-save-to-cloud"
:disabled="savingToCloud"
:close-after-click="true"
@click="() => isFilePickerOpen = true">
<template #icon>
<IconSave v-if="!savingToCloud" :size="20" />
<IconLoading v-else-if="savingToCloud" :size="20" />
</template>
{{ t('mail', 'Save message to Files') }}
</ActionButton>
</template>
<template v-if="quickActionMenu">
<ActionButton
Expand Down Expand Up @@ -513,13 +532,15 @@

<script>
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'
import {
NcActionButton as ActionButton,
NcActionLink as ActionLink,
NcActionText as ActionText,
NcLoadingIcon as IconLoading,
NcActionInput,
NcActionSeparator,
NcAssistantIcon,
Expand All @@ -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'
Expand Down Expand Up @@ -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'
Expand All @@ -593,6 +616,9 @@ export default {
DotsHorizontalIcon,
EnvelopePrimaryActions,
EventModal,
IconLoading,
IconSave,
FilePicker,
ImportantIcon,
ImportantOutlineIcon,
TaskModal,
Expand Down Expand Up @@ -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',
},
],
}
},

Expand Down Expand Up @@ -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
},
Expand Down
52 changes: 52 additions & 0 deletions src/components/MenuEnvelope.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
<!-- Standard Actions menu for Envelopes -->
<template>
<div>
<FilePicker
v-if="isFilePickerOpen"
:name="t('mail', 'Choose a folder to store the message in')"
:buttons="saveMessageButtons"
:allow-pick-directory="true"
:multiselect="false"
:mimetype-filter="['httpd/unix-directory']"
@close="() => isFilePickerOpen = false" />
<template v-if="!localMoreActionsOpen && !snoozeActionsOpen">
<ActionButton
v-if="hasWriteAcl"
Expand Down Expand Up @@ -201,6 +209,17 @@
</template>
{{ t('mail', 'Download message') }}
</ActionLink>
<ActionButton
class="message-save-to-cloud"
:disabled="savingToCloud"
:close-after-click="true"
@click="() => isFilePickerOpen = true">
<template #icon>
<IconSave v-if="!savingToCloud" :size="20" />
<IconLoading v-else-if="savingToCloud" :size="20" />
</template>
{{ t('mail', 'Save message to Files') }}
</ActionButton>
<ActionButton
v-if="isSieveEnabled"
:close-after-click="true"
Expand Down Expand Up @@ -274,11 +293,13 @@

<script>
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'
Expand All @@ -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'
Expand All @@ -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'

Expand All @@ -326,6 +349,9 @@ export default {
DotsHorizontalIcon,
TranslationIcon,
DownloadIcon,
IconLoading,
IconSave,
FilePicker,
InformationIcon,
OpenInNewIcon,
PlusIcon,
Expand Down Expand Up @@ -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',
},
],
}
},

Expand Down Expand Up @@ -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
},
Expand Down
10 changes: 10 additions & 0 deletions src/service/MessageService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading