Files
chat_backend/llm_be/chat_backend/email_tasks.py
T
westfarn 16442b336c
Unit Tests / test (push) Successful in 9s
Fix password reset flow (#1) (#27)
## Summary
- Closes #1
- Replace broken `csrf_exempt` `reset_password` FBV (responses never returned; missing `requests` import) with working DRF `ResetUserPassword`
- Deduplicate reset email helper; build set-password links from `FRONTEND_BASE_URL`
- Harden `SetUserPassword`: require unusable password, min 8 chars, handle missing slug
- Accept reCAPTCHA v2 (success only) and v3 (score ≥ 0.5); avoid email enumeration (200 after valid captcha)
- Add unit tests for reset + set-password edge cases

## Test plan
- [ ] `manage.py test chat_backend.tests.test_views_users.ResetPasswordTestCase chat_backend.tests.test_views_users.SetPasswordTestCase`
- [ ] With SMTP configured: request reset for known email → receive link → set password → sign in
- [ ] Unknown email still returns 200 and sends no mail
- [ ] Failed captcha returns 400
- [ ] Pair with chat_web_app `feature/password-reset-1` PRReviewed-on: #27
2026-07-27 05:07:42 -07:00

165 lines
5.0 KiB
Python

"""Outbound email helpers.
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
import logging
from functools import partial
from django.conf import settings
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__)
DEFAULT_FROM_EMAIL = "ryan@aimloperations.com"
SUPPORT_EMAIL = "ryan@aimloperations.com"
SITE_NAME = "Hesychia"
COMPANY_NAME = "AI ML Operations, LLC"
def brand_email_context(**extra):
base = settings.FRONTEND_BASE_URL.rstrip("/")
return {
"site_name": SITE_NAME,
"company_name": COMPANY_NAME,
"site_url": base,
"support_email": SUPPORT_EMAIL,
**extra,
}
def set_password_url(slug: str) -> str:
base = settings.FRONTEND_BASE_URL.rstrip("/")
return f"{base}/set_password/?slug={slug}"
@task
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(
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)
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,
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, user=None) -> OutboundEmail:
url = set_password_url(slug)
logger.info("Queueing invite email url=%s", url)
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, user=None) -> OutboundEmail:
url = set_password_url(slug)
logger.info("Queueing password reset email url=%s", url)
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, user=None
) -> OutboundEmail:
logger.info("Queueing feedback 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,
)