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 "
"/portal/email_sms/webhooks/smtp2go/.",
)
return (
[PROVIDER_EMAIL],
"Recent SMTP2GO events",
"No webhook events yet. SMTP2GO must POST opens/clicks to "
"/portal/email_sms/webhooks/smtp2go/ "
"(see email_sms README). SMTP2GO’s 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:///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