This commit is contained in:
j3d1 2024-05-03 23:34:47 +02:00
parent 0a124a19d8
commit 88ecfc0f61
19 changed files with 184 additions and 84 deletions

View file

@ -1,8 +1,6 @@
from django.conf import settings from django.conf import settings
from django.db import migrations from django.db import migrations
from authentication.models import ExtendedUser
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -11,6 +9,7 @@ class Migration(migrations.Migration):
] ]
def create_legacy_user(apps, schema_editor): def create_legacy_user(apps, schema_editor):
ExtendedUser = apps.get_model('authentication', 'ExtendedUser')
ExtendedUser.objects.create_user(settings.LEGACY_USER_NAME, 'mail@' + settings.MAIL_DOMAIN, ExtendedUser.objects.create_user(settings.LEGACY_USER_NAME, 'mail@' + settings.MAIL_DOMAIN,
settings.LEGACY_USER_PASSWORD) settings.LEGACY_USER_PASSWORD)

View file

@ -61,6 +61,7 @@ INSTALLED_APPS = [
'drf_yasg', 'drf_yasg',
'channels', 'channels',
'authentication', 'authentication',
'notifications',
'files', 'files',
'tickets', 'tickets',
'inventory', 'inventory',

View file

@ -3,7 +3,6 @@
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import files.models import files.models
from mail.models import Email
from mail.protocol import parse_email_body from mail.protocol import parse_email_body
@ -24,6 +23,7 @@ class Migration(migrations.Migration):
] ]
def generate_email_attachments(apps, schema_editor): def generate_email_attachments(apps, schema_editor):
Email = apps.get_model('mail', 'Email')
for email in Email.objects.all(): for email in Email.objects.all():
raw = email.raw raw = email.raw
if raw is None or raw == '': if raw is None or raw == '':

View file

@ -1,28 +0,0 @@
# Generated by Django 4.2.7 on 2024-04-26 13:08
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('mail', '0004_alter_emailattachment_file'),
]
operations = [
migrations.CreateModel(
name='UserNotificationChannel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel_type', models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)),
('channel_target', models.CharField(max_length=255)),
('event_filter', models.CharField(max_length=255)),
('active', models.BooleanField(default=True)),
('created', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -41,13 +41,4 @@ class EmailAttachment(AbstractFile):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
class UserNotificationChannel(models.Model):
user = models.ForeignKey(ExtendedUser, models.CASCADE)
channel_type = models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)
channel_target = models.CharField(max_length=255)
event_filter = models.CharField(max_length=255)
active = models.BooleanField(default=True)
created = models.DateTimeField(auto_now_add=True)
def validate_constraints(self, exclude=None): # TODO: email -> emailaddress, telegram -> chatid
return True

View file

