";
+ }).join("");
+ tbody.querySelectorAll('input[name="route_code"]').forEach(function (box) {
+ box.addEventListener("change", function () {
+ var checked = selectedCodes();
+ if (checked.length > maxRoutes) {
+ box.checked = false;
+ setStatus("Pick at most two routes.", true);
+ return;
+ }
+ recount();
+ });
+ });
+ }
+
+ function findRoutes() {
+ var zip = (document.getElementById("route-zip") || {}).value || "";
+ var address = (document.getElementById("route-address") || {}).value || "";
+ var city = (document.getElementById("route-city") || {}).value || "";
+ var state = (document.getElementById("route-state") || {}).value || "";
+ var radius = (document.getElementById("route-radius") || {}).value || "";
+ setStatus("Looking up routes…");
+ tableWrap.hidden = true;
+ renderSelected(null);
+ postQuote({
+ action: "search",
+ template_id: templateId(),
+ zip: zip,
+ address: address,
+ city: city,
+ state: state,
+ radius: radius
+ }).then(function (data) {
+ renderRoutes(data.routes || []);
+ if (!(data.routes || []).length) {
+ setStatus("No carrier routes for that search.", true);
+ } else {
+ setStatus("Select one or two routes. Cost uses " + (data.mail_class || "Standard") + ".");
+ }
+ }).catch(function (err) {
+ renderRoutes([]);
+ setStatus(err.message || "Route lookup failed.", true);
+ });
+ }
+
+ function syncVisibility() {
+ if (picker) picker.hidden = !isRouteAudience();
+ if (!isRouteAudience()) {
+ writeHidden("");
+ if (selectedBox) selectedBox.hidden = true;
+ }
+ refreshListCost();
+ }
+
+ if (findBtn) findBtn.addEventListener("click", function (e) {
+ e.preventDefault();
+ findRoutes();
+ });
+ var tmpl = document.getElementById("id_template_id");
+ if (tmpl) tmpl.addEventListener("change", function () {
+ syncVisibility();
+ if (isRouteAudience() && selectedCodes().length) recount();
+ });
+ if (audience) audience.addEventListener("change", syncVisibility);
+ syncVisibility();
+
+ window.MonicaPostcardRoutes = {
+ isPostcardAudience: isPostcardAudience,
+ isRouteAudience: isRouteAudience,
+ syncVisibility: syncVisibility
+ };
+ }
+
+ window.initPostcardRoutes = initPostcardRoutes;
+})();
diff --git a/site/directmail/README.md b/site/directmail/README.md
index bd34eac..68fbedc 100644
--- a/site/directmail/README.md
+++ b/site/directmail/README.md
@@ -62,6 +62,9 @@ Default postcard provider. Designer embeds PCM’s editor; orders use DirectMail
| `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_POSTCARD_PRICE_FIRSTCLASS` | Fallback $/piece if PCM omits FirstClass pricing |
+| `PCM_POSTCARD_PRICE_STANDARD` | Fallback $/piece for Standard |
+| `PCM_LIST_TYPE` | PCM purchased-list type (default `IRL`) |
| `POSTCARD_PROVIDER` | `pcm` (default) |
Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived
@@ -93,6 +96,16 @@ HMAC-SHA256 of body) matching **any** listed secret.
Correlation: we send `extRefNbr=` on each recipient; webhooks should
echo that (or `orderID`, matched to `Message.provider_message_id`).
+### Carrier-route (neighborhood) mailings
+
+Postcard compose can target **mailing-list contacts** or **USPS carrier routes**.
+Route mode calls PCM `POST /list/count/zipcode` (or `/radius`) with
+`breakdownType=ZipCRRT`, then recounts selected routes via
+`POST /list/count/carrier-route` and sends
+`POST /order/postcard/with-list-count`. Estimated cost uses design `pricing[]`
+or `PCM_POSTCARD_PRICE_FIRSTCLASS` / `PCM_POSTCARD_PRICE_STANDARD`. Default
+purchased list type is `IRL` (`PCM_LIST_TYPE`). This is not official EDDM postage.
+
### Campaign completion email
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
diff --git a/site/directmail/migrations/0002_carrier_route_audience.py b/site/directmail/migrations/0002_carrier_route_audience.py
new file mode 100644
index 0000000..327e6c4
--- /dev/null
+++ b/site/directmail/migrations/0002_carrier_route_audience.py
@@ -0,0 +1,39 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("directmail", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="campaign",
+ name="route_targeting",
+ field=models.JSONField(blank=True, default=dict),
+ ),
+ migrations.AlterField(
+ model_name="campaign",
+ name="audience",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("postcard_opt_in", "Mailing list · postcard opt-in"),
+ ("carrier_route", "USPS carrier routes"),
+ ],
+ default="",
+ max_length=32,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="message",
+ name="contact",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=models.CASCADE,
+ related_name="directmail_messages",
+ to="contacts.contact",
+ ),
+ ),
+ ]
diff --git a/site/directmail/models.py b/site/directmail/models.py
index 524f181..8b4a2d7 100644
--- a/site/directmail/models.py
+++ b/site/directmail/models.py
@@ -27,6 +27,7 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
class Audience(models.TextChoices):
POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard opt-in"
+ CARRIER_ROUTE = "carrier_route", "USPS carrier routes"
name = models.CharField(max_length=120)
channel = models.CharField(max_length=16, choices=Channel.choices)
@@ -58,6 +59,8 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
body_override = models.TextField(blank=True)
# Set when completion summary email is sent (campaign COMPLETED).
notify_sent_at = models.DateTimeField(null=True, blank=True)
+ # PCM purchased-list targeting (ZIP/CRRT codes, listCountID, quote).
+ route_targeting = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-created_at"]
@@ -83,7 +86,11 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
Campaign, on_delete=models.CASCADE, related_name="messages"
)
contact = models.ForeignKey(
- Contact, on_delete=models.CASCADE, related_name="directmail_messages"
+ Contact,
+ null=True,
+ blank=True,
+ on_delete=models.CASCADE,
+ related_name="directmail_messages",
)
channel = models.CharField(max_length=16, choices=Channel.choices)
status = models.CharField(
@@ -100,7 +107,8 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
ordering = ["-created_at"]
def __str__(self) -> str:
- return f"{self.channel} → {self.contact} ({self.status})"
+ who = self.contact or "carrier-route mailing"
+ return f"{self.channel} → {who} ({self.status})"
class ProviderEvent(TimeStampedModel):
diff --git a/site/directmail/providers/postcard/pcm.py b/site/directmail/providers/postcard/pcm.py
index f27fe2a..203bda6 100644
--- a/site/directmail/providers/postcard/pcm.py
+++ b/site/directmail/providers/postcard/pcm.py
@@ -338,6 +338,210 @@ def get_order(order_id: int | str) -> dict[str, Any]:
return data if isinstance(data, dict) else {}
+def get_design(design_id: int | str) -> dict[str, Any]:
+ data = pcm_request("GET", f"/design/{design_id}")
+ return data if isinstance(data, dict) else {}
+
+
+FIRST_CLASS_ONLY_SIZES = frozenset({"46", "46S"})
+MAX_SELECTED_ROUTES = 2
+DEFAULT_LIST_TYPE = "IRL"
+
+
+def mail_class_for_size(size: str | None) -> str:
+ key = str(size or "").strip()
+ if key in FIRST_CLASS_ONLY_SIZES:
+ return "FirstClass"
+ return "Standard"
+
+
+def _env_unit_price(mail_class: str) -> float | None:
+ attr = (
+ "PCM_POSTCARD_PRICE_FIRSTCLASS"
+ if mail_class == "FirstClass"
+ else "PCM_POSTCARD_PRICE_STANDARD"
+ )
+ raw = str(getattr(settings, attr, "") or "").strip()
+ if not raw:
+ return None
+ try:
+ value = float(raw)
+ except ValueError:
+ return None
+ return value if value >= 0 else None
+
+
+def pick_unit_price(
+ pricing: list[dict] | None,
+ *,
+ mail_class: str,
+ quantity: int,
+) -> tuple[float | None, str]:
+ """Return (dollars per piece, source). Source is pcm|fallback|missing."""
+ rows: list[dict] = []
+ for item in pricing or []:
+ if not isinstance(item, dict):
+ continue
+ if str(item.get("mailClass") or item.get("mail_class") or "") != mail_class:
+ continue
+ try:
+ price = float(item.get("price"))
+ break_qty = int(item.get("breakQty") or item.get("break_qty") or 0)
+ except (TypeError, ValueError):
+ continue
+ rows.append({"price": price, "breakQty": break_qty})
+ qty = max(int(quantity or 0), 0)
+ if rows:
+ eligible = [row for row in rows if qty >= row["breakQty"]]
+ chosen = max(eligible, key=lambda row: row["breakQty"]) if eligible else min(
+ rows, key=lambda row: row["breakQty"]
+ )
+ return chosen["price"], "pcm"
+ fallback = _env_unit_price(mail_class)
+ if fallback is not None:
+ return fallback, "fallback"
+ return None, "missing"
+
+
+def design_pricing(design_id: int | None) -> list[dict]:
+ """Load PCM per-piece pricing for a design (gallery payload preferred)."""
+ if not design_id:
+ return []
+ try:
+ design = get_design(design_id)
+ except PcmApiError:
+ design = {}
+ pricing = design.get("pricing") if isinstance(design, dict) else None
+ if isinstance(pricing, list) and pricing:
+ return [row for row in pricing if isinstance(row, dict)]
+ try:
+ for item in list_designs(product_type="postcard"):
+ if not isinstance(item, dict):
+ continue
+ did = item.get("designID") or item.get("design_id") or item.get("id")
+ if str(did) != str(design_id):
+ continue
+ extra = item.get("pricing")
+ if isinstance(extra, list):
+ return [row for row in extra if isinstance(row, dict)]
+ except PcmApiError:
+ logger.exception("PCM list_designs failed while loading pricing")
+ return []
+
+
+def extract_crrt_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
+ """Flatten ZipCRRT (or first) breakdown into {code, text, total} rows."""
+ breakdowns = payload.get("breakdown") if isinstance(payload, dict) else None
+ if not isinstance(breakdowns, list):
+ return []
+ preferred = None
+ fallback = None
+ for block in breakdowns:
+ if not isinstance(block, dict):
+ continue
+ kind = str(block.get("breakdownType") or "").upper().replace("_", "")
+ data = block.get("data") if isinstance(block.get("data"), list) else []
+ rows = []
+ for item in data:
+ if not isinstance(item, dict):
+ continue
+ code = str(item.get("code") or "").strip()
+ if not code:
+ continue
+ try:
+ total = int(item.get("total") or 0)
+ except (TypeError, ValueError):
+ total = 0
+ rows.append(
+ {
+ "code": code,
+ "text": str(item.get("text") or code).strip(),
+ "total": total,
+ }
+ )
+ if not rows:
+ continue
+ if kind in {"ZIPCRRT", "CRRT", "CARRIERROUTE"}:
+ preferred = rows
+ break
+ if fallback is None:
+ fallback = rows
+ return preferred or fallback or []
+
+
+def _list_type() -> str:
+ return (getattr(settings, "PCM_LIST_TYPE", None) or DEFAULT_LIST_TYPE).strip() or DEFAULT_LIST_TYPE
+
+
+def list_count_zipcode(*, zip_codes: list[str], list_type: str | None = None) -> dict[str, Any]:
+ data = pcm_request(
+ "POST",
+ "/list/count/zipcode",
+ json_body={
+ "listType": list_type or _list_type(),
+ "breakdownType": "ZipCRRT",
+ "zipCodes": zip_codes,
+ "demographics": [],
+ },
+ )
+ return data if isinstance(data, dict) else {}
+
+
+def list_count_radius(
+ *,
+ address: str,
+ city: str,
+ state: str,
+ zip_code: str,
+ miles: float,
+ list_type: str | None = None,
+) -> dict[str, Any]:
+ data = pcm_request(
+ "POST",
+ "/list/count/radius",
+ json_body={
+ "listType": list_type or _list_type(),
+ "breakdownType": "ZipCRRT",
+ "radius": {
+ "radius": miles,
+ "address": address,
+ "city": city,
+ "state": state,
+ "zip": zip_code,
+ },
+ "demographics": [],
+ },
+ )
+ return data if isinstance(data, dict) else {}
+
+
+def list_count_carrier_route(
+ *,
+ carrier_routes: list[str],
+ list_type: str | None = None,
+) -> dict[str, Any]:
+ data = pcm_request(
+ "POST",
+ "/list/count/carrier-route",
+ json_body={
+ "listType": list_type or _list_type(),
+ "breakdownType": "ZipCRRT",
+ "carrierRoutes": carrier_routes,
+ "demographics": [],
+ },
+ )
+ return data if isinstance(data, dict) else {}
+
+
+def _order_id_from_response(data: dict[str, Any]) -> str:
+ 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 place_postcard_order(
*,
design_id: int,
@@ -358,13 +562,32 @@ def place_postcard_order(
data = pcm_request("POST", "/order/postcard", json_body=payload)
if not isinstance(data, dict):
raise PcmApiError("Unexpected order response from PCM")
+ return _order_id_from_response(data)
- 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 place_postcard_order_with_list_count(
+ *,
+ design_id: int,
+ list_count_id: int,
+ record_count: int,
+ ext_ref: str,
+ mail_class: str = "Standard",
+) -> str:
+ """Place a postcard order against a PCM purchased list count."""
+ payload = {
+ "designID": design_id,
+ "mailClass": mail_class,
+ "extRefNbr": ext_ref,
+ "listCountID": list_count_id,
+ "recordCount": record_count,
+ "returnAddress": return_address_from_settings(),
+ }
+ data = pcm_request(
+ "POST", "/order/postcard/with-list-count", json_body=payload
+ )
+ if not isinstance(data, dict):
+ raise PcmApiError("Unexpected list-count order response from PCM")
+ return _order_id_from_response(data)
def design_id_from_template(template) -> int | None:
@@ -396,6 +619,24 @@ class PcmProvider:
"(save a design from the postcard designer first)"
)
+ targeting = {}
+ if message.campaign_id and isinstance(
+ getattr(message.campaign, "route_targeting", None), dict
+ ):
+ targeting = message.campaign.route_targeting
+ list_count_id = targeting.get("list_count_id")
+ record_count = targeting.get("record_count")
+ if list_count_id and record_count:
+ mail_class = str(targeting.get("mail_class") or "Standard")
+ order_id = place_postcard_order_with_list_count(
+ design_id=design_id,
+ list_count_id=int(list_count_id),
+ record_count=int(record_count),
+ ext_ref=str(message.pk),
+ mail_class=mail_class,
+ )
+ return PostcardResult(provider_id=order_id)
+
recipient = contact_to_pcm_recipient(
message.contact, ext_ref=str(message.pk)
)
diff --git a/site/directmail/services.py b/site/directmail/services.py
index 17b9831..6d1aec3 100644
--- a/site/directmail/services.py
+++ b/site/directmail/services.py
@@ -31,6 +31,7 @@ if TYPE_CHECKING:
AUDIENCE_CHANNEL = {
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
+ Campaign.Audience.CARRIER_ROUTE: Channel.POSTCARD,
}
# {{first_name}} preferred; {first_name} also accepted (composer hint legacy).
@@ -84,6 +85,211 @@ def channel_for_audience(audience: str) -> str:
raise ValueError(f"Unknown audience: {audience}") from exc
+def parse_route_targeting(raw: str | dict | None) -> dict:
+ """Parse compose JSON for a carrier-route campaign."""
+ from directmail.providers.postcard.pcm import MAX_SELECTED_ROUTES
+
+ data: dict = {}
+ if isinstance(raw, dict):
+ data = raw
+ elif isinstance(raw, str) and raw.strip():
+ import json
+
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise ValueError("Carrier route selection is invalid JSON.") from exc
+ if not isinstance(parsed, dict):
+ raise ValueError("Carrier route selection must be an object.")
+ data = parsed
+ codes = [
+ str(code).strip()
+ for code in (data.get("selected_codes") or data.get("routes") or [])
+ if str(code).strip()
+ ]
+ # Allow routes as list of objects from the quote endpoint.
+ if not codes and isinstance(data.get("routes"), list):
+ codes = [
+ str(row.get("code") or "").strip()
+ for row in data["routes"]
+ if isinstance(row, dict)
+ ]
+ codes = [c for c in codes if c]
+ codes = list(dict.fromkeys(codes))
+ if not codes:
+ raise ValueError("Select one or two USPS carrier routes.")
+ if len(codes) > MAX_SELECTED_ROUTES:
+ raise ValueError("Pick at most two carrier routes.")
+ try:
+ list_count_id = int(data.get("list_count_id") or 0)
+ record_count = int(data.get("record_count") or 0)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("Route quote is missing list count.") from exc
+ if list_count_id <= 0 or record_count <= 0:
+ raise ValueError("Find routes and select them before saving (need a PCM quote).")
+ estimated = data.get("estimated") if isinstance(data.get("estimated"), dict) else {}
+ route_rows = data.get("route_rows") if isinstance(data.get("route_rows"), list) else []
+ return {
+ "selected_codes": codes,
+ "list_count_id": list_count_id,
+ "record_count": record_count,
+ "mail_class": str(data.get("mail_class") or "Standard"),
+ "list_type": str(data.get("list_type") or "IRL"),
+ "zip": str(data.get("zip") or ""),
+ "estimated": estimated,
+ "route_rows": route_rows,
+ }
+
+
+def money_total(per_piece: float | None, quantity: int) -> float | None:
+ if per_piece is None:
+ return None
+ return round(float(per_piece) * max(int(quantity or 0), 0), 2)
+
+
+def postcard_quote(*, action: str, payload: dict) -> dict:
+ """Search routes or price a mailing-list / selected-route postcard."""
+ from directmail.providers.postcard.pcm import (
+ MAX_SELECTED_ROUTES,
+ design_pricing,
+ extract_crrt_rows,
+ list_count_carrier_route,
+ list_count_radius,
+ list_count_zipcode,
+ mail_class_for_size,
+ pick_unit_price,
+ )
+
+ design_id = payload.get("design_id")
+ size = str(payload.get("size") or "").strip()
+ try:
+ design_id_int = int(design_id) if design_id not in (None, "") else None
+ except (TypeError, ValueError):
+ design_id_int = None
+
+ requested_class = str(payload.get("mail_class") or "").strip()
+ mail_class = requested_class or mail_class_for_size(size)
+ if mail_class not in {"FirstClass", "Standard"}:
+ mail_class = mail_class_for_size(size)
+ pricing = design_pricing(design_id_int)
+
+ def _price(qty: int) -> tuple[float | None, str]:
+ return pick_unit_price(pricing, mail_class=mail_class, quantity=qty)
+
+ action = (action or "mailing_list").strip()
+ if action == "mailing_list":
+ count = opted_in_contacts(Channel.POSTCARD).count()
+ per_piece, source = _price(count)
+ return {
+ "action": action,
+ "mail_class": mail_class,
+ "count": count,
+ "per_piece": per_piece,
+ "total": money_total(per_piece, count),
+ "price_source": source,
+ }
+
+ if action == "search":
+ zip_code = str(payload.get("zip") or "").strip()
+ address = str(payload.get("address") or "").strip()
+ city = str(payload.get("city") or "").strip()
+ state = str(payload.get("state") or "").strip()
+ try:
+ miles = float(payload.get("radius") or 0)
+ except (TypeError, ValueError):
+ miles = 0
+ if address and city and state and zip_code and miles > 0:
+ data = list_count_radius(
+ address=address,
+ city=city,
+ state=state,
+ zip_code=zip_code,
+ miles=miles,
+ )
+ elif zip_code:
+ data = list_count_zipcode(zip_codes=[zip_code])
+ else:
+ raise ValueError("Enter a ZIP code (or address + ZIP + radius).")
+ rows = extract_crrt_rows(data)
+ priced = []
+ for row in rows:
+ per_piece, source = _price(row["total"])
+ priced.append(
+ {
+ **row,
+ "per_piece": per_piece,
+ "cost": money_total(per_piece, row["total"]),
+ "price_source": source,
+ }
+ )
+ return {
+ "action": action,
+ "mail_class": mail_class,
+ "zip": zip_code,
+ "routes": priced,
+ "discovery_list_count_id": data.get("listCountID") or data.get("list_count_id"),
+ }
+
+ if action == "select":
+ codes = [
+ str(c).strip()
+ for c in (payload.get("routes") or [])
+ if str(c).strip()
+ ]
+ codes = list(dict.fromkeys(codes))
+ if not codes:
+ raise ValueError("Select at least one carrier route.")
+ if len(codes) > MAX_SELECTED_ROUTES:
+ raise ValueError("Pick at most two carrier routes.")
+ data = list_count_carrier_route(carrier_routes=codes)
+ rows = extract_crrt_rows(data)
+ if not rows:
+ try:
+ record_count = int(data.get("recordCount") or data.get("record_count") or 0)
+ except (TypeError, ValueError):
+ record_count = 0
+ rows = [{"code": code, "text": code, "total": 0} for code in codes]
+ if record_count and len(rows) == 1:
+ rows[0]["total"] = record_count
+ record_count = int(data.get("recordCount") or data.get("record_count") or 0)
+ if not record_count:
+ record_count = sum(int(r.get("total") or 0) for r in rows)
+ per_piece, source = _price(record_count)
+ priced = []
+ for row in rows:
+ total = int(row.get("total") or 0)
+ priced.append(
+ {
+ **row,
+ "per_piece": per_piece,
+ "cost": money_total(per_piece, total),
+ "price_source": source,
+ }
+ )
+ list_count_id = data.get("listCountID") or data.get("list_count_id")
+ return {
+ "action": action,
+ "mail_class": mail_class,
+ "selected_codes": codes,
+ "routes": priced,
+ "route_rows": priced,
+ "list_count_id": list_count_id,
+ "record_count": record_count,
+ "per_piece": per_piece,
+ "total": money_total(per_piece, record_count),
+ "price_source": source,
+ "estimated": {
+ "per_piece": per_piece,
+ "pieces": record_count,
+ "total": money_total(per_piece, record_count),
+ "currency": "USD",
+ "source": source,
+ },
+ }
+
+ raise ValueError(f"Unknown quote action: {action}")
+
+
def create_campaign_draft(
*,
name: str,
@@ -93,9 +299,11 @@ def create_campaign_draft(
scheduled_for=None,
created_by: AbstractBaseUser | None = None,
template: MessageTemplate | None = None,
+ route_targeting: dict | None = None,
) -> Campaign:
"""Persist a draft campaign and per-recipient Message stubs."""
channel = channel_for_audience(audience)
+ targeting = route_targeting if isinstance(route_targeting, dict) else {}
campaign = Campaign.objects.create(
name=name,
channel=channel,
@@ -106,7 +314,18 @@ def create_campaign_draft(
body_override=body,
created_by=created_by,
template=template,
+ route_targeting=targeting,
)
+ if audience == Campaign.Audience.CARRIER_ROUTE:
+ Message.objects.create(
+ campaign=campaign,
+ contact=None,
+ channel=channel,
+ status=Message.Status.DRAFT,
+ scheduled_for=scheduled_for,
+ body_snapshot=body,
+ )
+ return campaign
contacts = list(opted_in_contacts(channel))
Message.objects.bulk_create(
[
diff --git a/site/directmail/tasks.py b/site/directmail/tasks.py
index 82c29ff..0720ce6 100644
--- a/site/directmail/tasks.py
+++ b/site/directmail/tasks.py
@@ -18,7 +18,9 @@ def send_campaign_message(message_id: str) -> None:
campaign = message.campaign
- if not contact_may_receive(message.contact, message.channel):
+ if message.contact_id is None:
+ pass
+ elif 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"])
diff --git a/site/directmail/templates/directmail/_postcard_route_picker.html b/site/directmail/templates/directmail/_postcard_route_picker.html
new file mode 100644
index 0000000..8c78e6b
--- /dev/null
+++ b/site/directmail/templates/directmail/_postcard_route_picker.html
@@ -0,0 +1,50 @@
+
+
+
+ PCM mails a purchased occupant list on the USPS carrier routes you pick
+ (First Class / Standard — not official EDDM postage). Cost is per piece
+ times households.
+