Compare commits

...

5 commits

Author SHA1 Message Date
9e0540d133 added a check in the make_reply function to ensure that mails have a body
All checks were successful
/ test (push) Successful in 2m42s
/ deploy (push) Successful in 5m38s
2025-02-09 17:24:42 +01:00
c2bcd53749 Disable send mail button when there is no text 2025-02-09 17:23:48 +01:00
86b4220eaa The Comment button is now disabled when there is no text and the AsyncButton can now be disabled without setting it to inProgress 2025-02-09 17:23:48 +01:00
13994a111e show pending_suspected_spam tickets in default overview
All checks were successful
/ test (push) Successful in 2m29s
2025-02-09 11:25:28 +01:00
554bc70413 when the 'X-Spam' flag is set in the mail header, set the state to 'pending_suspected_spam' and do not send auto-reply
All checks were successful
/ test (push) Successful in 2m37s
2025-02-07 23:34:39 +01:00
12 changed files with 9481 additions and 40 deletions

View file

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

View file

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

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -203,4 +203,4 @@ a {
} }
</style> </style>

View file

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

View file

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

View file

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

View file

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