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.