@ -5,8 +5,8 @@ from channels.layers import get_channel_layer
from channels.db import database_sync_to_async from channels.db import database_sync_to_async
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from core.settings import PRIMARY_HOST
from mail.models import Email, EventAddress, EmailAttachment from mail.models import Email, EventAddress, EmailAttachment
from notifications.templates import render_auto_reply
from notify_sessions.models import SystemEvent from notify_sessions.models import SystemEvent
from tickets.models import IssueThread from tickets.models import IssueThread
@ -83,14 +83,13 @@ def make_reply(reply_email, references=None, event=None):
return reply return reply
def make_notification(message, to, event=None): # TODO where should replies to this go def make_notification(message, to, title): # TODO where should replies to this go
from email.message import EmailMessage from email.message import EmailMessage
from core.settings import MAIL_DOMAIN from core.settings import MAIL_DOMAIN
event = event or "mail"
notification = EmailMessage() notification = EmailMessage()
notification["From"] = "notifications@%s" % MAIL_DOMAIN notification["From"] = "notifications@%s" % MAIL_DOMAIN
notification["To"] = to notification["To"] = to
notification["Subject"] = f"System3 Notification" notification["Subject"] = f"[C3LF Notification]%s" % title
# notification["Reply-To"] = f"{event}@{MAIL_DOMAIN}" # notification["Reply-To"] = f"{event}@{MAIL_DOMAIN}"
# notification["In-Reply-To"] = email.reference # notification["In-Reply-To"] = email.reference
# notification["Message-ID"] = email.id + "@" + MAIL_DOMAIN # notification["Message-ID"] = email.id + "@" + MAIL_DOMAIN
@ -197,11 +196,11 @@ def receive_email(envelope, log=None):
header_in_reply_to = parsed.get('In-Reply-To') header_in_reply_to = parsed.get('In-Reply-To')
header_message_id = parsed.get('Message-ID') header_message_id = parsed.get('Message-ID')
#if header_from != envelope.mail_from: # if header_from != envelope.mail_from:
# log.warning("Header from does not match envelope from") # log.warning("Header from does not match envelope from")
# log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}") # log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}")
# #
#if header_to != envelope.rcpt_tos[0]: # if header_to != envelope.rcpt_tos[0]:
# log.warning("Header to does not match envelope to") # log.warning("Header to does not match envelope to")
# log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}") # log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}")
@ -230,16 +229,7 @@ def receive_email(envelope, log=None):
references = collect_references(active_issue_thread) references = collect_references(active_issue_thread)
if not sender.startswith('noreply'): if not sender.startswith('noreply'):
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 = render_auto_reply(active_issue_thread)
We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \
workload is high. We will not forget about your request and get back in touch once we have updated information on your \
request. Requests for devices, wallets, credit cards or similar items will be handled with priority.
If you happen to find your lost item or just want to add additional information, please reply to this email. Please \
do not create a new request.
Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid())
reply_email = Email.objects.create( reply_email = Email.objects.create(
sender=recipient, recipient=sender, body=body, subject=subject, sender=recipient, recipient=sender, body=body, subject=subject,
in_reply_to=header_message_id, event=target_event, issue_thread=active_issue_thread) in_reply_to=header_message_id, event=target_event, issue_thread=active_issue_thread)
@ -250,20 +240,7 @@ Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid
active_issue_thread.state = 'pending_open' active_issue_thread.state = 'pending_open'
active_issue_thread.save() active_issue_thread.save()
notification = None return email, new, reply, active_issue_thread
if len(active_issue_thread.name) > 50:
notify_subject = active_issue_thread.name[:47] + "..."
else:
notify_subject = active_issue_thread.name
eventslug = target_event.slug if target_event else "37C3" # TODO 37C3 should not be hardcoded
if new:
notification = f"""New issue \"{active_issue_thread.name}\" [{active_issue_thread.short_uuid()}] created
https://{PRIMARY_HOST}/{eventslug}/ticket/{active_issue_thread.id}/"""
else:
notification = f"""Reply to issue \"{active_issue_thread.name}\" [{active_issue_thread.short_uuid()}]
https://{PRIMARY_HOST}/{eventslug}/ticket/{active_issue_thread.id}/"""
return email, new, reply, notification
class LMTPHandler: class LMTPHandler:
@ -285,7 +262,7 @@ class LMTPHandler:
content = None content = None
try: try:
content = envelope.content content = envelope.content
email, new, reply, notification = await database_sync_to_async(receive_email)(envelope, log) email, new, reply, thread = await database_sync_to_async(receive_email)(envelope, log)
log.info(f"Created email {email.id}") log.info(f"Created email {email.id}")
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)
@ -295,11 +272,10 @@ class LMTPHandler:
'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 notification: if thread:
log.info(f"Sending notification {notification}")
await channel_layer.group_send( await channel_layer.group_send(
'general', {"type": "generic.event", "name": "user_notification", "event_id": systemevent.id, 'general', {"type": "generic.event", "name": "user_notification", "event_id": systemevent.id,
"message": notification}) "ticket": thread, "new": new})
if new and reply: if new and reply:
log.info('Sending message to %s' % reply['To']) log.info('Sending message to %s' % reply['To'])
await send_smtp(reply) await send_smtp(reply)

View file

