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,
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)
+76 -32
View File
@@ -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,
)
@@ -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 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"),
+15 -1
View File
@@ -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)
+5 -3
View File
@@ -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)