Merge pull request #3923 from modoboa/feature/webmail-save-drafts

Webmail: save message as draft
This commit is contained in:
Antoine Nguyen
2026-02-16 16:23:18 +01:00
committed by GitHub
13 changed files with 309 additions and 69 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ jobs:
--health-timeout 5s
--health-retries 5
openldap:
image: docker.pkg.github.com/modoboa/docker-openldap/docker-openldap
image: ghcr.io/modoboa/docker-openldap/docker-openldap:latest
ports:
- 389/tcp
env:
-2
View File
@@ -87,8 +87,6 @@ services:
- redis
volumes:
- .:/code
networks:
- default
api-unsecured:
container_name: modoboa_api_unsecured
+12 -2
View File
@@ -108,8 +108,12 @@ export default {
getComposeSession(uid) {
return repository.get(`/webmail/compose-sessions/${uid}/`)
},
createComposeSession() {
return repository.post('/webmail/compose-sessions/')
createComposeSession(fromDraftMessageUid) {
const data = {}
if (fromDraftMessageUid) {
data.from_draft_message = fromDraftMessageUid
}
return repository.post('/webmail/compose-sessions/', data)
},
getAllowedSenders() {
return repository.get('/webmail/compose-sessions/allowed_senders/')
@@ -123,6 +127,12 @@ export default {
}
)
},
saveComposeSession(sessionUid, data) {
return repository.post(
`/webmail/compose-sessions/${sessionUid}/save/`,
data
)
},
removeAttachment(sessionUid, name) {
return repository.delete(
`/webmail/compose-sessions/${sessionUid}/attachments/${name}/`
@@ -40,6 +40,14 @@
:text="$gettext('Attachments') + ` (${attachmentCount})`"
@click="openAttachmentsDialog"
/>
<v-btn
class="ml-2"
icon="mdi-content-save-outline"
size="small"
:title="$gettext('Save as draft')"
:loading="working"
@click="saveDraft"
/>
</v-toolbar>
<v-form ref="formRef" class="flex-grow-1 d-flex flex-column">
<div>
@@ -266,33 +274,43 @@ const initForm = () => {
}
}
const prepareMessage = () => {
const result = { ...form.value }
if (result.to?.length) {
const to = []
for (const rcpt of result.to) {
to.push(typeof rcpt === 'string' ? rcpt : rcpt.emails[0].address)
}
result.to = to
}
if (result.cc?.length) {
const cc = []
for (const rcpt of result.cc) {
cc.push(typeof rcpt === 'string' ? rcpt : rcpt.emails[0].address)
}
result.cc = cc
}
if (result.bcc?.length) {
const bcc = []
for (const rcpt of result.bcc) {
bcc.push(typeof rcpt === 'string' ? rcpt : rcpt.emails[0].address)
}
result.bcc = bcc
}
if (route.query.mailid) {
result.mailid = route.query.mailid
}
return result
}
const submit = async (reload) => {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
working.value = true
const body = { ...form.value }
const to = []
for (const rcpt of body.to) {
to.push(typeof rcpt === 'string' ? rcpt : rcpt.emails[0].address)
}
body.to = to
if (body.cc?.length) {
const cc = []
for (const rcpt of body.cc) {
cc.push(typeof rcpt === 'string' ? rcpt : rcpt.emails[0].address)
}
body.cc = cc
}
if (body.bcc?.length) {
const bcc = []
for (const rcpt of body.bcc) {
bcc.push(typeof rcpt === 'string' ? rcpt : rcpt.emails[0].address)
}
body.bcc = bcc
}
const body = prepareMessage()
try {
await api.sendEmailFromComposeSession(route.query.uid, body)
router.push({ name: 'MailboxView' })
@@ -337,8 +355,29 @@ const lookForContacts = debounce(async (search) => {
}
}, 500)
const initialize = (body) => {
if (body.signature) {
const initialize = async (body) => {
if (route.query.mailid) {
// Load draft
const draft = await api.getEmailContent('Drafts', route.query.mailid)
form.value.sender = draft.data.from_address.address
if (draft.data.to?.length) {
form.value.to = draft.data.to.map((rcpt) => rcpt.address)
}
if (draft.data.cc?.length) {
form.value.cc = draft.data.cc.map((rcpt) => rcpt.address)
showCcField.value = true
}
if (draft.data.bcc?.length) {
form.value.bcc = draft.data.bcc.map((rcpt) => rcpt.address)
showBccField.value = true
}
if (draft.data.subject) {
form.value.subject = draft.data.subject
}
if (draft.data.body) {
form.value.body = draft.data.body
}
} else if (body.signature) {
if (form.value.body) {
form.value.body += body.signature
} else {
@@ -363,6 +402,18 @@ const closeSchedulingForm = () => {
showSchedulingForm.value = false
}
const saveDraft = async () => {
working.value = true
const body = prepareMessage()
try {
await api.saveComposeSession(route.query.uid, body)
} catch (error) {
console.log(error)
} finally {
working.value = false
}
}
const getItemTitle = (item) => {
if (typeof item === 'string') {
return item
@@ -379,8 +430,9 @@ watch(
)
if (!route.query.uid) {
api.createComposeSession().then((resp) => {
api.createComposeSession(route.query.mailid).then((resp) => {
const query = { ...route.query, uid: resp.data.uid }
attachmentCount.value = resp.data.attachments?.length || 0
router.push({ name: route.name, query })
initialize(resp.data)
})
+16
View File
@@ -53,6 +53,15 @@
@click="markEmailAsNotJunk"
>
</v-btn>
<v-btn
v-if="route.query.mailbox === 'Drafts'"
class="ml-2"
variant="tonal"
icon="mdi-pencil"
size="small"
@click="editDraft"
>
</v-btn>
</template>
<v-btn class="ml-2" variant="tonal" icon size="small">
<v-icon icon="mdi-cog" />
@@ -318,6 +327,13 @@ const replyToEmail = (all) => {
const forwardEmail = () => {
router.push({ name: 'ForwardEmailView', query: route.query })
}
const editDraft = () => {
router.push({
name: 'ComposeEmailView',
query: { mailid: route.query.mailid },
})
}
</script>
<style>
+4 -2
View File
@@ -353,8 +353,10 @@ def decode(value_bytes: bytes, encoding: str, append_to_error: str = "") -> str:
return value
def prepare_addresses(addresses, usage="header"):
"""Prepare addresses before using them
def prepare_addresses(
addresses: list[str] | str, usage: str = "header"
) -> list[str] | str:
"""Prepare addresses before using them.
:param list addresses: a list of addresses
:param string usage: how those addresses are going to be used
+45 -11
View File
@@ -3,6 +3,7 @@ from email.mime.base import MIMEBase
import json
import os
from tempfile import NamedTemporaryFile
from typing import TypedDict
import uuid
import six
@@ -10,7 +11,6 @@ import six
from django.conf import settings
from django.core.files.uploadhandler import FileUploadHandler, SkipFile
from django.http import Http404
from django.utils.encoding import smart_bytes
from django.utils.translation import gettext as _
from modoboa.lib.exceptions import InternalError
@@ -21,6 +21,11 @@ from modoboa.parameters import tools as param_tools
from .rfc6266 import build_header
Attachment = TypedDict(
"Attachment", {"fname": str, "content-type": str, "size": int, "tmpname": str}
)
class ComposeSessionManager:
def __init__(self, username: str):
@@ -63,15 +68,15 @@ def get_storage_path(filename):
return os.path.join(storage_dir, filename)
def save_attachment(request, session_uid: str, f) -> dict:
"""Save a new attachment to the filesystem.
def save_attachment_from_upload(request, session_uid: str, f) -> Attachment:
"""
Save a new attachment to the filesystem, directly from a Django upload.
The attachment is not saved using its own name to the
filesystem. To avoid conflicts, a random name is generated and
used instead.
:param f: an uploaded file object (see Django's documentation) or bytes
:return: the new random name
:param f: an uploaded file object (see Django's documentation)
"""
manager = ComposeSessionManager(request.user.username)
if not manager.exists(session_uid):
@@ -80,15 +85,12 @@ def save_attachment(request, session_uid: str, f) -> dict:
fp = NamedTemporaryFile(dir=get_storage_path(""), delete=False)
except Exception as e:
raise InternalError(str(e)) from None
if isinstance(f, six.binary_type | six.text_type):
fp.write(smart_bytes(f))
else:
for chunk in f.chunks():
fp.write(chunk)
for chunk in f.chunks():
fp.write(chunk)
fp.close()
session = manager.get_content(session_uid)
attachment = {
attachment: Attachment = {
"fname": str(f),
"content-type": f.content_type,
"size": f.size,
@@ -99,6 +101,38 @@ def save_attachment(request, session_uid: str, f) -> dict:
return attachment
def save_attachment(
request, session_uid: str, filename: str, content_type: str, content: str
) -> Attachment:
"""
Save a new attachment to the filesystem
The attachment is not saved using its own name to the
filesystem. To avoid conflicts, a random name is generated and
used instead.
"""
manager = ComposeSessionManager(request.user.username)
if not manager.exists(session_uid):
raise Http404
try:
fp = NamedTemporaryFile(dir=get_storage_path(""), delete=False)
except Exception as e:
raise InternalError(str(e)) from None
fp.write(content.encode("utf-8"))
fp.close()
session = manager.get_content(session_uid)
attachment: Attachment = {
"fname": filename,
"content-type": content_type,
"size": len(content),
"tmpname": os.path.basename(fp.name),
}
session["attachments"].append(attachment)
manager.set_content(session_uid, session)
return attachment
def remove_attachment(request, session_uid: str, name: str) -> str | None:
manager = ComposeSessionManager(request.user.username)
session = manager.get_content(session_uid)
+14 -1
View File
@@ -116,6 +116,19 @@ class ImapEmail(Email):
self.dformat if self.dformat in self.bs.contents else fallback_fmt
)
def fetch_attachments(self):
result = []
for partnum, filename in self.attachments.items():
attdef, content = self.imapc.fetchpart(self.mailid, self.mbox, partnum)
result.append(
{
"filename": filename,
"content_type": attdef["Content-Type"],
"content": content,
}
)
return result
@property
def subject(self):
"""Just a shortcut to return a subject in any case."""
@@ -190,7 +203,7 @@ class ImapEmail(Email):
"\r\t\n"
)
break
elif "disposition" in att and len(att["disposition"]) > 1:
if "disposition" in att and len(att["disposition"]) > 1:
for pos, value in enumerate(att["disposition"][1]):
if not value.startswith("filename"):
continue
+5 -5
View File
@@ -37,11 +37,11 @@ class BodyStructure:
current_mailbox: str
def __init__(self, definition=None):
def __init__(self, definition: list | None = None) -> None:
self.is_multipart = False
self.contents = {}
self.attachments = []
self.inlines = {}
self.contents: dict[str, list] = {}
self.attachments: list[dict] = []
self.inlines: dict[str, dict] = {}
if definition is not None:
self.load_from_definition(definition)
@@ -125,7 +125,7 @@ class BodyStructure:
def has_attachments(self) -> int:
return len(self.attachments)
def find_attachment(self, pnum):
def find_attachment(self, pnum: str) -> dict | None:
for att in self.attachments:
if pnum == att["pnum"]:
return att
+1 -1
View File
@@ -142,7 +142,7 @@ def create_message(
else:
msg = plain_msg(attributes.get("body", ""))
msg.from_email = sender
msg.to = attributes["to"]
msg.to = attributes.get("to", [])
msg.headers = headers
msg.extra_headers = extra_headers
for hdr in ["subject", "cc", "bcc"]:
+39 -8
View File
@@ -9,6 +9,7 @@ from modoboa.lib import email_utils
from modoboa.webmail import constants, models
from modoboa.webmail.lib import imapheader, signature
from modoboa.webmail.lib.imaputils import get_imapconnector
from modoboa.webmail.lib.utils import create_message
class GlobalParametersSerializer(serializers.Serializer):
@@ -260,18 +261,14 @@ class ScheduledDatetimeMixin:
return value
class SendEmailSerializer(ScheduledDatetimeMixin, serializers.Serializer):
class BaseEmailSerializer(serializers.Serializer):
sender = serializers.EmailField()
to = serializers.ListField(child=serializers.EmailField())
cc = serializers.ListField(child=serializers.EmailField(), required=False)
bcc = serializers.ListField(child=serializers.EmailField(), required=False)
subject = serializers.CharField(required=False)
body = serializers.CharField(required=False)
in_reply_to = serializers.CharField(required=False)
scheduled_datetime = serializers.DateTimeField(required=False)
to = serializers.ListField(child=serializers.EmailField(), required=False)
cc = serializers.ListField(child=serializers.EmailField(), required=False)
bcc = serializers.ListField(child=serializers.EmailField(), required=False)
def validate_to(self, value):
return email_utils.prepare_addresses(value, "envelope")
@@ -283,6 +280,35 @@ class SendEmailSerializer(ScheduledDatetimeMixin, serializers.Serializer):
return email_utils.prepare_addresses(value, "envelope")
class SendEmailSerializer(ScheduledDatetimeMixin, BaseEmailSerializer):
in_reply_to = serializers.CharField(required=False)
scheduled_datetime = serializers.DateTimeField(required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["to"].required = True
class SaveEmailSerializer(BaseEmailSerializer):
mailid = serializers.IntegerField(required=False)
def create(self, validated_data):
message = create_message(
self.context["request"].user, validated_data, self.context["attachments"]
)
drafts_folder = self.context["request"].user.parameters.get_value(
"drafts_folder"
)
with get_imapconnector(self.context["request"]) as imapc:
if "mailid" in validated_data:
imapc.delete_mail(drafts_folder, validated_data["mailid"])
mailid = imapc.push_mail(drafts_folder, message.message())
imapc.mark_messages_unread(drafts_folder, [str(mailid)])
return mailid
class ComposeSessionSerializer(serializers.Serializer):
attachments = UploadedAttachmentSerializer(many=True, required=False)
@@ -297,6 +323,11 @@ class ComposeSessionSerializer(serializers.Serializer):
return str(signature.EmailSignature(self.context["request"].user))
class CreateSessionSerializer(serializers.Serializer):
from_draft_message = serializers.IntegerField(required=False)
class AllowedSenderSerializer(serializers.Serializer):
address = serializers.EmailField()
+51
View File
@@ -285,6 +285,18 @@ class ComposeSessionViewSetTestCase(WebmailTestCase):
self.authenticate()
self._create_compose_session()
def test_create_from_draft(self):
self.authenticate()
data = {"from_draft_message": 3444}
url = reverse("v2:webmail-compose-session-list")
with self.settings(MEDIA_ROOT=self.workdir):
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 201)
uid = response.json()["uid"]
manager = ComposeSessionManager(self.user.username)
content = manager.get_content(uid)
self.assertEqual(len(content["attachments"]), 1)
def test_get(self):
self.authenticate()
uid = self._create_compose_session()
@@ -338,6 +350,45 @@ class ComposeSessionViewSetTestCase(WebmailTestCase):
response = self.client.delete(url)
self.assertEqual(response.status_code, 404)
def test_save(self):
self.authenticate()
uid = self._create_compose_session()
# Upload an attachment
url = reverse("v2:webmail-compose-session-attachments", args=[uid])
with self.settings(MEDIA_ROOT=self.workdir):
response = self.client.post(url, {"attachment": get_gif()})
# Save the message as draft
url = reverse("v2:webmail-compose-session-save", args=[uid])
response = self.client.post(
url,
{
"sender": self.user.email,
"to": ["test@example.test"],
"subject": "test",
"body": "Test",
},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["mailid"], 11)
def test_save_existing_draft(self):
self.authenticate()
uid = self._create_compose_session()
url = reverse("v2:webmail-compose-session-save", args=[uid])
response = self.client.post(
url,
{
"sender": self.user.email,
"to": ["test@example.test"],
"subject": "test",
"body": "Test",
"mailid": 11,
},
format="json",
)
self.assertEqual(response.status_code, 200)
def test_send(self):
self.authenticate()
uid = self._create_compose_session()
+45 -12
View File
@@ -312,8 +312,12 @@ class ComposeSessionViewSet(viewsets.GenericViewSet):
return response
def get_serializer_class(self):
if self.action == "create":
return serializers.CreateSessionSerializer
if self.action == "send":
return serializers.SendEmailSerializer
if self.action == "save":
return serializers.SaveEmailSerializer
if self.action == "allowed_senders":
return serializers.AllowedSenderSerializer
return serializers.ComposeSessionSerializer
@@ -327,10 +331,39 @@ class ComposeSessionViewSet(viewsets.GenericViewSet):
return response.Response(serializer.data)
def create(self, request):
uid = attachments.ComposeSessionManager(request.user.username).create()
serializer = self.get_serializer({"uid": uid})
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
manager = attachments.ComposeSessionManager(request.user.username)
uid = manager.create()
from_draft_message = serializer.validated_data.get("from_draft_message")
response_attrs = {"uid": uid}
if from_draft_message:
mailbox = request.user.parameters.get_value("drafts_folder")
email = lib.ImapEmail(
request,
f"{mailbox}:{from_draft_message}",
)
email.fetch_body_structure()
for attachment in email.fetch_attachments():
attachments.save_attachment(request, uid, **attachment)
response_attrs["attachments"] = manager.get_content(uid)["attachments"]
serializer = serializers.ComposeSessionSerializer(
response_attrs, context=self.get_serializer_context()
)
return response.Response(serializer.data, status=201)
@action(methods=["post"], detail=True)
def save(self, request, pk):
manager = attachments.ComposeSessionManager(request.user.username)
context = self.get_serializer_context()
context["attachments"] = manager.get_content(pk)["attachments"]
serializer = self.get_serializer(data=request.data, context=context)
serializer.is_valid(raise_exception=True)
mailid = serializer.save()
serializer.validated_data["mailid"] = mailid
return response.Response(serializer.validated_data)
@action(methods=["get"], detail=False)
def allowed_senders(self, request):
addresses = [{"address": request.user.email}]
@@ -360,7 +393,7 @@ class ComposeSessionViewSet(viewsets.GenericViewSet):
% request.upload_handlers[0].maxsize
]
return response.Response(errors, status=400)
result = attachments.save_attachment(
result = attachments.save_attachment_from_upload(
request, pk, serializer.validated_data["attachment"]
)
return response.Response(result)
@@ -385,15 +418,15 @@ class ComposeSessionViewSet(viewsets.GenericViewSet):
manager.get_content(pk)["attachments"],
)
return response.Response(status=204)
else:
status, error = send_mail(
request,
serializer.validated_data,
manager.get_content(pk)["attachments"],
)
if status:
attachments.remove_attachments_and_session(manager, pk)
return response.Response(status=204)
status, error = send_mail(
request,
serializer.validated_data,
manager.get_content(pk)["attachments"],
)
if status:
attachments.remove_attachments_and_session(manager, pk)
return response.Response(status=204)
return response.Response({"error": error}, status=400)