Compare commits

..

11 commits

Author SHA1 Message Date
d73bebd5de match incoming mail to event 2024-11-18 02:16:55 +01:00
2c609427ec add simple issue templates
All checks were successful
/ test (push) Successful in 54s
/ deploy (push) Successful in 4m55s
2024-11-13 23:13:13 +01:00
120507512d deploy: Simple protection for metrics endpoint
All checks were successful
/ test (pull_request) Successful in 52s
/ test (push) Successful in 52s
/ deploy (push) Successful in 3m45s
2024-11-13 18:15:00 +00:00
63d6b7a5a8 cicd: run on every pull request, but only deploy on testing
All checks were successful
/ test (pull_request) Successful in 52s
/ test (push) Successful in 54s
/ deploy (push) Successful in 4m45s
2024-11-12 17:05:03 +01:00
5ba4085e60 cicd: Run tests automatically
All checks were successful
/ test (push) Successful in 54s
/ deploy (push) Successful in 4m41s
2024-11-11 22:10:35 +01:00
be02a3e163 cicd: Deploy testing automatically
All checks were successful
/ deploy (push) Successful in 4m56s
2024-11-11 19:37:37 +00:00
444c2de16c change the ticket.state in the backend too 2024-11-10 19:01:45 +00:00
8831f67f00 ticket state changes to pending_open on first view 2024-11-10 19:01:45 +00:00
5a6349c5d3 train spam on state change to 'closed_spam' 2024-11-09 02:58:21 +01:00
a6a8b0defe add functions to train mails as spam/ham 2024-11-09 00:03:21 +01:00
4272aab643 save raw_mails as file 2024-11-08 22:54:57 +01:00
18 changed files with 266 additions and 37 deletions

View file

@ -0,0 +1,35 @@
name: Bug Report
about: File a bug report
labels:
- Kind/Bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox (Windows)
- Firefox (MacOS)
- Firefox (Linux)
- Firefox (Android)
- Firefox (iOS)
- Chrome (Windows)
- Chrome (MacOS)
- Chrome (Linux)
- Chrome (Android)
- Chrome (iOS)
- Safari
- Microsoft Edge

View file

@ -0,0 +1,27 @@
name: 'New Feature'
about: 'This template is for new features'
labels:
- Kind/Feature
body:
- type: markdown
attributes:
value: |
Before creating a Feature Ticket, please check for duplicates.
- type: markdown
attributes:
value: |
### Implementation Checklist
- [ ] concept
- [ ] frontend
- [ ] backend
- [ ] unittests
- [ ] tested on staging
visible: [ content ]
- type: textarea
id: description
attributes:
label: 'Feature Description'
description: 'Explain the the feature.'
placeholder: Description
validations:
required: true

View file

@ -0,0 +1,20 @@
on:
pull_request:
jobs:
test:
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache-dependency-path: '**/requirements.dev.txt'
- name: Install dependencies
working-directory: core
run: pip3 install -r requirements.dev.txt
- name: Run django tests
working-directory: core
run: python3 manage.py test

View file

@ -0,0 +1,60 @@
on:
push:
branches:
- testing
jobs:
test:
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache-dependency-path: '**/requirements.dev.txt'
- name: Install dependencies
working-directory: core
run: pip3 install -r requirements.dev.txt
- name: Run django tests
working-directory: core
run: python3 manage.py test
deploy:
needs: [test]
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Install ansible
run: |
apt update -y
apt install python3-pip -y
python3 -m pip install ansible
python3 -m pip install ansible-lint
- name: Populate relevant files
run: |
mkdir ~/.ssh
echo "${{ secrets.C3LF_SSH_TESTING }}" > ~/.ssh/id_ed25519
chmod 0600 ~/.ssh/id_ed25519
ls -lah ~/.ssh
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_ed25519
echo "andromeda.lab.or.it ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDXPoO0PE+B9PYwbGaLo98zhbmjAkp6eBtVeZe43v/+T" >> ~/.ssh/known_hosts
mkdir /etc/ansible
echo "${{ secrets.C3LF_INVENTORY_TESTING }}" > /etc/ansible/hosts
- name: Check ansible version
run: |
ansible --version
- name: List ansible hosts
run: |
ansible -m ping Andromeda
- name: Deploy testing
run: |
cd deploy/ansible
ansible-playbook playbooks/deploy-c3lf-sys3.yml

View file

