Initial commit

This commit is contained in:
ai_ml_operations
2026-09-06 04:27:41 -07:00
commit 8a97e3fbe2
302 changed files with 34038 additions and 0 deletions
View File
+40
View File
@@ -0,0 +1,40 @@
from django.contrib import admin
from email_sms.models import Campaign, Message, MessageTemplate, ProviderEvent
@admin.register(MessageTemplate)
class MessageTemplateAdmin(admin.ModelAdmin):
list_display = ("name", "channel", "created_at")
list_filter = ("channel",)
class MessageInline(admin.TabularInline):
model = Message
extra = 0
readonly_fields = ("status", "provider", "provider_message_id", "sent_at")
@admin.register(Campaign)
class CampaignAdmin(admin.ModelAdmin):
list_display = (
"name",
"channel",
"audience",
"status",
"scheduled_for",
"created_at",
)
list_filter = ("channel", "audience", "status")
inlines = [MessageInline]
@admin.register(Message)
class MessageAdmin(admin.ModelAdmin):
list_display = ("campaign", "contact", "channel", "status", "scheduled_for")
list_filter = ("channel", "status")
@admin.register(ProviderEvent)
class ProviderEventAdmin(admin.ModelAdmin):
list_display = ("provider", "event_type", "created_at")
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class EmailSmsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "email_sms"
verbose_name = "Email & SMS"
def ready(self):
from email_sms import hooks
hooks.register()
+24
View File
@@ -0,0 +1,24 @@
"""Channel dispatch — email / SMS."""
from dataclasses import dataclass
from contacts.models import Channel
from email_sms.models import Message
from email_sms.providers.email.smtp2go import send_email
from email_sms.providers.sms.smtp2go import send_sms
@dataclass
class ProviderResult:
provider: str
provider_id: str
def dispatch_message(message: Message) -> ProviderResult:
if message.channel == Channel.EMAIL:
result = send_email(message)
return ProviderResult(provider="smtp2go_email", provider_id=result)
if message.channel == Channel.SMS:
result = send_sms(message)
return ProviderResult(provider="smtp2go_sms", provider_id=result)
raise ValueError(f"Unsupported channel: {message.channel}")
+49
View File
@@ -0,0 +1,49 @@
"""Register portal nav, dashboard widgets, and due-work dispatch."""
from django.utils import timezone
from core.registry import (
register_dashboard_collector,
register_dispatcher,
register_feature,
register_portal_nav,
)
def register() -> None:
register_feature("email_sms")
register_portal_nav(
section="campaigns",
label="Campaigns",
url_name="email_sms:campaign_list",
group="Outreach",
order=20,
)
register_dashboard_collector(_dashboard)
register_dispatcher(_dispatch_due)
def _dashboard(request) -> dict:
from email_sms.models import Campaign
upcoming = Campaign.objects.exclude(
status__in=[Campaign.Status.COMPLETED, Campaign.Status.CANCELLED]
).order_by("scheduled_for", "-created_at")[:5]
return {"upcoming_campaigns": upcoming}
def _dispatch_due() -> int:
from email_sms.models import Message
from email_sms.tasks import send_campaign_message
now = timezone.now()
enqueued = 0
for message in Message.objects.filter(
status=Message.Status.SCHEDULED,
scheduled_for__lte=now,
).iterator():
message.status = Message.Status.QUEUED
message.save(update_fields=["status", "updated_at"])
send_campaign_message.enqueue(message_id=str(message.pk))
enqueued += 1
return enqueued
+91
View File
@@ -0,0 +1,91 @@
# Generated by Django 6.1 on 2026-08-26 11:38
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contacts', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MessageTemplate',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=120)),
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
('subject', models.CharField(blank=True, max_length=255)),
('body', models.TextField()),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Campaign',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=120)),
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
('audience', models.CharField(blank=True, choices=[('email_opt_in', 'Mailing list · email opt-in'), ('sms_opt_in', 'Mailing list · SMS opt-in')], default='', max_length=32)),
('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('sending', 'Sending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
('scheduled_for', models.DateTimeField(blank=True, null=True)),
('subject_override', models.CharField(blank=True, max_length=255)),
('body_override', models.TextField(blank=True)),
('notify_sent_at', models.DateTimeField(blank=True, null=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_sms_campaigns_created', to=settings.AUTH_USER_MODEL)),
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_sms_campaigns', to='email_sms.messagetemplate')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Message',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('queued', 'Queued'), ('sent', 'Sent'), ('delivered', 'Delivered'), ('opened', 'Opened'), ('clicked', 'Clicked'), ('failed', 'Failed'), ('bounced', 'Bounced'), ('suppressed', 'Suppressed')], default='draft', max_length=16)),
('provider', models.CharField(blank=True, max_length=64)),
('provider_message_id', models.CharField(blank=True, max_length=255)),
('scheduled_for', models.DateTimeField(blank=True, null=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('error', models.TextField(blank=True)),
('body_snapshot', models.TextField(blank=True)),
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='email_sms.campaign')),
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_sms_messages', to='contacts.contact')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ProviderEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('provider', models.CharField(max_length=64)),
('event_type', models.CharField(max_length=64)),
('payload', models.JSONField(blank=True, default=dict)),
('message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='email_sms.message')),
],
options={
'abstract': False,
},
),
]
+117
View File
@@ -0,0 +1,117 @@
from django.conf import settings
from django.db import models
from contacts.models import Channel, Contact
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class MessageTemplate(UUIDPrimaryKeyModel, TimeStampedModel):
name = models.CharField(max_length=120)
channel = models.CharField(max_length=16, choices=Channel.choices)
subject = models.CharField(max_length=255, blank=True)
body = models.TextField()
def __str__(self) -> str:
return f"{self.name} ({self.channel})"
class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
SCHEDULED = "scheduled", "Scheduled"
SENDING = "sending", "Sending"
COMPLETED = "completed", "Completed"
CANCELLED = "cancelled", "Cancelled"
class Audience(models.TextChoices):
EMAIL_OPT_IN = "email_opt_in", "Mailing list · email opt-in"
SMS_OPT_IN = "sms_opt_in", "Mailing list · SMS opt-in"
name = models.CharField(max_length=120)
channel = models.CharField(max_length=16, choices=Channel.choices)
audience = models.CharField(
max_length=32,
choices=Audience.choices,
blank=True,
default="",
)
template = models.ForeignKey(
MessageTemplate,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="email_sms_campaigns",
)
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.DRAFT
)
scheduled_for = models.DateTimeField(null=True, blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="email_sms_campaigns_created",
)
subject_override = models.CharField(max_length=255, blank=True)
body_override = models.TextField(blank=True)
# Set when completion summary email is sent (campaign COMPLETED).
notify_sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return self.name
class Message(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
SCHEDULED = "scheduled", "Scheduled"
QUEUED = "queued", "Queued"
SENT = "sent", "Sent"
DELIVERED = "delivered", "Delivered"
OPENED = "opened", "Opened"
CLICKED = "clicked", "Clicked"
FAILED = "failed", "Failed"
BOUNCED = "bounced", "Bounced"
SUPPRESSED = "suppressed", "Suppressed"
campaign = models.ForeignKey(
Campaign, on_delete=models.CASCADE, related_name="messages"
)
contact = models.ForeignKey(
Contact, on_delete=models.CASCADE, related_name="email_sms_messages"
)
channel = models.CharField(max_length=16, choices=Channel.choices)
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.DRAFT
)
provider = models.CharField(max_length=64, blank=True)
provider_message_id = models.CharField(max_length=255, blank=True)
scheduled_for = models.DateTimeField(null=True, blank=True)
sent_at = models.DateTimeField(null=True, blank=True)
error = models.TextField(blank=True)
body_snapshot = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.channel}{self.contact} ({self.status})"
class ProviderEvent(TimeStampedModel):
message = models.ForeignKey(
Message,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="events",
)
provider = models.CharField(max_length=64)
event_type = models.CharField(max_length=64)
payload = models.JSONField(default=dict, blank=True)
+64
View File
@@ -0,0 +1,64 @@
"""SMTP2GO email via Django's SMTP backend (mail.smtp2go.com)."""
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from contacts.models import Channel
from email_sms.services import one_click_unsubscribe_url, preferences_url, render_merge_tags
from public.email_branding import (
campaign_body_to_email_html,
campaign_body_to_plain_text,
email_brand_context,
)
# Reported back on SMTP2GO webhooks when this header is selected in webhook settings.
MONICA_MESSAGE_HEADER = "X-Monica-Message-Id"
def send_email(message) -> str:
contact = message.contact
if not contact.email:
raise ValueError("Contact has no email address")
campaign = message.campaign
subject = campaign.subject_override or (
campaign.template.subject if campaign.template else f"Message from {settings.SITE_NAME}"
)
body = message.body_snapshot or campaign.body_override or (
campaign.template.body if campaign.template else ""
)
subject = render_merge_tags(subject, contact)
body = render_merge_tags(body, contact)
site = (settings.PUBLIC_SITE_URL or "").rstrip("/")
prefs_path = preferences_url(str(contact.pk), Channel.EMAIL)
one_click_path = one_click_unsubscribe_url(str(contact.pk), Channel.EMAIL)
prefs_url = f"{site}{prefs_path}" if site else prefs_path
one_click_url = f"{site}{one_click_path}" if site else one_click_path
ctx = email_brand_context(
title=subject,
content=campaign_body_to_plain_text(body),
content_html=campaign_body_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=text_content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[contact.email],
headers={
"List-Unsubscribe": f"<{one_click_url}>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
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}"
+90
View File
@@ -0,0 +1,90 @@
"""SMTP2GO SMS REST API."""
import logging
import re
import requests
from django.conf import settings
from email_sms.services import render_merge_tags
logger = logging.getLogger(__name__)
def _format_destination(phone: str) -> str:
"""Normalize stored phone to E.164-ish string SMTP2GO accepts."""
digits = re.sub(r"\D", "", phone or "")
if not digits:
raise ValueError("Contact has no phone number")
if phone.strip().startswith("+") and digits:
return f"+{digits}"
# US 10-digit local numbers → +1…
if len(digits) == 10:
return f"+1{digits}"
if len(digits) == 11 and digits.startswith("1"):
return f"+{digits}"
return f"+{digits}"
def _provider_error_detail(response: requests.Response) -> str:
"""Prefer SMTP2GO JSON error text over bare HTTP reason."""
try:
data = response.json()
except ValueError:
text = (response.text or "").strip()
return text[:500] if text else response.reason
nested = data.get("data") if isinstance(data.get("data"), dict) else {}
err = nested.get("error") or data.get("error") or ""
code = nested.get("error_code") or data.get("error_code") or ""
if err and code:
return f"{err} ({code})"
return str(err or code or response.reason)
def send_sms(message) -> str:
contact = message.contact
if not contact.phone:
raise ValueError("Contact has no phone number")
api_key = settings.SMTP2GO_SMS_API_KEY
if not api_key:
raise RuntimeError("SMTP2GO_SMS_API_KEY is not configured")
campaign = message.campaign
body = message.body_snapshot or campaign.body_override or (
campaign.template.body if campaign.template else ""
)
body = render_merge_tags(body, contact)
destination = _format_destination(contact.phone)
# Current SMTP2GO /v3/sms/send schema: destination[] + content.
payload = {
"api_key": api_key,
"destination": [destination],
"content": body[:1600],
}
response = requests.post(
settings.SMTP2GO_SMS_API_URL,
json=payload,
timeout=30,
)
if not response.ok:
detail = _provider_error_detail(response)
raise requests.HTTPError(
f"{response.status_code} Client Error: {detail} for url: {response.url}",
response=response,
)
data = response.json() if response.content else {}
nested = data.get("data") if isinstance(data.get("data"), dict) else {}
messages = nested.get("messages") if isinstance(nested.get("messages"), list) else []
first = messages[0] if messages and isinstance(messages[0], dict) else {}
return str(
first.get("message_id")
or nested.get("sms_id")
or nested.get("message_id")
or data.get("sms_id")
or data.get("message_id")
or data.get("request_id")
or f"sms-{message.pk}"
)
+373
View File
@@ -0,0 +1,373 @@
"""Campaign draft/send helpers for email and SMS."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from django.urls import reverse
from django.utils import timezone
from contacts.consent import ( # noqa: F401 — re-export for tests + providers
channel_preferences,
contact_may_receive,
make_unsubscribe_token,
one_click_unsubscribe_url,
opted_in_contacts,
parse_unsubscribe_token,
preferences_url,
process_unsubscribe_token,
record_sms_stop,
set_channel_consent,
set_channel_preferences,
unsubscribe_all,
)
from contacts.models import Channel, Contact
from core.campaign_utm import ensure_campaign_utm_link
from core.scheduling import parse_scheduled_for
from email_sms.models import Campaign, Message, MessageTemplate
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractBaseUser
AUDIENCE_CHANNEL = {
Campaign.Audience.EMAIL_OPT_IN: Channel.EMAIL,
Campaign.Audience.SMS_OPT_IN: Channel.SMS,
}
# {{first_name}} preferred; {first_name} also accepted (composer hint legacy).
_MERGE_TAG_RE = re.compile(
r"\{\{\s*(first_name|last_name|email|phone|full_name)\s*\}\}"
r"|\{\s*(first_name|last_name|email|phone|full_name)\s*\}",
re.IGNORECASE,
)
REMOVABLE_MESSAGE_STATUSES = frozenset(
{
Message.Status.DRAFT,
Message.Status.SCHEDULED,
Message.Status.FAILED,
}
)
def render_merge_tags(text: str, contact: Contact | None) -> str:
"""Replace personalization tags with contact field values."""
if not text:
return text or ""
first = (getattr(contact, "first_name", None) or "").strip() if contact else ""
last = (getattr(contact, "last_name", None) or "").strip() if contact else ""
email = (getattr(contact, "email", None) or "").strip() if contact else ""
phone = (getattr(contact, "phone", None) or "").strip() if contact else ""
full = f"{first} {last}".strip()
values = {
"first_name": first,
"last_name": last,
"email": email,
"phone": phone,
"full_name": full,
}
def _replace(match: re.Match[str]) -> str:
key = (match.group(1) or match.group(2) or "").lower()
return values.get(key, "")
return _MERGE_TAG_RE.sub(_replace, text)
def message_is_removable(message: Message) -> bool:
return message.status in REMOVABLE_MESSAGE_STATUSES
def channel_for_audience(audience: str) -> str:
try:
return AUDIENCE_CHANNEL[audience]
except KeyError as exc:
raise ValueError(f"Unknown audience: {audience}") from exc
def create_campaign_draft(
*,
name: str,
audience: str,
subject: str = "",
body: str = "",
scheduled_for=None,
created_by: AbstractBaseUser | None = None,
template: MessageTemplate | None = None,
) -> Campaign:
"""Persist a draft campaign and per-recipient Message stubs."""
channel = channel_for_audience(audience)
campaign = Campaign.objects.create(
name=name,
channel=channel,
audience=audience,
status=Campaign.Status.DRAFT,
scheduled_for=scheduled_for,
subject_override=subject,
body_override=body,
created_by=created_by,
template=template,
)
if channel in (Channel.EMAIL, Channel.SMS):
body = ensure_campaign_utm_link(
body,
name=name,
medium=channel,
html=(channel == Channel.EMAIL),
campaign_id=campaign.pk,
)
if body != campaign.body_override:
campaign.body_override = body
campaign.save(update_fields=["body_override", "updated_at"])
contacts = list(opted_in_contacts(channel))
Message.objects.bulk_create(
[
Message(
campaign=campaign,
contact=contact,
channel=channel,
status=Message.Status.DRAFT,
scheduled_for=scheduled_for,
body_snapshot=body,
)
for contact in contacts
]
)
return campaign
def campaign_notify_recipient(campaign: Campaign) -> str:
"""Email address for the realtor summary (created_by, else CONTACT_EMAIL)."""
from django.conf import settings
user = campaign.created_by
if user is not None:
email = (getattr(user, "email", None) or "").strip()
if email:
return email
return (settings.CONTACT_EMAIL or "").strip()
def send_campaign_completion_notify(campaign: Campaign) -> bool:
"""
One-shot summary email when a campaign finishes sending.
Returns True if mail was sent (or already sent earlier).
"""
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:
return False
to_email = campaign_notify_recipient(campaign)
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__in=[
Message.Status.SENT,
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
]
),
),
delivered=Count(
"id",
filter=Q(
status__in=[
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
]
),
),
failed=Count(
"id",
filter=Q(
status__in=[
Message.Status.FAILED,
Message.Status.BOUNCED,
]
),
),
suppressed=Count("id", filter=Q(status=Message.Status.SUPPRESSED)),
total=Count("id"),
)
report_path = reverse("email_sms:campaign_detail", kwargs={"pk": campaign.pk})
public = (settings.PUBLIC_SITE_URL or "").rstrip("/")
report_url = f"{public}{report_path}" if public else report_path
subject = f"Campaign sent: {campaign.name}"
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=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
import logging
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
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,
Message.Status.SCHEDULED,
Message.Status.QUEUED,
]
).exists()
if pending:
return campaign
if campaign.status == Campaign.Status.SENDING:
campaign.status = Campaign.Status.COMPLETED
campaign.save(update_fields=["status", "updated_at"])
send_campaign_completion_notify(campaign)
return campaign
def enqueue_campaign_send(campaign: Campaign) -> int:
"""
Queue draft/scheduled/failed messages for send.
Dev uses ImmediateBackend → each enqueue runs inline via SMTP/console.
"""
from email_sms.tasks import send_campaign_message
sendable = list(
campaign.messages.filter(
status__in=[
Message.Status.DRAFT,
Message.Status.SCHEDULED,
Message.Status.FAILED,
]
)
)
if not sendable:
return 0
campaign.status = Campaign.Status.SENDING
campaign.save(update_fields=["status", "updated_at"])
enqueued = 0
for message in sendable:
message.status = Message.Status.QUEUED
message.save(update_fields=["status", "updated_at"])
try:
send_campaign_message.enqueue(message_id=str(message.pk))
except Exception: # noqa: BLE001 — task already persisted FAILED
pass
enqueued += 1
refresh_campaign_status(campaign)
return enqueued
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 (
campaign_body_to_email_html,
campaign_body_to_plain_text,
email_brand_context,
)
if campaign.channel != Channel.EMAIL:
raise ValueError("Test send is only available for email campaigns.")
subject = campaign.subject_override or (
campaign.template.subject if campaign.template_id else f"Message from {settings.SITE_NAME}"
)
body = campaign.body_override or (
campaign.template.body if campaign.template_id else ""
)
if not subject.strip():
raise ValueError("Campaign has no subject.")
if not body.strip():
raise ValueError("Campaign has no body.")
# Preview merge tags using first recipient when available.
sample = (
campaign.messages.select_related("contact")
.order_by("created_at")
.first()
)
sample_contact = sample.contact if sample else None
subject = render_merge_tags(subject, sample_contact)
body = render_merge_tags(body, sample_contact)
notice = (
"This is a test send from the portal. "
"Recipient list was not notified."
)
ctx = email_brand_context(
title=f"[TEST] {subject}",
content=f"{campaign_body_to_plain_text(body)}\n\n{notice}",
content_html=(
f"{campaign_body_to_email_html(body)}"
f'<p style="margin:24px 0 0;color:#6b7280;font-size:13px;">{notice}</p>'
),
)
text_content = get_template("emails/marketing_email.txt").render(ctx)
html_content = get_template("emails/marketing_email.html").render(ctx)
email = EmailMultiAlternatives(
subject=f"[TEST] {subject}",
body=text_content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email],
)
email.attach_alternative(html_content, "text/html")
email.send(fail_silently=False)
+52
View File
@@ -0,0 +1,52 @@
from django.tasks import task
from django.utils import timezone
from email_sms.channels import dispatch_message
from email_sms.models import Message
from email_sms.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
)
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:
result = dispatch_message(message)
message.status = Message.Status.SENT
message.provider = result.provider
message.provider_message_id = result.provider_id
message.sent_at = timezone.now()
message.error = ""
message.save(
update_fields=[
"status",
"provider",
"provider_message_id",
"sent_at",
"error",
"updated_at",
]
)
except Exception as exc: # noqa: BLE001 — persist provider failures
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)
@@ -0,0 +1,310 @@
{% extends "portal_base.html" %}
{% load static %}
{% block title %}{{ campaign.name }} · Campaign{% endblock %}
{% block topbar_title %}{{ campaign.name }}{% endblock %}
{% block portal_content %}
<div class="toolbar">
<div>
<span class="badge badge-{{ campaign.status }}" id="campaign-status-badge">{{ campaign.get_status_display }}</span>
<span class="muted" style="margin-left:8px">{{ campaign.get_channel_display }}</span>
{% if campaign.scheduled_for %}
<span class="muted" style="margin-left:8px">Scheduled {{ campaign.scheduled_for|date:"M j, g:i A" }}</span>
{% endif %}
<span class="muted" style="margin-left:8px" id="live-hint">Live · updates every 10s</span>
</div>
<a class="btn btn-sm btn-ghost" href="{% url 'email_sms:campaign_list' %}">← Campaigns</a>
</div>
{% if campaign.channel == "email" or can_send %}
<div class="panel" style="margin-bottom:16px">
<div class="panel-h"><h2>Send</h2></div>
<div class="panel-b form-grid">
{% if campaign.channel == "email" %}
<form method="post" action="{% url 'email_sms:campaign_test_send' campaign.pk %}" class="form-grid cols-2" style="align-items:end">
{% csrf_token %}
<div class="field">
<label for="id_test_email">Test send (your inbox)</label>
<input id="id_test_email" name="test_email" type="email" required
placeholder="you@example.com"
value="{{ request.user.email }}">
<div class="hint">Sends one [TEST] copy. Does not notify the recipient list.</div>
</div>
<div class="field">
<button class="btn btn-ghost" type="submit">Send test email</button>
</div>
</form>
{% endif %}
{% if can_send %}
<form method="post" action="{% url 'email_sms:campaign_send' campaign.pk %}"
onsubmit="return confirm('Send this campaign to all remaining recipients now?');">
{% csrf_token %}
<button class="btn btn-primary" type="submit">Send now to recipients</button>
<p class="hint-block" style="margin-top:8px">
{% if campaign.channel == "postcard" %}
Enqueues draft / scheduled / failed messages via PCM Integrations.
{% else %}
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
{% endif %}
</p>
</form>
{% endif %}
</div>
</div>
{% endif %}
<div class="panel" style="margin-bottom:16px">
<div class="panel-h"><h2>Tracked site link</h2></div>
<div class="panel-b">
{% include "core/_utm_link_panel.html" with utm_live=False utm_medium=campaign.channel utm_campaign_name=campaign.name %}
</div>
</div>
<div class="stat-row" id="campaign-stats">
<div class="stat-card">
<div class="label">Messages</div>
<div class="value" data-stat="total">{{ stats.total }}</div>
</div>
<div class="stat-card">
<div class="label">Sent</div>
<div class="value" data-stat="sent">{{ stats.sent }}</div>
</div>
<div class="stat-card">
<div class="label">Delivered</div>
<div class="value" data-stat="delivered">{{ stats.delivered }}</div>
</div>
{% if campaign.channel == "email" %}
<div class="stat-card">
<div class="label">Opens</div>
<div class="value" data-stat="opens">{{ stats.opens }}</div>
</div>
<div class="stat-card">
<div class="label">Clicks</div>
<div class="value" data-stat="clicks">{{ stats.clicks }}</div>
</div>
{% endif %}
<div class="stat-card">
<div class="label">Bounced / failed</div>
<div class="value" data-stat="failed">{{ stats.failed }}</div>
</div>
<div class="stat-card">
<div class="label">Suppressed</div>
<div class="value" data-stat="suppressed">{{ stats.suppressed }}</div>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel-h"><h2>Engagement</h2></div>
<div class="panel-b">
{% if campaign.channel == "email" %}
<p class="hint-block" style="margin-top:0">
Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened ·
<strong data-stat="clicks">{{ stats.clicks }}</strong> clicked
(<span data-stat="open_events">{{ stats.open_events }}</span> open events /
<span data-stat="click_events">{{ stats.click_events }}</span> click events from SMTP2GO).
</p>
{% elif campaign.channel == "sms" %}
<p class="hint-block" style="margin-top:0">
Delivery status updates from SMTP2GO SMS webhooks.
</p>
{% else %}
<p class="hint-block" style="margin-top:0">
Postcard status updates from PCM Integrations webhooks.
</p>
{% endif %}
<div class="chart-placeholder" id="engagement-chart" role="img"
aria-label="Campaign engagement chart">
{% for bar in stats.chart_bars %}
<div class="chart-bar-col">
<div class="bar" style="height:{{ bar.pct }}%"
title="{{ bar.label }}: {{ bar.value }}"
data-bar-label="{{ bar.label }}"></div>
<div class="chart-bar-meta">
<span class="chart-bar-value" data-bar-value="{{ bar.label }}">{{ bar.value }}</span>
<span class="chart-bar-label">{{ bar.label }}</span>
</div>
</div>
{% empty %}
<div class="chart-bar-col">
<div class="bar" style="height:12%"></div>
<div class="chart-bar-meta"><span class="chart-bar-label"></span></div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>{{ events_title }}</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead><tr><th>When</th><th>Event</th><th>Contact</th></tr></thead>
<tbody id="events-body">
{% for event in recent_events %}
<tr>
<td class="muted">{{ event.created_at|date:"M j, g:i A" }}</td>
<td><span class="badge">{{ event.event_type }}</span></td>
<td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">{{ events_empty|safe }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="panel" id="recipients-panel" data-page="{{ page_obj.number }}">
<div class="panel-h" style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<h2 style="margin:0">Recipients</h2>
<span class="muted" style="font-size:13px">
{{ page_obj.paginator.count }} total
{% if page_obj.paginator.num_pages > 1 %}
· page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
{% endif %}
</span>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Contact</th><th>Status</th><th>Provider id</th><th>Error</th><th></th></tr>
</thead>
<tbody id="recipients-body">
{% for message in recipient_messages %}
<tr data-message-id="{{ message.pk }}">
<td>
<div>{{ message.contact }}</div>
{% if message.destination %}
<div class="muted" style="font-size:12px;margin-top:2px">{{ message.destination }}</div>
{% endif %}
</td>
<td><span class="badge badge-{{ message.status }}">{{ message.get_status_display }}</span></td>
<td class="muted">{{ message.provider_message_id|default:"—" }}</td>
<td class="muted">{{ message.error|truncatechars:60|default:"—" }}</td>
<td style="white-space:nowrap;text-align:right">
{% if message.can_remove %}
<form method="post"
action="{% url 'email_sms:campaign_message_remove' campaign.pk message.pk %}"
style="display:inline"
onsubmit="return confirm('Remove this recipient from the campaign?');">
{% csrf_token %}
<input type="hidden" name="page" value="{{ page_obj.number }}">
<button class="btn btn-ghost btn-sm" type="submit">Remove</button>
</form>
{% else %}
<span class="muted"></span>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="5" class="empty-state">No messages on this campaign.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.paginator.num_pages > 1 %}
<div class="panel-b" style="display:flex;gap:8px;align-items:center;justify-content:flex-end;border-top:1px solid var(--monica-border)">
{% if page_obj.has_previous %}
<a class="btn btn-ghost btn-sm" href="?page={{ page_obj.previous_page_number }}">← Prev</a>
{% endif %}
<span class="muted" style="font-size:13px">Page {{ page_obj.number }} / {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a class="btn btn-ghost btn-sm" href="?page={{ page_obj.next_page_number }}">Next →</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<script src="{% static 'js/campaign-utm.js' %}"></script>
<script>
window.CampaignUtm.bindPanel(document.getElementById("utm-link-panel"));
(function () {
var panel = document.getElementById("recipients-panel");
var page = (panel && panel.getAttribute("data-page")) || "1";
var removeBase = "{% url 'email_sms:campaign_message_remove' campaign.pk '00000000-0000-0000-0000-000000000000' %}";
var csrfToken = "{{ csrf_token }}";
var url = "{% url 'email_sms:campaign_status_json' campaign.pk %}?page=" + encodeURIComponent(page);
function esc(s) {
return String(s || "").replace(/[&<>"']/g, function (c) {
return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c];
});
}
function removeUrl(id) {
return removeBase.replace("00000000-0000-0000-0000-000000000000", id);
}
function apply(data) {
var badge = document.getElementById("campaign-status-badge");
if (badge) {
badge.textContent = data.status_display;
badge.className = "badge badge-" + data.status;
}
Object.keys(data.stats || {}).forEach(function (key) {
if (key === "chart_bars") return;
document.querySelectorAll('[data-stat="' + key + '"]').forEach(function (el) {
el.textContent = data.stats[key];
});
});
var chart = document.getElementById("engagement-chart");
if (chart && Array.isArray(data.stats && data.stats.chart_bars)) {
chart.innerHTML = data.stats.chart_bars.map(function (bar) {
return '<div class="chart-bar-col">' +
'<div class="bar" style="height:' + esc(bar.pct) + '%" title="' +
esc(bar.label) + ': ' + esc(bar.value) + '" data-bar-label="' +
esc(bar.label) + '"></div>' +
'<div class="chart-bar-meta">' +
'<span class="chart-bar-value">' + esc(bar.value) + '</span>' +
'<span class="chart-bar-label">' + esc(bar.label) + '</span>' +
'</div></div>';
}).join("");
}
var body = document.getElementById("recipients-body");
if (body && data.messages) {
if (!data.messages.length) {
body.innerHTML = '<tr><td colspan="5" class="empty-state">No messages on this campaign.</td></tr>';
} else {
body.innerHTML = data.messages.map(function (m) {
var dest = m.destination
? '<div class="muted" style="font-size:12px;margin-top:2px">' + esc(m.destination) + '</div>'
: '';
var action = m.can_remove
? '<form method="post" action="' + esc(removeUrl(m.id)) + '" style="display:inline" ' +
'onsubmit="return confirm(\'Remove this recipient from the campaign?\');">' +
'<input type="hidden" name="csrfmiddlewaretoken" value="' + esc(csrfToken) + '">' +
'<input type="hidden" name="page" value="' + esc(page) + '">' +
'<button class="btn btn-ghost btn-sm" type="submit">Remove</button></form>'
: '<span class="muted">—</span>';
return "<tr data-message-id=\"" + esc(m.id) + "\">" +
"<td><div>" + esc(m.contact) + "</div>" + dest + "</td>" +
"<td><span class=\"badge badge-" + esc(m.status) + "\">" + esc(m.status_display) + "</span></td>" +
"<td class=\"muted\">" + esc(m.provider_message_id || "—") + "</td>" +
"<td class=\"muted\">" + esc(m.error || "—") + "</td>" +
"<td style=\"white-space:nowrap;text-align:right\">" + action + "</td></tr>";
}).join("");
}
}
var eventsBody = document.getElementById("events-body");
if (eventsBody && data.events) {
if (!data.events.length) {
eventsBody.innerHTML = '<tr><td colspan="3" class="empty-state">No provider events yet.</td></tr>';
} else {
eventsBody.innerHTML = data.events.map(function (e) {
var when = e.created_at ? new Date(e.created_at).toLocaleString() : "—";
return "<tr><td class=\"muted\">" + esc(when) + "</td>" +
"<td><span class=\"badge\">" + esc(e.event_type) + "</span></td>" +
"<td>" + esc(e.contact) + "</td></tr>";
}).join("");
}
}
}
function tick() {
fetch(url, { headers: { "Accept": "application/json" }, credentials: "same-origin" })
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(apply)
.catch(function () {});
}
setInterval(tick, 10000);
})();
</script>
{% endblock %}
@@ -0,0 +1,399 @@
{% extends "portal_base.html" %}
{% load static %}
{% block title %}Campaigns · Portal{% endblock %}
{% block topbar_title %}Campaign composer{% endblock %}
{% block extra_head %}
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
<style>
#email-editor-wrap {
position: relative;
overflow: visible;
z-index: 1;
}
#email-editor {
display: flex;
flex-direction: column;
min-height: 200px;
}
.ql-toolbar.ql-snow {
border-color: var(--monica-border);
border-radius: 4px 4px 0 0;
flex-shrink: 0;
}
.ql-container.ql-snow {
border-color: var(--monica-border);
border-radius: 0 0 4px 4px;
background: #fff;
height: auto !important;
min-height: 160px;
flex: 1;
overflow: visible;
}
.ql-editor {
min-height: 160px;
font-family: Georgia, "Times New Roman", serif;
font-size: 15px;
}
#preview-body img { max-width: 100%; height: auto; }
#preview-body { line-height: 1.55; color: #212121; }
#email-editor-wrap[hidden],
#sms-body-wrap[hidden],
#postcard-body-hint[hidden] { display: none !important; }
</style>
{% endblock %}
{% block portal_content %}
<div class="channel-tabs" id="compose-channel-tabs">
<a class="active" href="#compose-email" data-channel="email">Email</a>
<a href="#compose-sms" data-channel="sms">SMS</a>
</div>
<div class="split">
<div class="panel">
<div class="panel-h"><h2>Compose</h2></div>
<div class="panel-b">
{% if form_errors %}
<ul class="portal-flash" style="margin:0 0 8px">
{% for err in form_errors %}
<li class="error">{{ err }}</li>
{% endfor %}
</ul>
{% endif %}
<form method="post" action="{% url 'email_sms:campaign_list' %}" class="form-grid" id="campaign-compose">
{% csrf_token %}
<div class="field">
<label for="id_name">Campaign name</label>
<input id="id_name" name="name" type="text" required
placeholder="Spring seller tips" value="{{ form_data.name }}">
<div class="hint">Used as <code>utm_campaign</code> on the tracked site link.</div>
</div>
<div class="field" id="subject-field">
<label for="id_subject">Subject</label>
<input id="id_subject" name="subject" type="text"
placeholder="A quick tip for sellers this week"
value="{{ form_data.subject }}"
oninput="syncCampaignPreview()">
<div class="hint">Email only — ignored for SMS</div>
</div>
<div class="field" id="email-editor-wrap">
<label>Body</label>
<div id="email-editor"></div>
<textarea id="id_body" name="body" hidden>{{ form_data.body }}</textarea>
<div class="hint">Bold, fonts, sizes, links, images · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code></div>
</div>
<div class="field" id="sms-body-wrap" hidden>
<label for="id_body_sms">Body</label>
<textarea id="id_body_sms" style="min-height:140px"
placeholder="Hi {% templatetag openvariable %}first_name{% templatetag closevariable %}, …"
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
<div class="hint">Plain text for SMS · keep it short · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code></div>
</div>
<div class="form-grid cols-2">
<div class="field">
<label for="id_audience">Recipients</label>
<select id="id_audience" name="audience" required onchange="syncComposeChannel()">
{% for value, label in audience_choices %}
<option value="{{ value }}"{% if form_data.audience == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="id_scheduled_for">Schedule <span class="muted">(optional)</span></label>
<input id="id_scheduled_for" name="scheduled_for" type="datetime-local" step="60"
value="{{ form_data.scheduled_for }}">
<div class="hint">Date &amp; time · leave blank to keep as unscheduled draft</div>
</div>
</div>
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. Youll get an email when the send finishes.</p>
{% include "core/_utm_link_panel.html" with utm_live=True %}
<button class="btn btn-primary" type="submit">Save draft</button>
</form>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Preview</h2></div>
<div class="panel-b">
<div class="preview-pane" id="campaign-preview">
<div class="muted" id="preview-empty">Preview updates as you type.</div>
<div id="preview-content" hidden>
<div class="hint" id="preview-subject"></div>
<div id="preview-body" style="margin-top:8px"></div>
</div>
</div>
<p class="hint-block">After send → open the campaign report for delivery &amp; engagement.</p>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Recent campaigns</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Channel</th>
<th>Status</th>
<th>Scheduled</th>
<th></th>
</tr>
</thead>
<tbody>
{% for campaign in campaigns %}
<tr>
<td><a href="{% url 'email_sms:campaign_detail' campaign.pk %}">{{ campaign.name }}</a></td>
<td>{{ campaign.get_channel_display }}</td>
<td><span class="badge badge-{{ campaign.status }}">{{ campaign.get_status_display }}</span></td>
<td>{% if campaign.scheduled_for %}{{ campaign.scheduled_for|date:"M j, g:i A" }}{% else %}—{% endif %}</td>
<td><a href="{% url 'email_sms:campaign_detail' campaign.pk %}">Report</a></td>
</tr>
{% empty %}
<tr><td colspan="5" class="empty-state">No campaigns yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<script src="{% static 'js/campaign-utm.js' %}"></script>
<script>
(function () {
var uploadUrl = "{{ image_upload_url|escapejs }}";
var csrfToken = (document.querySelector('#campaign-compose [name=csrfmiddlewaretoken]') || {}).value || '';
var bodyField = document.getElementById('id_body');
var smsField = document.getElementById('id_body_sms');
var quill = null;
var utmLock = false;
var utmLinkLabel = "{{ utm_link_label|escapejs }}";
var utmSource = "{{ utm_source|escapejs }}";
function csrfHeader() {
return { 'X-CSRFToken': csrfToken };
}
function audienceChannel() {
var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'sms_opt_in') return 'sms';
if (audience === 'postcard_opt_in') return 'postcard';
return 'email';
}
function applyUtmToBodies(url, channel, label) {
if (utmLock) return;
utmLock = true;
try {
if (channel === 'email' && quill) {
window.CampaignUtm.ensureQuillLink(quill, url, label || utmLinkLabel, utmSource);
syncBodyFromQuill();
} else if (channel === 'sms' && smsField) {
smsField.value = window.CampaignUtm.replaceTextUrl(smsField.value, url, utmSource);
if (bodyField) bodyField.value = smsField.value;
syncCampaignPreview();
}
} finally {
utmLock = false;
}
}
function syncBodyFromQuill() {
if (!quill || !bodyField) return;
var html = quill.root.innerHTML;
if (html === '<p><br></p>' || html === '<p></p>') html = '';
bodyField.value = html;
syncCampaignPreview();
}
window.syncSmsBody = function () {
if (smsField && bodyField) bodyField.value = smsField.value;
syncCampaignPreview();
};
window.syncPostcardBody = function () {
var pc = document.getElementById('id_body_pc');
if (pc && bodyField) bodyField.value = pc.value;
syncCampaignPreview();
};
window.syncCampaignPreview = function () {
var subject = (document.getElementById('id_subject') || {}).value || '';
var audience = (document.getElementById('id_audience') || {}).value || '';
var isEmail = audience === 'email_opt_in';
var isSms = audience === 'sms_opt_in';
var isPostcard = audience === 'postcard_opt_in';
var body = '';
if (isEmail && quill) {
body = quill.root.innerHTML;
if (body === '<p><br></p>' || body === '<p></p>') body = '';
} else if (isPostcard) {
var tmpl = document.getElementById('id_template_id');
var label = tmpl && tmpl.selectedIndex >= 0 ? tmpl.options[tmpl.selectedIndex].text : '';
body = label && tmpl.value ? ('Postcard design: ' + label) : '';
var note = (document.getElementById('id_body_pc') || {}).value || '';
if (note) body = (body ? body + '\n\n' : '') + note;
} else {
body = (bodyField && bodyField.value) || '';
}
var empty = document.getElementById('preview-empty');
var content = document.getElementById('preview-content');
var subEl = document.getElementById('preview-subject');
var bodyEl = document.getElementById('preview-body');
if (!empty || !content) return;
if (!subject && !body) {
empty.hidden = false;
content.hidden = true;
return;
}
empty.hidden = true;
content.hidden = false;
subEl.textContent = subject ? ('Subject: ' + subject) : (isPostcard ? 'Postcard mailing' : '');
if (isEmail) {
bodyEl.style.whiteSpace = 'normal';
bodyEl.innerHTML = body;
} else if (isSms) {
bodyEl.style.whiteSpace = 'pre-wrap';
bodyEl.innerHTML = window.CampaignUtm.linkify(body);
} else {
bodyEl.style.whiteSpace = 'pre-wrap';
bodyEl.textContent = body;
}
};
window.syncComposeChannel = function () {
var audience = (document.getElementById('id_audience') || {}).value || '';
var isPostcard = audience === 'postcard_opt_in';
var isSms = audience === 'sms_opt_in';
var isEmail = audience === 'email_opt_in';
var tmplField = document.getElementById('postcard-template-field');
var emailWrap = document.getElementById('email-editor-wrap');
var smsWrap = document.getElementById('sms-body-wrap');
var pcHint = document.getElementById('postcard-body-hint');
var subjectField = document.getElementById('subject-field');
if (tmplField) tmplField.style.display = isPostcard ? '' : 'none';
if (subjectField) subjectField.style.display = isEmail ? '' : 'none';
if (emailWrap) emailWrap.hidden = !isEmail;
if (smsWrap) smsWrap.hidden = !isSms;
if (pcHint) pcHint.hidden = !isPostcard;
if (isEmail && quill) {
syncBodyFromQuill();
} else if (isSms && smsField && bodyField) {
if (smsField.value === '' && bodyField.value && bodyField.value.indexOf('<') === -1) {
smsField.value = bodyField.value;
}
bodyField.value = smsField.value;
bodyField.removeAttribute('required');
} else if (isPostcard && bodyField) {
var pc = document.getElementById('id_body_pc');
bodyField.value = pc ? pc.value : '';
bodyField.removeAttribute('required');
}
document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) {
var ch = a.getAttribute('data-channel');
var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard);
a.classList.toggle('active', active);
});
var panel = document.getElementById('utm-link-panel');
if (panel && panel._utmApply) panel._utmApply();
syncCampaignPreview();
};
function initQuill() {
var initial = (bodyField && bodyField.value) || '';
quill = new Quill('#email-editor', {
theme: 'snow',
placeholder: 'Hi {first_name}, …',
modules: {
toolbar: {
container: [
[{ font: [] }, { size: ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'],
['clean']
],
handlers: {
image: function () {
var input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/png,image/jpeg,image/gif,image/webp');
input.click();
input.onchange = function () {
var file = input.files && input.files[0];
if (!file) return;
var data = new FormData();
data.append('image', file);
fetch(uploadUrl, {
method: 'POST',
headers: csrfHeader(),
body: data,
credentials: 'same-origin'
}).then(function (res) {
return res.json().then(function (json) {
if (!res.ok) throw new Error(json.error || 'Upload failed');
return json;
});
}).then(function (json) {
var range = quill.getSelection(true);
quill.insertEmbed(range.index, 'image', json.url, 'user');
quill.setSelection(range.index + 1);
syncBodyFromQuill();
}).catch(function (err) {
alert(err.message || 'Image upload failed');
});
};
}
}
}
}
});
if (initial && initial.indexOf('<') !== -1) {
quill.root.innerHTML = initial;
} else if (initial) {
quill.setText(initial);
}
quill.on('text-change', function () {
if (utmLock) return;
syncBodyFromQuill();
});
document.getElementById('campaign-compose').addEventListener('submit', function () {
var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'email_opt_in') syncBodyFromQuill();
else if (audience === 'sms_opt_in' && smsField) bodyField.value = smsField.value;
else if (audience === 'postcard_opt_in') {
var pc = document.getElementById('id_body_pc');
bodyField.value = pc ? pc.value : '';
}
});
}
document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) {
a.addEventListener('click', function (e) {
e.preventDefault();
var ch = a.getAttribute('data-channel');
var audience = document.getElementById('id_audience');
if (!audience) return;
if (ch === 'sms') audience.value = 'sms_opt_in';
else if (ch === 'postcard') audience.value = 'postcard_opt_in';
else audience.value = 'email_opt_in';
syncComposeChannel();
});
});
var tmplSelect = document.getElementById('id_template_id');
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
initQuill();
window.CampaignUtm.bindPanel(document.getElementById('utm-link-panel'), {
getName: function () {
return (document.getElementById('id_name') || {}).value || '';
},
getMedium: audienceChannel,
csrfToken: csrfToken,
onUrlChange: applyUtmToBodies
});
syncComposeChannel();
})();
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
from django.urls import path
from email_sms import views
app_name = "email_sms"
urlpatterns = [
path("campaigns/", views.campaign_list, name="campaign_list"),
path("campaigns/<uuid:pk>/", views.campaign_detail, name="campaign_detail"),
path(
"campaigns/<uuid:pk>/status.json",
views.campaign_status_json,
name="campaign_status_json",
),
path("campaigns/<uuid:pk>/send/", views.campaign_send, name="campaign_send"),
path(
"campaigns/<uuid:pk>/test-send/",
views.campaign_test_send,
name="campaign_test_send",
),
path(
"campaigns/<uuid:pk>/messages/<uuid:message_id>/remove/",
views.campaign_message_remove,
name="campaign_message_remove",
),
path(
"campaigns/upload-image/",
views.campaign_image_upload,
name="campaign_image_upload",
),
path("webhooks/smtp2go/", views.smtp2go_webhook, name="smtp2go_webhook"),
path("webhooks/sms/", views.sms_webhook, name="sms_webhook"),
path("webhooks/email/", views.email_webhook, name="email_webhook"),
]
+635
View File
@@ -0,0 +1,635 @@
import hashlib
import hmac
import io
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.paginator import Paginator
from django.core.validators import validate_email
from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from contacts.consent import opted_in_contacts, record_sms_stop
from contacts.models import Channel
from core.campaign_utm import ensure_campaign_utm_link, utm_panel_context
from core.models import StoredFile
from core.scheduling import parse_scheduled_for
from email_sms.models import Campaign, Message, ProviderEvent
from email_sms.services import (
channel_for_audience,
create_campaign_draft,
enqueue_campaign_send,
message_is_removable,
send_campaign_test_email,
)
from email_sms.webhooks import (
PROVIDER_EMAIL,
PROVIDER_SMS,
campaign_engagement_stats,
classify_smtp2go_payload,
parse_webhook_payload,
process_smtp2go_email_webhook,
process_smtp2go_sms_webhook,
)
logger = logging.getLogger(__name__)
RECIPIENTS_PER_PAGE = 50
_ALLOWED_IMAGE_TYPES = frozenset(
{"image/jpeg", "image/png", "image/gif", "image/webp"}
)
_MAX_IMAGE_BYTES = 5 * 1024 * 1024
def _audience_choices() -> list[tuple[str, str]]:
"""Labeled audience options with live opted-in counts."""
rows = [
(Campaign.Audience.EMAIL_OPT_IN, Channel.EMAIL, "email"),
(Campaign.Audience.SMS_OPT_IN, Channel.SMS, "SMS"),
]
choices = []
for value, channel, label in rows:
count = opted_in_contacts(channel).count()
noun = "contact" if count == 1 else "contacts"
choices.append(
(value, f"Mailing list · {label} opt-in ({count} {noun})")
)
return choices
def _format_postal_address(addr: dict | None) -> str:
if not addr:
return ""
line1 = (addr.get("line1") or "").strip()
line2 = (addr.get("line2") or "").strip()
city = (addr.get("city") or "").strip()
state = (addr.get("state") or "").strip()
zip_code = (addr.get("zip") or "").strip()
city_line = ", ".join(p for p in (city, state) if p)
if zip_code:
city_line = f"{city_line} {zip_code}".strip()
return ", ".join(p for p in (line1, line2, city_line) if p)
def _message_destination(message) -> str:
"""Channel-specific destination shown on the recipients table."""
contact = message.contact
channel = message.channel or (message.campaign.channel if message.campaign_id else "")
if channel == Channel.EMAIL:
return (contact.email or "").strip()
if channel == Channel.SMS:
return (contact.phone or "").strip()
if channel == Channel.POSTCARD:
return _format_postal_address(contact.postal_address)
return ""
def _events_provider_filter(campaign: Campaign) -> tuple[list[str], str, str]:
"""Return (provider codes, panel title, empty-state hint) for campaign channel."""
if campaign.channel == Channel.SMS:
return (
[PROVIDER_SMS],
"Recent SMTP2GO events",
"No webhook events yet. SMTP2GO must POST SMS events to "
"<code>/portal/email_sms/webhooks/smtp2go/</code>.",
)
return (
[PROVIDER_EMAIL],
"Recent SMTP2GO events",
"No webhook events yet. SMTP2GO must POST opens/clicks to "
"<code>/portal/email_sms/webhooks/smtp2go/</code> "
"(see email_sms README). SMTP2GOs own “Clicked” feed does not fill "
"this table by itself.",
)
def _campaign_report(campaign: Campaign, *, page: int = 1) -> dict:
qs = campaign.messages.select_related("contact").order_by(
"contact__first_name", "contact__last_name", "created_at"
)
paginator = Paginator(qs, RECIPIENTS_PER_PAGE)
page_obj = paginator.get_page(page)
recipient_messages = list(page_obj.object_list)
for msg in recipient_messages:
msg.destination = _message_destination(msg)
msg.can_remove = message_is_removable(msg)
stats = campaign_engagement_stats(campaign)
providers, events_title, events_empty = _events_provider_filter(campaign)
recent_events = (
ProviderEvent.objects.filter(
message__campaign=campaign,
provider__in=providers,
)
.select_related("message", "message__contact")
.order_by("-created_at")[:25]
)
return {
"recipient_messages": recipient_messages,
"page_obj": page_obj,
"stats": stats,
"recent_events": recent_events,
"events_title": events_title,
"events_empty": events_empty,
}
def _webhook_authorized(request, *, secret: str = "", secrets: list[str] | None = None) -> bool:
"""Accept Bearer / ?token= matching any configured secret (constant-time)."""
candidates: list[str] = []
if secrets:
candidates.extend(s.strip() for s in secrets if (s or "").strip())
single = (secret or "").strip()
if single and single not in candidates:
candidates.append(single)
if not candidates:
return True
token = (request.GET.get("token") or "").strip()
auth = (request.headers.get("Authorization") or "").strip()
bearer = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
# Common signature-header names PCM / gateways may use (raw secret or HMAC).
sig_headers = (
request.headers.get("X-PCM-Signature")
or request.headers.get("X-Webhook-Signature")
or request.headers.get("X-Signature")
or request.headers.get("X-Hub-Signature-256")
or ""
).strip()
if sig_headers.lower().startswith("sha256="):
sig_headers = sig_headers[7:].strip()
body = request.body or b""
for candidate in candidates:
if token and hmac.compare_digest(token, candidate):
return True
if bearer and hmac.compare_digest(bearer, candidate):
return True
if sig_headers:
if hmac.compare_digest(sig_headers, candidate):
return True
digest = hmac.new(
candidate.encode("utf-8"), body, hashlib.sha256
).hexdigest()
if hmac.compare_digest(sig_headers, digest):
return True
return False
def _log_webhook_request(request, *, channel: str) -> None:
"""Full request dump for Grafana / log aggregation."""
try:
headers = {str(k): str(v) for k, v in request.headers.items()}
except Exception: # noqa: BLE001
headers = {"_error": "unable to serialize headers"}
try:
body_text = (request.body or b"").decode("utf-8", errors="replace")
except Exception: # noqa: BLE001
body_text = repr(request.body)
if len(body_text) > 12000:
body_text = body_text[:12000] + "…[truncated]"
logger.info(
"webhook_received channel=%s path=%s method=%s query=%s",
channel,
request.path,
request.method,
request.META.get("QUERY_STRING", ""),
)
logger.info("webhook_headers channel=%s headers=%s", channel, headers)
logger.info("webhook_body channel=%s body=%s", channel, body_text)
def _log_webhook_auth_failed(request, *, channel: str) -> None:
logger.warning(
"webhook_auth_failed channel=%s path=%s "
"missing_or_invalid_authorization_or_token",
channel,
request.path,
)
def _log_webhook_result(
*,
channel: str,
event=None,
error: str = "",
extra: str = "",
) -> None:
if error:
logger.error(
"webhook_error channel=%s error=%s %s",
channel,
error,
extra,
)
return
if not event:
logger.warning(
"webhook_unmatched channel=%s no_provider_event_created %s",
channel,
extra,
)
return
message = getattr(event, "message", None)
campaign = getattr(message, "campaign", None) if message else None
logger.info(
"webhook_processed channel=%s event_type=%s event_id=%s "
"matched=%s message_id=%s campaign_id=%s campaign_name=%s %s",
channel,
getattr(event, "event_type", ""),
getattr(event, "pk", None),
bool(message),
getattr(message, "pk", None),
getattr(campaign, "pk", None),
getattr(campaign, "name", "") or "",
extra,
)
@login_required
@require_http_methods(["GET", "POST"])
def campaign_list(request):
form_errors: list[str] = []
form_data = {
"name": "",
"subject": "",
"body": "",
"audience": Campaign.Audience.EMAIL_OPT_IN,
"scheduled_for": "",
"template_id": "",
}
if request.method == "POST":
name = (request.POST.get("name") or "").strip()
subject = (request.POST.get("subject") or "").strip()
body = (request.POST.get("body") or "").strip()
audience = (request.POST.get("audience") or "").strip()
scheduled_raw = request.POST.get("scheduled_for") or ""
template_id = (request.POST.get("template_id") or "").strip()
form_data.update(
{
"name": name,
"subject": subject,
"body": body,
"audience": audience,
"scheduled_for": scheduled_raw,
"template_id": template_id,
}
)
if not name:
form_errors.append("Campaign name is required.")
if audience not in Campaign.Audience.values:
form_errors.append("Choose a recipient list.")
else:
channel = channel_for_audience(audience)
if channel in (Channel.EMAIL, Channel.SMS):
body = ensure_campaign_utm_link(
body,
name=name or "campaign",
medium=channel,
html=(channel == Channel.EMAIL),
)
form_data["body"] = body
if not body:
form_errors.append("Body is required.")
if audience == Campaign.Audience.EMAIL_OPT_IN and not subject:
form_errors.append("Subject is required for email campaigns.")
scheduled_for = None
try:
scheduled_for = parse_scheduled_for(scheduled_raw)
except ValueError as exc:
form_errors.append(str(exc))
if not form_errors:
campaign = create_campaign_draft(
name=name,
audience=audience,
subject=subject,
body=body,
scheduled_for=scheduled_for,
created_by=request.user,
)
recipient_count = campaign.messages.count()
messages.success(
request,
f'Draft “{campaign.name}” saved '
f"({recipient_count} recipient"
f"{'' if recipient_count == 1 else 's'}).",
)
return redirect("email_sms:campaign_detail", pk=campaign.pk)
campaigns = Campaign.objects.all()[:100]
return render(
request,
"email_sms/campaign_list.html",
{
"campaigns": campaigns,
"audience_choices": _audience_choices(),
"form_data": form_data,
"form_errors": form_errors,
"image_upload_url": reverse("email_sms:campaign_image_upload"),
**utm_panel_context(live=True),
},
)
@login_required
def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
try:
page = max(1, int(request.GET.get("page") or 1))
except (TypeError, ValueError):
page = 1
ctx = _campaign_report(campaign, page=page)
return render(
request,
"email_sms/campaign_detail.html",
{
"campaign": campaign,
"recipient_messages": ctx["recipient_messages"],
"page_obj": ctx["page_obj"],
"stats": ctx["stats"],
"recent_events": ctx["recent_events"],
"events_title": ctx["events_title"],
"events_empty": ctx["events_empty"],
**utm_panel_context(campaign=campaign, live=False),
"can_send": campaign.status
in {
Campaign.Status.DRAFT,
Campaign.Status.SCHEDULED,
Campaign.Status.SENDING,
}
and campaign.messages.exclude(
status__in={
"sent",
"delivered",
"opened",
"clicked",
"suppressed",
}
).exists(),
},
)
@login_required
@require_GET
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 email_sms.services import refresh_campaign_status
refresh_campaign_status(campaign)
campaign.refresh_from_db()
try:
page = max(1, int(request.GET.get("page") or 1))
except (TypeError, ValueError):
page = 1
ctx = _campaign_report(campaign, page=page)
page_obj = ctx["page_obj"]
return JsonResponse(
{
"status": campaign.status,
"status_display": campaign.get_status_display(),
"stats": ctx["stats"],
"page": page_obj.number,
"num_pages": page_obj.paginator.num_pages,
"messages": [
{
"id": str(m.pk),
"contact": str(m.contact),
"destination": getattr(m, "destination", "") or "",
"status": m.status,
"status_display": m.get_status_display(),
"provider_message_id": m.provider_message_id or "",
"error": (m.error or "")[:120],
"can_remove": bool(getattr(m, "can_remove", False)),
}
for m in ctx["recipient_messages"]
],
"events": [
{
"event_type": e.event_type,
"contact": str(e.message.contact) if e.message_id else "",
"created_at": e.created_at.isoformat(),
}
for e in ctx["recent_events"]
],
}
)
@login_required
@require_POST
def campaign_message_remove(request, pk, message_id):
"""Drop a draft/scheduled/failed recipient from the campaign."""
campaign = get_object_or_404(Campaign, pk=pk)
message = get_object_or_404(Message, pk=message_id, campaign=campaign)
if not message_is_removable(message):
messages.error(
request,
"Only draft, scheduled, or failed recipients can be removed.",
)
return redirect("email_sms:campaign_detail", pk=campaign.pk)
label = str(message.contact)
message.delete()
messages.success(request, f"Removed {label} from this campaign.")
page = (request.POST.get("page") or request.GET.get("page") or "").strip()
if page and page.isdigit() and int(page) > 1:
return redirect(
f"{reverse('email_sms:campaign_detail', kwargs={'pk': campaign.pk})}"
f"?page={page}"
)
return redirect("email_sms:campaign_detail", pk=campaign.pk)
@login_required
@require_POST
def campaign_send(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
if campaign.status == Campaign.Status.CANCELLED:
messages.error(request, "Cancelled campaigns cannot be sent.")
return redirect("email_sms:campaign_detail", pk=campaign.pk)
count = enqueue_campaign_send(campaign)
campaign.refresh_from_db()
if count == 0:
messages.warning(request, "No draft/scheduled/failed messages to send.")
else:
sent = campaign.messages.filter(status="sent").count()
failed = campaign.messages.filter(status="failed").count()
messages.success(
request,
f"Send finished for {count} message(s): {sent} sent, {failed} failed.",
)
return redirect("email_sms:campaign_detail", pk=campaign.pk)
@login_required
@require_POST
def campaign_test_send(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
to_email = (request.POST.get("test_email") or "").strip()
if not to_email:
messages.error(request, "Enter an email address for the test send.")
return redirect("email_sms:campaign_detail", pk=campaign.pk)
try:
validate_email(to_email)
except ValidationError:
messages.error(request, "That test email address is not valid.")
return redirect("email_sms:campaign_detail", pk=campaign.pk)
try:
send_campaign_test_email(campaign, to_email)
except ValueError as exc:
messages.error(request, str(exc))
except Exception as exc: # noqa: BLE001 — surface SMTP misconfig to portal
messages.error(request, f"Test send failed: {exc}")
else:
messages.success(request, f"Test email sent to {to_email}.")
return redirect("email_sms:campaign_detail", pk=campaign.pk)
@login_required
@require_POST
def campaign_image_upload(request):
"""Upload an image for the email rich editor; store bytes in the DB."""
upload = request.FILES.get("image") or request.FILES.get("file")
if not upload:
return JsonResponse({"error": "No image uploaded."}, status=400)
content_type = (getattr(upload, "content_type", None) or "").lower()
if content_type not in _ALLOWED_IMAGE_TYPES:
return JsonResponse(
{"error": "Use a JPEG, PNG, GIF, or WebP image."}, status=400
)
if upload.size and upload.size > _MAX_IMAGE_BYTES:
return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400)
data = upload.read()
if len(data) > _MAX_IMAGE_BYTES:
return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400)
original = (getattr(upload, "name", None) or "image")[:255]
stored = StoredFile.objects.create(
kind=StoredFile.Kind.CAMPAIGN_IMAGE,
filename=original,
content_type=content_type,
size=len(data),
data=data,
uploaded_by=request.user if request.user.is_authenticated else None,
)
path = reverse("core:stored_file", kwargs={"pk": stored.pk})
url = request.build_absolute_uri(path)
return JsonResponse({"url": url, "id": str(stored.pk)})
@csrf_exempt
@require_http_methods(["GET", "POST"])
def smtp2go_webhook(request):
"""
Unified SMTP2GO webhook — email + SMS events + inbound STOP replies.
One SMTP2GO webhook URL (paid plans cap at 10 webhooks):
URL: https://<host>/portal/email_sms/webhooks/smtp2go/
Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET
Output type: JSON
Users: email SMTP user(s) *and* the SMS API key used to send
Email events: all delivery/engagement boxes
Email headers: X-Monica-Message-Id
SMS events: Submitted, Sending, Delivered, Failed, Rejected (Opt-out if shown)
Legacy aliases ``/webhooks/email/`` and ``/webhooks/sms/`` hit this same view.
Payload shape selects the processor (email vs sms_* vs inbound STOP).
"""
# Log/auth before parsing so request.body stays readable (HMAC + Grafana dump).
_log_webhook_request(request, channel="smtp2go")
if not _webhook_authorized(
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
):
_log_webhook_auth_failed(request, channel="smtp2go")
return HttpResponseForbidden("invalid webhook token")
payload = parse_webhook_payload(request)
if not payload:
payload = request.POST.dict() or {}
kind = classify_smtp2go_payload(payload)
channel = {
"sms_inbound": "sms",
"sms": "sms",
"email": "email",
}.get(kind, "smtp2go")
logger.info("webhook_classified channel=%s kind=%s", channel, kind)
if kind == "sms_inbound":
phone = (
payload.get("from")
or payload.get("phone")
or payload.get("source_number")
or payload.get("destination_number")
or ""
)
stopped = bool(phone) and record_sms_stop(str(phone))
logger.info(
"webhook_processed channel=sms event_type=inbound_stop "
"opt_out=%s phone=%s",
stopped,
phone,
)
return JsonResponse({"ok": True, "channel": "sms", "opt_out": stopped})
if kind == "unknown":
logger.warning(
"webhook_unmatched channel=smtp2go unrecognized_payload keys=%s",
sorted(str(k) for k in payload.keys()),
)
return JsonResponse(
{"ok": False, "error": "unrecognized_payload", "channel": None},
status=200,
)
try:
if kind == "sms":
event = process_smtp2go_sms_webhook(payload)
else:
event = process_smtp2go_email_webhook(payload)
except Exception as exc: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h)
logger.exception("SMTP2GO %s webhook processing failed", kind)
_log_webhook_result(channel=channel, error=str(exc))
return JsonResponse(
{"ok": False, "error": "processing_failed", "channel": channel},
status=200,
)
_log_webhook_result(channel=channel, event=event)
return JsonResponse(
{
"ok": True,
"channel": channel,
"matched": bool(event and event.message_id),
"event_id": event.pk if event else None,
}
)
# Legacy path names — same unified handler (keep SMTP2GO configs working).
email_webhook = smtp2go_webhook
sms_webhook = smtp2go_webhook
+807
View File
@@ -0,0 +1,807 @@
"""SMTP2GO email/SMS event webhooks → Message + ProviderEvent updates."""
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
from email_sms.models import Message, ProviderEvent
from email_sms.services import set_channel_consent
logger = logging.getLogger(__name__)
PROVIDER_EMAIL = "smtp2go_email"
PROVIDER_SMS = "smtp2go_sms"
PROVIDER_PCM = "pcm"
PROVIDER = PROVIDER_EMAIL # backward-compatible alias
# Do not move a message backward to a weaker delivery / engagement state.
_STATUS_RANK = {
Message.Status.DRAFT: 0,
Message.Status.SCHEDULED: 1,
Message.Status.QUEUED: 2,
Message.Status.SENT: 3,
Message.Status.FAILED: 3,
Message.Status.DELIVERED: 4,
Message.Status.OPENED: 5,
Message.Status.CLICKED: 6,
Message.Status.BOUNCED: 7,
Message.Status.SUPPRESSED: 7,
}
_ENGAGED_OR_DELIVERED = frozenset(
{
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
}
)
_SENT_OR_BETTER = frozenset(
{
Message.Status.SENT,
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
}
)
_MONICA_HEADER_KEYS = (
"X-Monica-Message-Id",
"x-monica-message-id",
"X_Monica_Message_Id",
"monica-message-id",
)
# SMTP2GO UI labels → canonical event strings from their docs.
_EMAIL_EVENT_ALIASES = {
"bounced": "bounce",
"rejected": "reject",
"opened": "open",
"clicked": "click",
"unsubscribed": "unsubscribe",
"resubscribed": "resubscribe",
}
# API sms_events use short names (delivered); webhook body often uses sms_delivered.
# UI test labels / Opt-Out may arrive without the sms_ prefix.
_SMS_EVENT_ALIASES = {
"submitted": "sms_submitted",
"sending": "sms_sending",
"delivered": "sms_delivered",
"failed": "sms_failed",
"rejected": "sms_rejected",
"opt_out": "sms_opt_out",
"optout": "sms_opt_out",
"sms_optout": "sms_opt_out",
}
_SMS_OPT_OUT_EVENTS = frozenset({"sms_opt_out"})
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 _normalize_sms_event(event: str) -> str:
"""Map UI / short API names to docs canonical sms_* event strings."""
event = (event or "").strip().lower().replace("-", "_").replace(" ", "_")
return _SMS_EVENT_ALIASES.get(event, event)
def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]:
"""Accept JSON or form-encoded SMTP2GO webhook bodies."""
content_type = (request.content_type or "").lower()
if "application/json" in content_type:
try:
data = json.loads(request.body.decode() or "{}")
except (json.JSONDecodeError, UnicodeDecodeError):
return {}
return data if isinstance(data, dict) else {}
# Form-encoded (SMTP2GO default)
return {key: request.POST.get(key) for key in request.POST.keys()}
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 = _as_str(payload.get(key))
if value:
return value
headers = payload.get("headers") or payload.get("email_headers") or {}
if isinstance(headers, dict):
for key in _MONICA_HEADER_KEYS:
value = _as_str(headers.get(key))
if value:
return value
# Case-insensitive scan
lower_map = {str(k).lower(): v for k, v in headers.items()}
for key in _MONICA_HEADER_KEYS:
value = _as_str(lower_map.get(key.lower()))
if value:
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_by_pk(monica_id)
if message:
return message
email_id = _as_str(payload.get("email_id") or payload.get("email-id"))
if email_id:
message = (
Message.objects.select_related("contact", "campaign")
.filter(provider_message_id=email_id)
.first()
)
if message:
return message
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 = _as_str(recipients[0]).lower()
if not rcpt:
return None
contact = Contact.objects.filter(email__iexact=rcpt).first()
if not contact:
return None
return (
Message.objects.select_related("contact", "campaign")
.filter(
contact=contact,
channel=Channel.EMAIL,
status__in=[
Message.Status.QUEUED,
Message.Status.SENT,
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
Message.Status.FAILED,
Message.Status.BOUNCED,
],
)
.order_by("-sent_at", "-updated_at")
.first()
)
def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "") -> None:
current_rank = _STATUS_RANK.get(message.status, 0)
new_rank = _STATUS_RANK.get(new_status, 0)
# Always allow bounce/suppress to overwrite delivered; allow delivered over sent.
if new_rank < current_rank and new_status not in {
Message.Status.BOUNCED,
Message.Status.SUPPRESSED,
Message.Status.FAILED,
}:
return
if message.status in {Message.Status.BOUNCED, Message.Status.SUPPRESSED} and new_status in {
Message.Status.SENT,
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
}:
return
fields = ["status", "updated_at"]
message.status = new_status
if error:
message.error = error[:2000]
fields.append("error")
elif new_status in _ENGAGED_OR_DELIVERED:
message.error = ""
fields.append("error")
message.save(update_fields=fields)
def _apply_email_event(message: Message, event: str, payload: dict[str, Any]) -> None:
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 = _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
message.save(
update_fields=["provider_message_id", "provider", "updated_at"]
)
if event == "processed":
if message.status in {Message.Status.QUEUED, Message.Status.DRAFT}:
_maybe_upgrade_status(message, Message.Status.SENT)
return
if event == "delivered":
_maybe_upgrade_status(message, Message.Status.DELIVERED)
return
if event == "bounce":
status = Message.Status.BOUNCED
_maybe_upgrade_status(
message,
status,
error=err or f"{bounce_kind or 'unknown'} bounce",
)
if bounce_kind == "hard":
set_channel_consent(
message.contact,
Channel.EMAIL,
opted_in=False,
reason="smtp2go_hard_bounce",
)
return
if event == "reject":
_maybe_upgrade_status(
message, Message.Status.FAILED, error=err or "rejected by provider"
)
return
if event == "spam":
_maybe_upgrade_status(
message, Message.Status.SUPPRESSED, error=err or "spam complaint"
)
set_channel_consent(
message.contact,
Channel.EMAIL,
opted_in=False,
reason="smtp2go_spam",
)
return
if event == "unsubscribe":
_maybe_upgrade_status(
message, Message.Status.SUPPRESSED, error="provider unsubscribe"
)
set_channel_consent(
message.contact,
Channel.EMAIL,
opted_in=False,
reason="smtp2go_unsubscribe",
)
return
if event == "open":
# Open implies delivery; do not overwrite a stronger click status.
if message.status != Message.Status.CLICKED:
_maybe_upgrade_status(message, Message.Status.OPENED)
return
if event == "click":
_maybe_upgrade_status(message, Message.Status.CLICKED)
return
# resubscribe — event row only (status unchanged)
def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | None:
"""
Persist ProviderEvent and update Message delivery status when possible.
Returns the stored event (even if message could not be matched).
"""
event = _normalize_email_event(_as_str(payload.get("event")))
if not event:
logger.warning("SMTP2GO webhook missing event: %s", payload)
return None
message = find_message_for_email_event(payload)
if message:
_apply_email_event(message, event, payload)
message.refresh_from_db()
else:
logger.info(
"SMTP2GO webhook unmatched event=%s rcpt=%s email_id=%s",
event,
payload.get("rcpt"),
payload.get("email_id"),
)
return ProviderEvent.objects.create(
message=message,
provider=PROVIDER_EMAIL,
event_type=event[:64],
payload=_json_safe(payload) if isinstance(payload, dict) else {},
)
def normalize_phone(value: str) -> str:
return "".join(ch for ch in (value or "") if ch.isdigit())
def _sms_provider_message_id(payload: dict[str, Any]) -> str:
"""SMS unique id from docs (`message_id`). Never use webhook `id`."""
return _as_str(payload.get("message_id") or payload.get("sms_id"))
def find_message_for_sms_event(payload: dict[str, Any]) -> Message | None:
provider_id = _sms_provider_message_id(payload)
if provider_id:
message = (
Message.objects.select_related("contact", "campaign")
.filter(channel=Channel.SMS, provider_message_id=provider_id)
.first()
)
if message:
return message
# Outbound delivery events use destination_number (recipient).
# Do not use source_number / from — those are the pool or inbound reply.
raw_phone = (
payload.get("destination_number")
or payload.get("to")
or payload.get("phone")
or ""
)
digits = normalize_phone(_as_str(raw_phone))
if len(digits) < 7:
return None
# Match last 10 digits so +1 / formatting differences still hit.
tail = digits[-10:]
contacts = Contact.objects.exclude(phone="").only("id", "phone")
contact = None
for row in contacts.iterator():
if normalize_phone(row.phone).endswith(tail):
contact = row
break
if not contact:
return None
return (
Message.objects.select_related("contact", "campaign")
.filter(
contact=contact,
channel=Channel.SMS,
status__in=[
Message.Status.QUEUED,
Message.Status.SENT,
Message.Status.DELIVERED,
Message.Status.FAILED,
Message.Status.SUPPRESSED,
],
)
.order_by("-sent_at", "-updated_at")
.first()
)
def _apply_sms_event(message: Message, event: str, payload: dict[str, Any]) -> None:
event = _normalize_sms_event(event)
err = _as_str(
payload.get("message")
or payload.get("status_code")
or payload.get("context")
)
provider_id = _sms_provider_message_id(payload)
if provider_id and message.provider_message_id != provider_id:
message.provider_message_id = provider_id
message.provider = PROVIDER_SMS
message.save(
update_fields=["provider_message_id", "provider", "updated_at"]
)
if event in {"sms_sending", "sms_submitted"}:
if message.status in {Message.Status.QUEUED, Message.Status.DRAFT}:
_maybe_upgrade_status(message, Message.Status.SENT)
return
if event == "sms_delivered":
_maybe_upgrade_status(message, Message.Status.DELIVERED)
return
if event in {"sms_failed", "sms_rejected"}:
_maybe_upgrade_status(
message,
Message.Status.FAILED,
error=err or event,
)
return
if event in _SMS_OPT_OUT_EVENTS:
_maybe_upgrade_status(
message, Message.Status.SUPPRESSED, error="sms opt-out"
)
set_channel_consent(
message.contact,
Channel.SMS,
opted_in=False,
reason="smtp2go_sms_opt_out",
)
return
def process_smtp2go_sms_webhook(payload: dict[str, Any]) -> ProviderEvent | None:
"""Persist SMS delivery/opt-out ProviderEvent and update Message when matched."""
event = _normalize_sms_event(_as_str(payload.get("event")))
if not event:
logger.warning("SMTP2GO SMS webhook missing event: %s", payload)
return None
message = find_message_for_sms_event(payload)
if message:
_apply_sms_event(message, event, payload)
message.refresh_from_db()
else:
# Opt-out with no matched campaign message still suppresses by phone.
if event in _SMS_OPT_OUT_EVENTS:
phone = _as_str(
payload.get("destination_number")
or payload.get("from")
or payload.get("source_number")
)
if phone:
from email_sms.services import record_sms_stop
record_sms_stop(phone)
logger.info(
"SMTP2GO SMS webhook unmatched event=%s phone=%s message_id=%s",
event,
payload.get("destination_number"),
payload.get("message_id"),
)
return ProviderEvent.objects.create(
message=message,
provider=PROVIDER_SMS,
event_type=event[:64],
payload=_json_safe(payload) if isinstance(payload, dict) else {},
)
def is_inbound_sms_stop(payload: dict[str, Any]) -> bool:
"""True for gateway-style inbound reply payloads (STOP / UNSUBSCRIBE).
SMTP2GO auto-handles STOP/UNSUB/UNSUBSCRIBE replies; this catches the
inbound gateway POST shape (no ``event`` field) when configured to
forward replies. Prefer this over relying on a webhook Opt-Out event —
the API ``sms_events`` list does not include opt-out.
"""
if payload.get("event"):
return False
text = _as_str(
payload.get("text")
or payload.get("message")
or payload.get("message_content")
).upper()
return text in {"STOP", "UNSUBSCRIBE", "UNSUB", "CANCEL", "END", "QUIT"}
def classify_smtp2go_payload(payload: dict[str, Any]) -> str:
"""
Decide email vs SMS vs inbound STOP for a unified SMTP2GO webhook URL.
Returns one of: ``sms_inbound``, ``sms``, ``email``, ``unknown``.
"""
if not isinstance(payload, dict) or not payload:
return "unknown"
if is_inbound_sms_stop(payload):
return "sms_inbound"
raw = _as_str(payload.get("event")).lower().replace("-", "_").replace(" ", "_")
if not raw:
return "unknown"
# Explicit SMS forms (docs sms_* + API/UI short names that are SMS-only).
if raw.startswith("sms_") or raw in {
"submitted",
"sending",
"opt_out",
"optout",
}:
return "sms"
email_event = _normalize_email_event(raw)
if email_event in {
"processed",
"open",
"click",
"bounce",
"spam",
"unsubscribe",
"resubscribe",
}:
return "email"
has_dest = bool(_as_str(payload.get("destination_number")))
has_rcpt = bool(_as_str(payload.get("rcpt")))
has_email_id = bool(
_as_str(payload.get("email_id") or payload.get("email-id"))
)
has_monica = bool(extract_monica_message_id(payload))
has_from_address = bool(_as_str(payload.get("from_address")))
has_sms_id = bool(_as_str(payload.get("message_id") or payload.get("sms_id")))
has_sms_body = bool(
_as_str(payload.get("message_content") or payload.get("source_number"))
)
email_leaning = has_rcpt or has_email_id or has_monica or has_from_address
sms_leaning = has_dest or has_sms_body or (
has_sms_id and not email_leaning
)
# Ambiguous short names shared by email + SMS API lists.
if raw in {"delivered", "failed", "rejected", "reject"}:
if sms_leaning and not email_leaning:
return "sms"
if email_leaning:
return "email"
# Bare reject without fields → email docs name; rejected alone → sms lean default
if raw == "reject":
return "email"
if raw == "rejected":
return "sms"
return "email"
if email_leaning:
return "email"
if sms_leaning:
return "sms"
# Default: email (historical primary SMTP2GO traffic).
return "email"
def _pcm_event_type(payload: dict[str, Any]) -> str:
for key in ("event", "eventType", "event_type", "type", "status"):
value = payload.get(key)
if value:
return str(value).strip()
return "unknown"
def find_message_for_pcm_event(payload: dict[str, Any]) -> Message | None:
"""Correlate PCM webhook to Message via extRefNbr or orderID."""
ext = (
payload.get("extRefNbr")
or payload.get("ext_ref_nbr")
or payload.get("externalReference")
or ""
)
if not ext and isinstance(payload.get("recipient"), dict):
ext = payload["recipient"].get("extRefNbr") or ""
ext = str(ext).strip()
if ext:
message = _message_by_pk(ext)
if message:
return message
order_id = (
payload.get("orderID")
or payload.get("orderId")
or payload.get("order_id")
or ""
)
order_id = str(order_id).strip()
if order_id:
message = (
Message.objects.select_related("contact", "campaign")
.filter(provider_message_id=order_id, channel=Channel.POSTCARD)
.first()
)
if message:
return message
return None
def _apply_pcm_status(message: Message, status: str, payload: dict[str, Any]) -> None:
status_norm = (status or "").strip().lower()
err = (
payload.get("message")
or payload.get("error")
or payload.get("reason")
or ""
)
err = str(err).strip()
order_id = (
payload.get("orderID")
or payload.get("orderId")
or payload.get("order_id")
or ""
)
if order_id and message.provider_message_id != str(order_id):
message.provider_message_id = str(order_id)
message.provider = PROVIDER_PCM
message.save(
update_fields=["provider_message_id", "provider", "updated_at"]
)
if status_norm in {"delivered"}:
_maybe_upgrade_status(message, Message.Status.DELIVERED)
return
if status_norm in {"undeliverable", "returned"}:
_maybe_upgrade_status(
message,
Message.Status.BOUNCED,
error=err or "undeliverable",
)
return
if status_norm in {"canceled", "cancelled"}:
_maybe_upgrade_status(
message, Message.Status.FAILED, error=err or "canceled"
)
return
if status_norm in {"pending", "processing", "processed", "mailed", "intransit", "in_transit"}:
if message.status in {
Message.Status.QUEUED,
Message.Status.DRAFT,
Message.Status.SCHEDULED,
}:
_maybe_upgrade_status(message, Message.Status.SENT)
return
def process_pcm_postcard_webhook(payload: dict[str, Any]) -> ProviderEvent | None:
"""Record a PCM Integrations postcard event and advance Message status."""
if not payload:
return None
# Nested data wrappers some webhook UIs use.
if "data" in payload and isinstance(payload["data"], dict):
inner = dict(payload["data"])
for key in ("event", "eventType", "type"):
if key in payload and key not in inner:
inner[key] = payload[key]
payload = inner
event_type = _pcm_event_type(payload)
message = find_message_for_pcm_event(payload)
if message:
status_for_apply = (
payload.get("status")
or payload.get("orderStatus")
or event_type
)
_apply_pcm_status(message, str(status_for_apply), payload)
return ProviderEvent.objects.create(
message=message,
provider=PROVIDER_PCM,
event_type=event_type[:64],
payload=payload,
)
def _engagement_chart_bars(metrics: list[tuple[str, int]]) -> list[dict]:
"""Build bar heights (percent) for the campaign engagement chart."""
peak = max((value for _, value in metrics), default=0)
bars: list[dict] = []
for label, value in metrics:
if peak <= 0:
pct = 12 if value == 0 else 100
else:
pct = max(12, int(round((value / peak) * 100))) if value else 8
bars.append({"label": label, "value": value, "pct": pct})
return bars
def campaign_engagement_stats(campaign) -> dict:
"""Aggregate delivery + open/click counts for the campaign report."""
messages_qs = campaign.messages.all()
statuses = list(messages_qs.values_list("status", flat=True))
message_ids = list(messages_qs.values_list("pk", flat=True))
events = ProviderEvent.objects.filter(message_id__in=message_ids)
open_message_ids = set(
events.filter(event_type__iexact="open").values_list("message_id", flat=True)
)
click_message_ids = set(
events.filter(event_type__iexact="click").values_list("message_id", flat=True)
)
# Status-based engagement also counts (webhook may set opened/clicked).
status_opened = sum(1 for s in statuses if s in {"opened", "clicked"})
status_clicked = sum(1 for s in statuses if s == "clicked")
opens = max(len(open_message_ids), status_opened)
clicks = max(len(click_message_ids), status_clicked)
sent = sum(1 for s in statuses if s in _SENT_OR_BETTER)
delivered = sum(1 for s in statuses if s in _ENGAGED_OR_DELIVERED)
failed = sum(1 for s in statuses if s in {"failed", "bounced"})
suppressed = sum(1 for s in statuses if s == "suppressed")
if campaign.channel == Channel.EMAIL:
chart_metrics = [
("Sent", sent),
("Delivered", delivered),
("Opens", opens),
("Clicks", clicks),
("Failed", failed),
]
else:
chart_metrics = [
("Sent", sent),
("Delivered", delivered),
("Failed", failed),
("Suppressed", suppressed),
]
return {
"total": len(statuses),
"sent": sent,
"delivered": delivered,
"failed": failed,
"bounced": sum(1 for s in statuses if s == "bounced"),
"suppressed": suppressed,
"opens": opens,
"clicks": clicks,
"open_events": events.filter(event_type__iexact="open").count(),
"click_events": events.filter(event_type__iexact="click").count(),
"chart_bars": _engagement_chart_bars(chart_metrics),
}