Add Django site, Docker packaging, and beta/prod Gitea deploys.
Deploy Beta / unit-tests (push) Successful in 9s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 2m31s

Unignore site/ (was blocked by mkdocs /site rule), add compose/Docker/uv tooling, and split deploys so push to main goes to beta while prod stays manual.
This commit is contained in:
2026-08-08 07:32:55 -05:00
parent 7dca98bbf6
commit 1f7d78de64
204 changed files with 21662 additions and 70 deletions
+116
View File
@@ -0,0 +1,116 @@
# 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 **two** webhooks in
SMTP2GO → **Settings → Webhooks** (email and SMS stay separate).
### 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.
### Email webhook
| Field | Value |
|-------|--------|
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/email/` |
| Authorization header | **Bearer** + `SMTP2GO_WEBHOOK_SECRET` |
| Output type | JSON |
| Email events | processed, bounced, rejected, spam, delivered, unsub/resub, opened, clicked |
| Email headers | `X-Monica-Message-Id` |
| SMS events | leave unchecked |
`X-Monica-Message-Id` is set on every campaign email send and is required so webhook
events match the correct recipient row.
Beta / other hosts: swap the hostname, keep the path.
### SMS webhook (separate)
| Field | Value |
|-------|--------|
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/sms/` |
| Authorization header | **Bearer** + same `SMTP2GO_WEBHOOK_SECRET` |
| Output type | JSON |
| Email events | leave unchecked |
| SMS events | Submitted, Sending, Delivered, Failed, Rejected, Opt-out |
This endpoint also accepts inbound reply POSTs (`text=STOP`, `from=…`) and opts the
contact out of SMS.
## PCM Integrations (postcards)
Default postcard provider. Designer embeds PCMs editor; orders use DirectMail API v3.
### Env
| Var | Purpose |
|-----|---------|
| `PCM_API_KEY` | Bearer token for `https://v3.pcmintegrations.com` |
| `PCM_WEBHOOK_SECRET` | Auth for inbound status webhooks |
| `PCM_RETURN_ADDRESS` | JSON return address on orders |
| `POSTCARD_PROVIDER` | `pcm` (default) |
### 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
Create a webhook subscription in the PCM dashboard (Working with Webhooks):
| Field | Value |
|-------|--------|
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/postcard/` |
| Authorization | **Bearer** + `PCM_WEBHOOK_SECRET` |
| Events | Order / recipient status (Pending, Processing, Processed, Delivered, Undeliverable, Canceled) |
| Environments | Sandbox and/or Production as needed |
Fallback: `?token=<PCM_WEBHOOK_SECRET>` on the URL.
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_WEBHOOK_SECRET=
PCM_RETURN_ADDRESS={}
```
### Endpoints (app)
| Path | Purpose |
|------|---------|
| `POST /portal/messaging/webhooks/email/` | Email delivery / open / click / bounce / … |
| `POST /portal/messaging/webhooks/sms/` | SMS delivery events + inbound STOP |
| `POST /portal/messaging/webhooks/postcard/` | PCM order / mail tracking events |
| `GET /portal/messaging/campaigns/<id>/status.json` | Live stats for the campaign report UI |
| `GET /portal/messaging/postcard/` | PCM designer iframe |
Code: `webhooks.py`, `providers/postcard/pcm.py`, `views.py`.
View File
+40
View File
@@ -0,0 +1,40 @@
from django.contrib import admin
from messaging.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")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class MessagingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "messaging"
+29
View File
@@ -0,0 +1,29 @@
"""Channel dispatch — email / SMS / postcard."""
from dataclasses import dataclass
from contacts.models import Channel
from messaging.models import Message
from messaging.providers.email.smtp2go import send_email
from messaging.providers.postcard import get_postcard_provider
from messaging.providers.sms.smtp2go import send_sms
@dataclass
class ProviderResult:
provider: str
provider_id: str
def dispatch_message(message: Message) -> ProviderResult:
if message.channel == Channel.EMAIL:
result = send_email(message)
return ProviderResult(provider="smtp2go_email", provider_id=result)
if message.channel == Channel.SMS:
result = send_sms(message)
return ProviderResult(provider="smtp2go_sms", provider_id=result)
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}")
+91
View File
@@ -0,0 +1,91 @@
# Generated by Django 6.1 on 2026-08-06 18:01
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)),
('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)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='campaigns', to='messaging.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'), ('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='messaging.campaign')),
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='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='messaging.message')),
],
options={
'abstract': False,
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-08-08 10:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('messaging', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='campaign',
name='audience',
field=models.CharField(blank=True, choices=[('email_opt_in', 'Mailing list · email opt-in'), ('sms_opt_in', 'Mailing list · SMS opt-in'), ('postcard_opt_in', 'Mailing list · postcard opt-in')], default='', max_length=32),
),
]
@@ -0,0 +1,18 @@
# Generated manually for Campaign.notify_sent_at
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("messaging", "0002_campaign_audience"),
]
operations = [
migrations.AddField(
model_name="campaign",
name="notify_sent_at",
field=models.DateTimeField(blank=True, null=True),
),
]
+115
View File
@@ -0,0 +1,115 @@
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):
EMAIL_OPT_IN = "email_opt_in", "Mailing list · email opt-in"
SMS_OPT_IN = "sms_opt_in", "Mailing list · SMS opt-in"
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="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,
)
subject_override = models.CharField(max_length=255, blank=True)
body_override = models.TextField(blank=True)
# Set when realtor 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"
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="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)
+50
View File
@@ -0,0 +1,50 @@
"""SMTP2GO email via Django's SMTP backend (mail.smtp2go.com)."""
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from contacts.models import Channel
from messaging.services import one_click_unsubscribe_url, preferences_url
# Reported back on SMTP2GO webhooks when this header is selected in webhook settings.
MONICA_MESSAGE_HEADER = "X-Monica-Message-Id"
def send_email(message) -> str:
contact = message.contact
if not contact.email:
raise ValueError("Contact has no email address")
campaign = message.campaign
subject = campaign.subject_override or (
campaign.template.subject if campaign.template else "Message from Monica"
)
body = message.body_snapshot or campaign.body_override or (
campaign.template.body if campaign.template else ""
)
site = (settings.PUBLIC_SITE_URL or "").rstrip("/")
prefs_path = preferences_url(str(contact.pk), Channel.EMAIL)
one_click_path = one_click_unsubscribe_url(str(contact.pk), Channel.EMAIL)
prefs_url = f"{site}{prefs_path}" if site else prefs_path
one_click_url = f"{site}{one_click_path}" if site else one_click_path
body_with_unsub = (
f"{body}\n\n---\n"
f"Manage preferences: {prefs_url}\n"
f"Unsubscribe from email: {one_click_url}"
)
email = EmailMultiAlternatives(
subject=subject,
body=body_with_unsub,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[contact.email],
headers={
"List-Unsubscribe": f"<{one_click_url}>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
MONICA_MESSAGE_HEADER: str(message.pk),
},
)
email.send(fail_silently=False)
# Placeholder until SMTP2GO webhook supplies the real email_id.
return f"smtp-{message.pk}"
@@ -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 messaging.providers.postcard.pcm import PcmProvider
return PcmProvider()
if name == "click2mail":
from messaging.providers.postcard.click2mail import Click2MailProvider
return Click2MailProvider()
if name == "postgrid":
from messaging.providers.postcard.postgrid import PostGridProvider
return PostGridProvider()
if name == "lob":
from messaging.providers.postcard.lob import LobProvider
return LobProvider()
from messaging.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 messaging.providers.postcard import PostcardResult
@dataclass
class Click2MailProvider:
name: str = "click2mail"
def send_postcard(self, message) -> PostcardResult:
if not settings.CLICK2MAIL_API_KEY:
raise RuntimeError("CLICK2MAIL_API_KEY is not configured")
# Placeholder: wire full Click2Mail job API when account credentials are ready.
raise NotImplementedError(
"Click2Mail adapter stub — configure account then implement job submit"
)
def get_status(self, provider_id: str) -> str:
return "unknown"
+66
View File
@@ -0,0 +1,66 @@
"""Lob postcard adapter (default)."""
from dataclasses import dataclass
import requests
from django.conf import settings
from messaging.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")
+260
View File
@@ -0,0 +1,260 @@
"""PCM Integrations (DirectMail API v3) postcard adapter."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any
import requests
from django.conf import settings
from messaging.providers.postcard import PostcardResult
logger = logging.getLogger(__name__)
PCM_API_BASE = "https://v3.pcmintegrations.com"
# 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."""
def _api_key() -> str:
return (settings.PCM_API_KEY or "").strip()
def _headers() -> dict[str, str]:
key = _api_key()
if not key:
raise PcmApiError("PCM_API_KEY is not configured")
return {
"Accept": "application/json",
"Authorization": f"Bearer {key}",
"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,
)
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 or CONTACT_* vars."""
raw = (settings.PCM_RETURN_ADDRESS or "").strip()
if raw:
data = json.loads(raw)
if not isinstance(data, dict):
raise PcmApiError("PCM_RETURN_ADDRESS must be a JSON object")
return {
"company": str(data.get("company") or ""),
"firstName": str(data.get("firstName") or data.get("first_name") or ""),
"lastName": str(data.get("lastName") or data.get("last_name") or ""),
"address": str(data.get("address") or data.get("line1") or ""),
"address2": str(data.get("address2") or data.get("line2") or ""),
"city": str(data.get("city") or ""),
"state": str(data.get("state") or ""),
"zipCode": str(data.get("zipCode") or data.get("zip") or ""),
}
name = (settings.SITE_NAME or "").strip()
parts = name.split(None, 1)
first = parts[0] if parts else "Monica"
last = parts[1] if len(parts) > 1 else ""
return {
"company": "",
"firstName": first,
"lastName": last,
"address": str(getattr(settings, "PCM_RETURN_LINE1", "") or ""),
"address2": str(getattr(settings, "PCM_RETURN_LINE2", "") or ""),
"city": str(getattr(settings, "PCM_RETURN_CITY", "") or ""),
"state": str(getattr(settings, "PCM_RETURN_STATE", "") or ""),
"zipCode": str(getattr(settings, "PCM_RETURN_ZIP", "") or ""),
}
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],
}
data = pcm_request("POST", "/order", 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 messaging.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"
+42
View File
@@ -0,0 +1,42 @@
"""SMTP2GO SMS REST API."""
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
def send_sms(message) -> str:
contact = message.contact
if not contact.phone:
raise ValueError("Contact has no phone number")
api_key = settings.SMTP2GO_SMS_API_KEY
if not api_key:
raise RuntimeError("SMTP2GO_SMS_API_KEY is not configured")
campaign = message.campaign
body = message.body_snapshot or campaign.body_override or (
campaign.template.body if campaign.template else ""
)
payload = {
"api_key": api_key,
"to": contact.phone,
"text": body[:1600],
}
response = requests.post(
settings.SMTP2GO_SMS_API_URL,
json=payload,
timeout=30,
)
response.raise_for_status()
data = response.json() if response.content else {}
# SMTP2GO returns varying shapes; store a useful id when present.
return str(
data.get("data", {}).get("sms_id")
or data.get("request_id")
or f"sms-{message.pk}"
)
+404
View File
@@ -0,0 +1,404 @@
"""Consent checks and unsubscribe helpers."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from django.core import signing
from django.db.models import QuerySet
from django.urls import reverse
from django.utils import timezone
from contacts.models import Channel, ConsentRecord, Contact, Suppression
from messaging.models import Campaign, Message, MessageTemplate
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractBaseUser
AUDIENCE_CHANNEL = {
Campaign.Audience.EMAIL_OPT_IN: Channel.EMAIL,
Campaign.Audience.SMS_OPT_IN: Channel.SMS,
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
}
UNSUB_SALT = "monica-site-unsubscribe"
UNSUB_MAX_AGE = 60 * 60 * 24 * 365 # 1 year
def contact_may_receive(contact: Contact, channel: str) -> bool:
if Suppression.objects.filter(
contact=contact, channel=channel, active=True
).exists():
return False
consent = ConsentRecord.objects.filter(contact=contact, channel=channel).first()
return bool(consent and consent.opted_in)
def channel_preferences(contact: Contact) -> dict[str, bool]:
"""Current opt-in flags for every channel (missing record = False)."""
flags = {c.value: False for c in Channel}
for record in contact.consents.all():
flags[record.channel] = record.opted_in
return flags
def set_channel_consent(
contact: Contact,
channel: str,
*,
opted_in: bool,
reason: str = "",
) -> None:
"""Write ConsentRecord + Suppression for one channel."""
if channel not in Channel.values:
raise ValueError(f"Unknown channel: {channel}")
ConsentRecord.objects.update_or_create(
contact=contact,
channel=channel,
defaults={"opted_in": opted_in, "reason": reason},
)
Suppression.objects.update_or_create(
contact=contact,
channel=channel,
defaults={
"active": not opted_in,
"reason": reason if not opted_in else "",
},
)
def set_channel_preferences(
contact: Contact,
preferences: dict[str, bool],
*,
reason: str = "",
) -> None:
"""Update consent for each provided channel key."""
for channel, opted_in in preferences.items():
if channel not in Channel.values:
continue
set_channel_consent(
contact, channel, opted_in=bool(opted_in), reason=reason
)
def unsubscribe_all(contact: Contact, *, reason: str = "unsubscribe_all") -> None:
for channel in Channel:
set_channel_consent(
contact, channel.value, opted_in=False, reason=reason
)
def make_unsubscribe_token(contact_id: str, channel: str = Channel.EMAIL) -> str:
return signing.dumps({"c": str(contact_id), "ch": channel}, salt=UNSUB_SALT)
def parse_unsubscribe_token(token: str) -> tuple[Contact | None, str]:
"""Return (contact, channel) or (None, '') on bad/expired token."""
try:
data = signing.loads(token, salt=UNSUB_SALT, max_age=UNSUB_MAX_AGE)
except signing.BadSignature:
return None, ""
contact = (
Contact.objects.filter(pk=data.get("c"))
.prefetch_related("consents")
.first()
)
if not contact:
return None, ""
channel = data.get("ch") or Channel.EMAIL
if channel not in Channel.values:
channel = Channel.EMAIL
return contact, channel
def process_unsubscribe_token(token: str) -> bool:
"""One-click opt-out for the channel encoded in the token."""
contact, channel = parse_unsubscribe_token(token)
if not contact:
return False
set_channel_consent(
contact, channel, opted_in=False, reason="unsubscribe_link"
)
return True
def preferences_url(contact_id: str, channel: str = Channel.EMAIL) -> str:
token = make_unsubscribe_token(contact_id, channel)
return reverse("public:unsubscribe", kwargs={"token": token})
def one_click_unsubscribe_url(contact_id: str, channel: str = Channel.EMAIL) -> str:
token = make_unsubscribe_token(contact_id, channel)
return reverse("public:unsubscribe_one_click", kwargs={"token": token})
def record_sms_stop(phone: str) -> bool:
digits = "".join(ch for ch in (phone or "") if ch.isdigit())
if len(digits) < 7:
return False
tail = digits[-10:]
contact = None
for row in Contact.objects.exclude(phone="").iterator():
stored = "".join(ch for ch in row.phone if ch.isdigit())
if stored.endswith(tail) or tail.endswith(stored[-10:]):
contact = row
break
if not contact:
return False
set_channel_consent(
contact, Channel.SMS, opted_in=False, reason="sms_stop"
)
return True
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 opted_in_contacts(channel: str) -> QuerySet[Contact]:
"""Contacts opted in for channel and not actively suppressed."""
suppressed = Suppression.objects.filter(
channel=channel, active=True
).values_list("contact_id", flat=True)
qs = (
Contact.objects.filter(
consents__channel=channel,
consents__opted_in=True,
)
.exclude(pk__in=suppressed)
.distinct()
.order_by("first_name", "last_name", "email")
)
if channel == Channel.POSTCARD:
qs = qs.filter(postal_address__has_key="line1").exclude(
postal_address__line1=""
)
return qs
def parse_scheduled_for(raw: str | None):
"""Parse optional ``datetime-local`` value into an aware datetime."""
value = (raw or "").strip()
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError as exc:
raise ValueError("Invalid schedule datetime.") from exc
if timezone.is_naive(parsed):
return timezone.make_aware(parsed, timezone.get_current_timezone())
return parsed
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.urls import reverse
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
counts = campaign.messages.aggregate(
sent=Count("id", filter=Q(status=Message.Status.SENT)),
delivered=Count("id", filter=Q(status=Message.Status.DELIVERED)),
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("messaging: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}"
body = (
f"Your {campaign.get_channel_display()} campaign “{campaign.name}"
f"has finished sending.\n\n"
f"Recipients: {counts['total']}\n"
f"Sent: {counts['sent']}\n"
f"Delivered: {counts['delivered']}\n"
f"Failed / bounced: {counts['failed']}\n"
f"Suppressed: {counts['suppressed']}\n\n"
f"Report: {report_url}\n"
)
email = EmailMultiAlternatives(
subject=subject,
body=body,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email],
)
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
)
return False
campaign.notify_sent_at = timezone.now()
campaign.save(update_fields=["notify_sent_at", "updated_at"])
return True
def refresh_campaign_status(campaign: Campaign) -> Campaign:
"""Set campaign to completed when no messages remain pending."""
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 messaging.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
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 "Message from Monica"
)
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.")
email = EmailMultiAlternatives(
subject=f"[TEST] {subject}",
body=(
f"{body}\n\n---\n"
"This is a test send from the Monica portal. "
"Recipient list was not notified."
),
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email],
)
email.send(fail_silently=False)
+45
View File
@@ -0,0 +1,45 @@
from django.tasks import task
from django.utils import timezone
from messaging.channels import dispatch_message
from messaging.models import Message
from messaging.services import contact_may_receive
@task
def send_campaign_message(message_id: str) -> None:
try:
message = Message.objects.select_related("contact", "campaign").get(
pk=message_id
)
except Message.DoesNotExist:
return
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"])
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"])
raise
@@ -0,0 +1,199 @@
{% 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 'messaging: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 'messaging: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 'messaging: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">
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
</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>
<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>
<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">
<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
({{ stats.open_events }} open events / {{ stats.click_events }} click events from SMTP2GO).
</p>
<div class="chart-placeholder" aria-hidden="true">
<div class="bar" style="height:55%"></div>
<div class="bar" style="height:70%"></div>
<div class="bar" style="height:40%"></div>
<div class="bar" style="height:30%"></div>
<div class="bar" style="height:20%"></div>
</div> </div>
</div>
<div class="panel">
<div class="panel-h"><h2>Recent SMTP2GO events</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">No provider events yet. Configure the SMTP2GO webhook after first send.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Recipients</h2></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></tr>
</thead>
<tbody id="recipients-body">
{% for message in messages %}
<tr data-message-id="{{ message.pk }}">
<td>{{ message.contact }}</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>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
var url = "{% url 'messaging:campaign_status_json' campaign.pk %}";
function esc(s) {
return String(s || "").replace(/[&<>"']/g, function (c) {
return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c];
});
}
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) {
document.querySelectorAll('[data-stat="' + key + '"]').forEach(function (el) {
el.textContent = data.stats[key];
});
});
var body = document.getElementById("recipients-body");
if (body && data.messages) {
if (!data.messages.length) {
body.innerHTML = '<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>';
} else {
body.innerHTML = data.messages.map(function (m) {
return "<tr data-message-id=\"" + esc(m.id) + "\">" +
"<td>" + esc(m.contact) + "</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></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,158 @@
{% extends "portal_base.html" %}
{% block title %}Campaigns · Portal{% endblock %}
{% block topbar_title %}Campaign composer{% endblock %}
{% block portal_content %}
<div class="channel-tabs">
<a class="active" href="#compose-email">Email</a>
<a href="#compose-sms">SMS</a>
<a href="{% url 'messaging:postcard_designer' %}">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 'messaging: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">
<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">
<label for="id_body">Body</label>
<textarea id="id_body" name="body" style="min-height:140px"
placeholder="Hi {first_name}, …"
oninput="syncCampaignPreview()">{{ form_data.body }}</textarea>
<div class="hint">Merge tags: first_name, last_name, unsubscribe_url · optional for postcard</div>
</div>
<div class="field" id="postcard-template-field">
<label for="id_template_id">Postcard template</label>
<select id="id_template_id" name="template_id">
<option value="">— Select saved design —</option>
{% for t in postcard_templates %}
<option value="{{ t.pk }}"{% if form_data.template_id == t.pk|stringformat:"s" %} selected{% endif %}>
{{ t.name }} (design {{ t.postcard_front.design_id }})
</option>
{% empty %}
<option value="" disabled>No templates yet — use Postcard designer</option>
{% endfor %}
</select>
<div class="hint"><a href="{% url 'messaging:postcard_designer' %}">Open postcard designer</a></div>
</div>
<div class="form-grid cols-2">
<div class="field">
<label for="id_audience">Recipients</label>
<select id="id_audience" name="audience" required onchange="syncComposeChannel()">
{% for value, label in audience_choices %}
<option value="{{ value }}"{% if form_data.audience == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="id_scheduled_for">Schedule <span class="muted">(optional)</span></label>
<input id="id_scheduled_for" name="scheduled_for" type="datetime-local" step="60"
value="{{ form_data.scheduled_for }}">
<div class="hint">Date &amp; time · leave blank to keep as unscheduled draft</div>
</div>
</div>
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. Youll get an email when the send finishes.</p>
<button class="btn btn-primary" type="submit">Save draft</button>
</form>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Preview</h2></div>
<div class="panel-b">
<div class="preview-pane" id="campaign-preview">
<div class="muted" id="preview-empty">Preview updates as you type.</div>
<div id="preview-content" hidden>
<div class="hint" id="preview-subject"></div>
<div id="preview-body" style="white-space:pre-wrap;margin-top:8px"></div>
</div>
</div>
<p class="hint-block">After send → open the campaign report for delivery &amp; engagement.</p>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Recent campaigns</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Channel</th>
<th>Status</th>
<th>Scheduled</th>
<th></th>
</tr>
</thead>
<tbody>
{% for campaign in campaigns %}
<tr>
<td><a href="{% url 'messaging: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 'messaging: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>
function syncCampaignPreview() {
var subject = (document.getElementById('id_subject') || {}).value || '';
var body = (document.getElementById('id_body') || {}).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) : '';
bodyEl.textContent = body;
}
function syncComposeChannel() {
var audience = (document.getElementById('id_audience') || {}).value || '';
var isPostcard = audience === 'postcard_opt_in';
var tmplField = document.getElementById('postcard-template-field');
var body = document.getElementById('id_body');
if (tmplField) tmplField.style.display = isPostcard ? '' : 'none';
if (body) {
if (isPostcard) body.removeAttribute('required');
else body.setAttribute('required', 'required');
}
}
syncCampaignPreview();
syncComposeChannel();
</script>
{% endblock %}
@@ -0,0 +1,134 @@
{% 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 'messaging: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 'messaging: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 'messaging: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">
Saved templates appear when composing a postcard campaign.
</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 'messaging: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 %}
+629
View File
@@ -0,0 +1,629 @@
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from contacts.models import Channel, ConsentRecord, Contact, Suppression
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
from messaging.services import (
contact_may_receive,
create_campaign_draft,
make_unsubscribe_token,
set_channel_consent,
)
class PreferenceCenterTests(TestCase):
def setUp(self):
self.client = Client()
self.contact = Contact.objects.create(
email="jordan.lee@example.com",
phone="5550184420",
first_name="Jordan",
last_name="Lee",
)
for channel in Channel:
set_channel_consent(
self.contact, channel.value, opted_in=True, reason="test_seed"
)
self.token = make_unsubscribe_token(str(self.contact.pk), Channel.EMAIL)
def test_preferences_get_shows_form(self):
url = reverse("public:unsubscribe", kwargs={"token": self.token})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Email marketing")
self.assertContains(response, "SMS updates")
self.assertContains(response, "Postcard mailings")
self.assertTrue(contact_may_receive(self.contact, Channel.EMAIL))
def test_invalid_token(self):
url = reverse("public:unsubscribe", kwargs={"token": "not-a-valid-token"})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "invalid or has expired")
def test_one_click_query_opts_out_email_only(self):
url = reverse("public:unsubscribe", kwargs={"token": self.token})
response = self.client.get(f"{url}?one_click=1")
self.assertEqual(response.status_code, 302)
self.contact.refresh_from_db()
self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL))
self.assertTrue(contact_may_receive(self.contact, Channel.SMS))
self.assertTrue(contact_may_receive(self.contact, Channel.POSTCARD))
def test_one_click_endpoint(self):
url = reverse(
"public:unsubscribe_one_click", kwargs={"token": self.token}
)
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL))
def test_one_click_post_rfc8058(self):
url = reverse(
"public:unsubscribe_one_click", kwargs={"token": self.token}
)
response = self.client.post(
url, {"List-Unsubscribe": "One-Click"}
)
self.assertEqual(response.status_code, 302)
self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL))
def test_save_preferences_partial_opt_out(self):
url = reverse("public:unsubscribe", kwargs={"token": self.token})
response = self.client.post(
url,
{
"action": "save",
"consent_email": "1",
# SMS unchecked
"consent_postcard": "1",
},
)
self.assertEqual(response.status_code, 302)
self.assertTrue(contact_may_receive(self.contact, Channel.EMAIL))
self.assertFalse(contact_may_receive(self.contact, Channel.SMS))
self.assertTrue(contact_may_receive(self.contact, Channel.POSTCARD))
sms_sup = Suppression.objects.get(
contact=self.contact, channel=Channel.SMS
)
self.assertTrue(sms_sup.active)
def test_unsubscribe_all(self):
url = reverse("public:unsubscribe", kwargs={"token": self.token})
response = self.client.post(url, {"action": "unsubscribe_all"})
self.assertEqual(response.status_code, 302)
for channel in Channel:
self.assertFalse(contact_may_receive(self.contact, channel.value))
class SmsStopWebhookTests(TestCase):
def setUp(self):
self.client = Client()
self.contact = Contact.objects.create(
email="avery@example.com",
phone="5550142291",
)
set_channel_consent(
self.contact, Channel.SMS, opted_in=True, reason="test"
)
def test_stop_webhook(self):
url = reverse("messaging:sms_webhook")
response = self.client.post(
url, {"from": "5550142291", "text": "STOP"}
)
self.assertEqual(response.status_code, 200)
self.assertFalse(contact_may_receive(self.contact, Channel.SMS))
class PortalConsentToggleTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="monica", password="test-pass-123"
)
self.client = Client()
self.client.login(username="monica", password="test-pass-123")
self.contact = Contact.objects.create(
email="sam@example.com",
first_name="Sam",
)
set_channel_consent(
self.contact, Channel.EMAIL, opted_in=True, reason="test"
)
def test_portal_can_toggle_consent(self):
url = reverse("contacts:detail", kwargs={"pk": self.contact.pk})
response = self.client.post(
url,
{
"notes": "updated",
"consent_sms": "1",
"consent_postcard": "1",
# email unchecked → opt out
},
)
self.assertEqual(response.status_code, 302)
self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL))
self.assertTrue(contact_may_receive(self.contact, Channel.SMS))
self.assertTrue(contact_may_receive(self.contact, Channel.POSTCARD))
email_consent = ConsentRecord.objects.get(
contact=self.contact, channel=Channel.EMAIL
)
self.assertEqual(email_consent.reason, "portal_manual")
class CampaignDraftSaveTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="composer", password="test-pass-123"
)
self.client = Client()
self.client.login(username="composer", password="test-pass-123")
self.contact = Contact.objects.create(
email="pat@example.com",
first_name="Pat",
)
set_channel_consent(
self.contact, Channel.EMAIL, opted_in=True, reason="test"
)
def test_save_draft_creates_campaign_and_messages(self):
url = reverse("messaging:campaign_list")
response = self.client.post(
url,
{
"name": "Spring tips",
"subject": "Hello sellers",
"body": "Hi {first_name}",
"audience": Campaign.Audience.EMAIL_OPT_IN,
"scheduled_for": "2026-08-10T09:30",
},
)
campaign = Campaign.objects.get(name="Spring tips")
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url,
reverse("messaging:campaign_detail", kwargs={"pk": campaign.pk}),
)
self.assertEqual(campaign.status, Campaign.Status.DRAFT)
self.assertEqual(campaign.channel, Channel.EMAIL)
self.assertEqual(campaign.audience, Campaign.Audience.EMAIL_OPT_IN)
self.assertIsNotNone(campaign.scheduled_for)
self.assertEqual(campaign.messages.count(), 1)
msg = campaign.messages.get()
self.assertEqual(msg.contact, self.contact)
self.assertEqual(msg.status, Message.Status.DRAFT)
def test_save_draft_requires_subject_for_email(self):
url = reverse("messaging:campaign_list")
response = self.client.post(
url,
{
"name": "No subject",
"subject": "",
"body": "Body only",
"audience": Campaign.Audience.EMAIL_OPT_IN,
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Subject is required")
self.assertFalse(Campaign.objects.filter(name="No subject").exists())
def test_create_campaign_draft_helper(self):
campaign = create_campaign_draft(
name="Helper draft",
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Subj",
body="Body",
created_by=self.user,
)
self.assertEqual(campaign.messages.count(), 1)
self.assertEqual(campaign.created_by, self.user)
class CampaignSendTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="sender", password="test-pass-123", email="sender@example.com"
)
self.client = Client()
self.client.login(username="sender", password="test-pass-123")
self.contact = Contact.objects.create(
email="pat@example.com",
first_name="Pat",
)
set_channel_consent(
self.contact, Channel.EMAIL, opted_in=True, reason="test"
)
self.campaign = create_campaign_draft(
name="Send me",
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Hello",
body="Body text",
created_by=self.user,
)
def test_detail_shows_send_controls(self):
url = reverse(
"messaging:campaign_detail", kwargs={"pk": self.campaign.pk}
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Send test email")
self.assertContains(response, "Send now to recipients")
def test_test_send_uses_locmem(self):
from django.core import mail
url = reverse(
"messaging:campaign_test_send", kwargs={"pk": self.campaign.pk}
)
with self.settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
DEFAULT_FROM_EMAIL="noreply@example.com",
):
response = self.client.post(url, {"test_email": "me@example.com"})
self.assertEqual(response.status_code, 302)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["me@example.com"])
self.assertTrue(mail.outbox[0].subject.startswith("[TEST]"))
# Recipients untouched
self.assertEqual(
self.campaign.messages.filter(status=Message.Status.DRAFT).count(), 1
)
def test_send_now_marks_messages_sent(self):
from django.core import mail
url = reverse(
"messaging:campaign_send", kwargs={"pk": self.campaign.pk}
)
with self.settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
DEFAULT_FROM_EMAIL="noreply@example.com",
PUBLIC_SITE_URL="http://testserver",
):
response = self.client.post(url)
self.assertEqual(response.status_code, 302)
self.campaign.refresh_from_db()
self.assertEqual(self.campaign.status, Campaign.Status.COMPLETED)
self.assertEqual(
self.campaign.messages.filter(status=Message.Status.SENT).count(), 1
)
# Recipient campaign email + one realtor completion summary.
self.assertEqual(len(mail.outbox), 2)
self.assertIsNotNone(self.campaign.notify_sent_at)
summary = mail.outbox[1]
self.assertIn("Campaign sent:", summary.subject)
self.assertEqual(summary.to, [self.user.email])
class Smtp2goEmailWebhookTests(TestCase):
def setUp(self):
self.client = Client()
self.contact = Contact.objects.create(
email="pat@example.com",
first_name="Pat",
)
set_channel_consent(
self.contact, Channel.EMAIL, opted_in=True, reason="test"
)
self.campaign = create_campaign_draft(
name="Webhook campaign",
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Hello",
body="Body",
)
self.message = self.campaign.messages.get()
self.message.status = Message.Status.SENT
self.message.provider_message_id = f"smtp-{self.message.pk}"
self.message.save()
def test_delivered_updates_status_via_monica_header(self):
url = reverse("messaging:email_webhook")
response = self.client.post(
url,
data={
"event": "delivered",
"rcpt": "pat@example.com",
"email_id": "smtp2go-abc-123",
"X-Monica-Message-Id": str(self.message.pk),
},
)
self.assertEqual(response.status_code, 200)
self.message.refresh_from_db()
self.assertEqual(self.message.status, Message.Status.DELIVERED)
self.assertEqual(self.message.provider_message_id, "smtp2go-abc-123")
self.assertTrue(
ProviderEvent.objects.filter(
message=self.message, event_type="delivered"
).exists()
)
def test_open_records_event_keeps_delivered(self):
self.message.status = Message.Status.DELIVERED
self.message.save(update_fields=["status"])
url = reverse("messaging:email_webhook")
self.client.post(
url,
data={
"event": "open",
"rcpt": "pat@example.com",
"X-Monica-Message-Id": str(self.message.pk),
},
)
self.message.refresh_from_db()
self.assertEqual(self.message.status, Message.Status.DELIVERED)
self.assertTrue(
ProviderEvent.objects.filter(
message=self.message, event_type="open"
).exists()
)
def test_hard_bounce_suppresses_contact(self):
url = reverse("messaging:email_webhook")
self.client.post(
url,
data={
"event": "bounce",
"bounce": "hard",
"rcpt": "pat@example.com",
"message": "550 user unknown",
"X-Monica-Message-Id": str(self.message.pk),
},
)
self.message.refresh_from_db()
self.assertEqual(self.message.status, Message.Status.BOUNCED)
self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL))
def test_webhook_secret_required_when_configured(self):
url = reverse("messaging:email_webhook")
with self.settings(SMTP2GO_WEBHOOK_SECRET="s3cret"):
denied = self.client.post(url, data={"event": "delivered"})
self.assertEqual(denied.status_code, 403)
ok = self.client.post(
f"{url}?token=s3cret",
data={
"event": "delivered",
"X-Monica-Message-Id": str(self.message.pk),
},
)
self.assertEqual(ok.status_code, 200)
def test_status_json_includes_opens(self):
User = get_user_model()
user = User.objects.create_user(username="viewer", password="test-pass-123")
self.client.login(username="viewer", password="test-pass-123")
ProviderEvent.objects.create(
message=self.message,
provider="smtp2go_email",
event_type="open",
payload={"event": "open"},
)
url = reverse(
"messaging:campaign_status_json", kwargs={"pk": self.campaign.pk}
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["stats"]["opens"], 1)
def test_bearer_authorization_header(self):
import json
url = reverse("messaging:email_webhook")
with self.settings(SMTP2GO_WEBHOOK_SECRET="s3cret"):
denied = self.client.post(
url,
data=json.dumps(
{
"event": "delivered",
"X-Monica-Message-Id": str(self.message.pk),
}
),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer wrong",
)
self.assertEqual(denied.status_code, 403)
ok = self.client.post(
url,
data=json.dumps(
{
"event": "delivered",
"X-Monica-Message-Id": str(self.message.pk),
"email_id": "e-1",
}
),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer s3cret",
)
self.assertEqual(ok.status_code, 200)
class Smtp2goSmsWebhookTests(TestCase):
def setUp(self):
self.client = Client()
self.contact = Contact.objects.create(
email="pat@example.com",
phone="+15550142291",
first_name="Pat",
)
set_channel_consent(
self.contact, Channel.SMS, opted_in=True, reason="test"
)
self.campaign = create_campaign_draft(
name="SMS blast",
audience=Campaign.Audience.SMS_OPT_IN,
subject="",
body="Hi there",
)
self.message = self.campaign.messages.get()
self.message.status = Message.Status.SENT
self.message.provider_message_id = "sms-provider-99"
self.message.save()
def test_sms_delivered_by_message_id(self):
url = reverse("messaging:sms_webhook")
import json
response = self.client.post(
url,
data=json.dumps(
{
"event": "sms_delivered",
"message_id": "sms-provider-99",
"destination_number": "5550142291",
}
),
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, event_type="sms_delivered"
).exists()
)
def test_sms_failed_by_phone_fallback(self):
url = reverse("messaging:sms_webhook")
import json
self.message.provider_message_id = "other-id"
self.message.save(update_fields=["provider_message_id"])
response = self.client.post(
url,
data=json.dumps(
{
"event": "sms_failed",
"message_id": "unknown",
"destination_number": "+1 (555) 014-2291",
"status_code": "undeliverable",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.message.refresh_from_db()
self.assertEqual(self.message.status, Message.Status.FAILED)
def test_inbound_stop_still_works(self):
url = reverse("messaging:sms_webhook")
response = self.client.post(
url, {"from": "5550142291", "text": "STOP"}
)
self.assertEqual(response.status_code, 200)
self.assertFalse(contact_may_receive(self.contact, Channel.SMS))
def test_sms_webhook_requires_bearer_when_secret_set(self):
url = reverse("messaging:sms_webhook")
import json
with self.settings(SMTP2GO_WEBHOOK_SECRET="s3cret"):
denied = self.client.post(
url,
data=json.dumps({"event": "sms_delivered", "message_id": "x"}),
content_type="application/json",
)
self.assertEqual(denied.status_code, 403)
ok = self.client.post(
url,
data=json.dumps(
{
"event": "sms_delivered",
"message_id": "sms-provider-99",
}
),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer s3cret",
)
self.assertEqual(ok.status_code, 200)
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("messaging: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("messaging:postcard_webhook")
with self.settings(PCM_WEBHOOK_SECRET="pcm-secret"):
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)
+39
View File
@@ -0,0 +1,39 @@
from django.urls import path
from messaging import views
app_name = "messaging"
urlpatterns = [
path("campaigns/", views.campaign_list, name="campaign_list"),
path("campaigns/<uuid:pk>/", views.campaign_detail, name="campaign_detail"),
path(
"campaigns/<uuid:pk>/status.json",
views.campaign_status_json,
name="campaign_status_json",
),
path("campaigns/<uuid:pk>/send/", views.campaign_send, name="campaign_send"),
path(
"campaigns/<uuid:pk>/test-send/",
views.campaign_test_send,
name="campaign_test_send",
),
path("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/sms/", views.sms_webhook, name="sms_webhook"),
path("webhooks/email/", views.email_webhook, name="email_webhook"),
path(
"webhooks/postcard/",
views.postcard_webhook,
name="postcard_webhook",
),
]
+549
View File
@@ -0,0 +1,549 @@
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.validators import validate_email
from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from contacts.models import Channel
from messaging.models import Campaign, MessageTemplate, ProviderEvent
from messaging.providers.postcard.pcm import (
PCM_SIZE_CHOICES,
PcmApiError,
create_custom_design,
get_design_embed_url,
list_designs,
)
from messaging.services import (
create_campaign_draft,
enqueue_campaign_send,
opted_in_contacts,
parse_scheduled_for,
record_sms_stop,
send_campaign_test_email,
)
from messaging.webhooks import (
campaign_engagement_stats,
is_inbound_sms_stop,
parse_webhook_payload,
process_pcm_postcard_webhook,
process_smtp2go_email_webhook,
process_smtp2go_sms_webhook,
)
def _audience_choices() -> list[tuple[str, str]]:
"""Labeled audience options with live opted-in counts."""
rows = [
(Campaign.Audience.EMAIL_OPT_IN, Channel.EMAIL, "email"),
(Campaign.Audience.SMS_OPT_IN, Channel.SMS, "SMS"),
(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 _campaign_report(campaign: Campaign) -> dict:
messages_qs = list(campaign.messages.select_related("contact").all()[:200])
stats = campaign_engagement_stats(campaign)
recent_events = (
ProviderEvent.objects.filter(message__campaign=campaign)
.select_related("message", "message__contact")
.order_by("-created_at")[:25]
)
return {
"messages": messages_qs,
"stats": stats,
"recent_events": recent_events,
}
def _webhook_authorized(request, *, secret: str) -> bool:
secret = (secret or "").strip()
if not secret:
return True
token = (request.GET.get("token") or "").strip()
auth = (request.headers.get("Authorization") or "").strip()
if token and token == secret:
return True
if auth.lower().startswith("bearer ") and auth[7:].strip() == secret:
return True
return False
@login_required
@require_http_methods(["GET", "POST"])
def campaign_list(request):
form_errors: list[str] = []
form_data = {
"name": "",
"subject": "",
"body": "",
"audience": Campaign.Audience.EMAIL_OPT_IN,
"scheduled_for": "",
"template_id": "",
}
if request.method == "POST":
name = (request.POST.get("name") or "").strip()
subject = (request.POST.get("subject") or "").strip()
body = (request.POST.get("body") or "").strip()
audience = (request.POST.get("audience") or "").strip()
scheduled_raw = request.POST.get("scheduled_for") or ""
template_id = (request.POST.get("template_id") or "").strip()
form_data.update(
{
"name": name,
"subject": subject,
"body": body,
"audience": audience,
"scheduled_for": scheduled_raw,
"template_id": template_id,
}
)
template = None
if template_id:
template = MessageTemplate.objects.filter(pk=template_id).first()
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 saved postcard template (design it under Postcard first)."
)
if not body:
body = "Postcard mailing"
else:
if not body:
form_errors.append("Body is required.")
if (
audience == Campaign.Audience.EMAIL_OPT_IN
and not subject
):
form_errors.append("Subject is required for email campaigns.")
scheduled_for = None
try:
scheduled_for = parse_scheduled_for(scheduled_raw)
except ValueError as exc:
form_errors.append(str(exc))
if not form_errors:
campaign = create_campaign_draft(
name=name,
audience=audience,
subject=subject,
body=body,
scheduled_for=scheduled_for,
created_by=request.user,
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("messaging:campaign_detail", pk=campaign.pk)
campaigns = Campaign.objects.all()[:100]
return render(
request,
"messaging/campaign_list.html",
{
"campaigns": campaigns,
"audience_choices": _audience_choices(),
"postcard_templates": _postcard_templates(),
"form_data": form_data,
"form_errors": form_errors,
},
)
@login_required
def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
ctx = _campaign_report(campaign)
return render(
request,
"messaging/campaign_detail.html",
{
"campaign": campaign,
"messages": ctx["messages"],
"stats": ctx["stats"],
"recent_events": ctx["recent_events"],
"can_send": campaign.status
in {
Campaign.Status.DRAFT,
Campaign.Status.SCHEDULED,
Campaign.Status.SENDING,
}
and campaign.messages.exclude(
status__in={"sent", "delivered", "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)
ctx = _campaign_report(campaign)
return JsonResponse(
{
"status": campaign.status,
"status_display": campaign.get_status_display(),
"stats": ctx["stats"],
"messages": [
{
"id": str(m.pk),
"contact": str(m.contact),
"status": m.status,
"status_display": m.get_status_display(),
"provider_message_id": m.provider_message_id or "",
"error": (m.error or "")[:120],
}
for m in ctx["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_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("messaging: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("messaging: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("messaging:campaign_detail", pk=campaign.pk)
try:
validate_email(to_email)
except ValidationError:
messages.error(request, "That test email address is not valid.")
return redirect("messaging: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("messaging:campaign_detail", pk=campaign.pk)
@login_required
def postcard_designer(request):
"""PCM Integrations designer — list designs + embed iframe."""
api_error = ""
designs: list[dict] = []
embed_url = ""
active_design_id = (request.GET.get("design_id") or "").strip()
active_name = ""
active_size = "46"
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)
# Merge saved local templates that may not appear in the remote page yet.
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)
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,
"messaging/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("messaging: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("messaging: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("messaging:postcard_designer")
messages.success(request, f"Design {design_id} created — edit below.")
return redirect(
f"{reverse('messaging: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("messaging:postcard_designer")
if not template_name:
messages.error(request, "Template name is required.")
return redirect(
f"{reverse('messaging:postcard_designer')}?design_id={design_id}"
)
try:
design_id_int = int(design_id)
except ValueError:
messages.error(request, "Invalid design id.")
return redirect("messaging: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('messaging:postcard_designer')}?design_id={design_id}"
)
@csrf_exempt
@require_POST
def postcard_webhook(request):
"""
PCM Integrations order / mail-tracking webhook.
Configure in PCM → Webhooks:
URL: https://<host>/portal/messaging/webhooks/postcard/
Authorization: Bearer + PCM_WEBHOOK_SECRET
Events: order / recipient status updates (Delivered, Undeliverable, …)
"""
if not _webhook_authorized(
request, secret=settings.PCM_WEBHOOK_SECRET or ""
):
return HttpResponseForbidden("invalid webhook token")
payload = parse_webhook_payload(request)
if not payload:
payload = request.POST.dict() or {}
event = process_pcm_postcard_webhook(payload)
return JsonResponse(
{
"ok": True,
"matched": bool(event and event.message_id),
"event_id": event.pk if event else None,
}
)
@csrf_exempt
@require_POST
def sms_webhook(request):
"""
SMTP2GO SMS webhook — delivery status events + inbound STOP replies.
Configure a *separate* webhook in SMTP2GO → Settings → Webhooks:
URL: https://<host>/portal/messaging/webhooks/sms/
Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET
Output type: JSON
SMS events: Submitted, Sending, Delivered, Failed, Rejected, Opt-out
(leave Email events unchecked on this webhook)
Inbound gateway POSTs without ``event`` (text=STOP, from=…) still opt out.
"""
if not _webhook_authorized(
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
):
return HttpResponseForbidden("invalid webhook token")
payload = parse_webhook_payload(request)
if not payload:
payload = request.POST.dict() or {}
# Inbound reply (STOP) — different payload shape than delivery events.
if is_inbound_sms_stop(payload):
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))
return JsonResponse({"ok": True, "opt_out": stopped})
event = process_smtp2go_sms_webhook(payload)
return JsonResponse(
{
"ok": True,
"matched": bool(event and event.message_id),
"event_id": event.pk if event else None,
}
)
@csrf_exempt
@require_POST
def email_webhook(request):
"""
SMTP2GO email event webhook (delivered / open / click / bounce / …).
Configure in SMTP2GO → Settings → Webhooks:
URL: https://<host>/portal/messaging/webhooks/email/
Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET
Output type: JSON
Email events: all delivery/engagement boxes
Email headers: X-Monica-Message-Id
"""
if not _webhook_authorized(
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
):
return HttpResponseForbidden("invalid webhook token")
payload = parse_webhook_payload(request)
event = process_smtp2go_email_webhook(payload)
return JsonResponse(
{
"ok": True,
"matched": bool(event and event.message_id),
"event_id": event.pk if event else None,
}
)
+575
View File
@@ -0,0 +1,575 @@
"""SMTP2GO email/SMS event webhooks → Message + ProviderEvent updates."""
from __future__ import annotations
import json
import logging
from typing import Any
from django.http import HttpRequest
from contacts.models import Channel, Contact
from messaging.models import Message, ProviderEvent
from messaging.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 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.BOUNCED: 5,
Message.Status.SUPPRESSED: 5,
}
_MONICA_HEADER_KEYS = (
"X-Monica-Message-Id",
"x-monica-message-id",
"X_Monica_Message_Id",
"monica-message-id",
)
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:
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 = payload.get(key)
if value:
return str(value).strip()
headers = payload.get("headers") or payload.get("email_headers") or {}
if isinstance(headers, dict):
for key in _MONICA_HEADER_KEYS:
value = headers.get(key)
if value:
return str(value).strip()
# Case-insensitive scan
lower_map = {str(k).lower(): v for k, v in headers.items()}
for key in _MONICA_HEADER_KEYS:
value = lower_map.get(key.lower())
if value:
return str(value).strip()
return ""
def find_message_for_email_event(payload: dict[str, Any]) -> Message | None:
monica_id = extract_monica_message_id(payload)
if monica_id:
message = (
Message.objects.select_related("contact", "campaign")
.filter(pk=monica_id)
.first()
)
if message:
return message
email_id = (payload.get("email_id") or payload.get("email-id") or "").strip()
if email_id:
message = (
Message.objects.select_related("contact", "campaign")
.filter(provider_message_id=email_id)
.first()
)
if message:
return message
rcpt = (payload.get("rcpt") or "").strip().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 = str(recipients[0]).strip().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.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 == Message.Status.DELIVERED
):
return
fields = ["status", "updated_at"]
message.status = new_status
if error:
message.error = error[:2000]
fields.append("error")
elif new_status == Message.Status.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 = (event or "").strip().lower()
bounce_kind = (payload.get("bounce") or "").strip().lower()
err = (payload.get("message") or payload.get("context") or "").strip()
email_id = (payload.get("email_id") or payload.get("email-id") or "").strip()
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
# open / click / 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 = (payload.get("event") or "").strip().lower()
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,
payload=payload,
)
def normalize_phone(value: str) -> str:
return "".join(ch for ch in (value or "") if ch.isdigit())
def find_message_for_sms_event(payload: dict[str, Any]) -> Message | None:
provider_id = (
payload.get("message_id")
or payload.get("sms_id")
or payload.get("id")
or ""
)
provider_id = str(provider_id).strip()
if provider_id:
message = (
Message.objects.select_related("contact", "campaign")
.filter(channel=Channel.SMS, provider_message_id=provider_id)
.first()
)
if message:
return message
raw_phone = (
payload.get("destination_number")
or payload.get("to")
or payload.get("phone")
or payload.get("from")
or ""
)
digits = normalize_phone(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,
],
)
.order_by("-sent_at", "-updated_at")
.first()
)
def _apply_sms_event(message: Message, event: str, payload: dict[str, Any]) -> None:
event = (event or "").strip().lower().replace("-", "_")
err = (
payload.get("message")
or payload.get("status_code")
or payload.get("context")
or ""
)
err = str(err).strip()
provider_id = (
payload.get("message_id") or payload.get("sms_id") or ""
)
provider_id = str(provider_id).strip()
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", "sending", "sms_submitted", "submitted"}:
if message.status in {Message.Status.QUEUED, Message.Status.DRAFT}:
_maybe_upgrade_status(message, Message.Status.SENT)
return
if event in {"sms_delivered", "delivered"}:
_maybe_upgrade_status(message, Message.Status.DELIVERED)
return
if event in {"sms_failed", "failed", "sms_rejected", "rejected"}:
_maybe_upgrade_status(
message,
Message.Status.FAILED,
error=err or event,
)
return
if event in {"sms_opt_out", "opt_out", "optout"}:
_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 = (payload.get("event") or "").strip().lower()
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.replace("-", "_") in {"sms_opt_out", "opt_out", "optout"}:
phone = (
payload.get("destination_number")
or payload.get("from")
or payload.get("source_number")
or ""
)
if phone:
from messaging.services import record_sms_stop
record_sms_stop(str(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,
payload=payload,
)
def is_inbound_sms_stop(payload: dict[str, Any]) -> bool:
"""True for gateway-style inbound reply payloads (STOP / UNSUBSCRIBE)."""
if payload.get("event"):
return False
text = (
payload.get("text")
or payload.get("message")
or payload.get("message_content")
or ""
)
text = str(text).strip().upper()
return text in {"STOP", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"}
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.objects.select_related("contact", "campaign")
.filter(pk=ext)
.first()
)
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 campaign_engagement_stats(campaign) -> dict[str, int]:
"""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)
)
return {
"total": len(statuses),
"sent": sum(1 for s in statuses if s in {"sent", "delivered"}),
"delivered": sum(1 for s in statuses if s == "delivered"),
"failed": sum(1 for s in statuses if s in {"failed", "bounced"}),
"bounced": sum(1 for s in statuses if s == "bounced"),
"suppressed": sum(1 for s in statuses if s == "suppressed"),
"opens": len(open_message_ids),
"clicks": len(click_message_ids),
"open_events": events.filter(event_type__iexact="open").count(),
"click_events": events.filter(event_type__iexact="click").count(),
}