From 9412dc3efcf1c9aedb7357cad1bcebfef3c0cee2 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 27 Jul 2026 06:41:50 -0500 Subject: [PATCH] Track outbound emails in admin with send status Persist OutboundEmail rows (queued/sending/sent/failed) for invite, reset, and feedback mail, show them on user detail + a dedicated admin list, and stamp X-Hesychia-Email-Id for a future SMTP2GO webhook (#28). --- llm_be/chat_backend/admin.py | 56 ++++++++- llm_be/chat_backend/email_tasks.py | 108 ++++++++++++------ .../migrations/0025_outbound_email.py | 39 +++++++ llm_be/chat_backend/models.py | 45 ++++++++ llm_be/chat_backend/tests/test_views_users.py | 16 ++- llm_be/chat_backend/views.py | 8 +- 6 files changed, 235 insertions(+), 37 deletions(-) create mode 100644 llm_be/chat_backend/migrations/0025_outbound_email.py diff --git a/llm_be/chat_backend/admin.py b/llm_be/chat_backend/admin.py index 4d898a3..285539a 100644 --- a/llm_be/chat_backend/admin.py +++ b/llm_be/chat_backend/admin.py @@ -12,6 +12,7 @@ from .models import ( DocumentWorkspace, Document, UserAuthEvent, + OutboundEmail, ) @@ -36,6 +37,19 @@ class UserAuthEventInline(admin.TabularInline): return False +class OutboundEmailInline(admin.TabularInline): + model = OutboundEmail + extra = 0 + can_delete = False + fields = ("created", "kind", "status", "subject", "to_email", "sent_at") + readonly_fields = ("created", "kind", "status", "subject", "to_email", "sent_at") + ordering = ("-created",) + show_change_link = True + + def has_add_permission(self, request, obj=None): + return False + + class CustomUserAdmin(admin.ModelAdmin): model = CustomUser list_display = ( @@ -55,7 +69,7 @@ class CustomUserAdmin(admin.ModelAdmin): ) search_fields = ("email", "username", "first_name", "last_name", "slug") readonly_fields = ("last_login", "date_joined", "get_set_password_url", "slug") - inlines = (UserAuthEventInline,) + inlines = (UserAuthEventInline, OutboundEmailInline) class UserAuthEventAdmin(admin.ModelAdmin): @@ -73,6 +87,45 @@ class UserAuthEventAdmin(admin.ModelAdmin): return False +class OutboundEmailAdmin(admin.ModelAdmin): + model = OutboundEmail + list_display = ( + "created", + "kind", + "status", + "subject", + "to_email", + "user", + "sent_at", + ) + list_filter = ("kind", "status") + search_fields = ("to_email", "subject", "user__email", "id") + readonly_fields = ( + "id", + "kind", + "status", + "to_email", + "from_email", + "subject", + "html_template", + "text_template", + "context", + "error_message", + "user", + "created", + "updated", + "sent_at", + ) + ordering = ("-created",) + date_hierarchy = "created" + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + class FeedbackAdmin(admin.ModelAdmin): model = Feedback search_fields = ("status", "text", "get_user_email") @@ -169,6 +222,7 @@ admin.site.register(Announcement, AnnouncmentAdmin) admin.site.register(Company, CompanyAdmin) admin.site.register(CustomUser, CustomUserAdmin) admin.site.register(UserAuthEvent, UserAuthEventAdmin) +admin.site.register(OutboundEmail, OutboundEmailAdmin) admin.site.register(LLMModels, LLMModelsAdmin) admin.site.register(Conversation, ConversationAdmin) diff --git a/llm_be/chat_backend/email_tasks.py b/llm_be/chat_backend/email_tasks.py index cdcf004..6fbe911 100644 --- a/llm_be/chat_backend/email_tasks.py +++ b/llm_be/chat_backend/email_tasks.py @@ -3,6 +3,8 @@ Uses Django 6 Tasks API so callers enqueue work the same way today and when a real worker backend is configured later. ImmediateBackend (default) still runs the send inside the current process. + +Each send creates an OutboundEmail row for admin visibility (queued/sent/failed). """ from __future__ import annotations @@ -15,6 +17,9 @@ from django.core.mail import EmailMultiAlternatives from django.db import transaction from django.tasks import task from django.template.loader import get_template +from django.utils import timezone + +from .models import OutboundEmail logger = logging.getLogger(__name__) @@ -41,80 +46,119 @@ def set_password_url(slug: str) -> str: @task -def send_templated_email( - subject: str, - to_email: str, - html_template: str, - text_template: str, - context: dict, -) -> bool: - """Send a branded HTML+text email via configured SMTP (SMTP2GO in prod).""" - logger.info("Sending email subject=%r to=%s", subject, to_email) - html_content = get_template(html_template).render(context) - text_content = get_template(text_template).render(context) +def send_templated_email(outbound_email_id: str) -> bool: + """Send a previously queued OutboundEmail via SMTP (SMTP2GO in prod).""" + try: + record = OutboundEmail.objects.get(pk=outbound_email_id) + except OutboundEmail.DoesNotExist: + logger.error("OutboundEmail %s missing; cannot send", outbound_email_id) + return False + + record.status = OutboundEmail.Status.SENDING + record.save(update_fields=["status", "updated"]) + + logger.info( + "Sending email id=%s subject=%r to=%s", + record.id, + record.subject, + record.to_email, + ) + html_content = get_template(record.html_template).render(record.context) + text_content = get_template(record.text_template).render(record.context) msg = EmailMultiAlternatives( - subject, text_content, DEFAULT_FROM_EMAIL, [to_email] + record.subject, text_content, record.from_email, [record.to_email] ) msg.attach_alternative(html_content, "text/html") + # Correlate with SMTP2GO webhooks later (custom header passthrough). + msg.extra_headers["X-Hesychia-Email-Id"] = str(record.id) + try: msg.send(fail_silently=False) - return True - except Exception: - logger.exception("Failed to send email subject=%r to=%s", subject, to_email) + except Exception as exc: + logger.exception( + "Failed to send email id=%s subject=%r to=%s", + record.id, + record.subject, + record.to_email, + ) + record.status = OutboundEmail.Status.FAILED + record.error_message = str(exc)[:2000] + record.save(update_fields=["status", "error_message", "updated"]) return False + record.status = OutboundEmail.Status.SENT + record.sent_at = timezone.now() + record.error_message = "" + record.save(update_fields=["status", "sent_at", "error_message", "updated"]) + return True + def enqueue_email( *, + kind: str, subject: str, to_email: str, html_template: str, text_template: str, context: dict, -) -> None: - """Enqueue after DB commit so workers never race uncommitted rows.""" - transaction.on_commit( - partial( - send_templated_email.enqueue, - subject=subject, - to_email=to_email, - html_template=html_template, - text_template=text_template, - context=context, - ) + user=None, +) -> OutboundEmail: + """Create an OutboundEmail row and enqueue send after DB commit.""" + record = OutboundEmail.objects.create( + kind=kind, + status=OutboundEmail.Status.QUEUED, + to_email=to_email, + from_email=DEFAULT_FROM_EMAIL, + subject=subject, + html_template=html_template, + text_template=text_template, + context=context, + user=user, ) + transaction.on_commit( + partial(send_templated_email.enqueue, outbound_email_id=str(record.id)) + ) + return record -def send_invite_email(slug: str, email_to: str) -> None: +def send_invite_email(slug: str, email_to: str, user=None) -> OutboundEmail: url = set_password_url(slug) logger.info("Queueing invite email url=%s", url) - enqueue_email( + return enqueue_email( + kind=OutboundEmail.Kind.INVITE, subject=f"Welcome to {SITE_NAME}", to_email=email_to, html_template="emails/invite_email.html", text_template="emails/invite_email.txt", context=brand_email_context(url=url), + user=user, ) -def send_password_reset_email(slug: str, email_to: str) -> None: +def send_password_reset_email(slug: str, email_to: str, user=None) -> OutboundEmail: url = set_password_url(slug) logger.info("Queueing password reset email url=%s", url) - enqueue_email( + return enqueue_email( + kind=OutboundEmail.Kind.PASSWORD_RESET, subject=f"Reset your {SITE_NAME} password", to_email=email_to, html_template="emails/reset_email.html", text_template="emails/reset_email.txt", context=brand_email_context(url=url), + user=user, ) -def send_feedback_email(title: str, feedback_text: str) -> None: +def send_feedback_email( + title: str, feedback_text: str, user=None +) -> OutboundEmail: logger.info("Queueing feedback email") - enqueue_email( + return enqueue_email( + kind=OutboundEmail.Kind.FEEDBACK, subject=f"New {SITE_NAME} feedback", to_email=SUPPORT_EMAIL, html_template="emails/feedback_email.html", text_template="emails/feedback_email.txt", context=brand_email_context(title=title, feedback_text=feedback_text), + user=user, ) diff --git a/llm_be/chat_backend/migrations/0025_outbound_email.py b/llm_be/chat_backend/migrations/0025_outbound_email.py new file mode 100644 index 0000000..ea38799 --- /dev/null +++ b/llm_be/chat_backend/migrations/0025_outbound_email.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0 on 2026-07-27 11:41 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat_backend', '0024_user_auth_event'), + ] + + operations = [ + migrations.CreateModel( + name='OutboundEmail', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('kind', models.CharField(choices=[('password_reset', 'Password reset'), ('invite', 'Invite'), ('feedback', 'Feedback')], max_length=32)), + ('status', models.CharField(choices=[('queued', 'Queued'), ('sending', 'Sending'), ('sent', 'Sent to SMTP'), ('failed', 'Failed')], db_index=True, default='queued', max_length=16)), + ('to_email', models.EmailField(max_length=254)), + ('from_email', models.EmailField(max_length=254)), + ('subject', models.CharField(max_length=255)), + ('html_template', models.CharField(max_length=255)), + ('text_template', models.CharField(max_length=255)), + ('context', models.JSONField(blank=True, default=dict)), + ('error_message', models.TextField(blank=True, default='')), + ('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('updated', models.DateTimeField(auto_now=True)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='outbound_emails', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created'], + }, + ), + ] diff --git a/llm_be/chat_backend/models.py b/llm_be/chat_backend/models.py index 72ecae6..ecb934b 100644 --- a/llm_be/chat_backend/models.py +++ b/llm_be/chat_backend/models.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import AbstractUser from django.utils import timezone from autoslug import AutoSlugField from chat_backend.storage import DatabaseStorage +import uuid # Create your models here. @@ -117,6 +118,50 @@ class UserAuthEvent(models.Model): ) +class OutboundEmail(models.Model): + """Record of emails queued/sent by the app (visible in admin).""" + + class Kind(models.TextChoices): + PASSWORD_RESET = "password_reset", "Password reset" + INVITE = "invite", "Invite" + FEEDBACK = "feedback", "Feedback" + + class Status(models.TextChoices): + QUEUED = "queued", "Queued" + SENDING = "sending", "Sending" + SENT = "sent", "Sent to SMTP" + FAILED = "failed", "Failed" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + kind = models.CharField(max_length=32, choices=Kind.choices) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.QUEUED, db_index=True + ) + to_email = models.EmailField() + from_email = models.EmailField() + subject = models.CharField(max_length=255) + html_template = models.CharField(max_length=255) + text_template = models.CharField(max_length=255) + context = models.JSONField(default=dict, blank=True) + error_message = models.TextField(blank=True, default="") + user = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="outbound_emails", + ) + created = models.DateTimeField(default=timezone.now, db_index=True) + updated = models.DateTimeField(auto_now=True) + sent_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created"] + + def __str__(self): + return f"{self.subject} → {self.to_email} ({self.status})" + + FEEDBACK_CHOICE = ( ("SUBMITTED", "Submitted"), ("RESOLVED", "Resolved"), diff --git a/llm_be/chat_backend/tests/test_views_users.py b/llm_be/chat_backend/tests/test_views_users.py index 8ace586..54dd863 100644 --- a/llm_be/chat_backend/tests/test_views_users.py +++ b/llm_be/chat_backend/tests/test_views_users.py @@ -5,7 +5,13 @@ from rest_framework import status from rest_framework.test import APITestCase from rest_framework_simplejwt.tokens import RefreshToken -from chat_backend.models import Announcement, CustomUser, Feedback, UserAuthEvent +from chat_backend.models import ( + Announcement, + CustomUser, + Feedback, + OutboundEmail, + UserAuthEvent, +) from .factories import make_company, make_user @@ -243,6 +249,11 @@ class ResetPasswordTestCase(APITestCase): self.assertEqual( event.event_type, UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED ) + outbound = OutboundEmail.objects.get(user=self.user) + self.assertEqual(outbound.kind, OutboundEmail.Kind.PASSWORD_RESET) + self.assertEqual(outbound.status, OutboundEmail.Status.SENT) + self.assertEqual(outbound.to_email, self.user.email) + self.assertIsNotNone(outbound.sent_at) def test_reset_unknown_email_still_returns_ok(self): with self._mock_captcha(success=True): @@ -315,6 +326,9 @@ class UserInviteTestCase(APITestCase): self.assertEqual(mail.outbox[0].to, ["newhire@example.com"]) event = UserAuthEvent.objects.get(user=invited) self.assertEqual(event.event_type, UserAuthEvent.EventType.INVITE_SENT) + outbound = OutboundEmail.objects.get(user=invited) + self.assertEqual(outbound.kind, OutboundEmail.Kind.INVITE) + self.assertEqual(outbound.status, OutboundEmail.Status.SENT) def test_non_manager_cannot_invite(self): member = make_user(email="member@example.com", company=self.company) diff --git a/llm_be/chat_backend/views.py b/llm_be/chat_backend/views.py index fe2e1d0..8e82656 100644 --- a/llm_be/chat_backend/views.py +++ b/llm_be/chat_backend/views.py @@ -175,7 +175,7 @@ class CustomUserInvite(APIView): company=request.user.company, ) - send_invite_email(user.slug, email_to_invite) + send_invite_email(user.slug, email_to_invite, user=user) UserAuthEvent.log( user, UserAuthEvent.EventType.INVITE_SENT, @@ -228,7 +228,7 @@ class ResetUserPassword(APIView): if user: user.set_unusable_password() user.save(update_fields=["password"]) - send_password_reset_email(user.slug, email) + send_password_reset_email(user.slug, email, user=user) UserAuthEvent.log( user, UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED, @@ -311,7 +311,9 @@ class FeedbackView(APIView): feedback_obj.user = request.user feedback_obj.save() - send_feedback_email(feedback_obj.title, feedback_obj.text) + send_feedback_email( + feedback_obj.title, feedback_obj.text, user=request.user + ) return Response(serializer.data, status=status.HTTP_201_CREATED) else: logger.error(serializer.errors)