Populate the client website template with catalog feature flags.

Extract always-on public/portal/UTM plus optional email_sms, directmail, blog, payments, social, and social_ai so new client sites can be bootstrapped from this seed.

Refs #1
Refs #2

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 07:55:26 -05:00
co-authored by Cursor
parent 45d0888d33
commit 787f0e48fb
297 changed files with 32534 additions and 3 deletions
+133
View File
@@ -0,0 +1,133 @@
# Messaging
Campaign compose/send, SMTP2GO email + SMS, PCM Integrations postcards, and delivery webhooks.
## SMTP2GO webhook setup
Campaign report page polls provider events every 10s. Create **one** SMTP2GO
webhook (email + SMS share a URL — paid plans cap at 10 webhooks).
### Auth (`SMTP2GO_WEBHOOK_SECRET`)
1. Set `SMTP2GO_WEBHOOK_SECRET` in `.env` / prod env (long random string).
2. In SMTP2GO, set **Authorization header** to **Bearer** and paste that same secret
(do not leave it as “None”).
3. Fallback: `?token=<SMTP2GO_WEBHOOK_SECRET>` on the webhook URL also works.
### Unified email + SMS webhook
| Field | Value |
|-------|--------|
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/smtp2go/` |
| Authorization header | **Bearer** + `SMTP2GO_WEBHOOK_SECRET` |
| Output type | JSON |
| Users | email SMTP user(s) **and** the SMS API key (`SMTP2GO_SMS_API_KEY`) |
| Email events | processed, bounced, rejected, spam, delivered, unsub/resub, opened, clicked |
| Email headers | `X-Monica-Message-Id` |
| SMS events | Submitted, Sending, Delivered, Failed, Rejected (and Opt-out if shown) |
The handler classifies each POST from the payload (`sms_*` / `destination_number`
→ SMS; `rcpt` / `email_id` / `X-Monica-Message-Id` → email; inbound `text=STOP`
without `event` → SMS opt-out).
`X-Monica-Message-Id` is set on every campaign email send and is required so email
webhook events match the correct recipient row. Invalid / missing header values
no longer 500 the endpoint (SMTP2GO “Test this webhook” often sends a sample
non-UUID).
SMS correlation uses `message_id` (SMS id), then `destination_number` phone
fallback. Do **not** treat webhook `id` as the SMS id.
**Opt-out:** SMTP2GO auto-handles replies `STOP` / `UNSUB` / `UNSUBSCRIBE`.
This endpoint also accepts inbound POSTs without an `event` field
(`text=STOP`, `from=…`) and opts the contact out of SMS. Later sends to
opted-out numbers are typically `sms_rejected`.
Beta / other hosts: swap the hostname, keep the path.
Legacy aliases (same handler): `/portal/messaging/webhooks/email/` and
`/portal/messaging/webhooks/sms/` — prefer `/smtp2go/` for new configs.
## PCM Integrations (postcards)
Default postcard provider. Designer embeds PCMs editor; orders use DirectMail API v3.
### Env
| Var | Purpose |
|-----|---------|
| `PCM_API_KEY` | API key from PCM portal (My Account → API Keys) |
| `PCM_API_SECRET` | Matching API secret; used with key on `POST /auth/login` |
| `PCM_CHILD_REF_NBR` | Optional child-app ref for multi-account |
| `PCM_WEBHOOK_SECRETS` | Comma-separated signature secrets (one per PCM subscription) |
| `PCM_WEBHOOK_SECRET` | Optional single-secret alias (merged into the list above) |
| `PCM_RETURN_ADDRESS` | JSON return address on orders |
| `POSTCARD_PROVIDER` | `pcm` (default) |
Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived
`token` used as `Authorization: Bearer …` on design/order calls
([PCM Logging In](https://docs.pcmintegrations.com/docs/directmail-api/ffef03a112bb0-logging-in)).
### Designer
Portal → **Postcard design**: create/list designs via API, edit in iframe
(`POST /design/custom`, `GET /design/{id}/edit?mode=embed`). Save as a
`MessageTemplate` (stores `design_id`) then pick it when composing a postcard campaign.
### Postcard webhook
PCM allows **one event per subscription**, and each subscription gets its own
**signature secret** (copy-only in the UI). Create one subscription per status
you care about; point them all at the same URL and paste every secret into env.
| Field | Value |
|-------|--------|
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/postcard/` |
| Events | One subscription each: Pending, Processing, Processed, Delivered, Undeliverable, Canceled (skip QrCodeScan unless needed) |
| Environments | Sandbox and/or Production as needed |
| Secrets | Copy each subscription signature → `PCM_WEBHOOK_SECRETS=sec1,sec2,…` |
We accept Bearer, `?token=`, or common signature headers (raw secret or
HMAC-SHA256 of body) matching **any** listed secret.
Correlation: we send `extRefNbr=<Message.uuid>` on each recipient; webhooks should
echo that (or `orderID`, matched to `Message.provider_message_id`).
### Campaign completion email
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
goes to `campaign.created_by.email`, else `CONTACT_EMAIL`. Guarded by
`Campaign.notify_sent_at` so it only sends once.
### Local development
SMTP2GO / PCM cannot reach `localhost`. Use a tunnel (Cloudflare Tunnel / ngrok) to `:8000`,
or test webhooks against beta/prod.
For real SMTP delivery locally (not console logs):
```bash
# in .env
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
SMTP2GO_WEBHOOK_SECRET=
SMTP2GO_SMS_API_KEY=# SMS sends only
PCM_API_KEY=
PCM_API_SECRET=
PCM_WEBHOOK_SECRETS=sec1,sec2,…
PCM_RETURN_ADDRESS={}
```
### Endpoints (app)
| Path | Purpose |
|------|---------|
| `POST /portal/messaging/webhooks/smtp2go/` | Unified SMTP2GO email + SMS (+ inbound STOP) |
| `POST /portal/messaging/webhooks/email/` | Legacy alias → same as `/smtp2go/` |
| `POST /portal/messaging/webhooks/sms/` | Legacy alias → same as `/smtp2go/` |
| `POST /portal/messaging/webhooks/postcard/` | PCM order / mail tracking events |
| `GET /portal/messaging/campaigns/<id>/status.json` | Live stats for the campaign report UI |
| `GET /portal/messaging/postcard/` | PCM designer iframe |
Code: `webhooks.py`, `providers/postcard/pcm.py`, `views.py`.
View File
+40
View File
@@ -0,0 +1,40 @@
from django.contrib import admin
from directmail.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 DirectmailConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "directmail"
verbose_name = "Direct mail"
def ready(self):
from directmail import hooks
hooks.register()
+21
View File
@@ -0,0 +1,21 @@
"""Channel dispatch — postcard / direct mail."""
from dataclasses import dataclass
from contacts.models import Channel
from directmail.models import Message
from directmail.providers.postcard import get_postcard_provider
@dataclass
class ProviderResult:
provider: str
provider_id: str
def dispatch_message(message: Message) -> ProviderResult:
if message.channel == Channel.POSTCARD:
provider = get_postcard_provider()
result = provider.send_postcard(message)
return ProviderResult(provider=provider.name, provider_id=result.provider_id)
raise ValueError(f"Unsupported channel: {message.channel}")
+58
View File
@@ -0,0 +1,58 @@
"""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("directmail")
register_portal_nav(
section="directmail",
label="Direct mail",
url_name="directmail:campaign_list",
group="Outreach",
order=30,
)
register_portal_nav(
section="postcard",
label="Postcard design",
url_name="directmail:postcard_designer",
group="Outreach",
order=31,
)
register_dashboard_collector(_dashboard)
register_dispatcher(_dispatch_due)
def _dashboard(request) -> dict:
from directmail.models import Campaign
upcoming = list(
Campaign.objects.exclude(
status__in=[Campaign.Status.COMPLETED, Campaign.Status.CANCELLED]
).order_by("scheduled_for", "-created_at")[:5]
)
return {"upcoming_directmail": upcoming}
def _dispatch_due() -> int:
from directmail.models import Message
from directmail.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
@@ -0,0 +1,93 @@
# 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()),
('postcard_front', models.JSONField(blank=True, default=dict)),
('postcard_back', models.JSONField(blank=True, default=dict)),
],
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=[('postcard_opt_in', 'Mailing list · postcard 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='directmail_campaigns_created', to=settings.AUTH_USER_MODEL)),
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='directmail_campaigns', to='directmail.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='directmail.campaign')),
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='directmail_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='directmail.message')),
],
options={
'abstract': False,
},
),
]
+118
View File
@@ -0,0 +1,118 @@
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()
postcard_front = models.JSONField(default=dict, blank=True)
postcard_back = models.JSONField(default=dict, blank=True)
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):
POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard 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="directmail_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="directmail_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="directmail_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)
@@ -0,0 +1,42 @@
"""Pluggable postcard providers."""
from dataclasses import dataclass
from typing import Protocol
from django.conf import settings
@dataclass
class PostcardResult:
provider_id: str
class PostcardProvider(Protocol):
name: str
def send_postcard(self, message) -> PostcardResult: ...
def get_status(self, provider_id: str) -> str: ...
def get_postcard_provider() -> PostcardProvider:
name = (settings.POSTCARD_PROVIDER or "pcm").lower()
if name == "pcm":
from directmail.providers.postcard.pcm import PcmProvider
return PcmProvider()
if name == "click2mail":
from directmail.providers.postcard.click2mail import Click2MailProvider
return Click2MailProvider()
if name == "postgrid":
from directmail.providers.postcard.postgrid import PostGridProvider
return PostGridProvider()
if name == "lob":
from directmail.providers.postcard.lob import LobProvider
return LobProvider()
from directmail.providers.postcard.pcm import PcmProvider
return PcmProvider()
@@ -0,0 +1,23 @@
"""Click2Mail postcard adapter (low-volume pay-per-piece option)."""
from dataclasses import dataclass
from django.conf import settings
from directmail.providers.postcard import PostcardResult
@dataclass
class Click2MailProvider:
name: str = "click2mail"
def send_postcard(self, message) -> PostcardResult:
if not settings.CLICK2MAIL_API_KEY:
raise RuntimeError("CLICK2MAIL_API_KEY is not configured")
# Placeholder: wire full Click2Mail job API when account credentials are ready.
raise NotImplementedError(
"Click2Mail adapter stub — configure account then implement job submit"
)
def get_status(self, provider_id: str) -> str:
return "unknown"
+66
View File
@@ -0,0 +1,66 @@
"""Lob postcard adapter (default)."""
from dataclasses import dataclass
import requests
from django.conf import settings
from directmail.providers.postcard import PostcardResult
@dataclass
class LobProvider:
name: str = "lob"
def send_postcard(self, message) -> PostcardResult:
api_key = settings.LOB_API_KEY
if not api_key:
raise RuntimeError("LOB_API_KEY is not configured")
contact = message.contact
address = contact.postal_address or {}
if not address.get("line1"):
raise ValueError("Contact postal_address.line1 required for postcard")
# Minimal Lob create-postcard payload; artwork URLs come from template JSON.
template = message.campaign.template
front = (template.postcard_front if template else {}) or {}
back = (template.postcard_back if template else {}) or {}
payload = {
"description": f"campaign-{message.campaign_id}",
"to": {
"name": contact.full_name or contact.email or "Resident",
"address_line1": address.get("line1", ""),
"address_line2": address.get("line2", ""),
"address_city": address.get("city", ""),
"address_state": address.get("state", ""),
"address_zip": address.get("zip", ""),
"address_country": address.get("country", "US"),
},
"front": front.get("html") or front.get("url") or "<html></html>",
"back": back.get("html") or back.get("url") or "<html></html>",
}
response = requests.post(
"https://api.lob.com/v1/postcards",
json=payload,
auth=(api_key, ""),
timeout=60,
headers={"Idempotency-Key": str(message.pk)},
)
response.raise_for_status()
data = response.json()
return PostcardResult(provider_id=str(data.get("id") or message.pk))
def get_status(self, provider_id: str) -> str:
api_key = settings.LOB_API_KEY
if not api_key:
return "unknown"
response = requests.get(
f"https://api.lob.com/v1/postcards/{provider_id}",
auth=(api_key, ""),
timeout=30,
)
if not response.ok:
return "unknown"
return str(response.json().get("status") or "unknown")
+422
View File
@@ -0,0 +1,422 @@
"""PCM Integrations (DirectMail API v3) postcard adapter."""
from __future__ import annotations
import json
import logging
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
import requests
from django.conf import settings
from directmail.providers.postcard import PostcardResult
logger = logging.getLogger(__name__)
PCM_API_BASE = "https://v3.pcmintegrations.com"
# Refresh a bit before PCM's expires timestamp.
_TOKEN_SKEW = timedelta(seconds=60)
# PCM size codes for custom designer designs.
PCM_SIZE_CHOICES = (
("46", "4.25 × 6"),
("68", "6 × 8.5"),
("69", "6 × 9"),
("611", "6 × 11"),
("811", "8.5 × 11"),
)
class PcmApiError(RuntimeError):
"""Raised when a PCM API call fails."""
_token_lock = threading.Lock()
_cached_token: str | None = None
_cached_expires: datetime | None = None
def _api_key() -> str:
return (settings.PCM_API_KEY or "").strip()
def _api_secret() -> str:
return (settings.PCM_API_SECRET or "").strip()
def _parse_expires(raw: Any) -> datetime:
"""Parse PCM expires timestamp; default to 55 minutes from now."""
if isinstance(raw, (int, float)):
# Unix seconds vs ms
ts = float(raw)
if ts > 1e12:
ts /= 1000.0
return datetime.fromtimestamp(ts, tz=timezone.utc)
if isinstance(raw, str) and raw.strip():
text = raw.strip().replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(text)
except ValueError:
dt = None
if dt is not None:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
return datetime.now(timezone.utc) + timedelta(minutes=55)
def clear_token_cache() -> None:
"""Drop cached bearer token (tests / forced re-login)."""
global _cached_token, _cached_expires
with _token_lock:
_cached_token = None
_cached_expires = None
def login(*, force: bool = False) -> str:
"""POST /auth/login with apiKey + apiSecret → bearer token.
See https://docs.pcmintegrations.com/docs/directmail-api/ffef03a112bb0-logging-in
"""
global _cached_token, _cached_expires
with _token_lock:
now = datetime.now(timezone.utc)
if (
not force
and _cached_token
and _cached_expires
and now < (_cached_expires - _TOKEN_SKEW)
):
return _cached_token
key = _api_key()
secret = _api_secret()
if not key or not secret:
raise PcmApiError(
"PCM_API_KEY and PCM_API_SECRET are required "
"(POST /auth/login, then use the returned token)"
)
body: dict[str, str] = {"apiKey": key, "apiSecret": secret}
child = (getattr(settings, "PCM_CHILD_REF_NBR", None) or "").strip()
if child:
body["childRefNbr"] = child
response = requests.post(
f"{PCM_API_BASE}/auth/login",
headers={
"Accept": "application/json",
"Content-Type": "application/json",
},
json=body,
timeout=30,
)
if response.status_code >= 400:
detail = (response.text or "")[:500]
raise PcmApiError(
f"PCM POST /auth/login → {response.status_code}: {detail}"
)
try:
data = response.json()
except ValueError as exc:
raise PcmApiError("PCM login returned non-JSON") from exc
if not isinstance(data, dict):
raise PcmApiError("PCM login returned unexpected payload")
token = str(data.get("token") or "").strip()
if not token:
raise PcmApiError("PCM login response missing token")
_cached_token = token
_cached_expires = _parse_expires(data.get("expires"))
logger.info(
"PCM login ok; token expires %s",
_cached_expires.isoformat(),
)
return token
def _headers(*, force_login: bool = False) -> dict[str, str]:
token = login(force=force_login)
return {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def pcm_request(
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
timeout: int = 60,
) -> Any:
"""Call PCM v3 API. ``path`` is absolute under the API host (e.g. ``/design``)."""
url = f"{PCM_API_BASE}{path}"
response = requests.request(
method,
url,
headers=_headers(),
params=params,
json=json_body,
timeout=timeout,
)
# Expired/revoked session → login once and retry.
if response.status_code == 401:
clear_token_cache()
response = requests.request(
method,
url,
headers=_headers(force_login=True),
params=params,
json=json_body,
timeout=timeout,
)
if response.status_code >= 400:
detail = (response.text or "")[:500]
raise PcmApiError(
f"PCM {method} {path}{response.status_code}: {detail}"
)
if not response.content:
return {}
try:
return response.json()
except ValueError:
return {"raw": response.text}
def return_address_from_settings() -> dict[str, str]:
"""Build PCM returnAddress from PCM_RETURN_ADDRESS JSON and/or PCM_RETURN_* vars.
PCM requires address/city/state (and zip) whenever firstName is set — an empty
street with SITE_NAME-derived firstName causes a 400 from POST /order/postcard.
"""
data: dict[str, Any] = {}
raw = (settings.PCM_RETURN_ADDRESS or "").strip()
if raw:
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise PcmApiError(
"PCM_RETURN_ADDRESS must be valid JSON "
'(e.g. {"firstName":"","lastName":"","address":"",'
'"city":"","state":"IL","zipCode":""})'
) from exc
if not isinstance(parsed, dict):
raise PcmApiError("PCM_RETURN_ADDRESS must be a JSON object")
data = parsed
name = (settings.SITE_NAME or "").strip()
parts = name.split(None, 1)
site_first = parts[0] if parts else ""
site_last = parts[1] if len(parts) > 1 else ""
address = {
"company": str(data.get("company") or "").strip(),
"firstName": str(
data.get("firstName") or data.get("first_name") or site_first or ""
).strip(),
"lastName": str(
data.get("lastName") or data.get("last_name") or site_last or ""
).strip(),
"address": str(
data.get("address")
or data.get("line1")
or getattr(settings, "PCM_RETURN_LINE1", "")
or ""
).strip(),
# PCM treats blank address2 oddly; space matches recipient payload.
"address2": str(
data.get("address2")
or data.get("line2")
or getattr(settings, "PCM_RETURN_LINE2", "")
or ""
).strip()
or " ",
"city": str(
data.get("city") or getattr(settings, "PCM_RETURN_CITY", "") or ""
).strip(),
"state": str(
data.get("state") or getattr(settings, "PCM_RETURN_STATE", "") or ""
).strip(),
"zipCode": str(
data.get("zipCode")
or data.get("zip")
or getattr(settings, "PCM_RETURN_ZIP", "")
or ""
).strip(),
}
missing = [
field
for field in ("firstName", "address", "city", "state", "zipCode")
if not address.get(field, "").strip()
]
if missing:
raise PcmApiError(
"PCM return address incomplete (missing "
+ ", ".join(missing)
+ "). Set PCM_RETURN_ADDRESS JSON with firstName, lastName, "
"address, city, state, zipCode "
"(or PCM_RETURN_LINE1 / CITY / STATE / ZIP)."
)
return address
def contact_to_pcm_recipient(contact, *, ext_ref: str) -> dict[str, str]:
address = contact.postal_address or {}
line1 = (address.get("line1") or "").strip()
if not line1:
raise ValueError("Contact postal_address.line1 required for postcard")
first = (contact.first_name or "").strip()
last = (contact.last_name or "").strip()
if not first and not last:
# PCM requires name or company.
first = (contact.full_name or contact.email or "Resident").strip()
return {
"firstName": first,
"lastName": last,
"address": line1,
"address2": (address.get("line2") or "").strip() or " ",
"city": (address.get("city") or "").strip(),
"state": (address.get("state") or "").strip(),
"zipCode": (address.get("zip") or "").strip(),
"extRefNbr": ext_ref,
}
def list_designs(*, product_type: str = "postcard", page: int = 1, per_page: int = 50) -> list[dict]:
data = pcm_request(
"GET",
"/design",
params={
"productType": product_type,
"page": page,
"perPage": per_page,
},
)
if isinstance(data, dict):
results = data.get("results") or data.get("designs") or []
return results if isinstance(results, list) else []
return []
def create_custom_design(*, name: str, size: str) -> dict[str, Any]:
"""POST /design/custom → designID + embed url."""
return pcm_request(
"POST",
"/design/custom",
json_body={"name": name, "size": size},
)
def get_design_embed_url(design_id: int | str, *, duplicate: bool = False) -> str:
"""GET /design/{id}/edit?mode=embed → iframe URL."""
params: dict[str, Any] = {"mode": "embed"}
if duplicate:
params["duplicate"] = "true"
data = pcm_request("GET", f"/design/{design_id}/edit", params=params)
if not isinstance(data, dict):
raise PcmApiError("Unexpected embed response from PCM")
url = data.get("embed_url") or data.get("url") or ""
if not url:
raise PcmApiError("PCM did not return an embed URL")
return str(url)
def get_order(order_id: int | str) -> dict[str, Any]:
data = pcm_request("GET", f"/order/{order_id}")
return data if isinstance(data, dict) else {}
def place_postcard_order(
*,
design_id: int,
recipient: dict[str, str],
ext_ref: str,
mail_class: str = "FirstClass",
) -> str:
"""Place a one-recipient postcard order; return PCM orderID as string."""
payload = {
"designID": design_id,
"mailClass": mail_class,
"extRefNbr": ext_ref,
"returnAddress": return_address_from_settings(),
"recipients": [recipient],
}
# Product-specific path — plain POST /order is 404 ("Cannot POST /order").
# Docs: Place Postcard Order → POST /order/postcard
data = pcm_request("POST", "/order/postcard", json_body=payload)
if not isinstance(data, dict):
raise PcmApiError("Unexpected order response from PCM")
order_id = data.get("orderID") or data.get("orderId") or data.get("id")
if order_id is None and isinstance(data.get("results"), list) and data["results"]:
order_id = data["results"][0].get("orderID")
if order_id is None:
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400])
return str(order_id)
def design_id_from_template(template) -> int | None:
"""Read design_id from MessageTemplate.postcard_front JSON."""
if not template:
return None
front = template.postcard_front or {}
if not isinstance(front, dict):
return None
raw = front.get("design_id") or front.get("designID")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
@dataclass
class PcmProvider:
name: str = "pcm"
def send_postcard(self, message) -> PostcardResult:
template = message.campaign.template if message.campaign_id else None
design_id = design_id_from_template(template)
if not design_id:
raise ValueError(
"Postcard campaign template missing PCM design_id "
"(save a design from the postcard designer first)"
)
recipient = contact_to_pcm_recipient(
message.contact, ext_ref=str(message.pk)
)
mail_class = "FirstClass"
if template and isinstance(template.postcard_front, dict):
mail_class = (
template.postcard_front.get("mail_class") or mail_class
)
order_id = place_postcard_order(
design_id=design_id,
recipient=recipient,
ext_ref=str(message.pk),
mail_class=str(mail_class),
)
return PostcardResult(provider_id=order_id)
def get_status(self, provider_id: str) -> str:
try:
data = get_order(provider_id)
except PcmApiError:
logger.exception("PCM get_status failed for %s", provider_id)
return "unknown"
return str(data.get("status") or "unknown")
@@ -0,0 +1,22 @@
"""PostGrid postcard adapter."""
from dataclasses import dataclass
from django.conf import settings
from directmail.providers.postcard import PostcardResult
@dataclass
class PostGridProvider:
name: str = "postgrid"
def send_postcard(self, message) -> PostcardResult:
if not settings.POSTGRID_API_KEY:
raise RuntimeError("POSTGRID_API_KEY is not configured")
raise NotImplementedError(
"PostGrid adapter stub — configure account then implement send"
)
def get_status(self, provider_id: str) -> str:
return "unknown"
+360
View File
@@ -0,0 +1,360 @@
"""Campaign draft/send helpers for direct mail."""
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.scheduling import parse_scheduled_for
from directmail.models import Campaign, Message, MessageTemplate
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractBaseUser
AUDIENCE_CHANNEL = {
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
}
# {{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,
)
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("directmail: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 directmail.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 directmail.channels import dispatch_message
from directmail.models import Message
from directmail.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,299 @@
{% extends "portal_base.html" %}
{% 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 'directmail: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 'directmail: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 'directmail: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="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 'directmail: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>
(function () {
var panel = document.getElementById("recipients-panel");
var page = (panel && panel.getAttribute("data-page")) || "1";
var removeBase = "{% url 'directmail:campaign_message_remove' campaign.pk '00000000-0000-0000-0000-000000000000' %}";
var csrfToken = "{{ csrf_token }}";
var url = "{% url 'directmail: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,376 @@
{% extends "portal_base.html" %}
{% 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>
<a href="#compose-postcard" data-channel="postcard">Postcard</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 'directmail: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>
<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 / postcard</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="field" id="postcard-body-hint" hidden>
<p class="hint-block" style="margin:0">
Postcard campaigns use the selected PCM design. Optional note below is stored on the draft only.
</p>
<label for="id_body_pc" style="margin-top:8px">Internal note <span class="muted">(optional)</span></label>
<textarea id="id_body_pc" style="min-height:72px"
placeholder="Optional internal note…"
oninput="syncPostcardBody()">{{ form_data.body }}</textarea>
</div>
<div class="field" id="postcard-template-field">
<label for="id_template_id">Postcard design</label>
<select id="id_template_id" name="template_id">
<option value="">— Select a PCM design —</option>
{% for d in postcard_designs %}
<option value="{{ d.value }}"{% if form_data.template_id == d.value %} selected{% endif %}>
{{ d.label }}
</option>
{% empty %}
<option value="" disabled>No designs yet — create one first</option>
{% endfor %}
</select>
<div class="hint" style="display:flex;gap:12px;flex-wrap:wrap;margin-top:8px">
<a class="btn btn-ghost btn-sm" href="{% url 'directmail:postcard_designer' %}">Create / update postcard design</a>
</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>
<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 'directmail: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 'directmail: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>
(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;
function csrfHeader() {
return { 'X-CSRFToken': csrfToken };
}
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 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 {
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);
});
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', 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();
syncComposeChannel();
})();
</script>
{% endblock %}
@@ -0,0 +1,135 @@
{% extends "portal_base.html" %}
{% block title %}Postcard designer · Portal{% endblock %}
{% block topbar_title %}Postcard designer{% endblock %}
{% block portal_content %}
{% if api_error %}
<ul class="portal-flash" style="margin:0 0 16px">
<li class="error">{{ api_error }}</li>
</ul>
{% endif %}
<div class="designer-layout pcm-designer">
<div class="designer-controls">
<div class="panel">
<div class="panel-h"><h2>New design</h2></div>
<div class="panel-b">
<form method="post" action="{% url 'directmail:postcard_design_create' %}" class="form-grid">
{% csrf_token %}
<div class="field">
<label for="id_design_name">Name</label>
<input id="id_design_name" name="name" type="text" required
placeholder="March just-listed" value="{{ new_name }}">
</div>
<div class="field">
<label for="id_design_size">Size</label>
<select id="id_design_size" name="size" required>
{% for code, label in size_choices %}
<option value="{{ code }}"{% if code == new_size %} selected{% endif %}>{{ label }} ({{ code }})</option>
{% endfor %}
</select>
</div>
<button class="btn btn-primary" type="submit">Create in PCM designer</button>
</form>
<p class="library-hint" style="margin-top:12px">
Opens PCM Integrations editor in the frame. Artwork stays on their side;
we store the design id for campaigns.
</p>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Your designs</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>ID</th>
<th></th>
</tr>
</thead>
<tbody>
{% for d in designs %}
<tr{% if d.design_id == active_design_id %} class="is-active"{% endif %}>
<td>{{ d.name }}</td>
<td class="muted">{{ d.design_id }}</td>
<td>
<a href="{% url 'directmail:postcard_designer' %}?design_id={{ d.design_id }}">Edit</a>
</td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">No designs yet — create one above.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if active_design_id %}
<div class="panel">
<div class="panel-h"><h2>Save for campaigns</h2></div>
<div class="panel-b">
<form method="post" action="{% url 'directmail:postcard_design_save' %}" class="form-grid">
{% csrf_token %}
<input type="hidden" name="design_id" value="{{ active_design_id }}">
<input type="hidden" name="size" value="{{ active_size }}">
<div class="field">
<label for="id_template_name">Template name</label>
<input id="id_template_name" name="template_name" type="text" required
value="{{ active_name }}">
</div>
<button class="btn btn-primary" type="submit">Save as postcard template</button>
</form>
<p class="library-hint" style="margin-top:12px">
Designs listed above are selectable in Campaigns when Recipients is postcard.
Saving as a template keeps a named local copy.
</p>
</div>
</div>
{% endif %}
{% if saved_templates %}
<div class="panel">
<div class="panel-h"><h2>Saved templates</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Name</th><th>Design</th><th></th></tr>
</thead>
<tbody>
{% for t in saved_templates %}
<tr>
<td>{{ t.name }}</td>
<td class="muted">{{ t.postcard_front.design_id }}</td>
<td>
<a href="{% url 'directmail:postcard_designer' %}?design_id={{ t.postcard_front.design_id }}">Open</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
<div class="designer-preview-col panel">
<div class="panel-h">
<h2>{% if active_design_id %}PCM editor · design {{ active_design_id }}{% else %}Editor{% endif %}</h2>
</div>
<div class="panel-b pcm-iframe-wrap">
{% if embed_url %}
<iframe
title="PCM postcard designer"
src="{{ embed_url }}"
allow="clipboard-write"
></iframe>
{% else %}
<div class="empty-state" style="padding:48px 24px;text-align:center">
Create a design or pick one from the list to open the PCM editor here.
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+281
View File
@@ -0,0 +1,281 @@
from django.contrib.auth import get_user_model
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from unittest.mock import patch
from contacts.models import Channel, ConsentRecord, Contact, Suppression
from directmail.models import Campaign, Message, MessageTemplate, ProviderEvent
from contacts.consent import channel_preferences
from directmail.services import (
contact_may_receive,
create_campaign_draft,
opted_in_contacts,
set_channel_consent,
)
class PcmPostcardWebhookTests(TestCase):
def setUp(self):
self.client = Client()
self.contact = Contact.objects.create(
email="mail@example.com",
first_name="Pat",
last_name="Lee",
postal_address=Contact.make_postal_address(
line1="123 Main St",
city="Naperville",
state="IL",
zip_code="60540",
),
)
set_channel_consent(
self.contact, Channel.POSTCARD, opted_in=True, reason="test"
)
template = MessageTemplate.objects.create(
name="PCM test design",
channel=Channel.POSTCARD,
body="Postcard",
postcard_front={"design_id": 99, "size": "46", "provider": "pcm"},
)
self.campaign = create_campaign_draft(
name="March mailer",
audience=Campaign.Audience.POSTCARD_OPT_IN,
body="Postcard mailing",
template=template,
)
self.message = self.campaign.messages.get()
self.message.status = Message.Status.SENT
self.message.provider = "pcm"
self.message.provider_message_id = "order-555"
self.message.save()
def test_delivered_by_ext_ref(self):
import json
url = reverse("directmail:postcard_webhook")
response = self.client.post(
url,
data=json.dumps(
{
"status": "Delivered",
"extRefNbr": str(self.message.pk),
"orderID": 555,
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.message.refresh_from_db()
self.assertEqual(self.message.status, Message.Status.DELIVERED)
self.assertTrue(
ProviderEvent.objects.filter(
message=self.message, provider="pcm", event_type="Delivered"
).exists()
)
def test_requires_bearer_when_secret_set(self):
import json
url = reverse("directmail:postcard_webhook")
with self.settings(PCM_WEBHOOK_SECRET="pcm-secret", PCM_WEBHOOK_SECRETS=""):
denied = self.client.post(
url,
data=json.dumps({"status": "Delivered", "orderID": 555}),
content_type="application/json",
)
self.assertEqual(denied.status_code, 403)
ok = self.client.post(
url,
data=json.dumps(
{
"status": "Delivered",
"orderID": "order-555",
}
),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer pcm-secret",
)
self.assertEqual(ok.status_code, 200)
def test_accepts_any_secret_from_list(self):
import json
url = reverse("directmail:postcard_webhook")
with self.settings(
PCM_WEBHOOK_SECRET="",
PCM_WEBHOOK_SECRETS="sec-a,sec-b",
):
denied = self.client.post(
url,
data=json.dumps({"status": "Delivered", "orderID": "order-555"}),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer wrong",
)
self.assertEqual(denied.status_code, 403)
ok = self.client.post(
url,
data=json.dumps({"status": "Delivered", "orderID": "order-555"}),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer sec-b",
)
self.assertEqual(ok.status_code, 200)
class PcmAuthTests(TestCase):
def setUp(self):
from directmail.providers.postcard import pcm as pcm_mod
pcm_mod.clear_token_cache()
def tearDown(self):
from directmail.providers.postcard import pcm as pcm_mod
pcm_mod.clear_token_cache()
def test_login_required_before_design_list(self):
from unittest.mock import MagicMock, patch
from directmail.providers.postcard import pcm as pcm_mod
login_resp = MagicMock()
login_resp.status_code = 200
login_resp.content = b'{"token":"session-tok","expires":"2099-01-01T00:00:00.000Z"}'
login_resp.json.return_value = {
"token": "session-tok",
"expires": "2099-01-01T00:00:00.000Z",
}
design_resp = MagicMock()
design_resp.status_code = 200
design_resp.content = b'{"results":[]}'
design_resp.json.return_value = {"results": []}
with self.settings(PCM_API_KEY="key", PCM_API_SECRET="secret"):
with patch("directmail.providers.postcard.pcm.requests.post") as post:
with patch(
"directmail.providers.postcard.pcm.requests.request"
) as request:
post.return_value = login_resp
request.return_value = design_resp
designs = pcm_mod.list_designs()
self.assertEqual(designs, [])
post.assert_called_once()
self.assertIn("/auth/login", post.call_args.args[0])
self.assertEqual(
post.call_args.kwargs["json"],
{"apiKey": "key", "apiSecret": "secret"},
)
auth = request.call_args.kwargs["headers"]["Authorization"]
self.assertEqual(auth, "Bearer session-tok")
def test_missing_secret_raises(self):
from directmail.providers.postcard import pcm as pcm_mod
with self.settings(PCM_API_KEY="key", PCM_API_SECRET=""):
with self.assertRaises(pcm_mod.PcmApiError) as ctx:
pcm_mod.login(force=True)
self.assertIn("PCM_API_SECRET", str(ctx.exception))
def test_return_address_requires_street_fields(self):
from directmail.providers.postcard import pcm as pcm_mod
with self.settings(
SITE_NAME="Monica Dhillon",
PCM_RETURN_ADDRESS="",
PCM_RETURN_LINE1="",
PCM_RETURN_CITY="",
PCM_RETURN_STATE="",
PCM_RETURN_ZIP="",
):
with self.assertRaises(pcm_mod.PcmApiError) as ctx:
pcm_mod.return_address_from_settings()
self.assertIn("PCM return address incomplete", str(ctx.exception))
def test_return_address_from_json(self):
from directmail.providers.postcard import pcm as pcm_mod
with self.settings(
PCM_RETURN_ADDRESS=(
'{"firstName":"Mo","lastName":"D","address":"1 Main",'
'"city":"Naperville","state":"IL","zipCode":"60540"}'
),
):
addr = pcm_mod.return_address_from_settings()
self.assertEqual(addr["address"], "1 Main")
self.assertEqual(addr["zipCode"], "60540")
class PostcardAddressDefaultConsentTests(TestCase):
def test_address_without_consent_is_postcard_eligible(self):
contact = Contact.objects.create(
email="addr@example.com",
first_name="Ann",
postal_address=Contact.make_postal_address(
line1="1 Oak St",
city="Naperville",
state="IL",
zip_code="60540",
),
)
self.assertTrue(contact_may_receive(contact, Channel.POSTCARD))
prefs = channel_preferences(contact)
self.assertTrue(prefs[Channel.POSTCARD])
self.assertEqual(opted_in_contacts(Channel.POSTCARD).count(), 1)
def test_explicit_postcard_opt_out_respected(self):
contact = Contact.objects.create(
email="out@example.com",
postal_address=Contact.make_postal_address(line1="2 Oak St"),
)
set_channel_consent(
contact, Channel.POSTCARD, opted_in=False, reason="opt_out"
)
self.assertFalse(contact_may_receive(contact, Channel.POSTCARD))
self.assertEqual(opted_in_contacts(Channel.POSTCARD).count(), 0)
class PostcardDesignPickTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="pcm-pick", password="test-pass-123"
)
self.client = Client()
self.client.login(username="pcm-pick", password="test-pass-123")
self.contact = Contact.objects.create(
email="mail@example.com",
postal_address=Contact.make_postal_address(line1="9 Main"),
)
def test_compose_can_pick_pcm_design_id(self):
from unittest.mock import patch
with patch(
"directmail.views._fetch_pcm_designs",
return_value=(
[{"design_id": "42", "name": "Spring card", "size": "46"}],
"",
),
):
url = reverse("directmail:campaign_list")
response = self.client.post(
url,
{
"name": "Mailer",
"subject": "",
"body": "",
"audience": Campaign.Audience.POSTCARD_OPT_IN,
"template_id": "d:42",
},
)
campaign = Campaign.objects.get(name="Mailer")
self.assertEqual(response.status_code, 302)
self.assertEqual(campaign.channel, Channel.POSTCARD)
self.assertIsNotNone(campaign.template_id)
self.assertEqual(
campaign.template.postcard_front.get("design_id"), 42
)
self.assertEqual(campaign.messages.count(), 1)
+37
View File
@@ -0,0 +1,37 @@
from django.urls import path
from directmail import views
app_name = "directmail"
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>/messages/<uuid:message_id>/remove/",
views.campaign_message_remove,
name="campaign_message_remove",
),
path("postcard/", views.postcard_designer, name="postcard_designer"),
path(
"postcard/create/",
views.postcard_design_create,
name="postcard_design_create",
),
path(
"postcard/save/",
views.postcard_design_save,
name="postcard_design_save",
),
path(
"webhooks/postcard/",
views.postcard_webhook,
name="postcard_webhook",
),
]
+956
View File
@@ -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). 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,
)
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
+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 directmail.models import Message, ProviderEvent
from directmail.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 directmail.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),
}