@ -2,7 +2,7 @@ from django.contrib.auth.models import Permission
from django.test import TestCase from django.test import TestCase
from authentication.models import ExtendedUser from authentication.models import ExtendedUser
from mail.models import UserNotificationChannel from notifications.models import UserNotificationChannel
class UserNotificationTestCase(TestCase): class UserNotificationTestCase(TestCase):

View file

View file

@ -0,0 +1,16 @@
auto_reply_body = '''Your request (#{{ ticket_uuid }}) has been received and will be reviewed by our lost&found angels.
We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \
workload is high. We will not forget about your request and get back in touch once we have updated information on your \
request. Requests for devices, wallets, credit cards or similar items will be handled with priority.
If you happen to find your lost item or just want to add additional information, please reply to this email. Please \
do not create a new request.
Your c3lf (Cloakroom + Lost&Found) Team'''
new_issue_notification = '''New issue "{{ ticket_name | limit_length }}" [{{ ticket_uuid }}] created
{{ ticket_url }}'''
reply_issue_notification = '''Reply to issue "{{ ticket_name }}" [{{ ticket_uuid }}]
{{ ticket_url }}'''

View file

@ -4,8 +4,9 @@ from channels.db import database_sync_to_async
from urllib.parse import quote as urlencode from urllib.parse import quote as urlencode
from core.settings import TELEGRAM_BOT_TOKEN, TELEGRAM_GROUP_CHAT_ID from core.settings import TELEGRAM_BOT_TOKEN, TELEGRAM_GROUP_CHAT_ID
from mail.models import UserNotificationChannel
from mail.protocol import send_smtp, make_notification from mail.protocol import send_smtp, make_notification
from notifications.models import UserNotificationChannel
from notifications.templates import render_notification_new_ticket, render_notification_reply_ticket
async def http_get(url): async def http_get(url):
@ -51,12 +52,13 @@ class NotificationDispatcher:
if (message and 'type' in message and message['type'] == 'generic.event' and 'name' in message and if (message and 'type' in message and message['type'] == 'generic.event' and 'name' in message and
message['name'] == 'user_notification'): message['name'] == 'user_notification'):
if 'message' in message and 'event_id' in message: if 'ticket' in message and 'event_id' in message and 'new' in message:
await self.dispatch(message['message'], message['event_id']) await self.dispatch(message['ticket'], message['event_id'], message['new'])
else: else:
print("Error: Invalid message format") print("Error: Invalid message format")
async def dispatch(self, message, event_id): async def dispatch(self, ticket, event_id, new):
message = render_notification_new_ticket(ticket) if new else render_notification_reply_ticket(ticket)
print("Dispatching message:", message, "with event_id:", event_id) print("Dispatching message:", message, "with event_id:", event_id)
targets = await self.get_notification_targets() targets = await self.get_notification_targets()
await telegram_notify(message, TELEGRAM_GROUP_CHAT_ID) await telegram_notify(message, TELEGRAM_GROUP_CHAT_ID)

View file

@ -0,0 +1,48 @@
# Generated by Django 4.2.7 on 2024-05-03 21:02
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from notifications.defauls import auto_reply_body, new_issue_notification, reply_issue_notification
from notifications.models import MessageTemplate
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
def create_required_templates(apps, schema_editor):
MessageTemplate.objects.create(name='auto_reply', message=auto_reply_body)
MessageTemplate.objects.create(name='new_issue_notification', message=new_issue_notification)
MessageTemplate.objects.create(name='reply_issue_notification', message=reply_issue_notification)
operations = [
migrations.CreateModel(
name='MessageTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('message', models.TextField()),
('created', models.DateTimeField(auto_now_add=True)),
('marked_confidential', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name='UserNotificationChannel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel_type',
models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)),
('channel_target', models.CharField(max_length=255)),
('event_filter', models.CharField(max_length=255)),
('active', models.BooleanField(default=True)),
('created', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.RunPython(create_required_templates),
]

View file

@ -0,0 +1,22 @@
from django.db import models
from authentication.models import ExtendedUser
class MessageTemplate(models.Model):
name = models.CharField(max_length=255)
message = models.TextField()
created = models.DateTimeField(auto_now_add=True)
marked_confidential = models.BooleanField(default=False)
class UserNotificationChannel(models.Model):
user = models.ForeignKey(ExtendedUser, models.CASCADE)
channel_type = models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)
channel_target = models.CharField(max_length=255)
event_filter = models.CharField(max_length=255)
active = models.BooleanField(default=True)
created = models.DateTimeField(auto_now_add=True)
def validate_constraints(self, exclude=None): # TODO: email -> emailaddress, telegram -> chatid
return True

