Send branded HTML emails and harden SMTP2GO webhooks.
Fix campaign stuck on sending under async queue, and stop UUID ValidationError when SMTP2GO tests send "Headers Unavailable".
This commit is contained in:
@@ -26,7 +26,8 @@ SMTP2GO → **Settings → Webhooks** (email and SMS stay separate).
|
||||
| SMS events | leave unchecked |
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import get_template
|
||||
|
||||
from contacts.models import Channel
|
||||
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.
|
||||
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)
|
||||
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
|
||||
body_with_unsub = (
|
||||
f"{body}\n\n---\n"
|
||||
f"Manage preferences: {prefs_url}\n"
|
||||
f"Unsubscribe from email: {one_click_url}"
|
||||
|
||||
ctx = email_brand_context(
|
||||
title=subject,
|
||||
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(
|
||||
subject=subject,
|
||||
body=body_with_unsub,
|
||||
body=text_content,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[contact.email],
|
||||
headers={
|
||||
@@ -45,6 +52,7 @@ def send_email(message) -> str:
|
||||
MONICA_MESSAGE_HEADER: str(message.pk),
|
||||
},
|
||||
)
|
||||
email.attach_alternative(html_content, "text/html")
|
||||
email.send(fail_silently=False)
|
||||
# Placeholder until SMTP2GO webhook supplies the real email_id.
|
||||
return f"smtp-{message.pk}"
|
||||
|
||||
+51
-17
@@ -256,8 +256,12 @@ def send_campaign_completion_notify(campaign: Campaign) -> bool:
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.db.models import Count, Q
|
||||
from django.template.loader import get_template
|
||||
from django.urls import reverse
|
||||
|
||||
from public.email_branding import email_brand_context
|
||||
|
||||
campaign.refresh_from_db()
|
||||
if campaign.notify_sent_at:
|
||||
return True
|
||||
if campaign.status != Campaign.Status.COMPLETED:
|
||||
@@ -267,6 +271,17 @@ def send_campaign_completion_notify(campaign: Campaign) -> bool:
|
||||
if not to_email:
|
||||
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(
|
||||
sent=Count("id", filter=Q(status=Message.Status.SENT)),
|
||||
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
|
||||
|
||||
subject = f"Campaign sent: {campaign.name}"
|
||||
body = (
|
||||
f"Your {campaign.get_channel_display()} campaign “{campaign.name}” "
|
||||
f"has finished sending.\n\n"
|
||||
f"Recipients: {counts['total']}\n"
|
||||
f"Sent: {counts['sent']}\n"
|
||||
f"Delivered: {counts['delivered']}\n"
|
||||
f"Failed / bounced: {counts['failed']}\n"
|
||||
f"Suppressed: {counts['suppressed']}\n\n"
|
||||
f"Report: {report_url}\n"
|
||||
ctx = email_brand_context(
|
||||
subject=subject,
|
||||
campaign_name=campaign.name,
|
||||
channel_display=campaign.get_channel_display(),
|
||||
total=counts["total"],
|
||||
sent=counts["sent"],
|
||||
delivered=counts["delivered"],
|
||||
failed=counts["failed"],
|
||||
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(
|
||||
subject=subject,
|
||||
body=body,
|
||||
body=text_content,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
email.attach_alternative(html_content, "text/html")
|
||||
try:
|
||||
email.send(fail_silently=False)
|
||||
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(
|
||||
"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
|
||||
|
||||
campaign.notify_sent_at = timezone.now()
|
||||
campaign.save(update_fields=["notify_sent_at", "updated_at"])
|
||||
return True
|
||||
|
||||
|
||||
def refresh_campaign_status(campaign: Campaign) -> Campaign:
|
||||
"""Set campaign to completed when no messages remain pending."""
|
||||
campaign.refresh_from_db()
|
||||
pending = campaign.messages.filter(
|
||||
status__in=[
|
||||
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."""
|
||||
from django.conf import settings
|
||||
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:
|
||||
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():
|
||||
raise ValueError("Campaign has no body.")
|
||||
|
||||
notice = (
|
||||
"This is a test send from the Monica portal. "
|
||||
"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=(
|
||||
f"{body}\n\n---\n"
|
||||
"This is a test send from the Monica portal. "
|
||||
"Recipient list was not notified."
|
||||
),
|
||||
body=text_content,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
email.attach_alternative(html_content, "text/html")
|
||||
email.send(fail_silently=False)
|
||||
@@ -3,11 +3,12 @@ from django.utils import timezone
|
||||
|
||||
from messaging.channels import dispatch_message
|
||||
from messaging.models import Message
|
||||
from messaging.services import contact_may_receive
|
||||
from messaging.services import contact_may_receive, refresh_campaign_status
|
||||
|
||||
|
||||
@task
|
||||
def send_campaign_message(message_id: str) -> None:
|
||||
campaign = None
|
||||
try:
|
||||
message = Message.objects.select_related("contact", "campaign").get(
|
||||
pk=message_id
|
||||
@@ -15,10 +16,13 @@ def send_campaign_message(message_id: str) -> None:
|
||||
except Message.DoesNotExist:
|
||||
return
|
||||
|
||||
campaign = message.campaign
|
||||
|
||||
if not contact_may_receive(message.contact, message.channel):
|
||||
message.status = Message.Status.SUPPRESSED
|
||||
message.error = "Contact opted out or suppressed"
|
||||
message.save(update_fields=["status", "error", "updated_at"])
|
||||
refresh_campaign_status(campaign)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -42,4 +46,7 @@ def send_campaign_message(message_id: str) -> None:
|
||||
message.status = Message.Status.FAILED
|
||||
message.error = str(exc)[:2000]
|
||||
message.save(update_fields=["status", "error", "updated_at"])
|
||||
refresh_campaign_status(campaign)
|
||||
raise
|
||||
|
||||
refresh_campaign_status(campaign)
|
||||
|
||||
@@ -271,6 +271,8 @@ class CampaignSendTests(TestCase):
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].to, ["me@example.com"])
|
||||
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
|
||||
self.assertEqual(
|
||||
self.campaign.messages.filter(status=Message.Status.DRAFT).count(), 1
|
||||
@@ -300,6 +302,10 @@ class CampaignSendTests(TestCase):
|
||||
summary = mail.outbox[1]
|
||||
self.assertIn("Campaign sent:", summary.subject)
|
||||
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):
|
||||
@@ -443,6 +449,50 @@ class Smtp2goEmailWebhookTests(TestCase):
|
||||
)
|
||||
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):
|
||||
def setUp(self):
|
||||
|
||||
+13
-1
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@@ -38,6 +39,8 @@ from messaging.webhooks import (
|
||||
process_smtp2go_sms_webhook,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _audience_choices() -> list[tuple[str, str]]:
|
||||
"""Labeled audience options with live opted-in counts."""
|
||||
@@ -256,6 +259,11 @@ def campaign_detail(request, pk):
|
||||
def campaign_status_json(request, pk):
|
||||
"""JSON snapshot for live-updating the campaign report page."""
|
||||
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)
|
||||
return JsonResponse(
|
||||
{
|
||||
@@ -580,7 +588,11 @@ def email_webhook(request):
|
||||
):
|
||||
return HttpResponseForbidden("invalid webhook token")
|
||||
payload = parse_webhook_payload(request)
|
||||
event = process_smtp2go_email_webhook(payload)
|
||||
try:
|
||||
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(
|
||||
{
|
||||
"ok": True,
|
||||
|
||||
+92
-27
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import HttpRequest
|
||||
|
||||
from contacts.models import Channel, Contact
|
||||
@@ -38,6 +40,45 @@ _MONICA_HEADER_KEYS = (
|
||||
"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]:
|
||||
"""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:
|
||||
try:
|
||||
data = json.loads(request.body.decode() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
# 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:
|
||||
"""Pull our correlation id from flat keys or a nested headers object."""
|
||||
for key in _MONICA_HEADER_KEYS:
|
||||
value = payload.get(key)
|
||||
value = _as_str(payload.get(key))
|
||||
if value:
|
||||
return str(value).strip()
|
||||
return value
|
||||
|
||||
headers = payload.get("headers") or payload.get("email_headers") or {}
|
||||
if isinstance(headers, dict):
|
||||
for key in _MONICA_HEADER_KEYS:
|
||||
value = headers.get(key)
|
||||
value = _as_str(headers.get(key))
|
||||
if value:
|
||||
return str(value).strip()
|
||||
return value
|
||||
# Case-insensitive scan
|
||||
lower_map = {str(k).lower(): v for k, v in headers.items()}
|
||||
for key in _MONICA_HEADER_KEYS:
|
||||
value = lower_map.get(key.lower())
|
||||
value = _as_str(lower_map.get(key.lower()))
|
||||
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 ""
|
||||
|
||||
|
||||
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:
|
||||
monica_id = extract_monica_message_id(payload)
|
||||
if monica_id:
|
||||
message = (
|
||||
Message.objects.select_related("contact", "campaign")
|
||||
.filter(pk=monica_id)
|
||||
.first()
|
||||
)
|
||||
message = _message_by_pk(monica_id)
|
||||
if 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:
|
||||
message = (
|
||||
Message.objects.select_related("contact", "campaign")
|
||||
@@ -95,13 +164,13 @@ def find_message_for_email_event(payload: dict[str, Any]) -> Message | None:
|
||||
if message:
|
||||
return message
|
||||
|
||||
rcpt = (payload.get("rcpt") or "").strip().lower()
|
||||
rcpt = _as_str(payload.get("rcpt")).lower()
|
||||
if not rcpt:
|
||||
recipients = payload.get("recipients")
|
||||
if isinstance(recipients, str) and recipients.strip():
|
||||
rcpt = recipients.split(",")[0].strip().lower()
|
||||
elif isinstance(recipients, list) and recipients:
|
||||
rcpt = str(recipients[0]).strip().lower()
|
||||
rcpt = _as_str(recipients[0]).lower()
|
||||
|
||||
if not rcpt:
|
||||
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:
|
||||
event = (event or "").strip().lower()
|
||||
bounce_kind = (payload.get("bounce") or "").strip().lower()
|
||||
err = (payload.get("message") or payload.get("context") or "").strip()
|
||||
event = _normalize_email_event(event)
|
||||
bounce_kind = _as_str(payload.get("bounce")).lower()
|
||||
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:
|
||||
message.provider_message_id = email_id
|
||||
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).
|
||||
"""
|
||||
event = (payload.get("event") or "").strip().lower()
|
||||
event = _normalize_email_event(_as_str(payload.get("event")))
|
||||
if not event:
|
||||
logger.warning("SMTP2GO webhook missing event: %s", payload)
|
||||
return None
|
||||
@@ -253,8 +322,8 @@ def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | No
|
||||
return ProviderEvent.objects.create(
|
||||
message=message,
|
||||
provider=PROVIDER_EMAIL,
|
||||
event_type=event,
|
||||
payload=payload,
|
||||
event_type=event[:64],
|
||||
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 = str(ext).strip()
|
||||
if ext:
|
||||
message = (
|
||||
Message.objects.select_related("contact", "campaign")
|
||||
.filter(pk=ext)
|
||||
.first()
|
||||
)
|
||||
message = _message_by_pk(ext)
|
||||
if message:
|
||||
return message
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -3,10 +3,12 @@
|
||||
import logging
|
||||
|
||||
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 leads.models import Lead
|
||||
from public.email_branding import email_brand_context
|
||||
|
||||
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)"
|
||||
|
||||
body = (
|
||||
f"New contact form inquiry from {name}.\n\n"
|
||||
f"Name: {name}\n"
|
||||
f"Email: {email}\n"
|
||||
f"Phone: {phone}\n"
|
||||
f"Address:\n{address}\n\n"
|
||||
f"Message:\n{message}\n\n"
|
||||
f"View in portal: {portal_url}\n"
|
||||
ctx = email_brand_context(
|
||||
name=name,
|
||||
email=email,
|
||||
phone=phone,
|
||||
address=address,
|
||||
message=message,
|
||||
portal_url=portal_url,
|
||||
)
|
||||
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}",
|
||||
body=body,
|
||||
body=text_content,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
reply_to=[contact.email] if contact.email else None,
|
||||
)
|
||||
mail.attach_alternative(html_content, "text/html")
|
||||
try:
|
||||
mail.send(fail_silently=False)
|
||||
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;"> </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;">
|
||||
© {% 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 %}
|
||||
· {{ 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 %} · {% 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 %}
|
||||
Reference in New Issue
Block a user