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 |
`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.
+13 -5
View File
@@ -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
View File
@@ -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)
+8 -1
View File
@@ -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)
+50
View File
@@ -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
View File
@@ -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
View File
@@ -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
+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
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;">&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 %}