Send branded HTML emails and harden SMTP2GO webhooks.
Deploy Beta / unit-tests (push) Successful in 9s
Deploy Beta / docker (push) Successful in 15s
Deploy Beta / deploy-beta (push) Successful in 1m36s

Fix campaign stuck on sending under async queue, and stop UUID ValidationError when SMTP2GO tests send "Headers Unavailable".
This commit is contained in:
2026-08-09 06:37:20 -05:00
parent 617bda3e8b
commit d830f07757
16 changed files with 558 additions and 63 deletions
+2 -1
View File
@@ -26,7 +26,8 @@ SMTP2GO → **Settings → Webhooks** (email and SMS stay separate).
| SMS events | leave unchecked | | SMS events | leave unchecked |
`X-Monica-Message-Id` is set on every campaign email send and is required so webhook `X-Monica-Message-Id` is set on every campaign email send and is required so webhook
events match the correct recipient row. events match the correct recipient row. Invalid / missing header values no longer
500 the endpoint (SMTP2GO “Test this webhook” often sends a sample non-UUID).
Beta / other hosts: swap the hostname, keep the path. Beta / other hosts: swap the hostname, keep the path.
+13 -5
View File
@@ -2,9 +2,11 @@
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from contacts.models import Channel from contacts.models import Channel
from messaging.services import one_click_unsubscribe_url, preferences_url from messaging.services import one_click_unsubscribe_url, preferences_url
from public.email_branding import email_brand_context, plain_text_to_email_html
# Reported back on SMTP2GO webhooks when this header is selected in webhook settings. # Reported back on SMTP2GO webhooks when this header is selected in webhook settings.
MONICA_MESSAGE_HEADER = "X-Monica-Message-Id" MONICA_MESSAGE_HEADER = "X-Monica-Message-Id"
@@ -28,15 +30,20 @@ def send_email(message) -> str:
one_click_path = one_click_unsubscribe_url(str(contact.pk), Channel.EMAIL) one_click_path = one_click_unsubscribe_url(str(contact.pk), Channel.EMAIL)
prefs_url = f"{site}{prefs_path}" if site else prefs_path prefs_url = f"{site}{prefs_path}" if site else prefs_path
one_click_url = f"{site}{one_click_path}" if site else one_click_path one_click_url = f"{site}{one_click_path}" if site else one_click_path
body_with_unsub = (
f"{body}\n\n---\n" ctx = email_brand_context(
f"Manage preferences: {prefs_url}\n" title=subject,
f"Unsubscribe from email: {one_click_url}" content=body,
content_html=plain_text_to_email_html(body),
prefs_url=prefs_url,
one_click_url=one_click_url,
) )
text_content = get_template("emails/marketing_email.txt").render(ctx)
html_content = get_template("emails/marketing_email.html").render(ctx)
email = EmailMultiAlternatives( email = EmailMultiAlternatives(
subject=subject, subject=subject,
body=body_with_unsub, body=text_content,
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
to=[contact.email], to=[contact.email],
headers={ headers={
@@ -45,6 +52,7 @@ def send_email(message) -> str:
MONICA_MESSAGE_HEADER: str(message.pk), MONICA_MESSAGE_HEADER: str(message.pk),
}, },
) )
email.attach_alternative(html_content, "text/html")
email.send(fail_silently=False) email.send(fail_silently=False)
# Placeholder until SMTP2GO webhook supplies the real email_id. # Placeholder until SMTP2GO webhook supplies the real email_id.
return f"smtp-{message.pk}" return f"smtp-{message.pk}"
+50 -16
View File
@@ -256,8 +256,12 @@ def send_campaign_completion_notify(campaign: Campaign) -> bool:
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.db.models import Count, Q from django.db.models import Count, Q
from django.template.loader import get_template
from django.urls import reverse from django.urls import reverse
from public.email_branding import email_brand_context
campaign.refresh_from_db()
if campaign.notify_sent_at: if campaign.notify_sent_at:
return True return True
if campaign.status != Campaign.Status.COMPLETED: if campaign.status != Campaign.Status.COMPLETED:
@@ -267,6 +271,17 @@ def send_campaign_completion_notify(campaign: Campaign) -> bool:
if not to_email: if not to_email:
return False return False
# Claim the notify slot atomically so concurrent refresh calls only send once.
now = timezone.now()
claimed = Campaign.objects.filter(
pk=campaign.pk,
status=Campaign.Status.COMPLETED,
notify_sent_at__isnull=True,
).update(notify_sent_at=now)
if not claimed:
return True
campaign.notify_sent_at = now
counts = campaign.messages.aggregate( counts = campaign.messages.aggregate(
sent=Count("id", filter=Q(status=Message.Status.SENT)), sent=Count("id", filter=Q(status=Message.Status.SENT)),
delivered=Count("id", filter=Q(status=Message.Status.DELIVERED)), delivered=Count("id", filter=Q(status=Message.Status.DELIVERED)),
@@ -287,22 +302,26 @@ def send_campaign_completion_notify(campaign: Campaign) -> bool:
report_url = f"{public}{report_path}" if public else report_path report_url = f"{public}{report_path}" if public else report_path
subject = f"Campaign sent: {campaign.name}" subject = f"Campaign sent: {campaign.name}"
body = ( ctx = email_brand_context(
f"Your {campaign.get_channel_display()} campaign “{campaign.name}" subject=subject,
f"has finished sending.\n\n" campaign_name=campaign.name,
f"Recipients: {counts['total']}\n" channel_display=campaign.get_channel_display(),
f"Sent: {counts['sent']}\n" total=counts["total"],
f"Delivered: {counts['delivered']}\n" sent=counts["sent"],
f"Failed / bounced: {counts['failed']}\n" delivered=counts["delivered"],
f"Suppressed: {counts['suppressed']}\n\n" failed=counts["failed"],
f"Report: {report_url}\n" suppressed=counts["suppressed"],
report_url=report_url,
) )
text_content = get_template("emails/campaign_complete.txt").render(ctx)
html_content = get_template("emails/campaign_complete.html").render(ctx)
email = EmailMultiAlternatives( email = EmailMultiAlternatives(
subject=subject, subject=subject,
body=body, body=text_content,
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email], to=[to_email],
) )
email.attach_alternative(html_content, "text/html")
try: try:
email.send(fail_silently=False) email.send(fail_silently=False)
except Exception: # noqa: BLE001 — don't block completion on mail errors except Exception: # noqa: BLE001 — don't block completion on mail errors
@@ -311,15 +330,16 @@ def send_campaign_completion_notify(campaign: Campaign) -> bool:
logging.getLogger(__name__).exception( logging.getLogger(__name__).exception(
"Campaign completion notify failed for %s", campaign.pk "Campaign completion notify failed for %s", campaign.pk
) )
Campaign.objects.filter(pk=campaign.pk).update(notify_sent_at=None)
campaign.notify_sent_at = None
return False return False
campaign.notify_sent_at = timezone.now()
campaign.save(update_fields=["notify_sent_at", "updated_at"])
return True return True
def refresh_campaign_status(campaign: Campaign) -> Campaign: def refresh_campaign_status(campaign: Campaign) -> Campaign:
"""Set campaign to completed when no messages remain pending.""" """Set campaign to completed when no messages remain pending."""
campaign.refresh_from_db()
pending = campaign.messages.filter( pending = campaign.messages.filter(
status__in=[ status__in=[
Message.Status.DRAFT, Message.Status.DRAFT,
@@ -377,6 +397,9 @@ def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
"""Send one preview copy to ``to_email`` without touching recipient rows.""" """Send one preview copy to ``to_email`` without touching recipient rows."""
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from public.email_branding import email_brand_context, plain_text_to_email_html
if campaign.channel != Channel.EMAIL: if campaign.channel != Channel.EMAIL:
raise ValueError("Test send is only available for email campaigns.") raise ValueError("Test send is only available for email campaigns.")
@@ -391,14 +414,25 @@ def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
if not body.strip(): if not body.strip():
raise ValueError("Campaign has no body.") raise ValueError("Campaign has no body.")
email = EmailMultiAlternatives( notice = (
subject=f"[TEST] {subject}",
body=(
f"{body}\n\n---\n"
"This is a test send from the Monica portal. " "This is a test send from the Monica portal. "
"Recipient list was not notified." "Recipient list was not notified."
)
ctx = email_brand_context(
title=f"[TEST] {subject}",
content=f"{body}\n\n{notice}",
content_html=(
f"{plain_text_to_email_html(body)}"
f'<p style="margin:24px 0 0;color:#6b7280;font-size:13px;">{notice}</p>'
), ),
)
text_content = get_template("emails/marketing_email.txt").render(ctx)
html_content = get_template("emails/marketing_email.html").render(ctx)
email = EmailMultiAlternatives(
subject=f"[TEST] {subject}",
body=text_content,
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email], to=[to_email],
) )
email.attach_alternative(html_content, "text/html")
email.send(fail_silently=False) email.send(fail_silently=False)
+8 -1
View File
@@ -3,11 +3,12 @@ from django.utils import timezone
from messaging.channels import dispatch_message from messaging.channels import dispatch_message
from messaging.models import Message from messaging.models import Message
from messaging.services import contact_may_receive from messaging.services import contact_may_receive, refresh_campaign_status
@task @task
def send_campaign_message(message_id: str) -> None: def send_campaign_message(message_id: str) -> None:
campaign = None
try: try:
message = Message.objects.select_related("contact", "campaign").get( message = Message.objects.select_related("contact", "campaign").get(
pk=message_id pk=message_id
@@ -15,10 +16,13 @@ def send_campaign_message(message_id: str) -> None:
except Message.DoesNotExist: except Message.DoesNotExist:
return return
campaign = message.campaign
if not contact_may_receive(message.contact, message.channel): if not contact_may_receive(message.contact, message.channel):
message.status = Message.Status.SUPPRESSED message.status = Message.Status.SUPPRESSED
message.error = "Contact opted out or suppressed" message.error = "Contact opted out or suppressed"
message.save(update_fields=["status", "error", "updated_at"]) message.save(update_fields=["status", "error", "updated_at"])
refresh_campaign_status(campaign)
return return
try: try:
@@ -42,4 +46,7 @@ def send_campaign_message(message_id: str) -> None:
message.status = Message.Status.FAILED message.status = Message.Status.FAILED
message.error = str(exc)[:2000] message.error = str(exc)[:2000]
message.save(update_fields=["status", "error", "updated_at"]) message.save(update_fields=["status", "error", "updated_at"])
refresh_campaign_status(campaign)
raise raise
refresh_campaign_status(campaign)
+50
View File
@@ -271,6 +271,8 @@ class CampaignSendTests(TestCase):
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["me@example.com"]) self.assertEqual(mail.outbox[0].to, ["me@example.com"])
self.assertTrue(mail.outbox[0].subject.startswith("[TEST]")) self.assertTrue(mail.outbox[0].subject.startswith("[TEST]"))
self.assertTrue(mail.outbox[0].alternatives)
self.assertEqual(mail.outbox[0].alternatives[0][1], "text/html")
# Recipients untouched # Recipients untouched
self.assertEqual( self.assertEqual(
self.campaign.messages.filter(status=Message.Status.DRAFT).count(), 1 self.campaign.messages.filter(status=Message.Status.DRAFT).count(), 1
@@ -300,6 +302,10 @@ class CampaignSendTests(TestCase):
summary = mail.outbox[1] summary = mail.outbox[1]
self.assertIn("Campaign sent:", summary.subject) self.assertIn("Campaign sent:", summary.subject)
self.assertEqual(summary.to, [self.user.email]) self.assertEqual(summary.to, [self.user.email])
self.assertTrue(mail.outbox[0].alternatives)
self.assertEqual(mail.outbox[0].alternatives[0][1], "text/html")
self.assertIn("#00626c", mail.outbox[0].alternatives[0][0])
self.assertTrue(summary.alternatives)
class Smtp2goEmailWebhookTests(TestCase): class Smtp2goEmailWebhookTests(TestCase):
@@ -443,6 +449,50 @@ class Smtp2goEmailWebhookTests(TestCase):
) )
self.assertEqual(ok.status_code, 200) self.assertEqual(ok.status_code, 200)
def test_invalid_monica_header_does_not_500(self):
"""SMTP2GO tests often send a non-UUID sample custom header."""
import json
url = reverse("messaging:email_webhook")
response = self.client.post(
url,
data=json.dumps(
{
"event": "delivered",
"rcpt": "nobody@example.com",
"X-Monica-Message-Id": "Headers Unavailable",
"email_id": "smtp2go-test-id",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["ok"])
self.assertFalse(data["matched"])
self.assertTrue(
ProviderEvent.objects.filter(event_type="delivered").exists()
)
def test_ui_event_label_aliases(self):
url = reverse("messaging:email_webhook")
response = self.client.post(
url,
data={
"event": "bounced",
"bounce": "soft",
"X-Monica-Message-Id": str(self.message.pk),
},
)
self.assertEqual(response.status_code, 200)
self.message.refresh_from_db()
self.assertEqual(self.message.status, Message.Status.BOUNCED)
self.assertTrue(
ProviderEvent.objects.filter(
message=self.message, event_type="bounce"
).exists()
)
class Smtp2goSmsWebhookTests(TestCase): class Smtp2goSmsWebhookTests(TestCase):
def setUp(self): def setUp(self):
+12
View File
@@ -1,5 +1,6 @@
import hashlib import hashlib
import hmac import hmac
import logging
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@@ -38,6 +39,8 @@ from messaging.webhooks import (
process_smtp2go_sms_webhook, process_smtp2go_sms_webhook,
) )
logger = logging.getLogger(__name__)
def _audience_choices() -> list[tuple[str, str]]: def _audience_choices() -> list[tuple[str, str]]:
"""Labeled audience options with live opted-in counts.""" """Labeled audience options with live opted-in counts."""
@@ -256,6 +259,11 @@ def campaign_detail(request, pk):
def campaign_status_json(request, pk): def campaign_status_json(request, pk):
"""JSON snapshot for live-updating the campaign report page.""" """JSON snapshot for live-updating the campaign report page."""
campaign = get_object_or_404(Campaign, pk=pk) campaign = get_object_or_404(Campaign, pk=pk)
# Async queue may finish after enqueue; re-evaluate completion on poll.
from messaging.services import refresh_campaign_status
refresh_campaign_status(campaign)
campaign.refresh_from_db()
ctx = _campaign_report(campaign) ctx = _campaign_report(campaign)
return JsonResponse( return JsonResponse(
{ {
@@ -580,7 +588,11 @@ def email_webhook(request):
): ):
return HttpResponseForbidden("invalid webhook token") return HttpResponseForbidden("invalid webhook token")
payload = parse_webhook_payload(request) payload = parse_webhook_payload(request)
try:
event = process_smtp2go_email_webhook(payload) event = process_smtp2go_email_webhook(payload)
except Exception: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h)
logger.exception("SMTP2GO email webhook processing failed")
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
return JsonResponse( return JsonResponse(
{ {
"ok": True, "ok": True,
+92 -27
View File
@@ -4,8 +4,10 @@ from __future__ import annotations
import json import json
import logging import logging
import uuid
from typing import Any from typing import Any
from django.core.exceptions import ValidationError
from django.http import HttpRequest from django.http import HttpRequest
from contacts.models import Channel, Contact from contacts.models import Channel, Contact
@@ -38,6 +40,45 @@ _MONICA_HEADER_KEYS = (
"monica-message-id", "monica-message-id",
) )
# SMTP2GO UI labels → canonical event strings from their docs.
_EMAIL_EVENT_ALIASES = {
"bounced": "bounce",
"rejected": "reject",
"opened": "open",
"clicked": "click",
"unsubscribed": "unsubscribe",
"resubscribed": "resubscribe",
}
def _as_str(value: Any) -> str:
"""Coerce webhook field values to a stripped string (lists / None safe)."""
if value is None:
return ""
if isinstance(value, (list, tuple)):
if not value:
return ""
value = value[0]
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
return str(value).strip()
def _json_safe(value: Any) -> Any:
"""Ensure ProviderEvent.payload can be stored as JSON."""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(k): _json_safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(v) for v in value]
return str(value)
def _normalize_email_event(event: str) -> str:
event = (event or "").strip().lower()
return _EMAIL_EVENT_ALIASES.get(event, event)
def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]: def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]:
"""Accept JSON or form-encoded SMTP2GO webhook bodies.""" """Accept JSON or form-encoded SMTP2GO webhook bodies."""
@@ -45,7 +86,7 @@ def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]:
if "application/json" in content_type: if "application/json" in content_type:
try: try:
data = json.loads(request.body.decode() or "{}") data = json.loads(request.body.decode() or "{}")
except json.JSONDecodeError: except (json.JSONDecodeError, UnicodeDecodeError):
return {} return {}
return data if isinstance(data, dict) else {} return data if isinstance(data, dict) else {}
# Form-encoded (SMTP2GO default) # Form-encoded (SMTP2GO default)
@@ -55,37 +96,65 @@ def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]:
def extract_monica_message_id(payload: dict[str, Any]) -> str: def extract_monica_message_id(payload: dict[str, Any]) -> str:
"""Pull our correlation id from flat keys or a nested headers object.""" """Pull our correlation id from flat keys or a nested headers object."""
for key in _MONICA_HEADER_KEYS: for key in _MONICA_HEADER_KEYS:
value = payload.get(key) value = _as_str(payload.get(key))
if value: if value:
return str(value).strip() return value
headers = payload.get("headers") or payload.get("email_headers") or {} headers = payload.get("headers") or payload.get("email_headers") or {}
if isinstance(headers, dict): if isinstance(headers, dict):
for key in _MONICA_HEADER_KEYS: for key in _MONICA_HEADER_KEYS:
value = headers.get(key) value = _as_str(headers.get(key))
if value: if value:
return str(value).strip() return value
# Case-insensitive scan # Case-insensitive scan
lower_map = {str(k).lower(): v for k, v in headers.items()} lower_map = {str(k).lower(): v for k, v in headers.items()}
for key in _MONICA_HEADER_KEYS: for key in _MONICA_HEADER_KEYS:
value = lower_map.get(key.lower()) value = _as_str(lower_map.get(key.lower()))
if value: if value:
return str(value).strip() return value
elif isinstance(headers, list):
# Some ESP shapes send [["X-Monica-Message-Id", "..."], ...]
for item in headers:
if isinstance(item, (list, tuple)) and len(item) >= 2:
if _as_str(item[0]).lower() in {
k.lower() for k in _MONICA_HEADER_KEYS
}:
value = _as_str(item[1])
if value:
return value
elif isinstance(item, str) and ":" in item:
name, _, rest = item.partition(":")
if name.strip().lower() in {k.lower() for k in _MONICA_HEADER_KEYS}:
value = rest.strip()
if value:
return value
return "" return ""
def _message_by_pk(pk: str) -> Message | None:
"""Lookup Message by UUID pk without raising on malformed ids."""
try:
uuid.UUID(str(pk))
except (ValueError, AttributeError, TypeError):
return None
try:
return (
Message.objects.select_related("contact", "campaign")
.filter(pk=pk)
.first()
)
except (ValidationError, ValueError):
return None
def find_message_for_email_event(payload: dict[str, Any]) -> Message | None: def find_message_for_email_event(payload: dict[str, Any]) -> Message | None:
monica_id = extract_monica_message_id(payload) monica_id = extract_monica_message_id(payload)
if monica_id: if monica_id:
message = ( message = _message_by_pk(monica_id)
Message.objects.select_related("contact", "campaign")
.filter(pk=monica_id)
.first()
)
if message: if message:
return message return message
email_id = (payload.get("email_id") or payload.get("email-id") or "").strip() email_id = _as_str(payload.get("email_id") or payload.get("email-id"))
if email_id: if email_id:
message = ( message = (
Message.objects.select_related("contact", "campaign") Message.objects.select_related("contact", "campaign")
@@ -95,13 +164,13 @@ def find_message_for_email_event(payload: dict[str, Any]) -> Message | None:
if message: if message:
return message return message
rcpt = (payload.get("rcpt") or "").strip().lower() rcpt = _as_str(payload.get("rcpt")).lower()
if not rcpt: if not rcpt:
recipients = payload.get("recipients") recipients = payload.get("recipients")
if isinstance(recipients, str) and recipients.strip(): if isinstance(recipients, str) and recipients.strip():
rcpt = recipients.split(",")[0].strip().lower() rcpt = recipients.split(",")[0].strip().lower()
elif isinstance(recipients, list) and recipients: elif isinstance(recipients, list) and recipients:
rcpt = str(recipients[0]).strip().lower() rcpt = _as_str(recipients[0]).lower()
if not rcpt: if not rcpt:
return None return None
@@ -157,11 +226,11 @@ def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "")
def _apply_email_event(message: Message, event: str, payload: dict[str, Any]) -> None: def _apply_email_event(message: Message, event: str, payload: dict[str, Any]) -> None:
event = (event or "").strip().lower() event = _normalize_email_event(event)
bounce_kind = (payload.get("bounce") or "").strip().lower() bounce_kind = _as_str(payload.get("bounce")).lower()
err = (payload.get("message") or payload.get("context") or "").strip() err = _as_str(payload.get("message") or payload.get("context"))
email_id = (payload.get("email_id") or payload.get("email-id") or "").strip() email_id = _as_str(payload.get("email_id") or payload.get("email-id"))
if email_id and message.provider_message_id != email_id: if email_id and message.provider_message_id != email_id:
message.provider_message_id = email_id message.provider_message_id = email_id
message.provider = PROVIDER_EMAIL message.provider = PROVIDER_EMAIL
@@ -233,7 +302,7 @@ def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | No
Returns the stored event (even if message could not be matched). Returns the stored event (even if message could not be matched).
""" """
event = (payload.get("event") or "").strip().lower() event = _normalize_email_event(_as_str(payload.get("event")))
if not event: if not event:
logger.warning("SMTP2GO webhook missing event: %s", payload) logger.warning("SMTP2GO webhook missing event: %s", payload)
return None return None
@@ -253,8 +322,8 @@ def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | No
return ProviderEvent.objects.create( return ProviderEvent.objects.create(
message=message, message=message,
provider=PROVIDER_EMAIL, provider=PROVIDER_EMAIL,
event_type=event, event_type=event[:64],
payload=payload, payload=_json_safe(payload) if isinstance(payload, dict) else {},
) )
@@ -442,11 +511,7 @@ def find_message_for_pcm_event(payload: dict[str, Any]) -> Message | None:
ext = payload["recipient"].get("extRefNbr") or "" ext = payload["recipient"].get("extRefNbr") or ""
ext = str(ext).strip() ext = str(ext).strip()
if ext: if ext:
message = ( message = _message_by_pk(ext)
Message.objects.select_related("contact", "campaign")
.filter(pk=ext)
.first()
)
if message: if message:
return message return message
+60
View File
@@ -0,0 +1,60 @@
"""Shared context for branded HTML/text emails (public site palette)."""
from __future__ import annotations
import html
import re
from django.conf import settings
from django.contrib.staticfiles.storage import staticfiles_storage
_URL_RE = re.compile(r"(https?://[^\s<]+)")
def email_brand_context(**extra):
site_url = (getattr(settings, "PUBLIC_SITE_URL", None) or "").rstrip("/")
if not site_url:
site_url = "https://mkdrealtor.com"
logo_path = staticfiles_storage.url("brand/exit_logo.png")
if logo_path.startswith("http://") or logo_path.startswith("https://"):
logo_url = logo_path
else:
logo_url = f"{site_url}{logo_path}"
brand_name = getattr(settings, "SITE_NAME", None) or "Monica Dhillon"
brand_legal = getattr(settings, "CREDIT_NAME", None) or brand_name
tagline = getattr(settings, "SITE_TAGLINE", None) or ""
host_label = site_url.replace("https://", "").replace("http://", "")
return {
"site_url": site_url,
"logo_url": logo_url,
"brand_name": brand_name,
"brand_legal": brand_legal,
"brand_tagline": tagline,
"host_label": host_label,
**extra,
}
def plain_text_to_email_html(text: str) -> str:
"""Escape plain text and turn paragraphs / URLs into simple HTML."""
raw = (text or "").replace("\r\n", "\n").strip()
if not raw:
return ""
blocks: list[str] = []
for para in re.split(r"\n\s*\n", raw):
lines = [html.escape(line) for line in para.split("\n")]
joined = "<br>\n".join(lines)
joined = _URL_RE.sub(
r'<a href="\1" style="color:#00626c;text-decoration:underline;">\1</a>',
joined,
)
blocks.append(
f'<p style="margin:0 0 16px;color:#212121;font-size:15px;'
f'line-height:1.6;">{joined}</p>'
)
return "\n".join(blocks)
+15 -11
View File
@@ -3,10 +3,12 @@
import logging import logging
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMessage from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from django.urls import reverse from django.urls import reverse
from leads.models import Lead from leads.models import Lead
from public.email_branding import email_brand_context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -49,23 +51,25 @@ def notify_admins_of_contact_form(lead: Lead) -> bool:
] ]
address = "\n".join(bit for bit in address_bits if bit) or "(none)" address = "\n".join(bit for bit in address_bits if bit) or "(none)"
body = ( ctx = email_brand_context(
f"New contact form inquiry from {name}.\n\n" name=name,
f"Name: {name}\n" email=email,
f"Email: {email}\n" phone=phone,
f"Phone: {phone}\n" address=address,
f"Address:\n{address}\n\n" message=message,
f"Message:\n{message}\n\n" portal_url=portal_url,
f"View in portal: {portal_url}\n"
) )
text_content = get_template("emails/contact_email.txt").render(ctx)
html_content = get_template("emails/contact_email.html").render(ctx)
mail = EmailMessage( mail = EmailMultiAlternatives(
subject=f"New contact form inquiry from {name}", subject=f"New contact form inquiry from {name}",
body=body, body=text_content,
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email], to=[to_email],
reply_to=[contact.email] if contact.email else None, reply_to=[contact.email] if contact.email else None,
) )
mail.attach_alternative(html_content, "text/html")
try: try:
mail.send(fail_silently=False) mail.send(fail_silently=False)
except Exception: except Exception:
@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{% block title %}{{ brand_name|default:"Monica Dhillon" }}{% endblock %}</title>
<!--[if mso]>
<style type="text/css">
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
</style>
<![endif]-->
<style type="text/css">
body, table, td, a {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
background-color: #f4f7f7;
color: #212121;
font-family: "Work Sans", Poppins, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
a { color: #00626c; }
.email-btn {
display: inline-block;
padding: 12px 24px;
background-color: #00626c;
color: #ffffff !important;
text-decoration: none;
border-radius: 4px;
font-weight: 600;
font-size: 14px;
letter-spacing: 0.3px;
}
.muted { color: #6b7280; font-size: 13px; line-height: 1.5; }
.field-label { color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; margin: 0 0 4px; }
.field-value { color: #212121; font-size: 15px; margin: 0 0 16px; line-height: 1.5; }
</style>
</head>
<body style="margin:0;padding:0;background-color:#f4f7f7;">
{% block preheader %}{% endblock %}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f4f7f7;">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:600px;background-color:#ffffff;border:1px solid #d9e3e4;">
<tr>
<td align="center" style="padding:28px 24px 20px;border-bottom:1px solid #d9e3e4;">
{% if logo_url %}
<a href="{{ site_url|default:'https://mkdrealtor.com' }}" style="text-decoration:none;">
<img src="{{ logo_url }}" alt="{{ brand_name|default:'Monica Dhillon' }} · EXIT Realty" width="160" style="display:block;width:160px;max-width:70%;height:auto;">
</a>
{% else %}
<p style="margin:0;font-size:20px;font-weight:700;letter-spacing:0.5px;color:#00626c;">
{{ brand_name|default:"Monica Dhillon" }}
</p>
{% endif %}
{% if brand_name %}
<p style="margin:12px 0 0;font-size:15px;font-weight:600;color:#212121;">{{ brand_name }}</p>
{% endif %}
{% if brand_tagline %}
<p style="margin:4px 0 0;font-size:12px;color:#6b7280;">{{ brand_tagline }}</p>
{% endif %}
{% block header_extra %}{% endblock %}
</td>
</tr>
<tr>
<td style="height:3px;line-height:3px;font-size:0;background-color:#00626c;">&nbsp;</td>
</tr>
<tr>
<td style="padding:28px 28px 8px;color:#212121;font-size:15px;line-height:1.6;font-family:'Work Sans',Poppins,-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
{% block content %}{% endblock %}
</td>
</tr>
<tr>
<td style="padding:8px 28px 28px;color:#6b7280;font-size:12px;line-height:1.5;text-align:center;border-top:1px solid #d9e3e4;font-family:'Work Sans',Poppins,-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
{% block footer %}
<p style="margin:16px 0 8px;color:#6b7280;">
{% block footer_note %}{% endblock %}
</p>
<p style="margin:0 0 4px;color:#6b7280;">
&copy; {% now "Y" %} {{ brand_name|default:"Monica Dhillon" }}. All rights reserved.
</p>
<p style="margin:0;color:#6b7280;">
<a href="{{ site_url|default:'https://mkdrealtor.com' }}" style="color:#00626c;text-decoration:none;">{{ host_label|default:"mkdrealtor.com" }}</a>
{% if brand_tagline %}
&nbsp;·&nbsp; {{ brand_tagline }}
{% endif %}
</p>
{% endblock %}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,40 @@
{% extends "emails/base_email.html" %}
{% block title %}{{ subject }}{% endblock %}
{% block content %}
<p style="margin:0 0 16px;color:#212121;">Your {{ channel_display }} campaign has finished sending.</p>
<p style="margin:0 0 8px;font-size:18px;font-weight:600;color:#00626c;">{{ campaign_name }}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:20px 0;">
<tr>
<td style="padding:8px 0;border-bottom:1px solid #d9e3e4;color:#6b7280;font-size:13px;">Recipients</td>
<td align="right" style="padding:8px 0;border-bottom:1px solid #d9e3e4;color:#212121;font-size:15px;font-weight:600;">{{ total }}</td>
</tr>
<tr>
<td style="padding:8px 0;border-bottom:1px solid #d9e3e4;color:#6b7280;font-size:13px;">Sent</td>
<td align="right" style="padding:8px 0;border-bottom:1px solid #d9e3e4;color:#212121;font-size:15px;font-weight:600;">{{ sent }}</td>
</tr>
<tr>
<td style="padding:8px 0;border-bottom:1px solid #d9e3e4;color:#6b7280;font-size:13px;">Delivered</td>
<td align="right" style="padding:8px 0;border-bottom:1px solid #d9e3e4;color:#212121;font-size:15px;font-weight:600;">{{ delivered }}</td>
</tr>
<tr>
<td style="padding:8px 0;border-bottom:1px solid #d9e3e4;color:#6b7280;font-size:13px;">Failed / bounced</td>
<td align="right" style="padding:8px 0;border-bottom:1px solid #d9e3e4;color:#212121;font-size:15px;font-weight:600;">{{ failed }}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:13px;">Suppressed</td>
<td align="right" style="padding:8px 0;color:#212121;font-size:15px;font-weight:600;">{{ suppressed }}</td>
</tr>
</table>
{% if report_url %}
<p style="margin:24px 0 0;">
<a class="email-btn" href="{{ report_url }}" style="display:inline-block;padding:12px 24px;background-color:#00626c;color:#ffffff !important;text-decoration:none;border-radius:4px;font-weight:600;font-size:14px;">Open campaign report</a>
</p>
{% endif %}
{% endblock %}
{% block footer_note %}Campaign summary from {{ brand_name|default:"Monica Dhillon" }}.{% endblock %}
@@ -0,0 +1,16 @@
Campaign sent: {{ campaign_name }}
Your {{ channel_display }} campaign "{{ campaign_name }}" has finished sending.
Recipients: {{ total }}
Sent: {{ sent }}
Delivered: {{ delivered }}
Failed / bounced: {{ failed }}
Suppressed: {{ suppressed }}
{% if report_url %}Report: {{ report_url }}
{% endif %}
{{ brand_name|default:"Monica Dhillon" }}
{{ site_url|default:"https://mkdrealtor.com" }}
{% if brand_tagline %}{{ brand_tagline }}{% endif %}
@@ -0,0 +1,33 @@
{% extends "emails/base_email.html" %}
{% block title %}New Contact Request{% endblock %}
{% block content %}
<p style="margin:0 0 16px;color:#212121;">Hello,</p>
<p style="margin:0 0 24px;color:#212121;">A new contact request was submitted on the site.</p>
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Name</p>
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;"><strong>{{ name }}</strong></p>
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Email</p>
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;">
<a href="mailto:{{ email }}" style="color:#00626c;text-decoration:none;">{{ email }}</a>
</p>
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Phone</p>
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;">{{ phone }}</p>
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Address</p>
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;white-space:pre-wrap;">{{ address }}</p>
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Message</p>
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;white-space:pre-wrap;">{{ message }}</p>
{% if portal_url %}
<p style="margin:24px 0 0;">
<a class="email-btn" href="{{ portal_url }}" style="display:inline-block;padding:12px 24px;background-color:#00626c;color:#ffffff !important;text-decoration:none;border-radius:4px;font-weight:600;font-size:14px;">View in portal</a>
</p>
{% endif %}
{% endblock %}
{% block footer_note %}This is an automated message from {{ brand_name|default:"Monica Dhillon" }}.{% endblock %}
@@ -0,0 +1,17 @@
New contact form inquiry — {{ brand_name|default:"Monica Dhillon" }}
Name: {{ name }}
Email: {{ email }}
Phone: {{ phone }}
Address:
{{ address }}
Message:
{{ message }}
{% if portal_url %}View in portal: {{ portal_url }}
{% endif %}
{{ brand_name|default:"Monica Dhillon" }}
{{ site_url|default:"https://mkdrealtor.com" }}
{% if brand_tagline %}{{ brand_tagline }}{% endif %}
@@ -0,0 +1,25 @@
{% extends "emails/base_email.html" %}
{% block title %}{{ title }}{% endblock %}
{% block header_extra %}
{% if title %}
<p style="margin:16px 0 0;font-size:18px;font-weight:600;color:#212121;line-height:1.4;">{{ title }}</p>
{% endif %}
{% endblock %}
{% block content %}
{{ content_html|safe }}
{% endblock %}
{% block footer_note %}
{% if prefs_url or one_click_url %}
{% if prefs_url %}
<a href="{{ prefs_url }}" style="color:#00626c;text-decoration:underline;">Manage preferences</a>
{% endif %}
{% if prefs_url and one_click_url %}&nbsp;·&nbsp;{% endif %}
{% if one_click_url %}
<a href="{{ one_click_url }}" style="color:#00626c;text-decoration:underline;">Unsubscribe from email</a>
{% endif %}
{% endif %}
{% endblock %}
@@ -0,0 +1,10 @@
{% if title %}{{ title }}
{% endif %}{{ content }}
{% if prefs_url %}Manage preferences: {{ prefs_url }}
{% endif %}{% if one_click_url %}Unsubscribe from email: {{ one_click_url }}
{% endif %}{{ brand_name|default:"Monica Dhillon" }}
{{ site_url|default:"https://mkdrealtor.com" }}
{% if brand_tagline %}{{ brand_tagline }}{% endif %}