diff --git a/appinfo/info.xml b/appinfo/info.xml
index a7d19c1cc5..66d9e49a44 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -46,6 +46,7 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud
OCA\Mail\BackgroundJob\CleanupJob
OCA\Mail\BackgroundJob\OutboxWorkerJob
OCA\Mail\BackgroundJob\IMipMessageJob
+ OCA\Mail\BackgroundJob\DraftsJob
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 1b1bdfef94..43b206baa1 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -400,11 +400,18 @@
'url' => '/api/list/unsubscribe/{id}',
'verb' => 'POST',
],
+ [
+ 'name' => 'drafts#move',
+ 'url' => '/api/drafts/move/{id}',
+ 'verb' => 'POST',
+ ],
+
],
'resources' => [
'accounts' => ['url' => '/api/accounts'],
'aliases' => ['url' => '/api/accounts/{accountId}/aliases'],
'autoComplete' => ['url' => '/api/autoComplete'],
+ 'drafts' => ['url' => '/api/drafts'],
'localAttachments' => ['url' => '/api/attachments'],
'mailboxes' => ['url' => '/api/mailboxes'],
'messages' => ['url' => '/api/messages'],
diff --git a/lib/Controller/DraftsController.php b/lib/Controller/DraftsController.php
index c36b8cd6a3..16cde87803 100644
--- a/lib/Controller/DraftsController.php
+++ b/lib/Controller/DraftsController.php
@@ -27,11 +27,14 @@
namespace OCA\Mail\Controller;
use OCA\Mail\Db\LocalMessage;
+use OCA\Mail\Exception\ClientException;
use OCA\Mail\Http\JsonResponse;
use OCA\Mail\Http\TrapError;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\DraftsService;
+use OCA\Mail\Service\SmimeService;
use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IRequest;
@@ -41,18 +44,22 @@ class DraftsController extends Controller {
private string $userId;
private AccountService $accountService;
private ITimeFactory $timeFactory;
+ private SmimeService $smimeService;
+
public function __construct(string $appName,
$UserId,
IRequest $request,
DraftsService $service,
AccountService $accountService,
- ITimeFactory $timeFactory) {
+ ITimeFactory $timeFactory,
+ SmimeService $smimeService) {
parent::__construct($appName, $request);
$this->userId = $UserId;
$this->service = $service;
$this->accountService = $accountService;
$this->timeFactory = $timeFactory;
+ $this->smimeService = $smimeService;
}
/**
@@ -63,15 +70,21 @@ public function __construct(string $appName,
* @param string $body
* @param string $editorBody
* @param bool $isHtml
+ * @param bool $smimeSign
+ * @param bool $smimeEncrypt
* @param array $to i. e. [['label' => 'Linus', 'email' => 'tent@stardewvalley.com'], ['label' => 'Pierre', 'email' => 'generalstore@stardewvalley.com']]
* @param array $cc
* @param array $bcc
* @param array $attachments
* @param int|null $aliasId
* @param string|null $inReplyToMessageId
+ * @param int|null $smimeCertificateId
* @param int|null $sendAt
* @param int|null $draftId
+ *
* @return JsonResponse
+ * @throws DoesNotExistException
+ * @throws ClientException
*/
#[TrapError]
public function create(
@@ -80,12 +93,15 @@ public function create(
string $body,
string $editorBody,
bool $isHtml,
+ ?bool $smimeSign,
+ ?bool $smimeEncrypt,
array $to = [],
array $cc = [],
array $bcc = [],
array $attachments = [],
?int $aliasId = null,
?string $inReplyToMessageId = null,
+ ?int $smimeCertificateId = null,
?int $sendAt = null,
?int $draftId = null) : JsonResponse {
$account = $this->accountService->find($this->userId, $accountId);
@@ -103,9 +119,17 @@ public function create(
$message->setInReplyToMessageId($inReplyToMessageId);
$message->setUpdatedAt($this->timeFactory->getTime());
$message->setSendAt($sendAt);
+ $message->setSmimeSign($smimeSign);
+ $message->setSmimeEncrypt($smimeEncrypt);
if ($sendAt !== null) {
$message->setType(LocalMessage::TYPE_OUTGOING);
}
+
+ if (!empty($smimeCertificateId)) {
+ $smimeCertificate = $this->smimeService->findCertificate($smimeCertificateId, $this->userId);
+ $message->setSmimeCertificateId($smimeCertificate->getId());
+ }
+
$this->service->saveMessage($account, $message, $to, $cc, $bcc, $attachments);
return JsonResponse::success($message, Http::STATUS_CREATED);
@@ -137,6 +161,8 @@ public function update(int $id,
string $body,
string $editorBody,
bool $isHtml,
+ ?bool $smimeSign,
+ ?bool $smimeEncrypt,
bool $failed = false,
array $to = [],
array $cc = [],
@@ -144,6 +170,7 @@ public function update(int $id,
array $attachments = [],
?int $aliasId = null,
?string $inReplyToMessageId = null,
+ ?int $smimeCertificateId = null,
?int $sendAt = null): JsonResponse {
$message = $this->service->getMessage($id, $this->userId);
$account = $this->accountService->find($this->userId, $accountId);
@@ -161,6 +188,13 @@ public function update(int $id,
$message->setInReplyToMessageId($inReplyToMessageId);
$message->setSendAt($sendAt);
$message->setUpdatedAt($this->timeFactory->getTime());
+ $message->setSmimeSign($smimeSign);
+ $message->setSmimeEncrypt($smimeEncrypt);
+
+ if (!empty($smimeCertificateId)) {
+ $smimeCertificate = $this->smimeService->findCertificate($smimeCertificateId, $this->userId);
+ $message->setSmimeCertificateId($smimeCertificate->getId());
+ }
$message = $this->service->updateMessage($account, $message, $to, $cc, $bcc, $attachments);
return JsonResponse::success($message, Http::STATUS_ACCEPTED);
diff --git a/src/components/Composer.vue b/src/components/Composer.vue
index 5e39466f20..20a276f74e 100644
--- a/src/components/Composer.vue
+++ b/src/components/Composer.vue
@@ -604,7 +604,7 @@ export default {
bodyVal: this.editorBody,
attachments: this.attachmentsData,
noReply: this.to.some((to) => to.email?.startsWith('noreply@') || to.email?.startsWith('no-reply@')),
- saveDraftDebounced: debounce(10 * 1000, this.saveDraft),
+ saveDraftDebounced: debounce(5 * 1000, this.saveDraft),
selectTo: this.to,
selectCc: this.cc,
selectBcc: this.bcc,
diff --git a/src/components/NewMessageModal.vue b/src/components/NewMessageModal.vue
index ed0802ac25..8446e22325 100644
--- a/src/components/NewMessageModal.vue
+++ b/src/components/NewMessageModal.vue
@@ -1,3 +1,4 @@
+
} Draft id promise
*/
+ // TODO: when there's no draft is saved, Cloing wont move ie case 2 doesn't work
onDraft(data, { showToast = false } = {}) {
if (!this.composerMessage) {
logger.info('Ignoring draft because there is no message anymore', { data })
return this.draftsPromise
}
+ this.changed = true
this.draftsPromise = this.draftsPromise.then(async (id) => {
this.savingDraft = true
this.draftSaved = false
- data.draftId = id
try {
let idToReturn
- if (this.composerMessage.type === 'outbox') {
- const dataForServer = this.getDataForServer(data)
- delete dataForServer.sendAt
- await this.$store.dispatch('outbox/updateMessage', {
- message: dataForServer,
- id: this.composerData.id,
- })
+ const dataForServer = this.getDataForServer(data, true)
+ if (!id) {
+ const { id } = await saveDraft(data.account, dataForServer)
+ dataForServer.id = id
+ await this.$store.dispatch('patchComposerData', { id, draftId: dataForServer.draftId })
this.canSaveDraft = true
this.draftSaved = true
+
+ idToReturn = id
} else {
- const dataForServer = this.getDataForServer(data, true)
- const { id } = await saveDraft(data.account, dataForServer)
+ dataForServer.id = id
+ await updateDraft(dataForServer)
this.canSaveDraft = true
this.draftSaved = true
-
- // Remove old draft envelope
- this.$store.commit('removeEnvelope', { id: data.draftId })
- this.$store.commit('removeMessage', { id: data.draftId })
-
- // Fetch new draft envelope
- await this.$store.dispatch('fetchEnvelope', {
- accountId: data.account,
- id,
- })
-
idToReturn = id
}
@@ -267,20 +259,21 @@ export default {
return this.draftsPromise
},
- getDataForServer(data, serializeRecipients = false) {
+ getDataForServer(data) {
return {
...data,
- accountId: data.account,
+ id: data.id,
+ accountId: data.accountId,
body: data.isHtml ? data.body.value : toPlain(data.body).value,
editorBody: data.body.value,
- to: serializeRecipients ? data.to.map(this.recipientToRfc822).join(', ') : data.to,
- cc: serializeRecipients ? data.cc.map(this.recipientToRfc822).join(', ') : data.cc,
- bcc: serializeRecipients ? data.bcc.map(this.recipientToRfc822).join(', ') : data.bcc,
+ to: data.to,
+ cc: data.cc,
+ bcc: data.bcc,
attachments: data.attachments,
aliasId: data.aliasId,
inReplyToMessageId: data.inReplyToMessageId,
sendAt: data.sendAt,
- draftId: data.draftId,
+ draftId: this.composerData?.draftId,
}
},
onAttachmentUploading(done, data) {
@@ -306,7 +299,7 @@ export default {
}
const dataForServer = this.getDataForServer({
...data,
- draftId: await this.draftsPromise,
+ id: await this.draftsPromise,
sendAt: data.sendAt ? data.sendAt : Math.floor((now + UNDO_DELAY) / 1000),
})
if (dataForServer.sendAt < Math.floor((now + UNDO_DELAY) / 1000)) {
@@ -314,10 +307,13 @@ export default {
}
if (!this.composerData.id) {
+ const { id } = await saveDraft(data.account, dataForServer)
+ dataForServer.id = id
await this.$store.dispatch('outbox/enqueueMessage', {
message: dataForServer,
})
} else {
+ dataForServer.id = this.composerData.id
await this.$store.dispatch('outbox/updateMessage', {
message: dataForServer,
id: this.composerData.id,
@@ -326,12 +322,11 @@ export default {
if (!data.sendAt || data.sendAt < Math.floor((now + UNDO_DELAY) / 1000)) {
// Awaiting here would keep the modal open for a long time and thus block the user
- this.$store.dispatch('outbox/sendMessageWithUndo', { id: this.composerData.id })
+ this.$store.dispatch('outbox/sendMessageWithUndo', { id: dataForServer.id })
}
- if (data.draftId) {
+ if (dataForServer.id) {
// Remove old draft envelope
- this.$store.commit('removeEnvelope', { id: data.draftId })
- this.$store.commit('removeMessage', { id: data.draftId })
+ this.$store.commit('removeMessage', { id: dataForServer.id })
}
await this.$store.dispatch('stopComposerSession')
this.$emit('close')
@@ -396,7 +391,7 @@ export default {
if (isOutbox) {
await this.$store.dispatch('outbox/deleteMessage', { id })
} else {
- await this.$store.dispatch('deleteMessage', { id })
+ deleteDraft(id)
}
showSuccess(t('mail', 'Message discarded'))
} catch (error) {
@@ -424,6 +419,7 @@ export default {
this.cookedComposerData = this.$refs.composer.getMessageData()
},
async patchComposerData(data) {
+ this.changed = true
this.updateCookedComposerData()
await this.$store.dispatch('patchComposerData', data)
},
@@ -431,20 +427,24 @@ export default {
this.modalFirstOpen = false
await this.$store.dispatch('closeMessageComposer')
- if (!this.$store.getters.composerMessageIsSaved) {
+ if (!this.$store.getters.composerMessageIsSaved && this.changed) {
await this.onDraft(this.cookedComposerData, { showToast: true })
}
+
},
async onClose() {
this.$store.commit('setComposerIndicatorDisabled', true)
await this.onMinimize()
// End the session only if all unsaved changes have been saved
- if (this.canSaveDraft) {
+ if (this.canSaveDraft && ((this.changed && this.draftSaved) || !this.changed)) {
logger.debug('Closing composer session due to close button click')
await this.$store.dispatch('stopComposerSession', {
restoreOriginalSendAt: true,
+ moveToImap: this.changed,
+ id: this.composerData.id,
})
+
}
},
},
diff --git a/src/service/DraftService.js b/src/service/DraftService.js
new file mode 100644
index 0000000000..0f01d134dd
--- /dev/null
+++ b/src/service/DraftService.js
@@ -0,0 +1,74 @@
+/**
+ * @copyright 2022 Christoph Wurst
+ *
+ * @author 2022 Christoph Wurst
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+import axios from '@nextcloud/axios'
+import { generateUrl } from '@nextcloud/router'
+
+import { convertAxiosError } from '../errors/convert'
+
+export async function saveDraft(accountId, data) {
+ const url = generateUrl('/apps/mail/api/drafts', {
+ accountId,
+ })
+
+ try {
+ return (await axios.post(url, data)).data.data
+ } catch (e) {
+ throw convertAxiosError(e)
+ }
+}
+
+export async function updateDraft(data) {
+ const url = generateUrl('/apps/mail/api/drafts/{id}', {
+ id: data.id,
+ })
+
+ try {
+ return (await axios.put(url, data)).data.data
+ } catch (e) {
+ throw convertAxiosError(e)
+ }
+}
+
+export async function deleteDraft(id) {
+ const url = generateUrl('/apps/mail/api/drafts/{id}', {
+ id,
+ })
+
+ try {
+ return (await axios.delete(url)).data.data
+ } catch (e) {
+ throw convertAxiosError(e)
+ }
+}
+
+export async function moveDraft(id) {
+ const url = generateUrl('/apps/mail/api/drafts/move/{id}', {
+ id,
+ })
+
+ try {
+ return (await axios.post(url)).data.data
+ } catch (e) {
+ throw convertAxiosError(e)
+ }
+}
diff --git a/src/store/actions.js b/src/store/actions.js
index 0a43ceb5e7..c8a8a6cda0 100644
--- a/src/store/actions.js
+++ b/src/store/actions.js
@@ -78,6 +78,7 @@ import {
syncEnvelopes,
updateEnvelopeTag,
} from '../service/MessageService'
+import { moveDraft, updateDraft } from '../service/DraftService'
import * as AliasService from '../service/AliasService'
import logger from '../logger'
import { normalizedEnvelopeListId } from './normalization'
@@ -483,21 +484,19 @@ export default {
}
})
},
- stopComposerSession({ commit, dispatch, getters }, { restoreOriginalSendAt = false } = {}) {
+ async stopComposerSession({ commit, dispatch, getters }, { restoreOriginalSendAt = false, moveToImap = false, id } = {}) {
return handleHttpAuthErrors(commit, async () => {
// Restore original sendAt timestamp when requested
const message = getters.composerMessage
if (restoreOriginalSendAt && message.type === 'outbox' && message.options?.originalSendAt) {
const body = message.data.body
- await dispatch('outbox/updateMessage', {
- id: message.data.id,
- message: {
- ...message.data,
- body: message.data.isHtml ? body.value : toPlain(body).value,
- sendAt: message.options.originalSendAt,
- },
- })
+ message.body = message.data.isHtml ? body.value : toPlain(body).value
+ message.sendAt = message.options.originalSendAt
+ updateDraft(message)
+ }
+ if (moveToImap) {
+ await moveDraft(id)
}
commit('stopComposerSession')
diff --git a/src/store/outbox/actions.js b/src/store/outbox/actions.js
index 054fba122c..ff963ab101 100644
--- a/src/store/outbox/actions.js
+++ b/src/store/outbox/actions.js
@@ -63,7 +63,6 @@ export default {
},
async enqueueMessage({ commit }, { message }) {
- message = await OutboxService.enqueueMessage(message)
commit('addMessage', { message })
// Future drafts/sends after an error should go through outbox logic
diff --git a/tests/Unit/Controller/DraftsControllerTest.php b/tests/Unit/Controller/DraftsControllerTest.php
index a59efe6ce3..60dde6bb19 100644
--- a/tests/Unit/Controller/DraftsControllerTest.php
+++ b/tests/Unit/Controller/DraftsControllerTest.php
@@ -36,6 +36,7 @@
use OCA\Mail\Http\JsonResponse;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\DraftsService;
+use OCA\Mail\Service\SmimeService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\Exception;
@@ -59,6 +60,7 @@ protected function setUp(): void {
$this->request = $this->createMock(IRequest::class);
$this->accountService = $this->createMock(AccountService::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
+ $this->smimeService = $this->createMock(SmimeService::class);
$this->controller = new DraftsController(
$this->appName,
@@ -66,7 +68,8 @@ protected function setUp(): void {
$this->request,
$this->service,
$this->accountService,
- $this->timeFactory
+ $this->timeFactory,
+ $this->smimeService
);
}
@@ -235,6 +238,8 @@ public function testCreate(): void {
$message->getBody(),
'message
',
$message->isHtml(),
+ null,
+ null,
$to,
$cc,
[],
@@ -282,6 +287,8 @@ public function testCreateFromDraft(): void {
$message->getBody(),
'message
',
$message->isHtml(),
+ null,
+ null,
$to,
$cc,
[],
@@ -289,6 +296,7 @@ public function testCreateFromDraft(): void {
$message->getAliasId(),
$message->getInReplyToMessageId(),
null,
+ null,
1
);
@@ -327,6 +335,8 @@ public function testCreateWithEmptyRecipients(): void {
$message->getBody(),
'message
',
$message->isHtml(),
+ null,
+ null,
[],
[],
[],
@@ -366,6 +376,8 @@ public function testCreateAccountNotFound(): void {
$message->getBody(),
'message
',
$message->isHtml(),
+ null,
+ null,
$to,
$cc,
[],
@@ -402,6 +414,8 @@ public function testCreateDbException(): void {
$message->getBody(),
'message
',
$message->isHtml(),
+ null,
+ null,
$to,
$cc,
[],
@@ -448,6 +462,8 @@ public function testUpdate(): void {
$message->getBody(),
'message
',
$message->isHtml(),
+ null,
+ null,
false,
$to,
$cc,
@@ -498,6 +514,8 @@ public function testUpdateMoveToOutbox(): void {
$message->getBody(),
'message
',
$message->isHtml(),
+ null,
+ null,
false,
$to,
$cc,
@@ -544,6 +562,8 @@ public function testUpdateMessageNotFound(): void {
$message->getBody(),
'message
',
$message->isHtml(),
+ null,
+ null,
false,
$to,
$cc,
@@ -593,6 +613,8 @@ public function testUpdateDbException(): void {
$message->getBody(),
'message
',
$message->isHtml(),
+ null,
+ null,
false,
$to,
$cc,