diff --git a/site/messaging/README.md b/site/messaging/README.md index 857b346..e04050c 100644 --- a/site/messaging/README.md +++ b/site/messaging/README.md @@ -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. diff --git a/site/messaging/providers/email/smtp2go.py b/site/messaging/providers/email/smtp2go.py index 3edd3b9..08c2fd9 100644 --- a/site/messaging/providers/email/smtp2go.py +++ b/site/messaging/providers/email/smtp2go.py @@ -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}" diff --git a/site/messaging/services.py b/site/messaging/services.py index 6b60428..2d05357 100644 --- a/site/messaging/services.py +++ b/site/messaging/services.py @@ -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'
{notice}
' + ), + ) + 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) \ No newline at end of file diff --git a/site/messaging/tasks.py b/site/messaging/tasks.py index 15206ba..61a9af4 100644 --- a/site/messaging/tasks.py +++ b/site/messaging/tasks.py @@ -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) diff --git a/site/messaging/tests.py b/site/messaging/tests.py index 7c6db4b..a012970 100644 --- a/site/messaging/tests.py +++ b/site/messaging/tests.py @@ -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): diff --git a/site/messaging/views.py b/site/messaging/views.py index bdc8b70..ca35c89 100644 --- a/site/messaging/views.py +++ b/site/messaging/views.py @@ -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, diff --git a/site/messaging/webhooks.py b/site/messaging/webhooks.py index 627e5df..ffdff72 100644 --- a/site/messaging/webhooks.py +++ b/site/messaging/webhooks.py @@ -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 diff --git a/site/public/email_branding.py b/site/public/email_branding.py new file mode 100644 index 0000000..267b310 --- /dev/null +++ b/site/public/email_branding.py @@ -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 = "{joined}
' + ) + return "\n".join(blocks) diff --git a/site/public/notifications.py b/site/public/notifications.py index 323bd71..b8a5352 100644 --- a/site/public/notifications.py +++ b/site/public/notifications.py @@ -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: diff --git a/site/public/templates/emails/base_email.html b/site/public/templates/emails/base_email.html new file mode 100644 index 0000000..ed541c0 --- /dev/null +++ b/site/public/templates/emails/base_email.html @@ -0,0 +1,113 @@ + + + + + + + +
+
|
+
Your {{ channel_display }} campaign has finished sending.
+ +{{ campaign_name }}
+ +| Recipients | +{{ total }} | +
| Sent | +{{ sent }} | +
| Delivered | +{{ delivered }} | +
| Failed / bounced | +{{ failed }} | +
| Suppressed | +{{ suppressed }} | +
Hello,
+A new contact request was submitted on the site.
+ +Name
+{{ name }}
+ ++ {{ email }} +
+ +Phone
+{{ phone }}
+ +Address
+{{ address }}
+ +Message
+{{ message }}
+ +{% if portal_url %} ++ View in portal +
+{% endif %} +{% endblock %} + +{% block footer_note %}This is an automated message from {{ brand_name|default:"Monica Dhillon" }}.{% endblock %} diff --git a/site/public/templates/emails/contact_email.txt b/site/public/templates/emails/contact_email.txt new file mode 100644 index 0000000..ae466b1 --- /dev/null +++ b/site/public/templates/emails/contact_email.txt @@ -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 %} diff --git a/site/public/templates/emails/marketing_email.html b/site/public/templates/emails/marketing_email.html new file mode 100644 index 0000000..a553127 --- /dev/null +++ b/site/public/templates/emails/marketing_email.html @@ -0,0 +1,25 @@ +{% extends "emails/base_email.html" %} + +{% block title %}{{ title }}{% endblock %} + +{% block header_extra %} +{% if title %} +{{ title }}
+{% endif %} +{% endblock %} + +{% block content %} +{{ content_html|safe }} +{% endblock %} + +{% block footer_note %} +{% if prefs_url or one_click_url %} +{% if prefs_url %} +Manage preferences +{% endif %} +{% if prefs_url and one_click_url %} · {% endif %} +{% if one_click_url %} +Unsubscribe from email +{% endif %} +{% endif %} +{% endblock %} diff --git a/site/public/templates/emails/marketing_email.txt b/site/public/templates/emails/marketing_email.txt new file mode 100644 index 0000000..5f0e874 --- /dev/null +++ b/site/public/templates/emails/marketing_email.txt @@ -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 %}