Compare commits
5 commits
9ac1e9706f
...
9e0540d133
Author | SHA1 | Date | |
---|---|---|---|
9e0540d133 | |||
c2bcd53749 | |||
86b4220eaa | |||
13994a111e | |||
554bc70413 |
12 changed files with 9481 additions and 40 deletions
|
@ -93,17 +93,17 @@ def make_reply(reply_email, references=None, event=None):
|
||||||
reply_email.save()
|
reply_email.save()
|
||||||
if references:
|
if references:
|
||||||
reply["References"] = " ".join(references)
|
reply["References"] = " ".join(references)
|
||||||
|
if reply_email.body != "":
|
||||||
reply.set_content(reply_email.body)
|
reply.set_content(reply_email.body)
|
||||||
|
return reply
|
||||||
return reply
|
else:
|
||||||
|
raise SpecialMailException("mail content emty")
|
||||||
|
|
||||||
async def send_smtp(message):
|
async def send_smtp(message):
|
||||||
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
|
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
|
||||||
|
|
||||||
|
|
||||||
def find_active_issue_thread(in_reply_to, address, subject, event):
|
def find_active_issue_thread(in_reply_to, address, subject, event, spam=False):
|
||||||
from re import match
|
from re import match
|
||||||
uuid_match = match(r'^ticket\+([a-f0-9-]{36})@', address)
|
uuid_match = match(r'^ticket\+([a-f0-9-]{36})@', address)
|
||||||
if uuid_match:
|
if uuid_match:
|
||||||
|
@ -114,7 +114,8 @@ def find_active_issue_thread(in_reply_to, address, subject, event):
|
||||||
if reply_to.exists():
|
if reply_to.exists():
|
||||||
return reply_to.first().issue_thread, False
|
return reply_to.first().issue_thread, False
|
||||||
else:
|
else:
|
||||||
issue = IssueThread.objects.create(name=subject, event=event)
|
issue = IssueThread.objects.create(name=subject, event=event,
|
||||||
|
initial_state='pending_suspected_spam' if spam else 'pending_new')
|
||||||
return issue, True
|
return issue, True
|
||||||
|
|
||||||
|
|
||||||
|
@ -213,6 +214,8 @@ def receive_email(envelope, log=None):
|
||||||
header_to = parsed.get('To')
|
header_to = parsed.get('To')
|
||||||
header_in_reply_to = ascii_strip(parsed.get('In-Reply-To'))
|
header_in_reply_to = ascii_strip(parsed.get('In-Reply-To'))
|
||||||
header_message_id = ascii_strip(parsed.get('Message-ID'))
|
header_message_id = ascii_strip(parsed.get('Message-ID'))
|
||||||
|
maybe_spam = parsed.get('X-Spam')
|
||||||
|
suspected_spam = (maybe_spam and maybe_spam.lower() == 'yes')
|
||||||
|
|
||||||
if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "":
|
if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "":
|
||||||
log.warning("Ignoring mailer daemon")
|
log.warning("Ignoring mailer daemon")
|
||||||
|
@ -232,7 +235,8 @@ def receive_email(envelope, log=None):
|
||||||
sender = decode_inline_encodings(sender)
|
sender = decode_inline_encodings(sender)
|
||||||
target_event = find_target_event(recipient)
|
target_event = find_target_event(recipient)
|
||||||
|
|
||||||
active_issue_thread, new = find_active_issue_thread(header_in_reply_to, recipient, subject, target_event)
|
active_issue_thread, new = find_active_issue_thread(
|
||||||
|
header_in_reply_to, recipient, subject, target_event, suspected_spam)
|
||||||
|
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
random_filename = 'mail-' + sha256(envelope.content).hexdigest()
|
random_filename = 'mail-' + sha256(envelope.content).hexdigest()
|
||||||
|
@ -250,7 +254,7 @@ def receive_email(envelope, log=None):
|
||||||
if new:
|
if new:
|
||||||
# auto reply if new issue
|
# auto reply if new issue
|
||||||
references = collect_references(active_issue_thread)
|
references = collect_references(active_issue_thread)
|
||||||
if not sender.startswith('noreply'):
|
if not sender.startswith('noreply') and not sender.startswith('no-reply') and not suspected_spam:
|
||||||
subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]"
|
subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]"
|
||||||
body = '''Your request (#{}) has been received and will be reviewed by our lost&found angels.
|
body = '''Your request (#{}) has been received and will be reviewed by our lost&found angels.
|
||||||
|
|
||||||
|
@ -299,10 +303,10 @@ class LMTPHandler:
|
||||||
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
|
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
|
||||||
reference=email.id)
|
reference=email.id)
|
||||||
log.info(f"Created system event {systemevent.id}")
|
log.info(f"Created system event {systemevent.id}")
|
||||||
channel_layer = get_channel_layer()
|
#channel_layer = get_channel_layer()
|
||||||
await channel_layer.group_send(
|
#await channel_layer.group_send(
|
||||||
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
# 'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
||||||
"message": "email received"})
|
# "message": "email received"})
|
||||||
log.info(f"Sent message to frontend")
|
log.info(f"Sent message to frontend")
|
||||||
if new and reply:
|
if new and reply:
|
||||||
log.info('Sending message to %s' % reply['To'])
|
log.info('Sending message to %s' % reply['To'])
|
||||||
|
|
|
@ -812,6 +812,44 @@ dGVzdGltYWdl
|
||||||
self.assertEqual(None, IssueThread.objects.all()[0].assigned_to)
|
self.assertEqual(None, IssueThread.objects.all()[0].assigned_to)
|
||||||
aiosmtplib.send.assert_called_once()
|
aiosmtplib.send.assert_called_once()
|
||||||
|
|
||||||
|
def test_mail_spam_header(self):
|
||||||
|
from aiosmtpd.smtp import Envelope
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
import aiosmtplib
|
||||||
|
aiosmtplib.send = make_mocked_coro()
|
||||||
|
handler = LMTPHandler()
|
||||||
|
server = mock.Mock()
|
||||||
|
session = mock.Mock()
|
||||||
|
envelope = Envelope()
|
||||||
|
envelope.mail_from = 'test1@test'
|
||||||
|
envelope.rcpt_tos = ['test2@test']
|
||||||
|
envelope.content = b'''Subject: test
|
||||||
|
From: test1@test
|
||||||
|
To: test2@test
|
||||||
|
Message-ID: <1@test>
|
||||||
|
X-Spam: Yes
|
||||||
|
|
||||||
|
test'''
|
||||||
|
result = async_to_sync(handler.handle_DATA)(server, session, envelope)
|
||||||
|
|
||||||
|
self.assertEqual(result, '250 Message accepted for delivery')
|
||||||
|
self.assertEqual(len(Email.objects.all()), 1) # do not send auto reply if spam is suspected
|
||||||
|
self.assertEqual(len(IssueThread.objects.all()), 1)
|
||||||
|
aiosmtplib.send.assert_not_called()
|
||||||
|
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('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('test', IssueThread.objects.all()[0].name)
|
||||||
|
self.assertEqual('pending_suspected_spam', IssueThread.objects.all()[0].state)
|
||||||
|
self.assertEqual(None, IssueThread.objects.all()[0].assigned_to)
|
||||||
|
states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0])
|
||||||
|
self.assertEqual(1, len(states))
|
||||||
|
self.assertEqual('pending_suspected_spam', states[0].state)
|
||||||
|
|
||||||
def test_mail_4byte_unicode_emoji(self):
|
def test_mail_4byte_unicode_emoji(self):
|
||||||
from aiosmtpd.smtp import Envelope
|
from aiosmtpd.smtp import Envelope
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
0
core/testdata.py
Normal file
0
core/testdata.py
Normal file
|
@ -102,12 +102,6 @@ def manual_ticket(request, event_slug):
|
||||||
subject=request.data['name'],
|
subject=request.data['name'],
|
||||||
body=request.data['body'],
|
body=request.data['body'],
|
||||||
)
|
)
|
||||||
systemevent = SystemEvent.objects.create(type='email received', reference=email.id)
|
|
||||||
channel_layer = get_channel_layer()
|
|
||||||
async_to_sync(channel_layer.group_send)(
|
|
||||||
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
|
||||||
"message": "email received"}
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
|
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
@ -133,12 +127,6 @@ def add_comment(request, pk):
|
||||||
issue_thread=issue,
|
issue_thread=issue,
|
||||||
comment=request.data['comment'],
|
comment=request.data['comment'],
|
||||||
)
|
)
|
||||||
systemevent = SystemEvent.objects.create(type='comment added', reference=comment.id)
|
|
||||||
channel_layer = get_channel_layer()
|
|
||||||
async_to_sync(channel_layer.group_send)(
|
|
||||||
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
|
||||||
"message": "comment added"}
|
|
||||||
)
|
|
||||||
return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
|
return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ STATE_CHOICES = (
|
||||||
('pending_physical_confirmation', 'Needs to be confirmed physically'),
|
('pending_physical_confirmation', 'Needs to be confirmed physically'),
|
||||||
('pending_return', 'Needs to be returned'),
|
('pending_return', 'Needs to be returned'),
|
||||||
('pending_postponed', 'Postponed'),
|
('pending_postponed', 'Postponed'),
|
||||||
|
('pending_suspected_spam', 'Suspected Spam'),
|
||||||
('waiting_details', 'Waiting for details'),
|
('waiting_details', 'Waiting for details'),
|
||||||
('waiting_pre_shipping', 'Waiting for Address/Shipping Info'),
|
('waiting_pre_shipping', 'Waiting for Address/Shipping Info'),
|
||||||
('closed_returned', 'Closed: Returned'),
|
('closed_returned', 'Closed: Returned'),
|
||||||
|
@ -46,6 +47,11 @@ class IssueThread(SoftDeleteModel):
|
||||||
event = models.ForeignKey(Event, null=True, on_delete=models.SET_NULL, related_name='issue_threads')
|
event = models.ForeignKey(Event, null=True, on_delete=models.SET_NULL, related_name='issue_threads')
|
||||||
manually_created = models.BooleanField(default=False)
|
manually_created = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
if 'initial_state' in kwargs:
|
||||||
|
self._initial_state = kwargs.pop('initial_state')
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def short_uuid(self):
|
def short_uuid(self):
|
||||||
return self.uuid[:8]
|
return self.uuid[:8]
|
||||||
|
|
||||||
|
@ -110,8 +116,9 @@ def set_uuid(sender, instance, **kwargs):
|
||||||
|
|
||||||
@receiver(post_save, sender=IssueThread)
|
@receiver(post_save, sender=IssueThread)
|
||||||
def create_issue_thread(sender, instance, created, **kwargs):
|
def create_issue_thread(sender, instance, created, **kwargs):
|
||||||
if created:
|
if created and instance.state_changes.count() == 0:
|
||||||
StateChange.objects.create(issue_thread=instance, state='pending_new')
|
initial_state = getattr(instance, '_initial_state', None)
|
||||||
|
StateChange.objects.create(issue_thread=instance, state=initial_state if initial_state else 'pending_new')
|
||||||
|
|
||||||
|
|
||||||
class Comment(models.Model):
|
class Comment(models.Model):
|
||||||
|
|
|
@ -7,6 +7,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- HTTP_HOST=core
|
- HTTP_HOST=core
|
||||||
- DB_FILE=dev.db
|
- DB_FILE=dev.db
|
||||||
|
- DEBUG_MODE_ACTIVE=true
|
||||||
volumes:
|
volumes:
|
||||||
- ../../core:/code
|
- ../../core:/code
|
||||||
- ../testdata.py:/code/testdata.py
|
- ../testdata.py:/code/testdata.py
|
||||||
|
|
9399
web/package-lock.json
generated
Normal file
9399
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -203,4 +203,4 @@ a {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<button @click.stop="handleClick" :disabled="disabled">
|
<button @click.stop="handleClick" :disabled="disabled || inProgress">
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"
|
||||||
:class="{'d-none': !disabled}"></span>
|
:class="{'d-none': !inProgress}"></span>
|
||||||
<span class="ml-2" :class="{'d-none': !disabled}">In Progress...</span>
|
<span class="ml-2" :class="{'d-none': !inProgress}">In Progress...</span>
|
||||||
<span :class="{'d-none': disabled}"><slot></slot></span>
|
<span :class="{'d-none': inProgress}"><slot></slot></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ export default {
|
||||||
name: 'AsyncButton',
|
name: 'AsyncButton',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
disabled: false,
|
inProgress: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
@ -21,17 +21,21 @@ export default {
|
||||||
type: Function,
|
type: Function,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async handleClick() {
|
async handleClick() {
|
||||||
if (this.task && typeof this.task === 'function') {
|
if (this.task && typeof this.task === 'function') {
|
||||||
this.disabled = true;
|
this.inProgress = true;
|
||||||
try {
|
try {
|
||||||
await this.task();
|
await this.task();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
this.disabled = false;
|
this.inProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -43,4 +47,4 @@ export default {
|
||||||
.spinner-border {
|
.spinner-border {
|
||||||
vertical-align: -0.125em;
|
vertical-align: -0.125em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
<textarea placeholder="add comment..." v-model="newComment"
|
<textarea placeholder="add comment..." v-model="newComment"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
</textarea>
|
</textarea>
|
||||||
<AsyncButton class="btn btn-secondary float-right" :task="addCommentAndClear">
|
<AsyncButton class="btn btn-secondary float-right" :task="addCommentAndClear" :disabled="!newComment">
|
||||||
<font-awesome-icon icon="comment"/>
|
<font-awesome-icon icon="comment"/>
|
||||||
Save Comment
|
Save Comment
|
||||||
</AsyncButton>
|
</AsyncButton>
|
||||||
|
@ -35,7 +35,7 @@
|
||||||
<div>
|
<div>
|
||||||
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
|
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
|
||||||
</textarea>
|
</textarea>
|
||||||
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear">
|
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear" :disabled="!newMail">
|
||||||
<font-awesome-icon icon="envelope"/>
|
<font-awesome-icon icon="envelope"/>
|
||||||
Send Mail
|
Send Mail
|
||||||
</AsyncButton>
|
</AsyncButton>
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
:columns="['id', 'name', 'last_activity', 'assigned_to',
|
:columns="['id', 'name', 'last_activity', 'assigned_to',
|
||||||
...(getEventSlug==='all'?['event']:[])]"
|
...(getEventSlug==='all'?['event']:[])]"
|
||||||
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
|
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
|
||||||
'pending_physical_confirmation','pending_return','pending_postponed'].map(stateInfo)">
|
'pending_physical_confirmation','pending_return','pending_postponed','pending_suspected_spam'].map(stateInfo)">
|
||||||
<template #section_header="{index, section, count}">
|
<template #section_header="{index, section, count}">
|
||||||
{{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span>
|
{{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
:columns="['id', 'name', 'last_activity', 'assigned_to',
|
:columns="['id', 'name', 'last_activity', 'assigned_to',
|
||||||
...(getEventSlug==='all'?['event']:[])]"
|
...(getEventSlug==='all'?['event']:[])]"
|
||||||
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
|
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
|
||||||
'pending_physical_confirmation','pending_return','pending_postponed'].map(stateInfo)">
|
'pending_physical_confirmation','pending_return','pending_postponed','pending_suspected_spam'].map(stateInfo)">
|
||||||
<template #section_header="{index, section, count}">
|
<template #section_header="{index, section, count}">
|
||||||
{{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span>
|
{{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
Loading…
Add table
Reference in a new issue