Fix password reset flow (#1) #27
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user