Fix PCM auth: login for API token and multi webhook secrets.
Deploy Beta / unit-tests (push) Successful in 8s
Deploy Beta / docker (push) Successful in 14s
Deploy Beta / deploy-beta (push) Successful in 1m34s

PCM DirectMail v3 needs POST /auth/login (apiKey+apiSecret) before design/order calls; accept each subscription's copy-only signature secret via PCM_WEBHOOK_SECRETS.
This commit is contained in:
2026-08-09 06:02:32 -05:00
parent 1ef6624a8b
commit 7496af72d0
8 changed files with 299 additions and 33 deletions
+8 -2
View File
@@ -40,10 +40,16 @@ SMTP2GO_SMS_API_KEY=
SMTP2GO_WEBHOOK_SECRET= SMTP2GO_WEBHOOK_SECRET=
# Postcards — PCM Integrations (DirectMail API v3) # Postcards — PCM Integrations (DirectMail API v3)
# Portal → My Account → API Keys: copy Sandbox or Production key + secret.
# App POSTs /auth/login then uses the returned Bearer token (not the raw key).
POSTCARD_PROVIDER=pcm POSTCARD_PROVIDER=pcm
PCM_API_KEY= PCM_API_KEY=
# Inbound webhook auth (Bearer / ?token=) — set a long random string PCM_API_SECRET=
PCM_WEBHOOK_SECRET= # Optional: PCM_CHILD_REF_NBR=
# Inbound webhook auth — PCM generates one signature secret per subscription.
# Paste all of them comma-separated (also accepts single PCM_WEBHOOK_SECRET=).
PCM_WEBHOOK_SECRETS=
# PCM_WEBHOOK_SECRET=
# Return address on every postcard order (JSON). Example: # Return address on every postcard order (JSON). Example:
# PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"123 Main St","city":"Naperville","state":"IL","zipCode":"60540"} # PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"123 Main St","city":"Naperville","state":"IL","zipCode":"60540"}
PCM_RETURN_ADDRESS= PCM_RETURN_ADDRESS=
+7 -2
View File
@@ -53,10 +53,15 @@ SMTP2GO_SMS_API_KEY=replace-me
# Email + SMS event webhook auth. SMTP2GO Authorization header = Bearer + this value. # Email + SMS event webhook auth. SMTP2GO Authorization header = Bearer + this value.
SMTP2GO_WEBHOOK_SECRET=replace-me SMTP2GO_WEBHOOK_SECRET=replace-me
# Postcards — PCM Integrations # Postcards — PCM Integrations (key+secret → POST /auth/login → Bearer token)
POSTCARD_PROVIDER=pcm POSTCARD_PROVIDER=pcm
PCM_API_KEY=replace-me PCM_API_KEY=replace-me
PCM_WEBHOOK_SECRET=replace-me PCM_API_SECRET=replace-me
# PCM_CHILD_REF_NBR=
# One signature secret per PCM webhook subscription (comma-separated).
PCM_WEBHOOK_SECRETS=replace-me,replace-me-2
# PCM_WEBHOOK_SECRET=replace-me # optional single-secret alias
# JSON return address for postcard orders # JSON return address for postcard orders
PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"replace-me","city":"replace-me","state":"IL","zipCode":"replace-me"} PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"replace-me","city":"replace-me","state":"IL","zipCode":"replace-me"}
# LOB_API_KEY= # only if POSTCARD_PROVIDER=lob # LOB_API_KEY= # only if POSTCARD_PROVIDER=lob
+3 -2
View File
@@ -471,7 +471,8 @@ SMTP2GO_SMS_API_KEY=...
# Postcards (PCM Integrations) # Postcards (PCM Integrations)
POSTCARD_PROVIDER=pcm # pcm|lob|click2mail|postgrid POSTCARD_PROVIDER=pcm # pcm|lob|click2mail|postgrid
PCM_API_KEY=... PCM_API_KEY=...
PCM_WEBHOOK_SECRET=... PCM_API_SECRET=...
PCM_WEBHOOK_SECRETS=...
PCM_RETURN_ADDRESS={...} PCM_RETURN_ADDRESS={...}
# Social (native) # Social (native)
@@ -522,7 +523,7 @@ TIANJI_WEBSITE_ID=...
alternates); `POSTCARD_PROVIDER=pcm`. alternates); `POSTCARD_PROVIDER=pcm`.
- PCM iframe designer; save `design_id` on `MessageTemplate`; postcard campaign audience; - PCM iframe designer; save `design_id` on `MessageTemplate`; postcard campaign audience;
inbound PCM webhooks → `ProviderEvent`; campaign completion notify email. inbound PCM webhooks → `ProviderEvent`; campaign completion notify email.
- Client prerequisites: PCM Integrations account + API key, A2P 10DLC for SMS. - Client prerequisites: PCM Integrations account + API key/secret, A2P 10DLC for SMS.
- **Exit:** realtor mails a postcard batch and sees delivery tracking. - **Exit:** realtor mails a postcard batch and sees delivery tracking.
### Phase 4 — Social automation ### Phase 4 — Social automation
+18 -7
View File
@@ -51,11 +51,18 @@ Default postcard provider. Designer embeds PCMs editor; orders use DirectMail
| Var | Purpose | | Var | Purpose |
|-----|---------| |-----|---------|
| `PCM_API_KEY` | Bearer token for `https://v3.pcmintegrations.com` | | `PCM_API_KEY` | API key from PCM portal (My Account → API Keys) |
| `PCM_WEBHOOK_SECRET` | Auth for inbound status webhooks | | `PCM_API_SECRET` | Matching API secret; used with key on `POST /auth/login` |
| `PCM_CHILD_REF_NBR` | Optional child-app ref for multi-account |
| `PCM_WEBHOOK_SECRETS` | Comma-separated signature secrets (one per PCM subscription) |
| `PCM_WEBHOOK_SECRET` | Optional single-secret alias (merged into the list above) |
| `PCM_RETURN_ADDRESS` | JSON return address on orders | | `PCM_RETURN_ADDRESS` | JSON return address on orders |
| `POSTCARD_PROVIDER` | `pcm` (default) | | `POSTCARD_PROVIDER` | `pcm` (default) |
Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived
`token` used as `Authorization: Bearer …` on design/order calls
([PCM Logging In](https://docs.pcmintegrations.com/docs/directmail-api/ffef03a112bb0-logging-in)).
### Designer ### Designer
Portal → **Postcard design**: create/list designs via API, edit in iframe Portal → **Postcard design**: create/list designs via API, edit in iframe
@@ -64,16 +71,19 @@ Portal → **Postcard design**: create/list designs via API, edit in iframe
### Postcard webhook ### Postcard webhook
Create a webhook subscription in the PCM dashboard (Working with Webhooks): PCM allows **one event per subscription**, and each subscription gets its own
**signature secret** (copy-only in the UI). Create one subscription per status
you care about; point them all at the same URL and paste every secret into env.
| Field | Value | | Field | Value |
|-------|--------| |-------|--------|
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/postcard/` | | URL | `https://mkdrealtor.com/portal/messaging/webhooks/postcard/` |
| Authorization | **Bearer** + `PCM_WEBHOOK_SECRET` | | Events | One subscription each: Pending, Processing, Processed, Delivered, Undeliverable, Canceled (skip QrCodeScan unless needed) |
| Events | Order / recipient status (Pending, Processing, Processed, Delivered, Undeliverable, Canceled) |
| Environments | Sandbox and/or Production as needed | | Environments | Sandbox and/or Production as needed |
| Secrets | Copy each subscription signature → `PCM_WEBHOOK_SECRETS=sec1,sec2,…` |
Fallback: `?token=<PCM_WEBHOOK_SECRET>` on the URL. We accept Bearer, `?token=`, or common signature headers (raw secret or
HMAC-SHA256 of body) matching **any** listed secret.
Correlation: we send `extRefNbr=<Message.uuid>` on each recipient; webhooks should Correlation: we send `extRefNbr=<Message.uuid>` on each recipient; webhooks should
echo that (or `orderID`, matched to `Message.provider_message_id`). echo that (or `orderID`, matched to `Message.provider_message_id`).
@@ -99,7 +109,8 @@ EMAIL_HOST_PASSWORD=…
SMTP2GO_WEBHOOK_SECRET= SMTP2GO_WEBHOOK_SECRET=
SMTP2GO_SMS_API_KEY=# SMS sends only SMTP2GO_SMS_API_KEY=# SMS sends only
PCM_API_KEY= PCM_API_KEY=
PCM_WEBHOOK_SECRET= PCM_API_SECRET=
PCM_WEBHOOK_SECRETS=sec1,sec2,…
PCM_RETURN_ADDRESS={} PCM_RETURN_ADDRESS={}
``` ```
+121 -5
View File
@@ -4,7 +4,9 @@ from __future__ import annotations
import json import json
import logging import logging
import threading
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
import requests import requests
@@ -16,6 +18,9 @@ logger = logging.getLogger(__name__)
PCM_API_BASE = "https://v3.pcmintegrations.com" PCM_API_BASE = "https://v3.pcmintegrations.com"
# Refresh a bit before PCM's expires timestamp.
_TOKEN_SKEW = timedelta(seconds=60)
# PCM size codes for custom designer designs. # PCM size codes for custom designer designs.
PCM_SIZE_CHOICES = ( PCM_SIZE_CHOICES = (
("46", "4.25 × 6"), ("46", "4.25 × 6"),
@@ -30,17 +35,117 @@ class PcmApiError(RuntimeError):
"""Raised when a PCM API call fails.""" """Raised when a PCM API call fails."""
_token_lock = threading.Lock()
_cached_token: str | None = None
_cached_expires: datetime | None = None
def _api_key() -> str: def _api_key() -> str:
return (settings.PCM_API_KEY or "").strip() return (settings.PCM_API_KEY or "").strip()
def _headers() -> dict[str, str]: def _api_secret() -> str:
key = _api_key() return (settings.PCM_API_SECRET or "").strip()
if not key:
raise PcmApiError("PCM_API_KEY is not configured")
def _parse_expires(raw: Any) -> datetime:
"""Parse PCM expires timestamp; default to 55 minutes from now."""
if isinstance(raw, (int, float)):
# Unix seconds vs ms
ts = float(raw)
if ts > 1e12:
ts /= 1000.0
return datetime.fromtimestamp(ts, tz=timezone.utc)
if isinstance(raw, str) and raw.strip():
text = raw.strip().replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(text)
except ValueError:
dt = None
if dt is not None:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
return datetime.now(timezone.utc) + timedelta(minutes=55)
def clear_token_cache() -> None:
"""Drop cached bearer token (tests / forced re-login)."""
global _cached_token, _cached_expires
with _token_lock:
_cached_token = None
_cached_expires = None
def login(*, force: bool = False) -> str:
"""POST /auth/login with apiKey + apiSecret → bearer token.
See https://docs.pcmintegrations.com/docs/directmail-api/ffef03a112bb0-logging-in
"""
global _cached_token, _cached_expires
with _token_lock:
now = datetime.now(timezone.utc)
if (
not force
and _cached_token
and _cached_expires
and now < (_cached_expires - _TOKEN_SKEW)
):
return _cached_token
key = _api_key()
secret = _api_secret()
if not key or not secret:
raise PcmApiError(
"PCM_API_KEY and PCM_API_SECRET are required "
"(POST /auth/login, then use the returned token)"
)
body: dict[str, str] = {"apiKey": key, "apiSecret": secret}
child = (getattr(settings, "PCM_CHILD_REF_NBR", None) or "").strip()
if child:
body["childRefNbr"] = child
response = requests.post(
f"{PCM_API_BASE}/auth/login",
headers={
"Accept": "application/json",
"Content-Type": "application/json",
},
json=body,
timeout=30,
)
if response.status_code >= 400:
detail = (response.text or "")[:500]
raise PcmApiError(
f"PCM POST /auth/login → {response.status_code}: {detail}"
)
try:
data = response.json()
except ValueError as exc:
raise PcmApiError("PCM login returned non-JSON") from exc
if not isinstance(data, dict):
raise PcmApiError("PCM login returned unexpected payload")
token = str(data.get("token") or "").strip()
if not token:
raise PcmApiError("PCM login response missing token")
_cached_token = token
_cached_expires = _parse_expires(data.get("expires"))
logger.info(
"PCM login ok; token expires %s",
_cached_expires.isoformat(),
)
return token
def _headers(*, force_login: bool = False) -> dict[str, str]:
token = login(force=force_login)
return { return {
"Accept": "application/json", "Accept": "application/json",
"Authorization": f"Bearer {key}", "Authorization": f"Bearer {token}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
@@ -63,6 +168,17 @@ def pcm_request(
json=json_body, json=json_body,
timeout=timeout, timeout=timeout,
) )
# Expired/revoked session → login once and retry.
if response.status_code == 401:
clear_token_cache()
response = requests.request(
method,
url,
headers=_headers(force_login=True),
params=params,
json=json_body,
timeout=timeout,
)
if response.status_code >= 400: if response.status_code >= 400:
detail = (response.text or "")[:500] detail = (response.text or "")[:500]
raise PcmApiError( raise PcmApiError(
+81 -1
View File
@@ -608,7 +608,7 @@ class PcmPostcardWebhookTests(TestCase):
import json import json
url = reverse("messaging:postcard_webhook") url = reverse("messaging:postcard_webhook")
with self.settings(PCM_WEBHOOK_SECRET="pcm-secret"): with self.settings(PCM_WEBHOOK_SECRET="pcm-secret", PCM_WEBHOOK_SECRETS=""):
denied = self.client.post( denied = self.client.post(
url, url,
data=json.dumps({"status": "Delivered", "orderID": 555}), data=json.dumps({"status": "Delivered", "orderID": 555}),
@@ -627,3 +627,83 @@ class PcmPostcardWebhookTests(TestCase):
HTTP_AUTHORIZATION="Bearer pcm-secret", HTTP_AUTHORIZATION="Bearer pcm-secret",
) )
self.assertEqual(ok.status_code, 200) self.assertEqual(ok.status_code, 200)
def test_accepts_any_secret_from_list(self):
import json
url = reverse("messaging:postcard_webhook")
with self.settings(
PCM_WEBHOOK_SECRET="",
PCM_WEBHOOK_SECRETS="sec-a,sec-b",
):
denied = self.client.post(
url,
data=json.dumps({"status": "Delivered", "orderID": "order-555"}),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer wrong",
)
self.assertEqual(denied.status_code, 403)
ok = self.client.post(
url,
data=json.dumps({"status": "Delivered", "orderID": "order-555"}),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer sec-b",
)
self.assertEqual(ok.status_code, 200)
class PcmAuthTests(TestCase):
def setUp(self):
from messaging.providers.postcard import pcm as pcm_mod
pcm_mod.clear_token_cache()
def tearDown(self):
from messaging.providers.postcard import pcm as pcm_mod
pcm_mod.clear_token_cache()
def test_login_required_before_design_list(self):
from unittest.mock import MagicMock, patch
from messaging.providers.postcard import pcm as pcm_mod
login_resp = MagicMock()
login_resp.status_code = 200
login_resp.content = b'{"token":"session-tok","expires":"2099-01-01T00:00:00.000Z"}'
login_resp.json.return_value = {
"token": "session-tok",
"expires": "2099-01-01T00:00:00.000Z",
}
design_resp = MagicMock()
design_resp.status_code = 200
design_resp.content = b'{"results":[]}'
design_resp.json.return_value = {"results": []}
with self.settings(PCM_API_KEY="key", PCM_API_SECRET="secret"):
with patch("messaging.providers.postcard.pcm.requests.post") as post:
with patch(
"messaging.providers.postcard.pcm.requests.request"
) as request:
post.return_value = login_resp
request.return_value = design_resp
designs = pcm_mod.list_designs()
self.assertEqual(designs, [])
post.assert_called_once()
self.assertIn("/auth/login", post.call_args.args[0])
self.assertEqual(
post.call_args.kwargs["json"],
{"apiKey": "key", "apiSecret": "secret"},
)
auth = request.call_args.kwargs["headers"]["Authorization"]
self.assertEqual(auth, "Bearer session-tok")
def test_missing_secret_raises(self):
from messaging.providers.postcard import pcm as pcm_mod
with self.settings(PCM_API_KEY="key", PCM_API_SECRET=""):
with self.assertRaises(pcm_mod.PcmApiError) as ctx:
pcm_mod.login(force=True)
self.assertIn("PCM_API_SECRET", str(ctx.exception))
+54 -13
View File
@@ -1,3 +1,6 @@
import hashlib
import hmac
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@@ -74,19 +77,60 @@ def _campaign_report(campaign: Campaign) -> dict:
} }
def _webhook_authorized(request, *, secret: str) -> bool: def _webhook_authorized(request, *, secret: str = "", secrets: list[str] | None = None) -> bool:
secret = (secret or "").strip() """Accept Bearer / ?token= matching any configured secret (constant-time)."""
if not secret: candidates: list[str] = []
if secrets:
candidates.extend(s.strip() for s in secrets if (s or "").strip())
single = (secret or "").strip()
if single and single not in candidates:
candidates.append(single)
if not candidates:
return True return True
token = (request.GET.get("token") or "").strip() token = (request.GET.get("token") or "").strip()
auth = (request.headers.get("Authorization") or "").strip() auth = (request.headers.get("Authorization") or "").strip()
if token and token == secret: bearer = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
return True # Common signature-header names PCM / gateways may use (raw secret or HMAC).
if auth.lower().startswith("bearer ") and auth[7:].strip() == secret: sig_headers = (
return True request.headers.get("X-PCM-Signature")
or request.headers.get("X-Webhook-Signature")
or request.headers.get("X-Signature")
or request.headers.get("X-Hub-Signature-256")
or ""
).strip()
if sig_headers.lower().startswith("sha256="):
sig_headers = sig_headers[7:].strip()
body = request.body or b""
for candidate in candidates:
if token and hmac.compare_digest(token, candidate):
return True
if bearer and hmac.compare_digest(bearer, candidate):
return True
if sig_headers:
if hmac.compare_digest(sig_headers, candidate):
return True
digest = hmac.new(
candidate.encode("utf-8"), body, hashlib.sha256
).hexdigest()
if hmac.compare_digest(sig_headers, digest):
return True
return False return False
def _pcm_webhook_secrets() -> list[str]:
"""All PCM subscription signature secrets from env."""
raw_list = (getattr(settings, "PCM_WEBHOOK_SECRETS", None) or "").strip()
single = (getattr(settings, "PCM_WEBHOOK_SECRET", None) or "").strip()
out: list[str] = []
if raw_list:
out.extend(p.strip() for p in raw_list.split(",") if p.strip())
if single and single not in out:
out.append(single)
return out
@login_required @login_required
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def campaign_list(request): def campaign_list(request):
@@ -453,14 +497,11 @@ def postcard_webhook(request):
""" """
PCM Integrations order / mail-tracking webhook. PCM Integrations order / mail-tracking webhook.
Configure in PCM → Webhooks: Configure in PCM → Webhooks (one subscription per event):
URL: https://<host>/portal/messaging/webhooks/postcard/ URL: https://<host>/portal/messaging/webhooks/postcard/
Authorization: Bearer + PCM_WEBHOOK_SECRET Copy each subscription's signature secret into PCM_WEBHOOK_SECRETS
Events: order / recipient status updates (Delivered, Undeliverable, …)
""" """
if not _webhook_authorized( if not _webhook_authorized(request, secrets=_pcm_webhook_secrets()):
request, secret=settings.PCM_WEBHOOK_SECRET or ""
):
return HttpResponseForbidden("invalid webhook token") return HttpResponseForbidden("invalid webhook token")
payload = parse_webhook_payload(request) payload = parse_webhook_payload(request)
if not payload: if not payload:
+7 -1
View File
@@ -241,8 +241,14 @@ SMTP2GO_WEBHOOK_SECRET = env("SMTP2GO_WEBHOOK_SECRET", "")
# --- Postcards (PCM Integrations default) --- # --- Postcards (PCM Integrations default) ---
POSTCARD_PROVIDER = env("POSTCARD_PROVIDER", "pcm") POSTCARD_PROVIDER = env("POSTCARD_PROVIDER", "pcm")
# Login credentials for POST /auth/login → short-lived Bearer token.
PCM_API_KEY = env("PCM_API_KEY", "") PCM_API_KEY = env("PCM_API_KEY", "")
# Shared secret for inbound PCM event webhooks (Bearer / ?token=). PCM_API_SECRET = env("PCM_API_SECRET", "")
# Optional child-app reference for PCM multi-account (childRefNbr).
PCM_CHILD_REF_NBR = env("PCM_CHILD_REF_NBR", "")
# PCM webhook auth: each subscription has its own signature secret (copy from PCM UI).
# Prefer PCM_WEBHOOK_SECRETS=sec1,sec2,… ; PCM_WEBHOOK_SECRET still accepted (single).
PCM_WEBHOOK_SECRETS = env("PCM_WEBHOOK_SECRETS", "")
PCM_WEBHOOK_SECRET = env("PCM_WEBHOOK_SECRET", "") PCM_WEBHOOK_SECRET = env("PCM_WEBHOOK_SECRET", "")
# JSON object: company, firstName, lastName, address, address2, city, state, zipCode # JSON object: company, firstName, lastName, address, address2, city, state, zipCode
PCM_RETURN_ADDRESS = env("PCM_RETURN_ADDRESS", "") PCM_RETURN_ADDRESS = env("PCM_RETURN_ADDRESS", "")