~linuxgoose/Inque

0a1da448b169b1a6009d63c6c51f55de7e91c5b5 — Jordan Robinson 2 months ago
Initial commit
A  => .gitignore +27 -0
@@ 1,27 @@
inque/.env
inque/db.sqlite3

# python
*.pyc

# environment
.envrc
.venv/
postgres-data/

# generated static
/static/

# testing
.coverage
htmlcov/

# docker
docker-postgres-data/
docker-compose.override.yml

# mdbook docs
/docs/book/
.env
db.sqlite3
debug.log

A  => inque/inque/__init__.py +0 -0
A  => inque/inque/asgi.py +16 -0
@@ 1,16 @@
"""
ASGI config for inque project.

It exposes the ASGI callable as a module-level variable named ``application``.

For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""

import os

from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'inque.settings')

application = get_asgi_application()

A  => inque/inque/settings.py +149 -0
@@ 1,149 @@
"""
Django settings for inque project.

Generated by 'django-admin startproject' using Django 5.2.5.

For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""

from pathlib import Path
import os
from pathlib import Path
from dotenv import load_dotenv

load_dotenv()

IMAP_HOST = os.getenv("IMAP_HOST")
IMAP_USER = os.getenv("IMAP_USER")
IMAP_PASSWORD = os.getenv("IMAP_PASSWORD")
IMAP_FOLDER = os.getenv("IMAP_FOLDER")

SMTP_HOST = os.getenv("SMTP_HOST")
SMTP_PORT = int(os.getenv("SMTP_PORT", 587))
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
FROM_EMAIL = os.getenv("FROM_EMAIL")

SUPPORT_EMAIL = os.getenv("SUPPORT_EMAIL")
SUPPORT_COMPANY = os.getenv("SUPPORT_COMPANY")

# If True, reject emails that are not plain-text
PLAIN_TEXT_ONLY = os.getenv("PLAIN_TEXT_ONLY", "True") == "True"

SUPPORT_AGENTS = [email.strip() for email in os.getenv("SUPPORT_AGENTS", "").split(",")]

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-njeo%4os=d%8kc!)hk6uti60jx!o%%=x2m$+on!@^^-5-_t)62'

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = []


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # Local apps
    'tickets',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'inque.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'inque.wsgi.application'


# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}


# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/

STATIC_URL = 'static/'

# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

