generated from westfarn/web_django_template
Initial commit
This commit is contained in:
@@ -0,0 +1,956 @@
|
||||
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 FileResponse, 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.models import Channel
|
||||
from core.models import StoredFile
|
||||
from directmail.models import Campaign, Message, MessageTemplate, ProviderEvent
|
||||
from directmail.providers.postcard.pcm import (
|
||||
PCM_SIZE_CHOICES,
|
||||
PcmApiError,
|
||||
create_custom_design,
|
||||
design_id_from_template,
|
||||
get_design_embed_url,
|
||||
list_designs,
|
||||
)
|
||||
from contacts.consent import opted_in_contacts
|
||||
from core.scheduling import parse_scheduled_for
|
||||
from directmail.services import (
|
||||
create_campaign_draft,
|
||||
enqueue_campaign_send,
|
||||
message_is_removable,
|
||||
)
|
||||
from directmail.webhooks import (
|
||||
PROVIDER_EMAIL,
|
||||
PROVIDER_PCM,
|
||||
PROVIDER_SMS,
|
||||
campaign_engagement_stats,
|
||||
classify_smtp2go_payload,
|
||||
parse_webhook_payload,
|
||||
process_pcm_postcard_webhook,
|
||||
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.POSTCARD_OPT_IN, Channel.EMAIL, "email"),
|
||||
(Campaign.Audience.SMS_OPT_IN, Channel.SMS, "SMS"),
|
||||
(Campaign.Audience.POSTCARD_OPT_IN, Channel.POSTCARD, "postcard"),
|
||||
]
|
||||
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 _postcard_templates():
|
||||
return MessageTemplate.objects.filter(channel=Channel.POSTCARD).order_by(
|
||||
"-updated_at"
|
||||
)[:50]
|
||||
|
||||
|
||||
def _fetch_pcm_designs() -> tuple[list[dict], str]:
|
||||
"""Return (normalized design rows, api_error)."""
|
||||
designs: list[dict] = []
|
||||
api_error = ""
|
||||
try:
|
||||
remote = list_designs(product_type="postcard")
|
||||
for item in remote:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
did = item.get("designID") or item.get("design_id") or item.get("id")
|
||||
if did is None:
|
||||
continue
|
||||
size_info = item.get("size") or {}
|
||||
size_key = (
|
||||
size_info.get("key")
|
||||
if isinstance(size_info, dict)
|
||||
else size_info
|
||||
) or ""
|
||||
designs.append(
|
||||
{
|
||||
"design_id": str(did),
|
||||
"name": item.get("friendlyName")
|
||||
or item.get("name")
|
||||
or f"Design {did}",
|
||||
"size": str(size_key),
|
||||
}
|
||||
)
|
||||
except PcmApiError as exc:
|
||||
api_error = str(exc)
|
||||
|
||||
seen = {d["design_id"] for d in designs}
|
||||
for tmpl in _postcard_templates():
|
||||
front = tmpl.postcard_front or {}
|
||||
did = front.get("design_id")
|
||||
if did is None:
|
||||
continue
|
||||
did_s = str(did)
|
||||
if did_s in seen:
|
||||
continue
|
||||
designs.insert(
|
||||
0,
|
||||
{
|
||||
"design_id": did_s,
|
||||
"name": tmpl.name,
|
||||
"size": str(front.get("size") or ""),
|
||||
},
|
||||
)
|
||||
seen.add(did_s)
|
||||
return designs, api_error
|
||||
|
||||
|
||||
def _postcard_design_choices() -> list[dict]:
|
||||
"""Options for campaign compose: PCM designs + saved templates."""
|
||||
designs, _ = _fetch_pcm_designs()
|
||||
by_id = {d["design_id"]: d for d in designs}
|
||||
choices: list[dict] = []
|
||||
for tmpl in _postcard_templates():
|
||||
did = design_id_from_template(tmpl)
|
||||
if did is None:
|
||||
continue
|
||||
did_s = str(did)
|
||||
choices.append(
|
||||
{
|
||||
"value": f"t:{tmpl.pk}",
|
||||
"label": f"{tmpl.name} (design {did_s})",
|
||||
"design_id": did_s,
|
||||
}
|
||||
)
|
||||
by_id.pop(did_s, None)
|
||||
for did_s, d in by_id.items():
|
||||
choices.append(
|
||||
{
|
||||
"value": f"d:{did_s}",
|
||||
"label": f"{d['name']} (design {did_s})",
|
||||
"design_id": did_s,
|
||||
"size": d.get("size") or "46",
|
||||
"name": d["name"],
|
||||
}
|
||||
)
|
||||
return choices
|
||||
|
||||
|
||||
def _resolve_postcard_template(raw: str) -> MessageTemplate | None:
|
||||
"""Resolve compose select value ``t:<uuid>`` or ``d:<design_id>``."""
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
if value.startswith("t:"):
|
||||
return MessageTemplate.objects.filter(
|
||||
pk=value[2:], channel=Channel.POSTCARD
|
||||
).first()
|
||||
if value.startswith("d:"):
|
||||
design_raw = value[2:].strip()
|
||||
try:
|
||||
design_id = int(design_raw)
|
||||
except ValueError:
|
||||
return None
|
||||
for tmpl in MessageTemplate.objects.filter(channel=Channel.POSTCARD):
|
||||
if design_id_from_template(tmpl) == design_id:
|
||||
return tmpl
|
||||
name = f"PCM design {design_id}"
|
||||
designs, _ = _fetch_pcm_designs()
|
||||
match = next(
|
||||
(d for d in designs if d["design_id"] == str(design_id)), None
|
||||
)
|
||||
size = (match or {}).get("size") or "46"
|
||||
if match and match.get("name"):
|
||||
name = match["name"]
|
||||
front = {
|
||||
"design_id": design_id,
|
||||
"size": size,
|
||||
"name": name,
|
||||
"provider": "pcm",
|
||||
}
|
||||
return MessageTemplate.objects.create(
|
||||
channel=Channel.POSTCARD,
|
||||
name=name[:120],
|
||||
subject="",
|
||||
body=f"PCM design {design_id}",
|
||||
postcard_front=front,
|
||||
postcard_back={},
|
||||
)
|
||||
# Legacy: bare MessageTemplate pk
|
||||
return MessageTemplate.objects.filter(
|
||||
pk=value, channel=Channel.POSTCARD
|
||||
).first()
|
||||
|
||||
|
||||
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.POSTCARD:
|
||||
return (
|
||||
[PROVIDER_PCM],
|
||||
"Recent PCM Integrations events",
|
||||
"No PCM webhook events yet. PCM must POST to "
|
||||
"<code>/portal/directmail/webhooks/postcard/</code>.",
|
||||
)
|
||||
if campaign.channel == Channel.SMS:
|
||||
return (
|
||||
[PROVIDER_SMS],
|
||||
"Recent SMTP2GO events",
|
||||
"No webhook events yet. SMTP2GO must POST SMS events to "
|
||||
"<code>/portal/directmail/webhooks/smtp2go/</code>.",
|
||||
)
|
||||
return (
|
||||
[PROVIDER_EMAIL],
|
||||
"Recent SMTP2GO events",
|
||||
"No webhook events yet. SMTP2GO must POST opens/clicks to "
|
||||
"<code>/portal/directmail/webhooks/smtp2go/</code> "
|
||||
"(see directmail 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,
|
||||
)
|
||||
|
||||
|
||||
def _pcm_webhook_secrets() -> list[str]:
|
||||
"""All PCM subscription signature secrets from env."""
|
||||
raw_list = (getattr(settings, "PCM_WEBHOOK_SECRETS", None) or "").strip()
|
||||
single = (getattr(settings, "PCM_WEBHOOK_SECRET", None) or "").strip()
|
||||
out: list[str] = []
|
||||
if raw_list:
|
||||
out.extend(p.strip() for p in raw_list.split(",") if p.strip())
|
||||
if single and single not in out:
|
||||
out.append(single)
|
||||
return out
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def campaign_list(request):
|
||||
form_errors: list[str] = []
|
||||
form_data = {
|
||||
"name": "",
|
||||
"subject": "",
|
||||
"body": "",
|
||||
"audience": Campaign.Audience.POSTCARD_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,
|
||||
}
|
||||
)
|
||||
|
||||
template = None
|
||||
if template_id:
|
||||
template = _resolve_postcard_template(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.")
|
||||
if audience == Campaign.Audience.POSTCARD_OPT_IN:
|
||||
if not template or template.channel != Channel.POSTCARD:
|
||||
form_errors.append(
|
||||
"Choose a postcard design (create one under Postcard design)."
|
||||
)
|
||||
if not body:
|
||||
body = "Postcard mailing"
|
||||
else:
|
||||
if not body:
|
||||
form_errors.append("Body is required.")
|
||||
if (
|
||||
audience == Campaign.Audience.POSTCARD_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,
|
||||
template=template,
|
||||
)
|
||||
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("directmail:campaign_detail", pk=campaign.pk)
|
||||
|
||||
campaigns = Campaign.objects.all()[:100]
|
||||
return render(
|
||||
request,
|
||||
"directmail/campaign_list.html",
|
||||
{
|
||||
"campaigns": campaigns,
|
||||
"audience_choices": _audience_choices(),
|
||||
"postcard_designs": _postcard_design_choices(),
|
||||
"form_data": form_data,
|
||||
"form_errors": form_errors,
|
||||
"image_upload_url": reverse("directmail:campaign_image_upload"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
"directmail/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"],
|
||||
"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 directmail.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("directmail: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('directmail:campaign_detail', kwargs={'pk': campaign.pk})}"
|
||||
f"?page={page}"
|
||||
)
|
||||
return redirect("directmail: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("directmail: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("directmail: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("directmail:campaign_detail", pk=campaign.pk)
|
||||
try:
|
||||
validate_email(to_email)
|
||||
except ValidationError:
|
||||
messages.error(request, "That test email address is not valid.")
|
||||
return redirect("directmail: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("directmail: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)})
|
||||
|
||||
|
||||
@require_GET
|
||||
def stored_file(request, pk):
|
||||
"""Public fetch for email clients / preview (UUID acts as capability token)."""
|
||||
stored = get_object_or_404(StoredFile, pk=pk)
|
||||
response = FileResponse(
|
||||
io.BytesIO(bytes(stored.data)),
|
||||
content_type=stored.content_type or "application/octet-stream",
|
||||
)
|
||||
if stored.filename:
|
||||
response["Content-Disposition"] = f'inline; filename="{stored.filename}"'
|
||||
response["Cache-Control"] = "public, max-age=86400"
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def postcard_designer(request):
|
||||
"""PCM Integrations designer — list designs + embed iframe."""
|
||||
designs, api_error = _fetch_pcm_designs()
|
||||
embed_url = ""
|
||||
active_design_id = (request.GET.get("design_id") or "").strip()
|
||||
active_name = ""
|
||||
active_size = "46"
|
||||
|
||||
if active_design_id:
|
||||
match = next(
|
||||
(d for d in designs if d["design_id"] == active_design_id), None
|
||||
)
|
||||
if match:
|
||||
active_name = match["name"]
|
||||
active_size = match.get("size") or "46"
|
||||
else:
|
||||
active_name = f"Design {active_design_id}"
|
||||
try:
|
||||
embed_url = get_design_embed_url(active_design_id)
|
||||
except PcmApiError as exc:
|
||||
api_error = api_error or str(exc)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"directmail/postcard_designer.html",
|
||||
{
|
||||
"api_error": api_error,
|
||||
"designs": designs,
|
||||
"embed_url": embed_url,
|
||||
"active_design_id": active_design_id,
|
||||
"active_name": active_name,
|
||||
"active_size": active_size,
|
||||
"size_choices": PCM_SIZE_CHOICES,
|
||||
"new_name": "",
|
||||
"new_size": "46",
|
||||
"saved_templates": _postcard_templates(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def postcard_design_create(request):
|
||||
name = (request.POST.get("name") or "").strip() or "Untitled postcard"
|
||||
size = (request.POST.get("size") or "46").strip()
|
||||
allowed = {code for code, _ in PCM_SIZE_CHOICES}
|
||||
if size not in allowed:
|
||||
messages.error(request, "Invalid postcard size.")
|
||||
return redirect("directmail:postcard_designer")
|
||||
try:
|
||||
data = create_custom_design(name=name, size=size)
|
||||
except PcmApiError as exc:
|
||||
messages.error(request, f"PCM create failed: {exc}")
|
||||
return redirect("directmail:postcard_designer")
|
||||
|
||||
design_id = data.get("designID") or data.get("design_id")
|
||||
if design_id is None:
|
||||
messages.error(request, "PCM did not return a design ID.")
|
||||
return redirect("directmail:postcard_designer")
|
||||
messages.success(request, f"Design {design_id} created — edit below.")
|
||||
return redirect(
|
||||
f"{reverse('directmail:postcard_designer')}?design_id={design_id}"
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def postcard_design_save(request):
|
||||
design_id = (request.POST.get("design_id") or "").strip()
|
||||
template_name = (request.POST.get("template_name") or "").strip()
|
||||
size = (request.POST.get("size") or "").strip()
|
||||
if not design_id:
|
||||
messages.error(request, "Missing design id.")
|
||||
return redirect("directmail:postcard_designer")
|
||||
if not template_name:
|
||||
messages.error(request, "Template name is required.")
|
||||
return redirect(
|
||||
f"{reverse('directmail:postcard_designer')}?design_id={design_id}"
|
||||
)
|
||||
try:
|
||||
design_id_int = int(design_id)
|
||||
except ValueError:
|
||||
messages.error(request, "Invalid design id.")
|
||||
return redirect("directmail:postcard_designer")
|
||||
|
||||
front = {
|
||||
"design_id": design_id_int,
|
||||
"size": size,
|
||||
"name": template_name,
|
||||
"provider": "pcm",
|
||||
}
|
||||
tmpl, created = MessageTemplate.objects.update_or_create(
|
||||
channel=Channel.POSTCARD,
|
||||
name=template_name,
|
||||
defaults={
|
||||
"subject": "",
|
||||
"body": f"PCM design {design_id_int}",
|
||||
"postcard_front": front,
|
||||
"postcard_back": {},
|
||||
},
|
||||
)
|
||||
verb = "Created" if created else "Updated"
|
||||
messages.success(
|
||||
request,
|
||||
f"{verb} postcard template “{tmpl.name}” (design {design_id_int}).",
|
||||
)
|
||||
return redirect(
|
||||
f"{reverse('directmail:postcard_designer')}?design_id={design_id}"
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def postcard_webhook(request):
|
||||
"""
|
||||
PCM Integrations order / mail-tracking webhook.
|
||||
|
||||
Configure in PCM → Webhooks (one subscription per event):
|
||||
URL: https://<host>/portal/directmail/webhooks/postcard/
|
||||
Copy each subscription's signature secret into PCM_WEBHOOK_SECRETS
|
||||
"""
|
||||
channel = "postcard"
|
||||
_log_webhook_request(request, channel=channel)
|
||||
if not _webhook_authorized(request, secrets=_pcm_webhook_secrets()):
|
||||
_log_webhook_auth_failed(request, channel=channel)
|
||||
return HttpResponseForbidden("invalid webhook token")
|
||||
payload = parse_webhook_payload(request)
|
||||
if not payload:
|
||||
payload = request.POST.dict() or {}
|
||||
try:
|
||||
event = process_pcm_postcard_webhook(payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("PCM postcard webhook processing failed")
|
||||
_log_webhook_result(channel=channel, error=str(exc))
|
||||
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
|
||||
_log_webhook_result(channel=channel, event=event)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"matched": bool(event and event.message_id),
|
||||
"event_id": event.pk if event else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_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/directmail/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
|
||||
|
||||
Reference in New Issue
Block a user