Template
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:
@@ -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 PCM’s 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`.
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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 ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 & 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. You’ll 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 & 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 %}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,956 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.paginator import Paginator
|
||||
from django.core.validators import validate_email
|
||||
from django.http import FileResponse, HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||
|
||||
from contacts.models import Channel
|
||||
from core.models import StoredFile
|
||||
from directmail.models import Campaign, Message, MessageTemplate, ProviderEvent
|
||||
from directmail.providers.postcard.pcm import (
|
||||
PCM_SIZE_CHOICES,
|
||||
PcmApiError,
|
||||
create_custom_design,
|
||||
design_id_from_template,
|
||||
get_design_embed_url,
|
||||
list_designs,
|
||||
)
|
||||
from contacts.consent import opted_in_contacts
|
||||
from core.scheduling import parse_scheduled_for
|
||||
from directmail.services import (
|
||||
create_campaign_draft,
|
||||
enqueue_campaign_send,
|
||||
message_is_removable,
|
||||
)
|
||||
from directmail.webhooks import (
|
||||
PROVIDER_EMAIL,
|
||||
PROVIDER_PCM,
|
||||
PROVIDER_SMS,
|
||||
campaign_engagement_stats,
|
||||
classify_smtp2go_payload,
|
||||
parse_webhook_payload,
|
||||
process_pcm_postcard_webhook,
|
||||
process_smtp2go_email_webhook,
|
||||
process_smtp2go_sms_webhook,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECIPIENTS_PER_PAGE = 50
|
||||
|
||||
_ALLOWED_IMAGE_TYPES = frozenset(
|
||||
{"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
)
|
||||
_MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def _audience_choices() -> list[tuple[str, str]]:
|
||||
"""Labeled audience options with live opted-in counts."""
|
||||
rows = [
|
||||
(Campaign.Audience.POSTCARD_OPT_IN, Channel.EMAIL, "email"),
|
||||
(Campaign.Audience.SMS_OPT_IN, Channel.SMS, "SMS"),
|
||||
(Campaign.Audience.POSTCARD_OPT_IN, Channel.POSTCARD, "postcard"),
|
||||
]
|
||||
choices = []
|
||||
for value, channel, label in rows:
|
||||
count = opted_in_contacts(channel).count()
|
||||
noun = "contact" if count == 1 else "contacts"
|
||||
choices.append(
|
||||
(value, f"Mailing list · {label} opt-in ({count} {noun})")
|
||||
)
|
||||
return choices
|
||||
|
||||
|
||||
def _postcard_templates():
|
||||
return MessageTemplate.objects.filter(channel=Channel.POSTCARD).order_by(
|
||||
"-updated_at"
|
||||
)[:50]
|
||||
|
||||
|
||||
def _fetch_pcm_designs() -> tuple[list[dict], str]:
|
||||
"""Return (normalized design rows, api_error)."""
|
||||
designs: list[dict] = []
|
||||
api_error = ""
|
||||
try:
|
||||
remote = list_designs(product_type="postcard")
|
||||
for item in remote:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
did = item.get("designID") or item.get("design_id") or item.get("id")
|
||||
if did is None:
|
||||
continue
|
||||
size_info = item.get("size") or {}
|
||||
size_key = (
|
||||
size_info.get("key")
|
||||
if isinstance(size_info, dict)
|
||||
else size_info
|
||||
) or ""
|
||||
designs.append(
|
||||
{
|
||||
"design_id": str(did),
|
||||
"name": item.get("friendlyName")
|
||||
or item.get("name")
|
||||
or f"Design {did}",
|
||||
"size": str(size_key),
|
||||
}
|
||||
)
|
||||
except PcmApiError as exc:
|
||||
api_error = str(exc)
|
||||
|
||||
seen = {d["design_id"] for d in designs}
|
||||
for tmpl in _postcard_templates():
|
||||
front = tmpl.postcard_front or {}
|
||||
did = front.get("design_id")
|
||||
if did is None:
|
||||
continue
|
||||
did_s = str(did)
|
||||
if did_s in seen:
|
||||
continue
|
||||
designs.insert(
|
||||
0,
|
||||
{
|
||||
"design_id": did_s,
|
||||
"name": tmpl.name,
|
||||
"size": str(front.get("size") or ""),
|
||||
},
|
||||
)
|
||||
seen.add(did_s)
|
||||
return designs, api_error
|
||||
|
||||
|
||||
def _postcard_design_choices() -> list[dict]:
|
||||
"""Options for campaign compose: PCM designs + saved templates."""
|
||||
designs, _ = _fetch_pcm_designs()
|
||||
by_id = {d["design_id"]: d for d in designs}
|
||||
choices: list[dict] = []
|
||||
for tmpl in _postcard_templates():
|
||||
did = design_id_from_template(tmpl)
|
||||
if did is None:
|
||||
continue
|
||||
did_s = str(did)
|
||||
choices.append(
|
||||
{
|
||||
"value": f"t:{tmpl.pk}",
|
||||
"label": f"{tmpl.name} (design {did_s})",
|
||||
"design_id": did_s,
|
||||
}
|
||||
)
|
||||
by_id.pop(did_s, None)
|
||||
for did_s, d in by_id.items():
|
||||
choices.append(
|
||||
{
|
||||
"value": f"d:{did_s}",
|
||||
"label": f"{d['name']} (design {did_s})",
|
||||
"design_id": did_s,
|
||||
"size": d.get("size") or "46",
|
||||
"name": d["name"],
|
||||
}
|
||||
)
|
||||
return choices
|
||||
|
||||
|
||||
def _resolve_postcard_template(raw: str) -> MessageTemplate | None:
|
||||
"""Resolve compose select value ``t:<uuid>`` or ``d:<design_id>``."""
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
if value.startswith("t:"):
|
||||
return MessageTemplate.objects.filter(
|
||||
pk=value[2:], channel=Channel.POSTCARD
|
||||
).first()
|
||||
if value.startswith("d:"):
|
||||
design_raw = value[2:].strip()
|
||||
try:
|
||||
design_id = int(design_raw)
|
||||
except ValueError:
|
||||
return None
|
||||
for tmpl in MessageTemplate.objects.filter(channel=Channel.POSTCARD):
|
||||
if design_id_from_template(tmpl) == design_id:
|
||||
return tmpl
|
||||
name = f"PCM design {design_id}"
|
||||
designs, _ = _fetch_pcm_designs()
|
||||
match = next(
|
||||
(d for d in designs if d["design_id"] == str(design_id)), None
|
||||
)
|
||||
size = (match or {}).get("size") or "46"
|
||||
if match and match.get("name"):
|
||||
name = match["name"]
|
||||
front = {
|
||||
"design_id": design_id,
|
||||
"size": size,
|
||||
"name": name,
|
||||
"provider": "pcm",
|
||||
}
|
||||
return MessageTemplate.objects.create(
|
||||
channel=Channel.POSTCARD,
|
||||
name=name[:120],
|
||||
subject="",
|
||||
body=f"PCM design {design_id}",
|
||||
postcard_front=front,
|
||||
postcard_back={},
|
||||
)
|
||||
# Legacy: bare MessageTemplate pk
|
||||
return MessageTemplate.objects.filter(
|
||||
pk=value, channel=Channel.POSTCARD
|
||||
).first()
|
||||
|
||||
|
||||
def _format_postal_address(addr: dict | None) -> str:
|
||||
if not addr:
|
||||
return ""
|
||||
line1 = (addr.get("line1") or "").strip()
|
||||
line2 = (addr.get("line2") or "").strip()
|
||||
city = (addr.get("city") or "").strip()
|
||||
state = (addr.get("state") or "").strip()
|
||||
zip_code = (addr.get("zip") or "").strip()
|
||||
city_line = ", ".join(p for p in (city, state) if p)
|
||||
if zip_code:
|
||||
city_line = f"{city_line} {zip_code}".strip()
|
||||
return ", ".join(p for p in (line1, line2, city_line) if p)
|
||||
|
||||
|
||||
def _message_destination(message) -> str:
|
||||
"""Channel-specific destination shown on the recipients table."""
|
||||
contact = message.contact
|
||||
channel = message.channel or (message.campaign.channel if message.campaign_id else "")
|
||||
if channel == Channel.EMAIL:
|
||||
return (contact.email or "").strip()
|
||||
if channel == Channel.SMS:
|
||||
return (contact.phone or "").strip()
|
||||
if channel == Channel.POSTCARD:
|
||||
return _format_postal_address(contact.postal_address)
|
||||
return ""
|
||||
|
||||
|
||||
def _events_provider_filter(campaign: Campaign) -> tuple[list[str], str, str]:
|
||||
"""Return (provider codes, panel title, empty-state hint) for campaign channel."""
|
||||
if campaign.channel == Channel.POSTCARD:
|
||||
return (
|
||||
[PROVIDER_PCM],
|
||||
"Recent PCM Integrations events",
|
||||
"No PCM webhook events yet. PCM must POST to "
|
||||
"<code>/portal/directmail/webhooks/postcard/</code>.",
|
||||
)
|
||||
if campaign.channel == Channel.SMS:
|
||||
return (
|
||||
[PROVIDER_SMS],
|
||||
"Recent SMTP2GO events",
|
||||
"No webhook events yet. SMTP2GO must POST SMS events to "
|
||||
"<code>/portal/directmail/webhooks/smtp2go/</code>.",
|
||||
)
|
||||
return (
|
||||
[PROVIDER_EMAIL],
|
||||
"Recent SMTP2GO events",
|
||||
"No webhook events yet. SMTP2GO must POST opens/clicks to "
|
||||
"<code>/portal/directmail/webhooks/smtp2go/</code> "
|
||||
"(see directmail README). SMTP2GO’s own “Clicked” feed does not fill "
|
||||
"this table by itself.",
|
||||
)
|
||||
|
||||
|
||||
def _campaign_report(campaign: Campaign, *, page: int = 1) -> dict:
|
||||
qs = campaign.messages.select_related("contact").order_by(
|
||||
"contact__first_name", "contact__last_name", "created_at"
|
||||
)
|
||||
paginator = Paginator(qs, RECIPIENTS_PER_PAGE)
|
||||
page_obj = paginator.get_page(page)
|
||||
recipient_messages = list(page_obj.object_list)
|
||||
for msg in recipient_messages:
|
||||
msg.destination = _message_destination(msg)
|
||||
msg.can_remove = message_is_removable(msg)
|
||||
stats = campaign_engagement_stats(campaign)
|
||||
providers, events_title, events_empty = _events_provider_filter(campaign)
|
||||
recent_events = (
|
||||
ProviderEvent.objects.filter(
|
||||
message__campaign=campaign,
|
||||
provider__in=providers,
|
||||
)
|
||||
.select_related("message", "message__contact")
|
||||
.order_by("-created_at")[:25]
|
||||
)
|
||||
return {
|
||||
"recipient_messages": recipient_messages,
|
||||
"page_obj": page_obj,
|
||||
"stats": stats,
|
||||
"recent_events": recent_events,
|
||||
"events_title": events_title,
|
||||
"events_empty": events_empty,
|
||||
}
|
||||
|
||||
|
||||
def _webhook_authorized(request, *, secret: str = "", secrets: list[str] | None = None) -> bool:
|
||||
"""Accept Bearer / ?token= matching any configured secret (constant-time)."""
|
||||
candidates: list[str] = []
|
||||
if secrets:
|
||||
candidates.extend(s.strip() for s in secrets if (s or "").strip())
|
||||
single = (secret or "").strip()
|
||||
if single and single not in candidates:
|
||||
candidates.append(single)
|
||||
if not candidates:
|
||||
return True
|
||||
|
||||
token = (request.GET.get("token") or "").strip()
|
||||
auth = (request.headers.get("Authorization") or "").strip()
|
||||
bearer = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||
# Common signature-header names PCM / gateways may use (raw secret or HMAC).
|
||||
sig_headers = (
|
||||
request.headers.get("X-PCM-Signature")
|
||||
or request.headers.get("X-Webhook-Signature")
|
||||
or request.headers.get("X-Signature")
|
||||
or request.headers.get("X-Hub-Signature-256")
|
||||
or ""
|
||||
).strip()
|
||||
if sig_headers.lower().startswith("sha256="):
|
||||
sig_headers = sig_headers[7:].strip()
|
||||
|
||||
body = request.body or b""
|
||||
for candidate in candidates:
|
||||
if token and hmac.compare_digest(token, candidate):
|
||||
return True
|
||||
if bearer and hmac.compare_digest(bearer, candidate):
|
||||
return True
|
||||
if sig_headers:
|
||||
if hmac.compare_digest(sig_headers, candidate):
|
||||
return True
|
||||
digest = hmac.new(
|
||||
candidate.encode("utf-8"), body, hashlib.sha256
|
||||
).hexdigest()
|
||||
if hmac.compare_digest(sig_headers, digest):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _log_webhook_request(request, *, channel: str) -> None:
|
||||
"""Full request dump for Grafana / log aggregation."""
|
||||
try:
|
||||
headers = {str(k): str(v) for k, v in request.headers.items()}
|
||||
except Exception: # noqa: BLE001
|
||||
headers = {"_error": "unable to serialize headers"}
|
||||
try:
|
||||
body_text = (request.body or b"").decode("utf-8", errors="replace")
|
||||
except Exception: # noqa: BLE001
|
||||
body_text = repr(request.body)
|
||||
if len(body_text) > 12000:
|
||||
body_text = body_text[:12000] + "…[truncated]"
|
||||
logger.info(
|
||||
"webhook_received channel=%s path=%s method=%s query=%s",
|
||||
channel,
|
||||
request.path,
|
||||
request.method,
|
||||
request.META.get("QUERY_STRING", ""),
|
||||
)
|
||||
logger.info("webhook_headers channel=%s headers=%s", channel, headers)
|
||||
logger.info("webhook_body channel=%s body=%s", channel, body_text)
|
||||
|
||||
|
||||
def _log_webhook_auth_failed(request, *, channel: str) -> None:
|
||||
logger.warning(
|
||||
"webhook_auth_failed channel=%s path=%s "
|
||||
"missing_or_invalid_authorization_or_token",
|
||||
channel,
|
||||
request.path,
|
||||
)
|
||||
|
||||
|
||||
def _log_webhook_result(
|
||||
*,
|
||||
channel: str,
|
||||
event=None,
|
||||
error: str = "",
|
||||
extra: str = "",
|
||||
) -> None:
|
||||
if error:
|
||||
logger.error(
|
||||
"webhook_error channel=%s error=%s %s",
|
||||
channel,
|
||||
error,
|
||||
extra,
|
||||
)
|
||||
return
|
||||
if not event:
|
||||
logger.warning(
|
||||
"webhook_unmatched channel=%s no_provider_event_created %s",
|
||||
channel,
|
||||
extra,
|
||||
)
|
||||
return
|
||||
message = getattr(event, "message", None)
|
||||
campaign = getattr(message, "campaign", None) if message else None
|
||||
logger.info(
|
||||
"webhook_processed channel=%s event_type=%s event_id=%s "
|
||||
"matched=%s message_id=%s campaign_id=%s campaign_name=%s %s",
|
||||
channel,
|
||||
getattr(event, "event_type", ""),
|
||||
getattr(event, "pk", None),
|
||||
bool(message),
|
||||
getattr(message, "pk", None),
|
||||
getattr(campaign, "pk", None),
|
||||
getattr(campaign, "name", "") or "",
|
||||
extra,
|
||||
)
|
||||
|
||||
|
||||
def _pcm_webhook_secrets() -> list[str]:
|
||||
"""All PCM subscription signature secrets from env."""
|
||||
raw_list = (getattr(settings, "PCM_WEBHOOK_SECRETS", None) or "").strip()
|
||||
single = (getattr(settings, "PCM_WEBHOOK_SECRET", None) or "").strip()
|
||||
out: list[str] = []
|
||||
if raw_list:
|
||||
out.extend(p.strip() for p in raw_list.split(",") if p.strip())
|
||||
if single and single not in out:
|
||||
out.append(single)
|
||||
return out
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def campaign_list(request):
|
||||
form_errors: list[str] = []
|
||||
form_data = {
|
||||
"name": "",
|
||||
"subject": "",
|
||||
"body": "",
|
||||
"audience": Campaign.Audience.POSTCARD_OPT_IN,
|
||||
"scheduled_for": "",
|
||||
"template_id": "",
|
||||
}
|
||||
|
||||
if request.method == "POST":
|
||||
name = (request.POST.get("name") or "").strip()
|
||||
subject = (request.POST.get("subject") or "").strip()
|
||||
body = (request.POST.get("body") or "").strip()
|
||||
audience = (request.POST.get("audience") or "").strip()
|
||||
scheduled_raw = request.POST.get("scheduled_for") or ""
|
||||
template_id = (request.POST.get("template_id") or "").strip()
|
||||
|
||||
form_data.update(
|
||||
{
|
||||
"name": name,
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"audience": audience,
|
||||
"scheduled_for": scheduled_raw,
|
||||
"template_id": template_id,
|
||||
}
|
||||
)
|
||||
|
||||
template = None
|
||||
if template_id:
|
||||
template = _resolve_postcard_template(template_id)
|
||||
|
||||
if not name:
|
||||
form_errors.append("Campaign name is required.")
|
||||
if audience not in Campaign.Audience.values:
|
||||
form_errors.append("Choose a recipient list.")
|
||||
if audience == Campaign.Audience.POSTCARD_OPT_IN:
|
||||
if not template or template.channel != Channel.POSTCARD:
|
||||
form_errors.append(
|
||||
"Choose a postcard design (create one under Postcard design)."
|
||||
)
|
||||
if not body:
|
||||
body = "Postcard mailing"
|
||||
else:
|
||||
if not body:
|
||||
form_errors.append("Body is required.")
|
||||
if (
|
||||
audience == Campaign.Audience.POSTCARD_OPT_IN
|
||||
and not subject
|
||||
):
|
||||
form_errors.append("Subject is required for email campaigns.")
|
||||
|
||||
scheduled_for = None
|
||||
try:
|
||||
scheduled_for = parse_scheduled_for(scheduled_raw)
|
||||
except ValueError as exc:
|
||||
form_errors.append(str(exc))
|
||||
|
||||
if not form_errors:
|
||||
campaign = create_campaign_draft(
|
||||
name=name,
|
||||
audience=audience,
|
||||
subject=subject,
|
||||
body=body,
|
||||
scheduled_for=scheduled_for,
|
||||
created_by=request.user,
|
||||
template=template,
|
||||
)
|
||||
recipient_count = campaign.messages.count()
|
||||
messages.success(
|
||||
request,
|
||||
f'Draft “{campaign.name}” saved '
|
||||
f"({recipient_count} recipient"
|
||||
f"{'' if recipient_count == 1 else 's'}).",
|
||||
)
|
||||
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
||||
|
||||
campaigns = Campaign.objects.all()[:100]
|
||||
return render(
|
||||
request,
|
||||
"directmail/campaign_list.html",
|
||||
{
|
||||
"campaigns": campaigns,
|
||||
"audience_choices": _audience_choices(),
|
||||
"postcard_designs": _postcard_design_choices(),
|
||||
"form_data": form_data,
|
||||
"form_errors": form_errors,
|
||||
"image_upload_url": reverse("directmail:campaign_image_upload"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def campaign_detail(request, pk):
|
||||
campaign = get_object_or_404(Campaign, pk=pk)
|
||||
try:
|
||||
page = max(1, int(request.GET.get("page") or 1))
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
ctx = _campaign_report(campaign, page=page)
|
||||
return render(
|
||||
request,
|
||||
"directmail/campaign_detail.html",
|
||||
{
|
||||
"campaign": campaign,
|
||||
"recipient_messages": ctx["recipient_messages"],
|
||||
"page_obj": ctx["page_obj"],
|
||||
"stats": ctx["stats"],
|
||||
"recent_events": ctx["recent_events"],
|
||||
"events_title": ctx["events_title"],
|
||||
"events_empty": ctx["events_empty"],
|
||||
"can_send": campaign.status
|
||||
in {
|
||||
Campaign.Status.DRAFT,
|
||||
Campaign.Status.SCHEDULED,
|
||||
Campaign.Status.SENDING,
|
||||
}
|
||||
and campaign.messages.exclude(
|
||||
status__in={
|
||||
"sent",
|
||||
"delivered",
|
||||
"opened",
|
||||
"clicked",
|
||||
"suppressed",
|
||||
}
|
||||
).exists(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def campaign_status_json(request, pk):
|
||||
"""JSON snapshot for live-updating the campaign report page."""
|
||||
campaign = get_object_or_404(Campaign, pk=pk)
|
||||
# Async queue may finish after enqueue; re-evaluate completion on poll.
|
||||
from directmail.services import refresh_campaign_status
|
||||
|
||||
refresh_campaign_status(campaign)
|
||||
campaign.refresh_from_db()
|
||||
try:
|
||||
page = max(1, int(request.GET.get("page") or 1))
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
ctx = _campaign_report(campaign, page=page)
|
||||
page_obj = ctx["page_obj"]
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": campaign.status,
|
||||
"status_display": campaign.get_status_display(),
|
||||
"stats": ctx["stats"],
|
||||
"page": page_obj.number,
|
||||
"num_pages": page_obj.paginator.num_pages,
|
||||
"messages": [
|
||||
{
|
||||
"id": str(m.pk),
|
||||
"contact": str(m.contact),
|
||||
"destination": getattr(m, "destination", "") or "",
|
||||
"status": m.status,
|
||||
"status_display": m.get_status_display(),
|
||||
"provider_message_id": m.provider_message_id or "",
|
||||
"error": (m.error or "")[:120],
|
||||
"can_remove": bool(getattr(m, "can_remove", False)),
|
||||
}
|
||||
for m in ctx["recipient_messages"]
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"event_type": e.event_type,
|
||||
"contact": str(e.message.contact) if e.message_id else "—",
|
||||
"created_at": e.created_at.isoformat(),
|
||||
}
|
||||
for e in ctx["recent_events"]
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def campaign_message_remove(request, pk, message_id):
|
||||
"""Drop a draft/scheduled/failed recipient from the campaign."""
|
||||
campaign = get_object_or_404(Campaign, pk=pk)
|
||||
message = get_object_or_404(Message, pk=message_id, campaign=campaign)
|
||||
if not message_is_removable(message):
|
||||
messages.error(
|
||||
request,
|
||||
"Only draft, scheduled, or failed recipients can be removed.",
|
||||
)
|
||||
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
||||
|
||||
label = str(message.contact)
|
||||
message.delete()
|
||||
messages.success(request, f"Removed {label} from this campaign.")
|
||||
page = (request.POST.get("page") or request.GET.get("page") or "").strip()
|
||||
if page and page.isdigit() and int(page) > 1:
|
||||
return redirect(
|
||||
f"{reverse('directmail:campaign_detail', kwargs={'pk': campaign.pk})}"
|
||||
f"?page={page}"
|
||||
)
|
||||
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def campaign_send(request, pk):
|
||||
campaign = get_object_or_404(Campaign, pk=pk)
|
||||
if campaign.status == Campaign.Status.CANCELLED:
|
||||
messages.error(request, "Cancelled campaigns cannot be sent.")
|
||||
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
||||
|
||||
count = enqueue_campaign_send(campaign)
|
||||
campaign.refresh_from_db()
|
||||
if count == 0:
|
||||
messages.warning(request, "No draft/scheduled/failed messages to send.")
|
||||
else:
|
||||
sent = campaign.messages.filter(status="sent").count()
|
||||
failed = campaign.messages.filter(status="failed").count()
|
||||
messages.success(
|
||||
request,
|
||||
f"Send finished for {count} message(s): {sent} sent, {failed} failed.",
|
||||
)
|
||||
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def campaign_test_send(request, pk):
|
||||
campaign = get_object_or_404(Campaign, pk=pk)
|
||||
to_email = (request.POST.get("test_email") or "").strip()
|
||||
if not to_email:
|
||||
messages.error(request, "Enter an email address for the test send.")
|
||||
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
||||
try:
|
||||
validate_email(to_email)
|
||||
except ValidationError:
|
||||
messages.error(request, "That test email address is not valid.")
|
||||
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
||||
|
||||
try:
|
||||
send_campaign_test_email(campaign, to_email)
|
||||
except ValueError as exc:
|
||||
messages.error(request, str(exc))
|
||||
except Exception as exc: # noqa: BLE001 — surface SMTP misconfig to portal
|
||||
messages.error(request, f"Test send failed: {exc}")
|
||||
else:
|
||||
messages.success(request, f"Test email sent to {to_email}.")
|
||||
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def campaign_image_upload(request):
|
||||
"""Upload an image for the email rich editor; store bytes in the DB."""
|
||||
upload = request.FILES.get("image") or request.FILES.get("file")
|
||||
if not upload:
|
||||
return JsonResponse({"error": "No image uploaded."}, status=400)
|
||||
content_type = (getattr(upload, "content_type", None) or "").lower()
|
||||
if content_type not in _ALLOWED_IMAGE_TYPES:
|
||||
return JsonResponse(
|
||||
{"error": "Use a JPEG, PNG, GIF, or WebP image."}, status=400
|
||||
)
|
||||
if upload.size and upload.size > _MAX_IMAGE_BYTES:
|
||||
return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400)
|
||||
|
||||
data = upload.read()
|
||||
if len(data) > _MAX_IMAGE_BYTES:
|
||||
return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400)
|
||||
|
||||
original = (getattr(upload, "name", None) or "image")[:255]
|
||||
stored = StoredFile.objects.create(
|
||||
kind=StoredFile.Kind.CAMPAIGN_IMAGE,
|
||||
filename=original,
|
||||
content_type=content_type,
|
||||
size=len(data),
|
||||
data=data,
|
||||
uploaded_by=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
path = reverse("core:stored_file", kwargs={"pk": stored.pk})
|
||||
url = request.build_absolute_uri(path)
|
||||
return JsonResponse({"url": url, "id": str(stored.pk)})
|
||||
|
||||
|
||||
@require_GET
|
||||
def stored_file(request, pk):
|
||||
"""Public fetch for email clients / preview (UUID acts as capability token)."""
|
||||
stored = get_object_or_404(StoredFile, pk=pk)
|
||||
response = FileResponse(
|
||||
io.BytesIO(bytes(stored.data)),
|
||||
content_type=stored.content_type or "application/octet-stream",
|
||||
)
|
||||
if stored.filename:
|
||||
response["Content-Disposition"] = f'inline; filename="{stored.filename}"'
|
||||
response["Cache-Control"] = "public, max-age=86400"
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def postcard_designer(request):
|
||||
"""PCM Integrations designer — list designs + embed iframe."""
|
||||
designs, api_error = _fetch_pcm_designs()
|
||||
embed_url = ""
|
||||
active_design_id = (request.GET.get("design_id") or "").strip()
|
||||
active_name = ""
|
||||
active_size = "46"
|
||||
|
||||
if active_design_id:
|
||||
match = next(
|
||||
(d for d in designs if d["design_id"] == active_design_id), None
|
||||
)
|
||||
if match:
|
||||
active_name = match["name"]
|
||||
active_size = match.get("size") or "46"
|
||||
else:
|
||||
active_name = f"Design {active_design_id}"
|
||||
try:
|
||||
embed_url = get_design_embed_url(active_design_id)
|
||||
except PcmApiError as exc:
|
||||
api_error = api_error or str(exc)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"directmail/postcard_designer.html",
|
||||
{
|
||||
"api_error": api_error,
|
||||
"designs": designs,
|
||||
"embed_url": embed_url,
|
||||
"active_design_id": active_design_id,
|
||||
"active_name": active_name,
|
||||
"active_size": active_size,
|
||||
"size_choices": PCM_SIZE_CHOICES,
|
||||
"new_name": "",
|
||||
"new_size": "46",
|
||||
"saved_templates": _postcard_templates(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def postcard_design_create(request):
|
||||
name = (request.POST.get("name") or "").strip() or "Untitled postcard"
|
||||
size = (request.POST.get("size") or "46").strip()
|
||||
allowed = {code for code, _ in PCM_SIZE_CHOICES}
|
||||
if size not in allowed:
|
||||
messages.error(request, "Invalid postcard size.")
|
||||
return redirect("directmail:postcard_designer")
|
||||
try:
|
||||
data = create_custom_design(name=name, size=size)
|
||||
except PcmApiError as exc:
|
||||
messages.error(request, f"PCM create failed: {exc}")
|
||||
return redirect("directmail:postcard_designer")
|
||||
|
||||
design_id = data.get("designID") or data.get("design_id")
|
||||
if design_id is None:
|
||||
messages.error(request, "PCM did not return a design ID.")
|
||||
return redirect("directmail:postcard_designer")
|
||||
messages.success(request, f"Design {design_id} created — edit below.")
|
||||
return redirect(
|
||||
f"{reverse('directmail:postcard_designer')}?design_id={design_id}"
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def postcard_design_save(request):
|
||||
design_id = (request.POST.get("design_id") or "").strip()
|
||||
template_name = (request.POST.get("template_name") or "").strip()
|
||||
size = (request.POST.get("size") or "").strip()
|
||||
if not design_id:
|
||||
messages.error(request, "Missing design id.")
|
||||
return redirect("directmail:postcard_designer")
|
||||
if not template_name:
|
||||
messages.error(request, "Template name is required.")
|
||||
return redirect(
|
||||
f"{reverse('directmail:postcard_designer')}?design_id={design_id}"
|
||||
)
|
||||
try:
|
||||
design_id_int = int(design_id)
|
||||
except ValueError:
|
||||
messages.error(request, "Invalid design id.")
|
||||
return redirect("directmail:postcard_designer")
|
||||
|
||||
front = {
|
||||
"design_id": design_id_int,
|
||||
"size": size,
|
||||
"name": template_name,
|
||||
"provider": "pcm",
|
||||
}
|
||||
tmpl, created = MessageTemplate.objects.update_or_create(
|
||||
channel=Channel.POSTCARD,
|
||||
name=template_name,
|
||||
defaults={
|
||||
"subject": "",
|
||||
"body": f"PCM design {design_id_int}",
|
||||
"postcard_front": front,
|
||||
"postcard_back": {},
|
||||
},
|
||||
)
|
||||
verb = "Created" if created else "Updated"
|
||||
messages.success(
|
||||
request,
|
||||
f"{verb} postcard template “{tmpl.name}” (design {design_id_int}).",
|
||||
)
|
||||
return redirect(
|
||||
f"{reverse('directmail:postcard_designer')}?design_id={design_id}"
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def postcard_webhook(request):
|
||||
"""
|
||||
PCM Integrations order / mail-tracking webhook.
|
||||
|
||||
Configure in PCM → Webhooks (one subscription per event):
|
||||
URL: https://<host>/portal/directmail/webhooks/postcard/
|
||||
Copy each subscription's signature secret into PCM_WEBHOOK_SECRETS
|
||||
"""
|
||||
channel = "postcard"
|
||||
_log_webhook_request(request, channel=channel)
|
||||
if not _webhook_authorized(request, secrets=_pcm_webhook_secrets()):
|
||||
_log_webhook_auth_failed(request, channel=channel)
|
||||
return HttpResponseForbidden("invalid webhook token")
|
||||
payload = parse_webhook_payload(request)
|
||||
if not payload:
|
||||
payload = request.POST.dict() or {}
|
||||
try:
|
||||
event = process_pcm_postcard_webhook(payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("PCM postcard webhook processing failed")
|
||||
_log_webhook_result(channel=channel, error=str(exc))
|
||||
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
|
||||
_log_webhook_result(channel=channel, event=event)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"matched": bool(event and event.message_id),
|
||||
"event_id": event.pk if event else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def smtp2go_webhook(request):
|
||||
"""
|
||||
Unified SMTP2GO webhook — email + SMS events + inbound STOP replies.
|
||||
|
||||
One SMTP2GO webhook URL (paid plans cap at 10 webhooks):
|
||||
|
||||
URL: https://<host>/portal/directmail/webhooks/smtp2go/
|
||||
Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET
|
||||
Output type: JSON
|
||||
Users: email SMTP user(s) *and* the SMS API key used to send
|
||||
Email events: all delivery/engagement boxes
|
||||
Email headers: X-Monica-Message-Id
|
||||
SMS events: Submitted, Sending, Delivered, Failed, Rejected (Opt-out if shown)
|
||||
|
||||
Legacy aliases ``/webhooks/email/`` and ``/webhooks/sms/`` hit this same view.
|
||||
Payload shape selects the processor (email vs sms_* vs inbound STOP).
|
||||
"""
|
||||
# Log/auth before parsing so request.body stays readable (HMAC + Grafana dump).
|
||||
_log_webhook_request(request, channel="smtp2go")
|
||||
if not _webhook_authorized(
|
||||
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
|
||||
):
|
||||
_log_webhook_auth_failed(request, channel="smtp2go")
|
||||
return HttpResponseForbidden("invalid webhook token")
|
||||
|
||||
payload = parse_webhook_payload(request)
|
||||
if not payload:
|
||||
payload = request.POST.dict() or {}
|
||||
|
||||
kind = classify_smtp2go_payload(payload)
|
||||
channel = {
|
||||
"sms_inbound": "sms",
|
||||
"sms": "sms",
|
||||
"email": "email",
|
||||
}.get(kind, "smtp2go")
|
||||
logger.info("webhook_classified channel=%s kind=%s", channel, kind)
|
||||
|
||||
if kind == "sms_inbound":
|
||||
phone = (
|
||||
payload.get("from")
|
||||
or payload.get("phone")
|
||||
or payload.get("source_number")
|
||||
or payload.get("destination_number")
|
||||
or ""
|
||||
)
|
||||
stopped = bool(phone) and record_sms_stop(str(phone))
|
||||
logger.info(
|
||||
"webhook_processed channel=sms event_type=inbound_stop "
|
||||
"opt_out=%s phone=%s",
|
||||
stopped,
|
||||
phone,
|
||||
)
|
||||
return JsonResponse({"ok": True, "channel": "sms", "opt_out": stopped})
|
||||
|
||||
if kind == "unknown":
|
||||
logger.warning(
|
||||
"webhook_unmatched channel=smtp2go unrecognized_payload keys=%s",
|
||||
sorted(str(k) for k in payload.keys()),
|
||||
)
|
||||
return JsonResponse(
|
||||
{"ok": False, "error": "unrecognized_payload", "channel": None},
|
||||
status=200,
|
||||
)
|
||||
|
||||
try:
|
||||
if kind == "sms":
|
||||
event = process_smtp2go_sms_webhook(payload)
|
||||
else:
|
||||
event = process_smtp2go_email_webhook(payload)
|
||||
except Exception as exc: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h)
|
||||
logger.exception("SMTP2GO %s webhook processing failed", kind)
|
||||
_log_webhook_result(channel=channel, error=str(exc))
|
||||
return JsonResponse(
|
||||
{"ok": False, "error": "processing_failed", "channel": channel},
|
||||
status=200,
|
||||
)
|
||||
|
||||
_log_webhook_result(channel=channel, event=event)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"channel": channel,
|
||||
"matched": bool(event and event.message_id),
|
||||
"event_id": event.pk if event else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Legacy path names — same unified handler (keep SMTP2GO configs working).
|
||||
email_webhook = smtp2go_webhook
|
||||
sms_webhook = smtp2go_webhook
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user