CommCenter — Bulk Notification Sender (FEAT-417)¶
CommCenter is the HTTP front door for sending a templated communication blast (email / Teams / Zoom / Telegram / SMS) to a list of recipients, or to a single recipient synchronously. It publishes one message per recipient onto the existing NotifyWorker Redis Stream — it does not talk to any provider directly and does not change NotifyWorker itself.
- Handler:
parrot.handlers.comm_center.CommCenterHandler - Sending core:
parrot.services.comm_center(ingest.py,render.py,dispatch.py) — usable without aiohttp (spec G12). - Optional dependency:
ai-parrot-server[comm-center](async-notify,pandas,openpyxl). Importing the handler or the service modules never requires it; only actually publishing does, and that failure returns a clear503naming the extra. async-notifyminimum version: 1.6.0. CommCenter enqueues an inline Jinja2 template string in the xadd payload;async-notify < 1.6.0silently ignores that key and looks for atemplate_fileinTEMPLATE_DIRinstead, delivering an empty body with no error on either side.async-notify1.6.0 shipped inline Jinja2 template source support, which CommCenter relies on. A runtime guard inparrot.services.comm_center.dispatchraises a clearRuntimeErrornaming the required version if an older release is installed — checked once per process, right after the "is it installed at all" check, inside_get_notify_client(). Because that function is only ever called from the actual publish path (not before a response is sent), the guard does not surface as an HTTP503on either send endpoint: forPOST /senderit fires inside the backgrounded fan-out task, leaving the batch's rows retryable rather than producing an HTTP response; forPOST /messageit is caught by the generic publish-failure handler and returned as502(status=publish_failed). (The503_map_error()does return is for a different call site — resolving atemplate_filevianotify.confduring request handling, before any publish attempt.)
Two-pass rendering model¶
A template body is rendered twice:
- Pass 1 (
CommCenterService, once per batch): resolves computed functions ({{today}},{{now}}, …) using Jinja2'sDebugUndefined, so every placeholder it cannot resolve — i.e. every per-recipient field — is re-emitted literally:"Hola {{ name }}, hoy es 2026-08-06". - Pass 2 (
NotifyWorker→AbstractProvider._render_, once per recipient): resolves the record placeholders ({{name}},{{email}}, …) from the row's own fields.
A malformed template fails at pass 1, before a single message is
published — this is deliberate: one bad template returns 400
immediately instead of producing N silent worker failures.
Endpoints¶
All endpoints require @is_authenticated.
| Method | Path | Notes |
|---|---|---|
POST |
/api/v1/comm_center/sender |
Bulk send. dry_run is a body field. |
GET |
/api/v1/comm_center/sender |
Paginated batch list — limit (default 25, max 100), offset, status, provider, created_after, created_before |
GET |
/api/v1/comm_center/sender/{batch_id} |
details, status, limit (default 100, max 1000), offset |
POST |
/api/v1/comm_center/sender/{batch_id}/retry |
force (bool, default false) |
POST |
/api/v1/comm_center/message |
Single recipient, synchronous, explicit provider. dry_run is a body field. |
GET / POST |
/api/v1/comm_center/templates |
List / create |
GET / PUT / PATCH / DELETE |
/api/v1/comm_center/templates/{template_id} |
Read / update / delete |
GET |
/api/v1/comm_center/placeholders |
Static catalog — recipient fields, computed functions, reserved names |
POST /sender — bulk send¶
Accepts recipients via exactly one transport:
- Inline JSON:
{"recipients": [{"name": "Ana", "email": "..."}], ...} multipart/form-data: an uploaded Excel/CSV file, other fields as form fields.- Base64:
{"file_b64": "...", "filename": "recipients.csv", ...}
Exactly one of template_id, template_name, template, or
template_file must be given. provider is the batch-wide default; a
per-row provider column overrides it. Returns 202 with batch_id,
total, queued, skipped, resolved_functions, skipped_details.
Rows missing their provider's contact field are skipped with a
reason, never silently dropped — the rest of the batch still sends.
Fan-out happens in a background task; the response does not wait for it.
GET /sender — list batches (FEAT-445 TASK-2319)¶
The tracking table is flat (one row per recipient, batch_id repeated),
so batch-level metadata is derived by aggregation — there is no separate
"batches" table to page through directly. Returns:
{
"batches": [
{
"batch_id": "uuid", "created_at": "ISO-8601", "created_by": 42,
"total": 150, "queued": 140, "skipped": 8, "publish_failed": 2,
"pending": 0, "publishing": 0,
"template_ref": "monthly-report", "provider": "email"
}
],
"total": 47, "limit": 25, "offset": 0
}
Query params: limit (default 25, clamped to 100), offset (default 0),
status (batches with at least one row in this status), provider
(batches with at least one row for this provider), created_after /
created_before (batches with at least one row whose created_at falls
in this ISO-8601 range). All four filters select whole batches — a
matching batch's own aggregate counts always reflect every row in that
batch, never just the rows that happened to match the filter.
Disambiguated from GET /sender/{batch_id} by the absence of a path
parameter.
POST /message — single recipient (G13)¶
A thin, arity-1 caller over the same prepare() the bulk endpoint
uses (verified byte-identical payloads for the same input). Publishes
synchronously inside the request (no background task for one
xadd). provider is required and explicit — there is no per-record
override with a single recipient.
Deliberate divergence from the bulk endpoint: an invalid recipient or a
missing provider returns 400 with the reason (not 202 + skipped);
a publish failure returns 502 with the row left retryable via
/sender/{batch_id}/retry.
dry_run (G14)¶
Set dry_run: true on either send endpoint to run the full pipeline
(ingest → template resolution → partial render → provider resolution →
validation) and stop before any xadd and before any tracking row is
written. Returns 200 (not 202) with resolved_functions,
skipped_details, and a preview — the first queued recipient's text
rendered through both passes, so it is byte-identical to what a real
send would deliver. batch_id/message_id are null; status is
"dry_run". The guard is enforced in the service layer
(dispatch.fan_out), not only in the handler.
Per-provider recipient shape¶
The wire payload's recipient key is singular — NotifyWrapper pops
exactly that key; a plural key silently sends to nobody. Only the keys
each provider needs are emitted:
| Provider | Emitted dict | Builds |
|---|---|---|
email, gmail, smtp, ses, sendgrid, office365, outlook |
{"name": …, "account": {"provider": …, "address": <email>}} |
Actor + Account |
twilio (SMS-like) |
{"name": …, "account": {"provider": …, "number": <phone>}} |
Actor + Account |
teams |
{"name": …, "team_id": …, "channel_id": …} |
TeamsChannel |
telegram |
{"chat_name": …, "chat_id": …} |
Chat |
slack, zoom |
{"channel_name": …, "channel_id": …} |
Channel |
team_id is checked before channel_id by NotifyWrapper — a dict
carrying both always becomes a TeamsChannel, never a Channel.
Placeholders¶
GET /placeholders returns three groups:
- Recipient fields (pass 2):
name(required),username,email(required for email-like providers),phone(required for SMS-like providers),address. - Computed functions (pass 1): the five
DATE_RESOLVERS(today,yesterday,first_of_month,current_month,previous_month) plus two module-local extras,nowandcurrent_year. - Reserved names:
recipient,message,subject— bound by Notify's own pass-2 context. Do not use them as template placeholders.
Any recipient column beyond the canonical five is forwarded verbatim as an extra pass-2 placeholder.
The {{username}} fallback¶
AbstractProvider._render_ binds username to the recipient object by
default; a row without a username column would otherwise render an
internal object representation (verified: <Ana Gomez:
c1c4f2c8-…>) instead of a name. CommCenter's build_wire_payload()
always emits username, falling back to name, so this can never
happen through this API.
Known limitations¶
- No delivery confirmation.
NotifyWorkeracknowledges and logs each message but publishes no result stream back. Tracking status tops out atqueued/skipped/publish_failed— there is nodeliveredstate, and the UI must not claim there is one. - Bare-placeholder limitation. Record placeholders must be written
as bare
{{ field }}. Filters and conditionals over an unresolved value ({{ name|upper }},{% if email %}) are not supported in the batch-level partial render, because it relies on Jinja2'sDebugUndefinedto preserve unresolved fields literally. - HTML injection. Both render passes run with
autoescape=False(matchingnotify.templates's own pass-2 configuration), so a recipient value is interpolated unescaped into HTML templates. There is no built-in escaping for untrusted recipient data today.
Database setup¶
CommCenter depends on two Postgres tables in the navigator schema —
notification_templates and notification_batch_recipients — authored as
DDL alongside their models
(packages/ai-parrot-server/src/parrot/handlers/models/) but not applied
automatically; applying them is an operator/deployment step, per this
repo's convention for handler DDL.
Apply both files, in order, with:
DSN resolution order: DATABASE_URL → NAVIGATOR_DSN → PG_URL — the
target uses whichever of the three is set, so it also works unattended in
an environment that already exports NAVIGATOR_DSN:
Both .sql files are idempotent (CREATE TABLE IF NOT EXISTS,
CREATE OR REPLACE FUNCTION, DROP TRIGGER IF EXISTS, CREATE INDEX IF
NOT EXISTS) — running the target twice, or against a database that
already has the tables, is safe and makes no changes on the second run.
Without this step, the template CRUD endpoints fail with 500 (no table
to read/write) and no batch can be tracked.
Retry & the row status state machine¶
GET /sender/{batch_id} aggregates a flat, per-recipient tracking table
(navigator.notification_batch_recipients — there is no separate
"batches" header table). Each row's status follows:
created → pending → (set publishing, then xadd) → publishing
│
xadd returns an entry id
▼
queued (terminal)
validation failed → skipped (terminal)
xadd raised → publish_failed (retryable)
POST /sender/{batch_id}/retry re-publishes pending and
publish_failed rows; it never retries queued or skipped rows,
and only includes publishing rows when ?force=true (reported as
ambiguous otherwise) — the pre-xadd publishing marker narrows
duplicate-delivery risk to rows caught mid-call.