View file

@ -0,0 +1,54 @@
import jinja2
from core.settings import PRIMARY_HOST
from notifications.models import MessageTemplate
# auto_reply_title = f"Re: {{ ticket_name }} [#{{ ticket_uuid }}]"
TEMLATE_VARS = ['ticket_name', 'ticket_uuid', 'ticket_id', 'ticket_url',
'event_slug', 'event_name',
'username', 'user_nick',
'web_host'] # TODO customer_name, tracking_code
def limit_length(s, length=50):
if len(s) > length:
return s[:(length - 3)] + "..."
return s
def ticket_url(ticket):
eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded
return f"https://{PRIMARY_HOST}/{eventslug}/ticket/{ticket.id}/"
def render_template(template, **kwargs):
try:
environment = jinja2.Environment()
environment.filters['limit_length'] = limit_length
tmpl = MessageTemplate.objects.get(name=template)
template = environment.from_string(tmpl.message)
return template.render(**kwargs, web_host=PRIMARY_HOST)
except MessageTemplate.DoesNotExist:
return None
def render_auto_reply(ticket):
eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded
return render_template('auto_reply', ticket_name=ticket.name, ticket_uuid=ticket.short_uuid(),
ticket_id=ticket.id, event_slug=eventslug, ticket_url=ticket_url(ticket))
def render_notification_new_ticket(ticket):
eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded
return render_template('new_issue_notification', ticket_name=ticket.name, ticket_uuid=ticket.short_uuid(),
ticket_id=ticket.id, event_slug=eventslug, ticket_url=ticket_url(ticket))
def render_notification_reply_ticket(ticket):
eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded
return render_template('reply_issue_notification', ticket_name=ticket.name, ticket_uuid=ticket.short_uuid(),
ticket_id=ticket.id, event_slug=eventslug, ticket_url=ticket_url(ticket))

View file

2
core/server.py Normal file → Executable file
View file

@ -12,7 +12,7 @@ django.setup()
from helper import init_loop from helper import init_loop
from mail.protocol import LMTPHandler from mail.protocol import LMTPHandler
from mail.socket import UnixSocketLMTPController from mail.socket import UnixSocketLMTPController
from mail.notifications import NotificationDispatcher from notifications.dispatch import NotificationDispatcher
class UvicornServer(uvicorn.Server): class UvicornServer(uvicorn.Server):

View file

@ -2,17 +2,15 @@
from django.db import migrations, models from django.db import migrations, models
from tickets.models import IssueThread
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('tickets', '0005_remove_issuethread_last_activity'), ('tickets', '0005_remove_issuethread_last_activity'),
] ]
def set_uuid(apps, schema_editor): def set_uuid(apps, schema_editor):
import uuid import uuid
IssueThread = apps.get_model('tickets', 'IssueThread')
for issue_thread in IssueThread.objects.all(): for issue_thread in IssueThread.objects.all():
issue_thread.uuid = str(uuid.uuid4()) issue_thread.uuid = str(uuid.uuid4())
issue_thread.save() issue_thread.save()

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.7 on 2024-05-03 21:21
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0004_alter_event_created_at_alter_item_created_at'),
('tickets', '0008_alter_issuethread_options_and_more'),
]
operations = [
migrations.AddField(
model_name='issuethread',
name='event',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_threads', to='inventory.event'),
),
]

View file

@ -33,6 +33,7 @@ class IssueThread(SoftDeleteModel):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
uuid = models.CharField(max_length=255, unique=True, null=False, blank=False) uuid = models.CharField(max_length=255, unique=True, null=False, blank=False)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
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 short_uuid(self): def short_uuid(self):