A  => inque/inque/urls.py +22 -0
@@ 1,22 @@
"""
URL configuration for inque project.

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  path('', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
Including another URLconf
    1. Import the include() function: from django.urls import include, path
    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path

urlpatterns = [
    path('admin/', admin.site.urls),
]

A  => inque/inque/wsgi.py +16 -0
@@ 1,16 @@
"""
WSGI config for inque 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/5.2/howto/deployment/wsgi/
"""

import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'inque.settings')

application = get_wsgi_application()

A  => inque/manage.py +22 -0
@@ 1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


def main():
    """Run administrative tasks."""
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'inque.settings')
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == '__main__':
    main()

A  => inque/tickets/__init__.py +0 -0
A  => inque/tickets/admin.py +7 -0
@@ 1,7 @@
from django.contrib import admin

# Register your models here.

from .models import Ticket, Message
admin.site.register(Ticket)
admin.site.register(Message)
\ No newline at end of file

A  => inque/tickets/apps.py +6 -0
@@ 1,6 @@
from django.apps import AppConfig


class TicketsConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'tickets'

A  => inque/tickets/imap_reader.py +311 -0
@@ 1,311 @@
import imaplib
import email
from email.header import decode_header
from tickets.models import Ticket, Message
from django.conf import settings
from tickets.mail_sender import send_email
from tickets.utils import get_message_id, generate_message_id

def is_support_agent(email_address):
    return email_address.lower() in [e.lower() for e in settings.SUPPORT_AGENTS]

def strip_command_lines(message_body):
    """
    Removes lines starting with "!" from the message body.
    """
    if not message_body:
        return ""

    lines = message_body.splitlines()
    filtered_lines = [line for line in lines if not line.strip().startswith("!")]
    return "\n".join(filtered_lines)

def parse_commands(ticket, message_body, sender_email, raw_email_bytes):
    """
    Execute inline commands like:
    !list_open
    !close <ticket_id>
    !reopen <ticket_id>
    !assign <ticket_id> <agent_email>
    """
    if not is_support_agent(sender_email):
        return  # Only agents can modify tickets

    lines = message_body.splitlines()
    # Only parse lines that start with "!" to avoid random text
    command_lines = [line for line in lines if line.strip().startswith("!")]
    for line in command_lines:
        line = line.strip()
        cmd = line.lower()

        # List open tickets
        if cmd == "!list_open":
            open_tickets = Ticket.objects.filter(status="open").order_by("created_at")
            if not open_tickets.exists():
                body = "There are no open tickets."
            else:
                body_lines = [
                    f"#{t.id} | {t.subject} | Reporter: {t.reporter} | Assignee: {t.assignee or 'Unassigned'} | Created: {t.created_at.strftime('%Y-%m-%d %H:%M')}"
                    for t in open_tickets
                ]
                body = "Open tickets:\n\n" + "\n".join(body_lines)

            send_email(
                to_email=sender_email,
                subject="Open Tickets List",
                body=body,
                ticket=None,
                sender_email=None
            )
            continue

        # Commands that target a specific ticket
        parts = cmd.split()
        if len(parts) < 2:
            continue  # Skip invalid commands

        action = parts[0]

        try:
            ticket_id = int(parts[1])
            ticket = Ticket.objects.get(id=ticket_id)
        except (ValueError, Ticket.DoesNotExist):
            send_email(
                to_email=sender_email,
                subject=f"Ticket #{parts[1]} Not Found",
                body=f"No ticket with ID {parts[1]} exists.",
                ticket=None,
                sender_email=None
            )
            continue

        if action == "!close":
            ticket.status = "closed"
            ticket.save()
            send_email(
                to_email=sender_email,
                subject=f"[Ticket #{ticket.id}] Closed",
                body=f"Ticket #{ticket.id} has been closed.",
                ticket=None,
                sender_email=None
            )
            message_id = generate_message_id()
            references = message_id
            send_email(
                to_email=ticket.reporter,
                subject=f"[Ticket #{ticket.id}] Closed",
                body=f"Ticket #{ticket.id} has been closed. If you still require support, please reply to this email.\n\n– Support",
                ticket=ticket,
                sender_email=None,
                in_reply_to=message_id,
                references=references
            )
        elif action == "!reopen":
            ticket.status = "open"
            ticket.save()
            send_email(
                to_email=sender_email,
                subject=f"[Ticket #{ticket.id}] Reopened",
                body=f"Ticket #{ticket.id} has been reopened.",
                ticket=None,
                sender_email=None
            )
        elif action == "!assign" and len(parts) == 3:
            ticket.assignee = parts[2]
            ticket.save()
            send_email(
                to_email=sender_email,
                subject=f"[Ticket #{ticket.id}] Assigned",
                body=f"Ticket #{ticket.id} has been assigned to {parts[2]}.",
                ticket=None,
                sender_email=None
            )

        elif action == ("!reply"):
            parts = line.split(maxsplit=2)

            ticket_id = int(parts[1])
            ticket = Ticket.objects.get(id=ticket_id)

            last_message = ticket.messages.order_by("-created_at").first()
            if last_message:
                in_reply_to = last_message.message_id
                references = " ".join(ticket.messages.order_by("created_at").values_list("message_id", flat=True))
            else:
                in_reply_to = generate_message_id()
                references = in_reply_to

            message_id = generate_message_id()
            references = message_id
            
            # Create body for message
            if len(parts) == 3:
                body=f"{parts[2]}"
            else:
                body=strip_command_lines(message_body)

            send_email(
                to_email=ticket.reporter,
                subject=f"RE: [Ticket #{ticket.id}] {ticket.subject}",
                body=body,
                ticket=ticket,
                sender_email=None,
                in_reply_to=message_id,
                references=references
            )

            # Store message in the ticket
            Message.objects.create(
                ticket=ticket,
                sender=sender_email,
                body=body,
                raw_email=raw_email_bytes.decode("utf-8"),
                message_id=message_id,
                in_reply_to=in_reply_to
            )
                

def fetch_emails():
    created_tickets = []
    updated_tickets = []

    mail = imaplib.IMAP4_SSL(settings.IMAP_HOST)
    mail.login(settings.IMAP_USER, settings.IMAP_PASSWORD)
    mail.select(settings.IMAP_FOLDER)

    status, messages = mail.search(None, 'UNSEEN')
    if status != "OK":
        return created_tickets, updated_tickets

    for num in messages[0].split():
        status, data = mail.fetch(num, '(RFC822)')
        if status != "OK":
            continue

        raw_email_bytes = data[0][1]
        msg = email.message_from_bytes(raw_email_bytes)
        subject, encoding = decode_header(msg["Subject"])[0]
        if isinstance(subject, bytes):
            subject = subject.decode(encoding or "utf-8")
        from_email = email.utils.parseaddr(msg.get("From"))[1]
        in_reply_to = msg.get("In-Reply-To")

        # Extract plain-text body
        body = None
        is_html = False
        if msg.is_multipart():
            for part in msg.walk():
                if part.get_content_type() == "text/plain":
                    body = part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8")
                    break
                elif part.get_content_type() == "text/html":
                    is_html = True
                    if not settings.PLAIN_TEXT_ONLY and body is None:
                        body = part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8")
        else:
            if msg.get_content_type() == "text/plain":
                body = msg.get_payload(decode=True).decode(msg.get_content_charset() or "utf-8")
            elif msg.get_content_type() == "text/html":
                is_html = True
                if not settings.PLAIN_TEXT_ONLY:
                    body = msg.get_payload(decode=True).decode(msg.get_content_charset() or "utf-8")

        # Reject non-plain-text emails if setting enabled
        if body is None:
            send_email(
                to_email=from_email,
                subject=f"Re: {subject}",
                body="Your email was rejected because only plain-text emails are allowed.",
                ticket=None,
                sender_email=None
            )
            continue

        # Check for command-like messages
        if body.strip().lower().startswith("!"):
            try:
                parse_commands(None, body, from_email, raw_email_bytes)
            except Exception as e:
                print(e)
            # Skip creating a ticket if it’s only a command
            if body.strip().lower() == "!list_open" or body.strip().lower().startswith(("!close", "!reopen", "!assign", "!reply")):
                continue

        # Determine if new ticket or reply
        in_reply_to = msg.get("In-Reply-To")
        if in_reply_to:
            in_reply_to = in_reply_to.strip().split()[0]
        message_id = get_message_id(raw_email_bytes.decode("utf-8"))

        references = msg.get("References")
        parent_message_id = None
        ticket = None
        if references:
            refs = [r.strip() for r in references.split()]  # split on whitespace
            parent_message_id = refs[0]  # last ID is usually the parent

        try:
            if parent_message_id:
                message = Message.objects.get(message_id=parent_message_id)
                ticket = message.ticket
                updated_tickets.append(ticket)

                send_email(to_email='', 
                           body=body, 
                           subject=f"RE: [Ticket #{ticket.id}] {ticket.subject}",
                           ticket=ticket,
                           sender_email=from_email,
                           in_reply_to=message_id,
                           references=message_id,
                        )
                
            else:

                ticket = Ticket.objects.create(subject=subject, reporter=from_email)
                created_tickets.append(ticket)

                # Send confirmation to reporter
                send_email(
                    to_email=from_email,
                    subject=f"[Ticket #{ticket.id}] {subject}",
                    body=f"Hi,\n\nWe have received your ticket #{ticket.id} and are looking into it.\n\n– {settings.SUPPORT_COMPANY} Support",
                    in_reply_to=message_id,
                    references=message_id,
                    ticket=ticket,
                    sender_email=from_email
                )

                # Notify all support agents
                message_id = get_message_id(raw_email_bytes.decode("utf-8"))
                print(f"New ticket created with Message-ID: {message_id}")
                for agent_email in settings.SUPPORT_AGENTS:
                    send_email(
                        to_email=agent_email,
                        subject=f"New Ticket #{ticket.id}: {subject}",
                        body=f"Hello,\n\nA new ticket has been submitted.\n\nTicket #{ticket.id}\nReporter: {from_email}\nSubject: {subject}\n\nMessage:\n{body}\n\n– {settings.SUPPORT_COMPANY} Support",
                        in_reply_to=message_id,
                        references=message_id,
                        ticket=ticket,
                        sender_email=from_email
                    )
        except Exception as e:
            print(f"Error processing email: {e}")
            # fallback: create new ticket if lookup fails
            ticket = Ticket.objects.create(subject=subject, reporter=from_email)
            created_tickets.append(ticket)

        # Store message in the ticket
        Message.objects.create(
            ticket=ticket,
            sender=from_email,
            body=body,
            raw_email=raw_email_bytes.decode("utf-8"),
            message_id=message_id,
            in_reply_to=in_reply_to
        )

        # Parse commands
        parse_commands(ticket, body, from_email, raw_email_bytes)

    mail.logout()
    return created_tickets, updated_tickets
\ No newline at end of file

A  => inque/tickets/mail_sender.py +33 -0
@@ 1,33 @@
import smtplib
from email.mime.text import MIMEText
from inque.settings import SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, FROM_EMAIL, SUPPORT_EMAIL, SUPPORT_COMPANY
from tickets.utils import get_message_id, generate_message_id
from django.conf import settings
from tickets.models import Message

def send_email(to_email, subject, body, ticket, sender_email, in_reply_to=None, references=None, reply_to=None):
    msg = MIMEText(body, "plain")
    msg["Subject"] = subject
    msg["From"] = f"{SUPPORT_COMPANY} Support <{SUPPORT_EMAIL}>"  # Display name + mailbox
    msg["Reply-To"] = SUPPORT_EMAIL

    # Determine recipients
    if to_email == '':
        if sender_email == ticket.reporter:
            recipients = ticket.assignee if ticket.assignee else settings.SUPPORT_AGENTS
        else:
            recipients = [ticket.reporter]
    else:
        recipients = [to_email]
    msg["To"] = ", ".join(recipients)

    # Set references and message ids
    if in_reply_to:
        msg["In-Reply-To"] = in_reply_to
    if references:
        msg["References"] = references

    with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
        server.starttls()
        server.login(SMTP_USER, SMTP_PASSWORD)
        server.sendmail(SUPPORT_EMAIL, recipients, msg.as_string())
\ No newline at end of file

A  => inque/tickets/management/commands/fetch_emails.py +15 -0
@@ 1,15 @@
from django.core.management.base import BaseCommand
from tickets.imap_reader import fetch_emails

class Command(BaseCommand):
    help = "Fetch new emails and create/update tickets"

    def handle(self, *args, **kwargs):
        created, updated = fetch_emails()
        self.stdout.write(self.style.SUCCESS(
            f"Fetched emails successfully. Tickets created: {len(created)}, updated: {len(updated)}"
        ))
        for ticket in created:
            self.stdout.write(f"Created Ticket #{ticket.id}: {ticket.subject}")
        for ticket in updated:
            self.stdout.write(f"Updated Ticket #{ticket.id}: {ticket.subject}")
\ No newline at end of file

A  => inque/tickets/management/commands/list_open_tickets.py +16 -0
@@ 1,16 @@
from django.core.management.base import BaseCommand
from tickets.models import Ticket

class Command(BaseCommand):
    help = "List all open tickets"

    def handle(self, *args, **kwargs):
        tickets = Ticket.objects.filter(status="open").order_by("created_at")
        if not tickets.exists():
            self.stdout.write("No open tickets.")
            return

        for ticket in tickets:
            self.stdout.write(
                f"#{ticket.id} | Subject: {ticket.subject} | Reporter: {ticket.reporter} | Assignee: {ticket.assignee or 'Unassigned'} | Created: {ticket.created_at.strftime('%Y-%m-%d %H:%M')}"
            )

A  => inque/tickets/migrations/0001_initial.py +38 -0
@@ 1,38 @@
# Generated by Django 5.2.6 on 2025-09-26 19:28

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Ticket',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('subject', models.CharField(max_length=255)),
                ('reporter', models.EmailField(max_length=254)),
                ('status', models.CharField(choices=[('open', 'Open'), ('closed', 'Closed'), ('pending', 'Pending')], default='open', max_length=20)),
                ('assignee', models.EmailField(blank=True, max_length=254, null=True)),
                ('created_at', models.DateTimeField(auto_now_add=True)),
                ('updated_at', models.DateTimeField(auto_now=True)),
            ],
        ),
        migrations.CreateModel(
            name='Message',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('sender', models.EmailField(max_length=254)),
                ('body', models.TextField()),
                ('created_at', models.DateTimeField(auto_now_add=True)),
                ('raw_email', models.TextField(blank=True)),
                ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='tickets.ticket')),
            ],
        ),
    ]

A  => inque/tickets/migrations/0002_message_message_id.py +19 -0
@@ 1,19 @@
# Generated by Django 5.2.6 on 2025-09-26 20:31

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('tickets', '0001_initial'),
    ]

    operations = [
        migrations.AddField(
            model_name='message',
            name='message_id',
            field=models.CharField(default=None, max_length=1024, unique=True),
            preserve_default=False,
        ),
    ]

A  => inque/tickets/migrations/0003_alter_message_message_id.py +18 -0
@@ 1,18 @@
# Generated by Django 5.2.6 on 2025-09-26 20:31

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('tickets', '0002_message_message_id'),
    ]

    operations = [
        migrations.AlterField(
            model_name='message',
            name='message_id',
            field=models.CharField(blank=True, max_length=1024, null=True, unique=True),
        ),
    ]

A  => inque/tickets/migrations/0004_alter_message_message_id.py +18 -0
@@ 1,18 @@
# Generated by Django 5.2.6 on 2025-09-26 20:31

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('tickets', '0003_alter_message_message_id'),
    ]

    operations = [
        migrations.AlterField(
            model_name='message',
            name='message_id',
            field=models.CharField(blank=True, default=None, max_length=1024, null=True, unique=True),
        ),
    ]

A  => inque/tickets/migrations/0005_message_in_reply_to.py +18 -0
@@ 1,18 @@
# Generated by Django 5.2.6 on 2025-09-26 20:40

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('tickets', '0004_alter_message_message_id'),
    ]

    operations = [
        migrations.AddField(
            model_name='message',
            name='in_reply_to',
            field=models.CharField(blank=True, default=None, max_length=1024, null=True),
        ),
    ]

A  => inque/tickets/migrations/__init__.py +0 -0
A  => inque/tickets/models.py +25 -0
@@ 1,25 @@
from django.db import models

class Ticket(models.Model):
    subject = models.CharField(max_length=255)
    reporter = models.EmailField()
    status = models.CharField(max_length=20, choices=[
        ("open", "Open"),
        ("closed", "Closed"),
        ("pending", "Pending"),
    ], default="open")
    assignee = models.EmailField(blank=True, null=True)  # New field
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

class Message(models.Model):
    ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="messages")
    sender = models.EmailField()
    body = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    raw_email = models.TextField(blank=True)
    message_id = models.CharField(max_length=1024, unique=True, blank=True, null=True, default=None)
    in_reply_to = models.CharField(max_length=1024, blank=True, null=True, default=None)

    def __str__(self):
        return f"Message from {self.sender} on ticket #{self.ticket.id}"
\ No newline at end of file

A  => inque/tickets/tests.py +3 -0
@@ 1,3 @@
from django.test import TestCase

# Create your tests here.

A  => inque/tickets/utils.py +20 -0
@@ 1,20 @@
import email

def get_message_id(raw_email):
    """
    Extract the Message-ID header from a raw RFC822 email.
    """
    msg = email.message_from_string(raw_email)
    return msg.get("Message-ID")

import time
import uuid

def generate_message_id(domain="bocpress.co.uk"):
    """
    Generates a unique RFC 2822 Message-ID.
    Format: <timestamp.uuid@domain>
    """
    timestamp = int(time.time() * 1000)  # milliseconds
    unique_id = uuid.uuid4().hex
    return f"<{timestamp}.{unique_id}@{domain}>"

A  => inque/tickets/views.py +3 -0
@@ 1,3 @@
from django.shortcuts import render

# Create your views here.