@ -15,6 +15,9 @@ import sys
import dotenv import dotenv
from pathlib import Path from pathlib import Path
def truthy_str(s):
return s.lower() in ['true', '1', 't', 'y', 'yes', 'yeah', 'yup', 'certainly', 'sure', 'positive', 'uh-huh', '👍']
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@ -24,10 +27,10 @@ dotenv.load_dotenv(BASE_DIR / '.env')
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv' SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = truthy_str(os.getenv('DEBUG_MODE_ACTIVE', 'False'))
ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost')] ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost')]
@ -40,6 +43,8 @@ LEGACY_USER_PASSWORD = os.getenv('LEGACY_API_PASSWORD', 'legacy_password')
SYSTEM3_VERSION = "0.0.0-dev.0" SYSTEM3_VERSION = "0.0.0-dev.0"
ACTIVE_SPAM_TRAINING = truthy_str(os.getenv('ACTIVE_SPAM_TRAINING', 'False'))
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
@ -210,10 +215,8 @@ CHANNEL_LAYERS = {
'hosts': [(os.getenv('REDIS_HOST', 'localhost'), 6379)], 'hosts': [(os.getenv('REDIS_HOST', 'localhost'), 6379)],
}, },
} }
}
SMTP_GATEWAY_HOST = os.getenv('SMTP_GATEWAY_HOST', 'localhost') }
SMTP_GATEWAY_PORT = int(os.getenv('SMTP_GATEWAY_PORT', '25'))
PROMETHEUS_METRIC_NAMESPACE = 'c3lf' PROMETHEUS_METRIC_NAMESPACE = 'c3lf'

View file

