show mail attachments in frontend #47

Merged
j3d1 merged 1 commit from jedi/dev into live 2024-01-14 17:34:20 +00:00
11 changed files with 252 additions and 23 deletions
Showing only changes of commit 04f774404a - Show all commits

View file

@ -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 = [

View file

@ -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

View file

@ -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):

View file

@ -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()

View file

@ -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)

View file

@ -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'])

View file

@ -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'},

View file

@ -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": {

View file

@ -0,0 +1,48 @@
<template>
<a :href="image_data">{{ download }}</a>
</template>
<style scoped>
</style>
<script>
import {mapActions} from "vuex";
export default {
name: "AuthenticatedDataLink",
props: {
href: {
type: String,
required: true
},
download: {
type: String,
required: false,
default: "unnamed"
}
},
data() {
return {
image_data: "",
servers: []
}
},
methods: {
...mapActions(['fetchImage']),
loadImage() {
this.fetchImage(this.href).then((response) => {
const mime_type = response.headers.get("content-type");
response.arrayBuffer().then((buf) => {
const base64 = btoa(new Uint8Array(buf)
.reduce((data, byte) => data + String.fromCharCode(byte), ""));
this.image_data = "data:" + mime_type + ";base64," + base64;
});
})
}
},
mounted() {
this.loadImage();
}
}
</script>

View file

@ -18,6 +18,14 @@
<font-awesome-icon icon="user"/>
</button-->
</div>
<div class="card-footer" v-if="item.attachments.length">
<ul>
<li v-for="attachment in item.attachments">
<AuthenticatedImage :src="`/media/2/256/${attachment.hash}/`" :alt="attachment.name" v-if="attachment.mime_type.startsWith('image/')"/>
<AuthenticatedDataLink :href="`/media/2/256/${attachment.hash}/`" :download="attachment.name" v-else/>
</li>
</ul>
</div>
</div>
<!--button class="show-replies">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-arrow-forward"
@ -44,8 +52,12 @@
<script>
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AuthenticatedDataLink from "@/components/AuthenticatedDataLink.vue";
export default {
name: 'TimelineMail',
components: {AuthenticatedImage, AuthenticatedDataLink},
props: {
'item': {
type: Object,

View file

@ -7,6 +7,7 @@ import router from '../router';
import * as base64 from 'base-64';
import * as utf8 from 'utf8';
import {ticketStateColorLookup, ticketStateIconLookup} from "@/utils";
import createMutationsSharer from "vuex-shared-mutations";
Vue.use(Vuex);
const axios = AxiosBootstrap.create({
@ -103,7 +104,6 @@ const store = new Vuex.Store({
slug: slug,
text: 'Unknown'
}
}
},
isLoggedIn(state) {
@ -273,10 +273,11 @@ const store = new Vuex.Store({
//async verifyToken({commit, state}) {
async afterLogin({dispatch}) {
const boxes = dispatch('loadBoxes');
const states = dispatch('fetchTicketStates');
const items = dispatch('loadEventItems');
const tickets = dispatch('loadTickets');
const user = dispatch('loadUserInfo');
await Promise.all([boxes, items, tickets, user]);
await Promise.all([boxes, items, tickets, user, states]);
},
async fetchImage({state}, url) {
return await fetch(url, {headers: {'Authorization': `Token ${state.token}`}});
@ -394,7 +395,31 @@ const store = new Vuex.Store({
const {data} = await axios.patch(`/2/tickets/${id}/`, ticket);
commit('updateTicket', data);
}
}
},
plugins: [createMutationsSharer({
predicate: [
'replaceLoadedItems',
'setItemCache',
'setLayout',
'replaceBoxes',
'updateItem',
'removeItem',
'appendItem',
'replaceTickets',
'replaceUsers',
'replaceGroups',
'updateTicket',
'openAddBoxModal',
'closeAddBoxModal',
'createToast',
'removeToast',
'setRemember',
'setUser',
'setPermissions',
'setToken',
'logout',
]
})],
});
export default store;