Fix PCM auth: login for API token and multi webhook secrets.
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:
+8
-2
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -51,11 +51,18 @@ Default postcard provider. Designer embeds PCM’s 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={…}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
return (settings.PCM_API_SECRET or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_expires(raw: Any) -> datetime:
|
||||||
|
"""Parse PCM expires timestamp; default to 55 minutes from now."""
|
||||||
|
if isinstance(raw, (int, float)):
|
||||||
|
# Unix seconds vs ms
|
||||||
|
ts = float(raw)
|
||||||
|
if ts > 1e12:
|
||||||
|
ts /= 1000.0
|
||||||
|
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||||
|
if isinstance(raw, str) and raw.strip():
|
||||||
|
text = raw.strip().replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
dt = None
|
||||||
|
if dt is not None:
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
return datetime.now(timezone.utc) + timedelta(minutes=55)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_token_cache() -> None:
|
||||||
|
"""Drop cached bearer token (tests / forced re-login)."""
|
||||||
|
global _cached_token, _cached_expires
|
||||||
|
with _token_lock:
|
||||||
|
_cached_token = None
|
||||||
|
_cached_expires = None
|
||||||
|
|
||||||
|
|
||||||
|
def login(*, force: bool = False) -> str:
|
||||||
|
"""POST /auth/login with apiKey + apiSecret → bearer token.
|
||||||
|
|
||||||
|
See https://docs.pcmintegrations.com/docs/directmail-api/ffef03a112bb0-logging-in
|
||||||
|
"""
|
||||||
|
global _cached_token, _cached_expires
|
||||||
|
|
||||||
|
with _token_lock:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and _cached_token
|
||||||
|
and _cached_expires
|
||||||
|
and now < (_cached_expires - _TOKEN_SKEW)
|
||||||
|
):
|
||||||
|
return _cached_token
|
||||||
|
|
||||||
key = _api_key()
|
key = _api_key()
|
||||||
if not key:
|
secret = _api_secret()
|
||||||
raise PcmApiError("PCM_API_KEY is not configured")
|
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
@@ -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))
|
||||||
|
|||||||
+52
-11
@@ -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 ""
|
||||||
|
# Common signature-header names PCM / gateways may use (raw secret or HMAC).
|
||||||
|
sig_headers = (
|
||||||
|
request.headers.get("X-PCM-Signature")
|
||||||
|
or request.headers.get("X-Webhook-Signature")
|
||||||
|
or request.headers.get("X-Signature")
|
||||||
|
or request.headers.get("X-Hub-Signature-256")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if sig_headers.lower().startswith("sha256="):
|
||||||
|
sig_headers = sig_headers[7:].strip()
|
||||||
|
|
||||||
|
body = request.body or b""
|
||||||
|
for candidate in candidates:
|
||||||
|
if token and hmac.compare_digest(token, candidate):
|
||||||
return True
|
return True
|
||||||
if auth.lower().startswith("bearer ") and auth[7:].strip() == secret:
|
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 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:
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user