@ -1,16 +0,0 @@
"""
WSGI config for core project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()

View file

@ -0,0 +1,36 @@
# Generated by Django 4.2.7 on 2024-11-08 20:37
from django.core.files.base import ContentFile
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mail', '0005_alter_eventaddress_event'),
]
def move_raw_mails_to_file(apps, schema_editor):
Email = apps.get_model('mail', 'Email')
for email in Email.objects.all():
raw_content = email.raw
path = "mail_{}".format(email.id)
if len(raw_content):
email.raw_file.save(path, ContentFile(raw_content))
email.save()
operations = [
migrations.AddField(
model_name='email',
name='raw_file',
field=models.FileField(null=True, upload_to='raw_mail/'),
),
migrations.RunPython(move_raw_mails_to_file),
migrations.RemoveField(
model_name='email',
name='raw',
),
migrations.AlterField(
model_name='email',
name='raw_file',
field=models.FileField(upload_to='raw_mail/'),
),
]

View file

@ -3,7 +3,7 @@ import random
from django.db import models from django.db import models
from django_softdelete.models import SoftDeleteModel from django_softdelete.models import SoftDeleteModel
from core.settings import MAIL_DOMAIN from core.settings import MAIL_DOMAIN, ACTIVE_SPAM_TRAINING
from files.models import AbstractFile from files.models import AbstractFile
from inventory.models import Event from inventory.models import Event
from tickets.models import IssueThread from tickets.models import IssueThread
@ -18,7 +18,7 @@ class Email(SoftDeleteModel):
recipient = models.CharField(max_length=255) recipient = models.CharField(max_length=255)
reference = models.CharField(max_length=255, null=True, unique=True) reference = models.CharField(max_length=255, null=True, unique=True)
in_reply_to = models.CharField(max_length=255, null=True) in_reply_to = models.CharField(max_length=255, null=True)
raw = models.TextField() raw_file = models.FileField(upload_to='raw_mail/')
issue_thread = models.ForeignKey(IssueThread, models.SET_NULL, null=True, related_name='emails') issue_thread = models.ForeignKey(IssueThread, models.SET_NULL, null=True, related_name='emails')
event = models.ForeignKey(Event, models.SET_NULL, null=True) event = models.ForeignKey(Event, models.SET_NULL, null=True)
@ -28,6 +28,18 @@ class Email(SoftDeleteModel):
self.reference = f'<{random.randint(0, 1000000000):09}@{MAIL_DOMAIN}>' self.reference = f'<{random.randint(0, 1000000000):09}@{MAIL_DOMAIN}>'
self.save() self.save()
def train_spam(self):
if ACTIVE_SPAM_TRAINING and self.raw_file.path:
import subprocess
path = self.raw_file.path
subprocess.run(["rspamc", "learn_spam", path])
def train_ham(self):
if ACTIVE_SPAM_TRAINING and self.raw_file.path:
import subprocess
path = self.raw_file.path
subprocess.run(["rspamc", "learn_ham", path])
class EventAddress(models.Model): class EventAddress(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)

View file

@ -10,8 +10,6 @@ from mail.models import Email, EventAddress, EmailAttachment
from notify_sessions.models import SystemEvent from notify_sessions.models import SystemEvent
from tickets.models import IssueThread from tickets.models import IssueThread
from core.settings import SMTP_GATEWAY_HOST, SMTP_GATEWAY_PORT
class SpecialMailException(Exception): class SpecialMailException(Exception):
pass pass
@ -90,10 +88,10 @@ def make_reply(reply_email, references=None, event=None):
async def send_smtp(message): async def send_smtp(message):
await aiosmtplib.send(message, hostname=SMTP_GATEWAY_HOST, port=SMTP_GATEWAY_PORT, 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): def find_active_issue_thread(in_reply_to, address, subject, event):
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:
@ -104,7 +102,7 @@ def find_active_issue_thread(in_reply_to, address, subject):
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) issue = IssueThread.objects.create(name=subject, event=event)
return issue, True return issue, True
@ -204,11 +202,14 @@ def receive_email(envelope, log=None):
subject = unescape_and_decode_base64(subject) subject = unescape_and_decode_base64(subject)
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) active_issue_thread, new = find_active_issue_thread(header_in_reply_to, recipient, subject, target_event)
from hashlib import sha256
random_filename = 'mail-' + sha256(envelope.content).hexdigest()
email = Email.objects.create( email = Email.objects.create(
sender=sender, recipient=recipient, body=body, subject=subject, reference=header_message_id, sender=sender, recipient=recipient, body=body, subject=subject, reference=header_message_id,
in_reply_to=header_in_reply_to, raw=envelope.content, event=target_event, in_reply_to=header_in_reply_to, raw_file=ContentFile(envelope.content, name=random_filename), event=target_event,
issue_thread=active_issue_thread) issue_thread=active_issue_thread)
for attachment in attachments: for attachment in attachments:
email.attachments.add(attachment) email.attachments.add(attachment)

View file

@ -142,6 +142,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once() aiosmtplib.send.assert_called_once()
self.assertEqual('test ä', Email.objects.all()[0].subject) self.assertEqual('test ä', Email.objects.all()[0].subject)
self.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body) self.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_quoted_printable_2(self): def test_handle_quoted_printable_2(self):
from aiosmtpd.smtp import Envelope from aiosmtpd.smtp import Envelope
@ -162,6 +163,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once() aiosmtplib.send.assert_called_once()
self.assertEqual('suche_Mütze', Email.objects.all()[0].subject) self.assertEqual('suche_Mütze', Email.objects.all()[0].subject)
self.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body) self.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_base64(self): def test_handle_base64(self):
from aiosmtpd.smtp import Envelope from aiosmtpd.smtp import Envelope
@ -182,6 +184,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once() aiosmtplib.send.assert_called_once()
self.assertEqual('test', Email.objects.all()[0].subject) self.assertEqual('test', Email.objects.all()[0].subject)
self.assertEqual('Text mit Base64-Kodierung: äöüß', Email.objects.all()[0].body) self.assertEqual('Text mit Base64-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_client_reply(self): def test_handle_client_reply(self):
issue_thread = IssueThread.objects.create( issue_thread = IssueThread.objects.create(
@ -229,6 +232,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
self.assertEqual(IssueThread.objects.all()[0].name, 'test') self.assertEqual(IssueThread.objects.all()[0].name, 'test')
self.assertEqual(IssueThread.objects.all()[0].state, 'pending_new') self.assertEqual(IssueThread.objects.all()[0].state, 'pending_new')
self.assertEqual(IssueThread.objects.all()[0].assigned_to, None) self.assertEqual(IssueThread.objects.all()[0].assigned_to, None)
self.assertTrue( Email.objects.all()[2].raw_file.path)
def test_handle_client_reply_2(self): def test_handle_client_reply_2(self):
issue_thread = IssueThread.objects.create( issue_thread = IssueThread.objects.create(
@ -281,6 +285,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
self.assertEqual(IssueThread.objects.all()[0].name, 'test') self.assertEqual(IssueThread.objects.all()[0].name, 'test')
self.assertEqual(IssueThread.objects.all()[0].state, 'pending_open') self.assertEqual(IssueThread.objects.all()[0].state, 'pending_open')
self.assertEqual(IssueThread.objects.all()[0].assigned_to, None) self.assertEqual(IssueThread.objects.all()[0].assigned_to, None)
self.assertTrue( Email.objects.all()[2].raw_file.path)
def test_mail_reply(self): def test_mail_reply(self):
issue_thread = IssueThread.objects.create( issue_thread = IssueThread.objects.create(
@ -384,6 +389,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0]) states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0])
self.assertEqual(1, len(states)) self.assertEqual(1, len(states))
self.assertEqual('pending_new', states[0].state) self.assertEqual('pending_new', states[0].state)
self.assertEqual(event, IssueThread.objects.all()[0].event)
def test_mail_html_body(self): def test_mail_html_body(self):
from aiosmtpd.smtp import Envelope from aiosmtpd.smtp import Envelope

View file

@ -73,3 +73,5 @@ watchfiles==0.21.0
websockets==12.0 websockets==12.0
yarl==1.9.4 yarl==1.9.4
zope.interface==6.1 zope.interface==6.1
django-prometheus==2.3.1
prometheus_client==0.21.0

View file

@ -0,0 +1,31 @@
# Generated by Django 4.2.7 on 2024-06-23 02:17
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('mail', '0006_email_raw_file'),
('tickets', '0010_issuethread_event_itemrelation_and_more'),
]
def train_old_mails(apps, schema_editor):
from tickets.models import IssueThread
for t in IssueThread.objects.all():
try:
state = t.state
i = 0
for e in t.emails.all():
if e.raw_file:
if state == 'closed_spam' and i == 0:
e.train_spam()
else:
e.train_ham()
i += 1
except:
pass
operations = [
migrations.RunPython(train_old_mails),
]

View file

@ -60,6 +60,8 @@ class IssueThread(SoftDeleteModel):
if self.state == value: if self.state == value:
return return
self.state_changes.create(state=value) self.state_changes.create(state=value)
if value == 'closed_spam' and self.emails.exists():
self.emails.first().train_spam()
@property @property
def assigned_to(self): def assigned_to(self):

View file

@ -11,4 +11,6 @@ c3lf-nodes:
mail_domain: <mail_domain> mail_domain: <mail_domain>
main_email: <main_email> main_email: <main_email>
legacy_api_user: <legacy_api_user> legacy_api_user: <legacy_api_user>
legacy_api_password: <legacy_api_password> legacy_api_password: <legacy_api_password>
debug_mode_active: false
django_secret_key: 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv'

View file

@ -10,3 +10,6 @@ LEGACY_API_USER={{ legacy_api_user }}
LEGACY_API_PASSWORD={{ legacy_api_password }} LEGACY_API_PASSWORD={{ legacy_api_password }}
MEDIA_ROOT=/var/www/c3lf-sys3/userfiles MEDIA_ROOT=/var/www/c3lf-sys3/userfiles
STATIC_ROOT=/var/www/c3lf-sys3/staticfiles STATIC_ROOT=/var/www/c3lf-sys3/staticfiles
ACTIVE_SPAM_TRAINING=True
DEBUG_MODE_ACTIVE={{ debug_mode_active }}
DJANGO_SECRET_KEY={{ django_secret_key }}

View file

@ -70,6 +70,13 @@ server {
alias /var/www/c3lf-sys3/staticfiles/; alias /var/www/c3lf-sys3/staticfiles/;
} }
location /metrics {
allow 95.156.226.90;
allow 127.0.0.1;
allow ::1;
deny all;
}
listen 443 ssl http2; # managed by Certbot listen 443 ssl http2; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/{{ web_domain }}/fullchain.pem; # managed by Certbot ssl_certificate /etc/letsencrypt/live/{{ web_domain }}/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/{{ web_domain }}/privkey.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/{{ web_domain }}/privkey.pem; # managed by Certbot

View file

@ -16,12 +16,6 @@ services:
ports: ports:
- "3306:3306" - "3306:3306"
mailpit:
image: axllent/mailpit
ports:
- "8025:8025"
- "1025:1025"
core: core:
build: build:
context: ../../core context: ../../core

View file

@ -125,6 +125,10 @@ export default {
}, },
mounted() { mounted() {
this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(()=>{ this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(()=>{
if (this.ticket.state == "pending_new"){
this.selected_state = "pending_open";
this.changeTicketStatus(this.ticket)
};
this.selected_state = this.ticket.state; this.selected_state = this.ticket.state;
this.selected_assignee = this.ticket.assigned_to this.selected_assignee = this.ticket.assigned_to
})]); })]);