From 0a1da448b169b1a6009d63c6c51f55de7e91c5b5 Mon Sep 17 00:00:00 2001 From: Jordan Robinson Date: Sat, 27 Sep 2025 11:17:54 +0100 Subject: [PATCH] Initial commit --- .gitignore | 27 ++ inque/inque/__init__.py | 0 inque/inque/asgi.py | 16 + inque/inque/settings.py | 149 +++++++++ inque/inque/urls.py | 22 ++ inque/inque/wsgi.py | 16 + inque/manage.py | 22 ++ inque/tickets/__init__.py | 0 inque/tickets/admin.py | 7 + inque/tickets/apps.py | 6 + inque/tickets/imap_reader.py | 311 ++++++++++++++++++ inque/tickets/mail_sender.py | 33 ++ .../management/commands/fetch_emails.py | 15 + .../management/commands/list_open_tickets.py | 16 + inque/tickets/migrations/0001_initial.py | 38 +++ .../migrations/0002_message_message_id.py | 19 ++ .../0003_alter_message_message_id.py | 18 + .../0004_alter_message_message_id.py | 18 + .../migrations/0005_message_in_reply_to.py | 18 + inque/tickets/migrations/__init__.py | 0 inque/tickets/models.py | 25 ++ inque/tickets/tests.py | 3 + inque/tickets/utils.py | 20 ++ inque/tickets/views.py | 3 + 24 files changed, 802 insertions(+) create mode 100644 .gitignore create mode 100644 inque/inque/__init__.py create mode 100644 inque/inque/asgi.py create mode 100644 inque/inque/settings.py create mode 100644 inque/inque/urls.py create mode 100644 inque/inque/wsgi.py create mode 100755 inque/manage.py create mode 100644 inque/tickets/__init__.py create mode 100644 inque/tickets/admin.py create mode 100644 inque/tickets/apps.py create mode 100644 inque/tickets/imap_reader.py create mode 100644 inque/tickets/mail_sender.py create mode 100644 inque/tickets/management/commands/fetch_emails.py create mode 100644 inque/tickets/management/commands/list_open_tickets.py create mode 100644 inque/tickets/migrations/0001_initial.py create mode 100644 inque/tickets/migrations/0002_message_message_id.py create mode 100644 inque/tickets/migrations/0003_alter_message_message_id.py create mode 100644 inque/tickets/migrations/0004_alter_message_message_id.py create mode 100644 inque/tickets/migrations/0005_message_in_reply_to.py create mode 100644 inque/tickets/migrations/__init__.py create mode 100644 inque/tickets/models.py create mode 100644 inque/tickets/tests.py create mode 100644 inque/tickets/utils.py create mode 100644 inque/tickets/views.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..29149c76161a0dab2c805f93d1f694b35bc1bc3f --- /dev/null +++ b/.gitignore @@ -0,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 diff --git a/inque/inque/__init__.py b/inque/inque/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/inque/inque/asgi.py b/inque/inque/asgi.py new file mode 100644 index 0000000000000000000000000000000000000000..2bc6985b8a2e9630697083ea422329ba1e18ad60 --- /dev/null +++ b/inque/inque/asgi.py @@ -0,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() diff --git a/inque/inque/settings.py b/inque/inque/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..e4c87e81338e9d419c41fd558f46c45d5b7f5660 --- /dev/null +++ b/inque/inque/settings.py @@ -0,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' diff --git a/inque/inque/urls.py b/inque/inque/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..83d1b27fd8a621b8833f4d6957f5c2ee88961a58 --- /dev/null +++ b/inque/inque/urls.py @@ -0,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), +] diff --git a/inque/inque/wsgi.py b/inque/inque/wsgi.py new file mode 100644 index 0000000000000000000000000000000000000000..1faa8298592c15be8debfa17b6e9fa3b06f9d4ee --- /dev/null +++ b/inque/inque/wsgi.py @@ -0,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() diff --git a/inque/manage.py b/inque/manage.py new file mode 100755 index 0000000000000000000000000000000000000000..51921135ca2549265fa0fa87fb16ea267c9bafcf --- /dev/null +++ b/inque/manage.py @@ -0,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() diff --git a/inque/tickets/__init__.py b/inque/tickets/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/inque/tickets/admin.py b/inque/tickets/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..5a40daa723ee57a8d9f97e2a3918650d5f3e42ce --- /dev/null +++ b/inque/tickets/admin.py @@ -0,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 diff --git a/inque/tickets/apps.py b/inque/tickets/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..45a7d76d9d067f8668393dfb1c1b165b92d7f7f8 --- /dev/null +++ b/inque/tickets/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TicketsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'tickets' diff --git a/inque/tickets/imap_reader.py b/inque/tickets/imap_reader.py new file mode 100644 index 0000000000000000000000000000000000000000..996792d3a0497015a44590d9dd2b5e9902d5a46f --- /dev/null +++ b/inque/tickets/imap_reader.py @@ -0,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 + !reopen + !assign + """ + 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 diff --git a/inque/tickets/mail_sender.py b/inque/tickets/mail_sender.py new file mode 100644 index 0000000000000000000000000000000000000000..a690b19e5f245f0020b912ab63ee479cf48502be --- /dev/null +++ b/inque/tickets/mail_sender.py @@ -0,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 diff --git a/inque/tickets/management/commands/fetch_emails.py b/inque/tickets/management/commands/fetch_emails.py new file mode 100644 index 0000000000000000000000000000000000000000..bde0c4448cf9ed4e82f9a824b98e50b298d7b5a2 --- /dev/null +++ b/inque/tickets/management/commands/fetch_emails.py @@ -0,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 diff --git a/inque/tickets/management/commands/list_open_tickets.py b/inque/tickets/management/commands/list_open_tickets.py new file mode 100644 index 0000000000000000000000000000000000000000..0c5f6e59cc6acd3c9eabf3b99f0d69617bc41a84 --- /dev/null +++ b/inque/tickets/management/commands/list_open_tickets.py @@ -0,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')}" + ) diff --git a/inque/tickets/migrations/0001_initial.py b/inque/tickets/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..0e8f5c24f602c8f0803285ff67d52e3be0ab53d7 --- /dev/null +++ b/inque/tickets/migrations/0001_initial.py @@ -0,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')), + ], + ), + ] diff --git a/inque/tickets/migrations/0002_message_message_id.py b/inque/tickets/migrations/0002_message_message_id.py new file mode 100644 index 0000000000000000000000000000000000000000..bf0dfe36bd43c1003b899da5c35ed129397a6308 --- /dev/null +++ b/inque/tickets/migrations/0002_message_message_id.py @@ -0,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, + ), + ] diff --git a/inque/tickets/migrations/0003_alter_message_message_id.py b/inque/tickets/migrations/0003_alter_message_message_id.py new file mode 100644 index 0000000000000000000000000000000000000000..9884191b43016f89f8de0ca4d080bc901411615e --- /dev/null +++ b/inque/tickets/migrations/0003_alter_message_message_id.py @@ -0,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), + ), + ] diff --git a/inque/tickets/migrations/0004_alter_message_message_id.py b/inque/tickets/migrations/0004_alter_message_message_id.py new file mode 100644 index 0000000000000000000000000000000000000000..9e5a3edbcb461817e7e1f39ca1a40e2669892aed --- /dev/null +++ b/inque/tickets/migrations/0004_alter_message_message_id.py @@ -0,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), + ), + ] diff --git a/inque/tickets/migrations/0005_message_in_reply_to.py b/inque/tickets/migrations/0005_message_in_reply_to.py new file mode 100644 index 0000000000000000000000000000000000000000..cf20e87168efbdd7e6a8e601e8422605a1fe0ccb --- /dev/null +++ b/inque/tickets/migrations/0005_message_in_reply_to.py @@ -0,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), + ), + ] diff --git a/inque/tickets/migrations/__init__.py b/inque/tickets/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/inque/tickets/models.py b/inque/tickets/models.py new file mode 100644 index 0000000000000000000000000000000000000000..fdeb7fb273b28a39710d28f1f4c568a1f113c181 --- /dev/null +++ b/inque/tickets/models.py @@ -0,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 diff --git a/inque/tickets/tests.py b/inque/tickets/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/inque/tickets/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/inque/tickets/utils.py b/inque/tickets/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..6f009fac4477b0d321f8f017ed380408fe77238c --- /dev/null +++ b/inque/tickets/utils.py @@ -0,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 = int(time.time() * 1000) # milliseconds + unique_id = uuid.uuid4().hex + return f"<{timestamp}.{unique_id}@{domain}>" diff --git a/inque/tickets/views.py b/inque/tickets/views.py new file mode 100644 index 0000000000000000000000000000000000000000..91ea44a218fbd2f408430959283f0419c921093e --- /dev/null +++ b/inque/tickets/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.