Unit Tests / test (push) Successful in 9s
## 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
165 lines
5.0 KiB
Python
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,
|
|
)
|