Track outbound emails in admin with send status
CI / test (pull_request) Failing after 9s
Unit Tests / test (pull_request) Failing after 9s

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).
This commit is contained in:
2026-07-27 06:41:50 -05:00
parent 6a985a2477
commit 9412dc3efc
6 changed files with 235 additions and 37 deletions
+55 -1
View File
@@ -12,6 +12,7 @@ from .models import (
DocumentWorkspace, DocumentWorkspace,
Document, Document,
UserAuthEvent, UserAuthEvent,
OutboundEmail,
) )
@@ -36,6 +37,19 @@ class UserAuthEventInline(admin.TabularInline):
return False 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): class CustomUserAdmin(admin.ModelAdmin):
model = CustomUser model = CustomUser
list_display = ( list_display = (
@@ -55,7 +69,7 @@ class CustomUserAdmin(admin.ModelAdmin):
) )
search_fields = ("email", "username", "first_name", "last_name", "slug") search_fields = ("email", "username", "first_name", "last_name", "slug")
readonly_fields = ("last_login", "date_joined", "get_set_password_url", "slug") readonly_fields = ("last_login", "date_joined", "get_set_password_url", "slug")
inlines = (UserAuthEventInline,) inlines = (UserAuthEventInline, OutboundEmailInline)
class UserAuthEventAdmin(admin.ModelAdmin): class UserAuthEventAdmin(admin.ModelAdmin):
@@ -73,6 +87,45 @@ class UserAuthEventAdmin(admin.ModelAdmin):
return False 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): class FeedbackAdmin(admin.ModelAdmin):
model = Feedback model = Feedback
search_fields = ("status", "text", "get_user_email") search_fields = ("status", "text", "get_user_email")
@@ -169,6 +222,7 @@ admin.site.register(Announcement, AnnouncmentAdmin)
admin.site.register(Company, CompanyAdmin) admin.site.register(Company, CompanyAdmin)
admin.site.register(CustomUser, CustomUserAdmin) admin.site.register(CustomUser, CustomUserAdmin)
admin.site.register(UserAuthEvent, UserAuthEventAdmin) admin.site.register(UserAuthEvent, UserAuthEventAdmin)
admin.site.register(OutboundEmail, OutboundEmailAdmin)
admin.site.register(LLMModels, LLMModelsAdmin) admin.site.register(LLMModels, LLMModelsAdmin)
admin.site.register(Conversation, ConversationAdmin) admin.site.register(Conversation, ConversationAdmin)
+76 -32
View File
@@ -3,6 +3,8 @@
Uses Django 6 Tasks API so callers enqueue work the same way today and when a 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 real worker backend is configured later. ImmediateBackend (default) still runs
the send inside the current process. the send inside the current process.
Each send creates an OutboundEmail row for admin visibility (queued/sent/failed).
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,6 +17,9 @@ from django.core.mail import EmailMultiAlternatives
from django.db import transaction from django.db import transaction
from django.tasks import task from django.tasks import task
from django.template.loader import get_template from django.template.loader import get_template
from django.utils import timezone
from .models import OutboundEmail
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -41,80 +46,119 @@ def set_password_url(slug: str) -> str:
@task @task
def send_templated_email( def send_templated_email(outbound_email_id: str) -> bool:
subject: str, """Send a previously queued OutboundEmail via SMTP (SMTP2GO in prod)."""
to_email: str, try:
html_template: str, record = OutboundEmail.objects.get(pk=outbound_email_id)
text_template: str, except OutboundEmail.DoesNotExist:
context: dict, logger.error("OutboundEmail %s missing; cannot send", outbound_email_id)
) -> bool: return False
"""Send a branded HTML+text email via configured SMTP (SMTP2GO in prod)."""
logger.info("Sending email subject=%r to=%s", subject, to_email) record.status = OutboundEmail.Status.SENDING
html_content = get_template(html_template).render(context) record.save(update_fields=["status", "updated"])
text_content = get_template(text_template).render(context)
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( 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") 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: try:
msg.send(fail_silently=False) msg.send(fail_silently=False)
return True except Exception as exc:
except Exception: logger.exception(
logger.exception("Failed to send email subject=%r to=%s", subject, to_email) "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 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( def enqueue_email(
*, *,
kind: str,
subject: str, subject: str,
to_email: str, to_email: str,
html_template: str, html_template: str,
text_template: str, text_template: str,
context: dict, context: dict,
) -> None: user=None,
"""Enqueue after DB commit so workers never race uncommitted rows.""" ) -> OutboundEmail:
transaction.on_commit( """Create an OutboundEmail row and enqueue send after DB commit."""
partial( record = OutboundEmail.objects.create(
send_templated_email.enqueue, kind=kind,
subject=subject, status=OutboundEmail.Status.QUEUED,
to_email=to_email, to_email=to_email,
html_template=html_template, from_email=DEFAULT_FROM_EMAIL,
text_template=text_template, subject=subject,
context=context, 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) url = set_password_url(slug)
logger.info("Queueing invite email url=%s", url) logger.info("Queueing invite email url=%s", url)
enqueue_email( return enqueue_email(
kind=OutboundEmail.Kind.INVITE,
subject=f"Welcome to {SITE_NAME}", subject=f"Welcome to {SITE_NAME}",
to_email=email_to, to_email=email_to,
html_template="emails/invite_email.html", html_template="emails/invite_email.html",
text_template="emails/invite_email.txt", text_template="emails/invite_email.txt",
context=brand_email_context(url=url), 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) url = set_password_url(slug)
logger.info("Queueing password reset email url=%s", url) 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", subject=f"Reset your {SITE_NAME} password",
to_email=email_to, to_email=email_to,
html_template="emails/reset_email.html", html_template="emails/reset_email.html",
text_template="emails/reset_email.txt", text_template="emails/reset_email.txt",
context=brand_email_context(url=url), 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") logger.info("Queueing feedback email")
enqueue_email( return enqueue_email(
kind=OutboundEmail.Kind.FEEDBACK,
subject=f"New {SITE_NAME} feedback", subject=f"New {SITE_NAME} feedback",
to_email=SUPPORT_EMAIL, to_email=SUPPORT_EMAIL,
html_template="emails/feedback_email.html", html_template="emails/feedback_email.html",
text_template="emails/feedback_email.txt", text_template="emails/feedback_email.txt",
context=brand_email_context(title=title, feedback_text=feedback_text), context=brand_email_context(title=title, feedback_text=feedback_text),
user=user,
) )
@@ -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'],
},
),
]
+45
View File
@@ -3,6 +3,7 @@ from django.contrib.auth.models import AbstractUser
from django.utils import timezone from django.utils import timezone
from autoslug import AutoSlugField from autoslug import AutoSlugField
from chat_backend.storage import DatabaseStorage from chat_backend.storage import DatabaseStorage
import uuid
# Create your models here. # 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 = ( FEEDBACK_CHOICE = (
("SUBMITTED", "Submitted"), ("SUBMITTED", "Submitted"),
("RESOLVED", "Resolved"), ("RESOLVED", "Resolved"),
+15 -1
View File
@@ -5,7 +5,13 @@ from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from rest_framework_simplejwt.tokens import RefreshToken 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 from .factories import make_company, make_user
@@ -243,6 +249,11 @@ class ResetPasswordTestCase(APITestCase):
self.assertEqual( self.assertEqual(
event.event_type, UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED 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): def test_reset_unknown_email_still_returns_ok(self):
with self._mock_captcha(success=True): with self._mock_captcha(success=True):
@@ -315,6 +326,9 @@ class UserInviteTestCase(APITestCase):
self.assertEqual(mail.outbox[0].to, ["newhire@example.com"]) self.assertEqual(mail.outbox[0].to, ["newhire@example.com"])
event = UserAuthEvent.objects.get(user=invited) event = UserAuthEvent.objects.get(user=invited)
self.assertEqual(event.event_type, UserAuthEvent.EventType.INVITE_SENT) 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): def test_non_manager_cannot_invite(self):
member = make_user(email="member@example.com", company=self.company) member = make_user(email="member@example.com", company=self.company)
+5 -3
View File
@@ -175,7 +175,7 @@ class CustomUserInvite(APIView):
company=request.user.company, company=request.user.company,
) )
send_invite_email(user.slug, email_to_invite) send_invite_email(user.slug, email_to_invite, user=user)
UserAuthEvent.log( UserAuthEvent.log(
user, user,
UserAuthEvent.EventType.INVITE_SENT, UserAuthEvent.EventType.INVITE_SENT,
@@ -228,7 +228,7 @@ class ResetUserPassword(APIView):
if user: if user:
user.set_unusable_password() user.set_unusable_password()
user.save(update_fields=["password"]) user.save(update_fields=["password"])
send_password_reset_email(user.slug, email) send_password_reset_email(user.slug, email, user=user)
UserAuthEvent.log( UserAuthEvent.log(
user, user,
UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED, UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED,
@@ -311,7 +311,9 @@ class FeedbackView(APIView):
feedback_obj.user = request.user feedback_obj.user = request.user
feedback_obj.save() 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) return Response(serializer.data, status=status.HTTP_201_CREATED)
else: else:
logger.error(serializer.errors) logger.error(serializer.errors)