From 04f774404a76115702ce4cc23d2eace830d74e2c Mon Sep 17 00:00:00 2001 From: jedi Date: Sun, 14 Jan 2024 16:01:32 +0100 Subject: [PATCH] show mail attachments in frontend --- core/files/media_v2.py | 18 ++++- core/files/models.py | 7 ++ core/mail/api_v2.py | 8 +- core/mail/migrations/0003_emailattachment.py | 2 +- core/mail/tests/v2/test_mails.py | 62 +++++++++++---- core/tickets/api_v2.py | 2 + core/tickets/tests/v2/test_tickets.py | 84 +++++++++++++++++++- web/package.json | 1 + web/src/components/AuthenticatedDataLink.vue | 48 +++++++++++ web/src/components/TimelineMail.vue | 12 +++ web/src/store/index.js | 31 +++++++- 11 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 web/src/components/AuthenticatedDataLink.vue diff --git a/core/files/media_v2.py b/core/files/media_v2.py index 7cbf865..d62ee98 100644 --- a/core/files/media_v2.py +++ b/core/files/media_v2.py @@ -11,6 +11,7 @@ from rest_framework.response import Response from core.settings import MEDIA_ROOT from files.models import File +from mail.models import EmailAttachment @swagger_auto_schema(method='GET', auto_schema=None) @@ -21,7 +22,11 @@ def media_urls(request, hash): if request.META.get('HTTP_IF_NONE_MATCH') and request.META.get('HTTP_IF_NONE_MATCH') == hash: return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) - file = File.objects.get(hash=hash) + file = File.objects.filter(hash=hash).first() + attachment = EmailAttachment.objects.filter(hash=hash).first() + file = file if file else attachment + if not file: + return Response(status=status.HTTP_404_NOT_FOUND) hash_path = file.file return HttpResponse(status=status.HTTP_200_OK, content_type=file.mime_type, @@ -33,9 +38,10 @@ def media_urls(request, hash): 'Age': 0, 'ETag': file.hash, }) - except File.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) + except EmailAttachment.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) @swagger_auto_schema(method='GET', auto_schema=None) @@ -47,7 +53,11 @@ def thumbnail_urls(request, size, hash): if request.META.get('HTTP_IF_NONE_MATCH') and request.META.get('HTTP_IF_NONE_MATCH') == hash + "_" + str(size): return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) try: - file = File.objects.get(hash=hash) + file = File.objects.filter(hash=hash).first() + attachment = EmailAttachment.objects.filter(hash=hash).first() + file = file if file else attachment + if not file: + return Response(status=status.HTTP_404_NOT_FOUND) hash_path = file.file if not os.path.exists(MEDIA_ROOT + f'/thumbnails/{size}/{hash_path}'): from PIL import Image @@ -72,6 +82,8 @@ def thumbnail_urls(request, size, hash): except File.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) + except EmailAttachment.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) urlpatterns = [ diff --git a/core/files/models.py b/core/files/models.py index c16c417..df46fd3 100644 --- a/core/files/models.py +++ b/core/files/models.py @@ -77,6 +77,13 @@ class AbstractFile(models.Model): objects = FileManager() + def save(self, *args, **kwargs): + from django.utils import timezone + if not self.created_at: + self.created_at = timezone.now() + self.updated_at = timezone.now() + super().save(*args, **kwargs) + class Meta: abstract = True diff --git a/core/mail/api_v2.py b/core/mail/api_v2.py index 5227096..0d96785 100644 --- a/core/mail/api_v2.py +++ b/core/mail/api_v2.py @@ -1,6 +1,12 @@ from rest_framework import routers, viewsets, serializers -from mail.models import Email +from mail.models import Email, EmailAttachment + + +class AttachmentSerializer(serializers.ModelSerializer): + class Meta: + model = EmailAttachment + fields = ['hash', 'mime_type', 'name'] class EmailSerializer(serializers.ModelSerializer): diff --git a/core/mail/migrations/0003_emailattachment.py b/core/mail/migrations/0003_emailattachment.py index f3c7281..1d570ba 100644 --- a/core/mail/migrations/0003_emailattachment.py +++ b/core/mail/migrations/0003_emailattachment.py @@ -26,7 +26,7 @@ class Migration(migrations.Migration): def generate_email_attachments(apps, schema_editor): for email in Email.objects.all(): raw = email.raw - if raw is None: + if raw is None or raw == '': continue parsed, body, attachments = parse_email_body(raw.encode('utf-8'), NullLogger()) email.attachments.clear() diff --git a/core/mail/tests/v2/test_mails.py b/core/mail/tests/v2/test_mails.py index 75ca740..8d940d7 100644 --- a/core/mail/tests/v2/test_mails.py +++ b/core/mail/tests/v2/test_mails.py @@ -92,8 +92,8 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test session = mock.Mock() envelope = Envelope() envelope.mail_from = 'test1@test' - envelope.rcpt_tos = ['test2@test'] - envelope.content = b'Subject: test\nFrom: test3@test\nTo: test4@test\nMessage-ID: <1@test>\n\ntest' + envelope.rcpt_tos = ['test2@localhost'] + envelope.content = b'Subject: test\nFrom: test3@test\nTo: test4@localhost\nMessage-ID: <1@test>\n\ntest' result = async_to_sync(handler.handle_DATA)(server, session, envelope) self.assertEqual(result, '250 Message accepted for delivery') self.assertEqual(len(Email.objects.all()), 2) @@ -101,14 +101,14 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test aiosmtplib.send.assert_called_once() self.assertEqual('test', Email.objects.all()[0].subject) self.assertEqual('test1@test', Email.objects.all()[0].sender) - self.assertEqual('test2@test', Email.objects.all()[0].recipient) + self.assertEqual('test2@localhost', Email.objects.all()[0].recipient) self.assertEqual('test', Email.objects.all()[0].body) self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[0].issue_thread) self.assertEqual('<1@test>', Email.objects.all()[0].reference) self.assertEqual(None, Email.objects.all()[0].in_reply_to) self.assertEqual(expected_auto_reply_subject.format('test', IssueThread.objects.all()[0].short_uuid()), Email.objects.all()[1].subject) - self.assertEqual('test2@test', Email.objects.all()[1].sender) + self.assertEqual('test2@localhost', Email.objects.all()[1].sender) self.assertEqual('test1@test', Email.objects.all()[1].recipient) self.assertEqual(expected_auto_reply.format(IssueThread.objects.all()[0].short_uuid()), Email.objects.all()[1].body) @@ -284,19 +284,19 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test def test_mail_reply(self): issue_thread = IssueThread.objects.create( - name="test", + name="test subject", ) mail1 = Email.objects.create( subject='test subject', body='test', sender='test1@test', - recipient='test2@' + MAIL_DOMAIN, + recipient='test2@localhost', issue_thread=issue_thread, ) mail1_reply = Email.objects.create( - subject='Re: test subject', + subject='Message received', body='Thank you for your message.', - sender='test2@' + MAIL_DOMAIN, + sender='test2@localhost', recipient='test1@test', in_reply_to=mail1.reference, issue_thread=issue_thread, @@ -310,8 +310,22 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test self.assertEqual(len(Email.objects.all()), 3) self.assertEqual(len(IssueThread.objects.all()), 1) aiosmtplib.send.assert_called_once() - self.assertEqual(Email.objects.all()[2].subject, 'Re: test subject') - self.assertEqual(Email.objects.all()[2].sender, 'test2@' + MAIL_DOMAIN) + self.assertEqual(Email.objects.all()[0].subject, 'test subject') + self.assertEqual(Email.objects.all()[0].sender, 'test1@test') + self.assertEqual(Email.objects.all()[0].recipient, 'test2@localhost') + self.assertEqual(Email.objects.all()[0].body, 'test') + self.assertEqual(Email.objects.all()[0].issue_thread, issue_thread) + self.assertEqual(Email.objects.all()[0].reference, mail1.reference) + self.assertEqual(Email.objects.all()[1].subject, 'Message received') + self.assertEqual(Email.objects.all()[1].sender, 'test2@localhost') + self.assertEqual(Email.objects.all()[1].recipient, 'test1@test') + self.assertEqual(Email.objects.all()[1].body, 'Thank you for your message.') + self.assertEqual(Email.objects.all()[1].issue_thread, issue_thread) + self.assertTrue(Email.objects.all()[1].reference.startswith("<")) + self.assertTrue(Email.objects.all()[1].reference.endswith("@localhost>")) + self.assertEqual(Email.objects.all()[1].in_reply_to, mail1.reference) + self.assertEqual(Email.objects.all()[2].subject, 'Re: test subject [#{0}]'.format(issue_thread.short_uuid())) + self.assertEqual(Email.objects.all()[2].sender, 'test2@localhost') self.assertEqual(Email.objects.all()[2].recipient, 'test1@test') self.assertEqual(Email.objects.all()[2].body, 'test') self.assertEqual(Email.objects.all()[2].issue_thread, issue_thread) @@ -633,25 +647,26 @@ dGVzdGltYWdl def test_mail_plus_issue_thread(self): issue_thread = IssueThread.objects.create( - name="test", + name="test subject", ) mail1 = Email.objects.create( subject='test subject', body='test', sender='test1@test', - recipient='test2@test', + recipient='test2@localhost', issue_thread=issue_thread, ) mail1_reply = Email.objects.create( subject='Message received', body='Thank you for your message.', - sender='test2@test', + sender='test2@localhost', recipient='test1@test', in_reply_to=mail1.reference, issue_thread=issue_thread, ) from aiosmtpd.smtp import Envelope from asgiref.sync import async_to_sync + from email.message import EmailMessage import aiosmtplib import logging logging.disable(logging.CRITICAL) @@ -677,4 +692,23 @@ dGVzdGltYWdl self.assertEqual(Email.objects.all()[2].body, 'bar') self.assertEqual(Email.objects.all()[2].issue_thread, issue_thread) self.assertEqual(Email.objects.all()[2].reference, '<3@test>') - self.assertEqual('test', IssueThread.objects.all()[0].name) + self.assertEqual('test subject', IssueThread.objects.all()[0].name) + response = self.client.post(f'/api/2/tickets/{issue_thread.id}/reply/', { + 'message': 'test' + }) + aiosmtplib.send.assert_called_once(); + self.assertEqual(response.status_code, 201) + self.assertEqual(4, len(Email.objects.all())) + self.assertEqual(4, len(Email.objects.filter(issue_thread=issue_thread))) + self.assertEqual(1, len(IssueThread.objects.all())) + self.assertEqual(Email.objects.all()[3].subject, 'Re: test subject [#{0}]'.format(issue_thread.short_uuid())) + self.assertEqual(Email.objects.all()[3].sender, 'test2@localhost') + self.assertEqual(Email.objects.all()[3].recipient, 'test1@test') + self.assertEqual(Email.objects.all()[3].body, 'test') + self.assertEqual(Email.objects.all()[3].issue_thread, issue_thread) + self.assertTrue(Email.objects.all()[3].reference.startswith("<")) + self.assertTrue(Email.objects.all()[3].reference.endswith("@localhost>")) + self.assertEqual(Email.objects.all()[3].in_reply_to, mail1.reference) + self.assertEqual('test subject', IssueThread.objects.all()[0].name) + self.assertEqual('pending_new', IssueThread.objects.all()[0].state) + self.assertEqual(None, IssueThread.objects.all()[0].assigned_to) diff --git a/core/tickets/api_v2.py b/core/tickets/api_v2.py index 3105e00..9b80501 100644 --- a/core/tickets/api_v2.py +++ b/core/tickets/api_v2.py @@ -10,6 +10,7 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from core.settings import MAIL_DOMAIN +from mail.api_v2 import AttachmentSerializer from mail.models import Email from mail.protocol import send_smtp, make_reply, collect_references from notify_sessions.models import SystemEvent @@ -75,6 +76,7 @@ class IssueSerializer(serializers.ModelSerializer): 'recipient': email.recipient, 'subject': email.subject, 'body': email.body, + 'attachments': AttachmentSerializer(email.attachments.all(), many=True).data, }) return sorted(timeline, key=lambda x: x['timestamp']) diff --git a/core/tickets/tests/v2/test_tickets.py b/core/tickets/tests/v2/test_tickets.py index 9529aec..961a8f4 100644 --- a/core/tickets/tests/v2/test_tickets.py +++ b/core/tickets/tests/v2/test_tickets.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from django.test import TestCase, Client from authentication.models import ExtendedUser -from mail.models import Email +from mail.models import Email, EmailAttachment from tickets.models import IssueThread, StateChange, Comment from django.contrib.auth.models import Permission from knox.models import AuthToken @@ -147,6 +147,88 @@ class IssueApiTest(TestCase): self.assertEqual(comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), response.json()[2]['timeline'][1]['timestamp']) + def test_issues_with_files(self): + from django.core.files.base import ContentFile + from hashlib import sha256 + now = datetime.now() + issue = IssueThread.objects.create( + name="test issue", + ) + mail1 = Email.objects.create( + subject='test', + body='test', + sender='test', + recipient='test', + issue_thread=issue, + timestamp=now, + ) + mail2 = Email.objects.create( + subject='test', + body='test', + sender='test', + recipient='test', + issue_thread=issue, + in_reply_to=mail1.reference, + timestamp=now + timedelta(seconds=2), + ) + comment = Comment.objects.create( + issue_thread=issue, + comment="test", + timestamp=now + timedelta(seconds=3), + ) + file1 = EmailAttachment.objects.create( + name='file1', mime_type='text/plain', file=ContentFile(b"foo1", "f1"), + hash=sha256(b"foo1").hexdigest(), email=mail1 + ) + file2 = EmailAttachment.objects.create( + name='file2', mime_type='text/plain', file=ContentFile(b"foo2", "f2"), + hash=sha256(b"foo2").hexdigest(), email=mail1 + ) + + response = self.client.get('/api/2/tickets/') + self.assertEqual(200, response.status_code) + self.assertEqual(1, len(response.json())) + self.assertEqual(issue.id, response.json()[0]['id']) + self.assertEqual('pending_new', response.json()[0]['state']) + self.assertEqual('test issue', response.json()[0]['name']) + self.assertEqual(None, response.json()[0]['assigned_to']) + self.assertEqual(36, len(response.json()[0]['uuid'])) + self.assertEqual(comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[0]['last_activity']) + self.assertEqual(4, len(response.json()[0]['timeline'])) + self.assertEqual('state', response.json()[0]['timeline'][0]['type']) + self.assertEqual('mail', response.json()[0]['timeline'][1]['type']) + self.assertEqual('mail', response.json()[0]['timeline'][2]['type']) + self.assertEqual('comment', response.json()[0]['timeline'][3]['type']) + self.assertEqual(mail1.id, response.json()[0]['timeline'][1]['id']) + self.assertEqual(mail2.id, response.json()[0]['timeline'][2]['id']) + self.assertEqual(comment.id, response.json()[0]['timeline'][3]['id']) + self.assertEqual('pending_new', response.json()[0]['timeline'][0]['state']) + self.assertEqual('test', response.json()[0]['timeline'][1]['sender']) + self.assertEqual('test', response.json()[0]['timeline'][1]['recipient']) + self.assertEqual('test', response.json()[0]['timeline'][1]['subject']) + self.assertEqual('test', response.json()[0]['timeline'][1]['body']) + self.assertEqual(mail1.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[0]['timeline'][1]['timestamp']) + self.assertEqual('test', response.json()[0]['timeline'][2]['sender']) + self.assertEqual('test', response.json()[0]['timeline'][2]['recipient']) + + self.assertEqual('test', response.json()[0]['timeline'][2]['subject']) + self.assertEqual('test', response.json()[0]['timeline'][2]['body']) + self.assertEqual(mail2.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[0]['timeline'][2]['timestamp']) + self.assertEqual('test', response.json()[0]['timeline'][3]['comment']) + self.assertEqual(comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[0]['timeline'][3]['timestamp']) + self.assertEqual(2, len(response.json()[0]['timeline'][1]['attachments'])) + self.assertEqual(0, len(response.json()[0]['timeline'][2]['attachments'])) + self.assertEqual('file1', response.json()[0]['timeline'][1]['attachments'][0]['name']) + self.assertEqual('file2', response.json()[0]['timeline'][1]['attachments'][1]['name']) + self.assertEqual('text/plain', response.json()[0]['timeline'][1]['attachments'][0]['mime_type']) + self.assertEqual('text/plain', response.json()[0]['timeline'][1]['attachments'][1]['mime_type']) + self.assertEqual(file1.hash, response.json()[0]['timeline'][1]['attachments'][0]['hash']) + self.assertEqual(file2.hash, response.json()[0]['timeline'][1]['attachments'][1]['hash']) + def test_manual_creation(self): response = self.client.post('/api/2/tickets/manual/', {'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'}, diff --git a/web/package.json b/web/package.json index eb946c4..ab6e8b7 100644 --- a/web/package.json +++ b/web/package.json @@ -29,6 +29,7 @@ "vue-router": "^3.1.3", "vuex": "^3.1.2", "vuex-router-sync": "^5.0.0", + "vuex-shared-mutations": "^1.0.2", "yarn": "^1.22.21" }, "devDependencies": { diff --git a/web/src/components/AuthenticatedDataLink.vue b/web/src/components/AuthenticatedDataLink.vue new file mode 100644 index 0000000..f121af6 --- /dev/null +++ b/web/src/components/AuthenticatedDataLink.vue @@ -0,0 +1,48 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelineMail.vue b/web/src/components/TimelineMail.vue index df780d3..ba2ff30 100644 --- a/web/src/components/TimelineMail.vue +++ b/web/src/components/TimelineMail.vue @@ -18,6 +18,14 @@ +