Communications Service — Blueprint
Blueprint — Communications Service
Status: partial (postal live; email/WhatsApp scaffolded but not unified) · Slug:
comms· Kind: Cross-cutting · Module #15 FOUNDATIONAL. Every other module sends messages through this service. Designed and migrated EARLY in the second wave (after Audit, alongside Reports).
1. Module summary
Communications Service is the single outbound message system of Aayojana. It owns
one ledger (communication_log), one template engine (Jinja2 multi-channel),
one audience-segmentation engine, one scheduler, and one async dispatch
queue. Every other module — Vitta (80G receipts), Compliance (renewal reminders),
Events (festival invitations), Education (certificate mailings), Members Suite
(onboarding emails), Newsletter (issue blasts) — calls a small public Python API and
never touches a provider SDK directly. Channels are: postal (already live via
dispatch_records, extended here, not replaced), email (Workspace SMTP today,
Resend swap-ready), WhatsApp (Meta Cloud / Indian BSP), SMS (planned). The service
is multi-tenant, idempotent, retry-aware, and exposes both REST endpoints and an
in-process async function send(...) that other modules import.
2. Data model
All tables live in aayojana.comms.models (new sub-package). Every table carries
tenant_id (TenantMixin) and created/updated/createdBy/updatedBy (AuditMixin).
The Postal channel does not get a new table — it continues to write
dispatch_records (existing) and adds a row in communication_log referencing it
via channel='postal' + ref_id=str(dispatch_records.id).
# src/aayojana/comms/models.py
from datetime import datetime
from sqlalchemy import (
JSON, Boolean, DateTime, ForeignKey, Integer, String, Text,
UniqueConstraint, Index, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from aayojana.models.base import AuditMixin, Base, TenantMixin, uuid4_str
# ============ TEMPLATES ============
class Template(Base, TenantMixin, AuditMixin):
"""A named, multi-channel template. Body lives in TemplateVersion (versioned)."""
__tablename__ = "comms_templates"
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_template_tenant_name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
name: Mapped[str] = mapped_column(String(120), nullable=False)
# 'donation_80g_receipt' | 'compliance_renewal_t30' | 'newsletter_monthly' | ...
description: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_module: Mapped[str] = mapped_column(String(32), nullable=False)
# 'vitta' | 'compliance' | 'events' | 'newsletter' | 'members' | ...
channels: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# ['email','postal'] — same template renders to multiple
locked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# locked = system template, only platform admins may edit
active_version_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("comms_template_versions.id"), nullable=True
)
versions: Mapped[list["TemplateVersion"]] = relationship(
back_populates="template",
foreign_keys="TemplateVersion.template_id",
cascade="all, delete-orphan",
)
class TemplateVersion(Base, TenantMixin, AuditMixin):
"""Immutable. A new edit creates a new version; the active_version_id pointer flips."""
__tablename__ = "comms_template_versions"
__table_args__ = (
UniqueConstraint("template_id", "version", name="uq_tplver_template_version"),
Index("ix_tplver_content_hash", "content_hash"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
template_id: Mapped[str] = mapped_column(
String(36), ForeignKey("comms_templates.id"), nullable=False, index=True
)
version: Mapped[int] = mapped_column(Integer, nullable=False)
subject_jinja: Mapped[str | None] = mapped_column(Text, nullable=True)
# Email subject line (Jinja). NULL for postal/SMS.
body_html_jinja: Mapped[str | None] = mapped_column(Text, nullable=True)
# Email + Postal-PDF source. VijayaDV PUA preserved in TEXT, never normalised.
body_text_jinja: Mapped[str | None] = mapped_column(Text, nullable=True)
# SMS body / email plain-text fallback / WhatsApp text-message body.
whatsapp_template_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
# BSP-pre-approved name (e.g. 'donation_80g_receipt_v3').
whatsapp_param_order: Mapped[list | None] = mapped_column(JSON, nullable=True)
# ['member_name','amount','fy'] — mapped onto BSP placeholders 1..N.
sample_vars: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Vars that must be supplied; UI uses these to render preview.
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
# SHA-256 of subject+body+whatsapp template name. Used for de-dupe + audit.
created_by_user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
valid_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
template: Mapped["Template"] = relationship(
back_populates="versions", foreign_keys=[template_id]
)
# ============ AUDIENCE SEGMENTS ============
class AudienceSegment(Base, TenantMixin, AuditMixin):
"""A named, saved, re-runnable query that resolves to a list of recipients.
Resolution strategies:
- tag-based: any/all members with given tags
- donor-history: gave > X in last Y months / has FCRA-tagged donation
- branch: members where membership.branch_id IN (...)
- custom-sql: platform-admin-only escape hatch, still tenant-scoped
- manual-list: ad-hoc paste of MIDs
"""
__tablename__ = "comms_audience_segments"
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_segment_tenant_name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
strategy: Mapped[str] = mapped_column(String(32), nullable=False)
# 'tag' | 'donor_history' | 'branch' | 'custom_sql' | 'manual_list' | 'composite'
config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
# Strategy-specific payload (tag IDs / amount thresholds / SQL snippet / MID list)
last_resolved_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# ============ JOBS (campaign / scheduled / one-shot) ============
class CommsJob(Base, TenantMixin, AuditMixin):
"""A scheduled or one-shot dispatch of a template to an audience.
A CommsJob fans out into one CommsJobRecipient per resolved recipient.
Each recipient ultimately produces one CommunicationLog row per (channel)."""
__tablename__ = "comms_jobs"
__table_args__ = (
Index("ix_comms_jobs_tenant_status", "tenant_id", "status"),
Index("ix_comms_jobs_scheduled", "scheduled_for"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
name: Mapped[str] = mapped_column(String(160), nullable=False)
template_id: Mapped[str] = mapped_column(
String(36), ForeignKey("comms_templates.id"), nullable=False
)
template_version_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("comms_template_versions.id"), nullable=True
)
# Pinned at job-create time so an in-flight campaign isn't disturbed by a template edit.
segment_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("comms_audience_segments.id"), nullable=True
)
inline_recipients: Mapped[list | None] = mapped_column(JSON, nullable=True)
# Optional inline list when no segment is named.
channels: Mapped[list] = mapped_column(JSON, nullable=False)
# ['email'] or ['email','whatsapp'] — fan-out happens per-channel-per-recipient.
vars: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Job-level template vars (per-recipient vars merged on top at render time).
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
cron_expr: Mapped[str | None] = mapped_column(String(60), nullable=True)
# NULL = one-shot; non-NULL = recurring (next-fire computed on each completion).
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
# 'draft' | 'scheduled' | 'running' | 'completed' | 'failed' | 'cancelled'
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
requested_by_user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
approved_by_user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
# For two-key send-approval on >N-recipient campaigns (configurable).
class CommsJobRecipient(Base, TenantMixin, AuditMixin):
"""Per-job per-recipient state. Idempotency key for retries."""
__tablename__ = "comms_job_recipients"
__table_args__ = (
UniqueConstraint("job_id", "recipient_type", "recipient_id", "channel",
name="uq_jobrecipient"),
Index("ix_jobrecipient_status", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
job_id: Mapped[str] = mapped_column(
String(36), ForeignKey("comms_jobs.id"), nullable=False, index=True
)
recipient_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 'member' | 'user' | 'external_email' | 'partner' | 'vendor' | 'government_office'
recipient_id: Mapped[str] = mapped_column(String(64), nullable=False)
# member.id / user.id / email-as-string / partner.id / etc.
channel: Mapped[str] = mapped_column(String(16), nullable=False)
# 'email' | 'whatsapp' | 'sms' | 'postal'
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
# 'pending' | 'queued' | 'sent' | 'delivered' | 'bounced' | 'failed' | 'skipped'
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
skip_reason: Mapped[str | None] = mapped_column(String(120), nullable=True)
# 'no-email' | 'opted-out' | 'bounced-too-many' | etc.
communication_log_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("communication_log.id"), nullable=True
)
# ============ COMMUNICATION LOG (the unified ledger) ============
class CommunicationLog(Base, TenantMixin, AuditMixin):
"""ONE row per outbound message attempt. Append-only at service layer.
Postal sends ALSO write here (in addition to dispatch_records) — ref_id holds
the dispatch_records.id so the older table remains the print-of-record."""
__tablename__ = "communication_log"
__table_args__ = (
Index("ix_commlog_tenant_created", "tenant_id", "created"),
Index("ix_commlog_recipient", "recipient_type", "recipient_id"),
Index("ix_commlog_channel_status", "channel", "status"),
Index("ix_commlog_idem", "idempotency_key"),
UniqueConstraint("tenant_id", "idempotency_key",
name="uq_commlog_tenant_idem"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
job_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("comms_jobs.id"), nullable=True
)
# NULL for one-off transactional sends invoked via send() outside of a job.
channel: Mapped[str] = mapped_column(String(16), nullable=False)
# 'email' | 'whatsapp' | 'sms' | 'postal'
provider: Mapped[str] = mapped_column(String(32), nullable=False)
# 'workspace_smtp' | 'resend' | 'meta_whatsapp' | 'airtel_bsp' | 'india_post' | 'manual'
recipient_type: Mapped[str] = mapped_column(String(32), nullable=False)
recipient_id: Mapped[str] = mapped_column(String(64), nullable=False)
recipient_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
# email / phone / postal-pin — for log-search without joining members table.
template_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("comms_templates.id"), nullable=True
)
template_version_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("comms_template_versions.id"), nullable=True
)
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
rendered_body: Mapped[str | None] = mapped_column(Text, nullable=True)
# Email/SMS body or postal-letter rendered text. Stored for audit + resend.
payload_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
# SHA-256 of (template_version + variables) — de-dupe identical sends.
idempotency_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
# Caller-supplied. Re-calling send() with the same key is a no-op.
status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued")
# 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked' | 'bounced' | 'failed'
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
opened_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
clicked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
bounced_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
ref_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
# Provider's tracking ID — Resend message-id, WhatsApp wamid, dispatch_records.id, etc.
error: Mapped[str | None] = mapped_column(Text, nullable=True)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# ============ INBOUND DELIVERY EVENTS (provider webhooks) ============
class CommsDeliveryEvent(Base, TenantMixin, AuditMixin):
"""One row per inbound webhook event from a provider. Idempotent on
(provider, event_id). Mirrors PaymentEvent's pattern."""
__tablename__ = "comms_delivery_events"
__table_args__ = (
UniqueConstraint("provider", "event_id", name="uq_delivery_provider_event"),
Index("ix_delivery_log", "communication_log_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
event_id: Mapped[str] = mapped_column(String(120), nullable=False)
event_type: Mapped[str] = mapped_column(String(40), nullable=False)
# 'delivered' | 'bounced' | 'opened' | 'clicked' | 'spam_complaint' | 'unsubscribe'
communication_log_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("communication_log.id"), nullable=True
)
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
signature_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# ============ SUPPRESSION LIST ============
class CommsSuppression(Base, TenantMixin, AuditMixin):
"""Per-tenant per-recipient-address opt-out / hard-bounce list.
Adapter checks this BEFORE every send and skips with skip_reason='suppressed'.
"""
__tablename__ = "comms_suppressions"
__table_args__ = (
UniqueConstraint("tenant_id", "channel", "address",
name="uq_suppression_tenant_channel_addr"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
channel: Mapped[str] = mapped_column(String(16), nullable=False)
address: Mapped[str] = mapped_column(String(255), nullable=False)
reason: Mapped[str] = mapped_column(String(40), nullable=False)
# 'user_unsubscribe' | 'hard_bounce' | 'spam_complaint' | 'admin_block'
bounce_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_event_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# ============ PROVIDER CREDENTIALS (or use Secret Manager) ============
class ProviderCredential(Base, TenantMixin, AuditMixin):
"""Encrypted per-tenant provider config. Alternative: pull from GCP Secret
Manager keyed on tenant_id + provider. Recommendation: Secret Manager for
Pro/Enterprise tiers, this table for Basic-tier shared infra."""
__tablename__ = "comms_provider_credentials"
__table_args__ = (
UniqueConstraint("tenant_id", "provider", name="uq_provider_creds_tenant"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
config_encrypted: Mapped[bytes] = mapped_column(nullable=False)
# AES-GCM encrypted JSON. Key from KMS / env. Same scheme as Aadhaar at-rest encryption.
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
daily_quota: Mapped[int | None] = mapped_column(Integer, nullable=True)
3. Reuse map
| Existing artefact | How comms uses it |
|---|---|
aayojana.models.dispatch.DispatchRecord |
Postal channel continues to write here; Comms references via communication_log.ref_id. NOT replaced. |
aayojana.services.email_service |
Wrapped by comms.channels.email.WorkspaceSMTPAdapter. Existing send_email() is kept as a private function inside the adapter. The two existing helpers (send_seva_reminder, send_seva_confirmation) are migrated to be Templates seva_reminder_email and seva_confirmation_email and removed in a follow-up cleanup. |
aayojana.services.whatsapp_service |
Wrapped by comms.channels.whatsapp.MetaCloudAdapter. The phone-normaliser and BSP-template-name dispatch logic move into the adapter; the two send_seva_*_wa helpers also migrate to Templates. |
aayojana.services.pdf_service |
Postal adapter delegates here for envelope+letter PDF rendering today; will switch to WeasyPrint via reports.pdf_service once Reports lands. |
aayojana.routers.dispatch |
Stays as the postal-specific UI; its create-record path is intercepted to also write communication_log. |
aayojana.routers.payment_webhooks |
Used as the idempotency pattern for comms_delivery_events: (provider, event_id) unique, signature verify, recorded-then-applied. |
aayojana.models.member.Member + Address + Tag |
Consumed read-only by audience segmentation. |
aayojana.models.organisation.Organisation |
tenant_id scoping. |
aayojana.config.settings |
SMTP/WhatsApp envvars; per-tenant overrides via ProviderCredential. |
4. API surface
Mounted under /api/comms/. All endpoints require require_admin or
require_module_admin('comms') except the public unsubscribe link.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/comms/send |
Transactional send (one recipient, one channel). Called BY OTHER MODULES. Idempotent. |
POST |
/api/comms/jobs |
Create a campaign (template + segment + schedule). |
GET |
/api/comms/jobs |
List jobs with filters (status, owner_module, scheduled). |
GET |
/api/comms/jobs/{id} |
Job detail with recipient progress. |
POST |
/api/comms/jobs/{id}/approve |
Two-key approval for jobs >N recipients. |
POST |
/api/comms/jobs/{id}/cancel |
Cancel a scheduled or running job. |
GET |
/api/comms/log |
Query communication_log with filters (channel, recipient, status, date-range). |
GET |
/api/comms/log/{id} |
Single log entry with full rendered body for audit. |
GET |
/api/comms/templates |
List templates (filterable by owner_module / channel). |
POST |
/api/comms/templates |
Create template. |
GET |
/api/comms/templates/{id} |
Template + version history. |
POST |
/api/comms/templates/{id}/versions |
Create new version (immutable). |
POST |
/api/comms/templates/{id}/preview |
Render preview for given vars + channel. |
POST |
/api/comms/templates/{id}/activate/{version} |
Flip active_version_id. |
GET |
/api/comms/segments |
List audience segments. |
POST |
/api/comms/segments |
Create segment. |
POST |
/api/comms/segments/{id}/resolve |
Dry-run: returns count + sample 50 recipients. |
GET |
/api/comms/suppressions |
List opt-outs / bounces. |
POST |
/api/comms/suppressions |
Add manual suppression. |
DELETE |
/api/comms/suppressions/{id} |
Remove (admin only). |
POST |
/api/comms/webhooks/resend |
Resend delivery webhook receiver. |
POST |
/api/comms/webhooks/whatsapp |
Meta WhatsApp delivery webhook. |
POST |
/api/comms/webhooks/sms-bsp |
SMS BSP delivery webhook. |
GET |
/comms/unsubscribe/{token} |
Public one-click unsubscribe (no auth). |
GET |
/admin/comms |
Dashboard HTML. |
GET |
/admin/comms/templates/{id}/edit |
Template editor HTML. |
GET |
/admin/comms/jobs/{id} |
Campaign monitor HTML. |
GET |
/admin/comms/segment-builder |
Audience segment builder HTML. |
5. Service layer — the public API
The contract every other module imports. Keep this small; do not let modules reach into adapters or the log table directly.
# src/aayojana/comms/service.py
from typing import Literal
from sqlalchemy.ext.asyncio import AsyncSession
Channel = Literal["email", "whatsapp", "sms", "postal"]
RecipientType = Literal["member", "user", "external_email",
"partner", "vendor", "government_office"]
async def send(
db: AsyncSession,
*,
tenant_id: str,
channel: Channel,
recipient_type: RecipientType,
recipient_id: str,
template_name: str, # looked up by (tenant_id, name)
vars: dict | None = None,
idempotency_key: str | None = None, # caller-supplied; module + entity + event
scheduled_for: datetime | None = None, # None = immediate queue
) -> "CommunicationLog":
"""Single-recipient send. Returns CommunicationLog (status='queued' if async,
'sent' if sync small-payload). Re-calling with the same idempotency_key
returns the existing log unchanged. Raises CommsConfigError if no provider
configured for (tenant_id, channel)."""
async def send_to_segment(
db: AsyncSession,
*,
tenant_id: str,
segment_name: str,
template_name: str,
channels: list[Channel],
vars: dict | None = None,
scheduled_for: datetime | None = None,
cron_expr: str | None = None,
requested_by_user_id: int | None = None,
) -> "CommsJob":
"""Creates a CommsJob, resolves segment to recipients, fans out
CommsJobRecipient rows. If `scheduled_for` is None and `cron_expr` is None,
the job runs immediately. Returns the CommsJob."""
async def render_template(
db: AsyncSession,
*,
tenant_id: str,
template_name: str,
channel: Channel,
vars: dict,
version: int | None = None, # None = active version
) -> "RenderedMessage":
"""Returns RenderedMessage(subject, body_html, body_text, attachments[]).
Pure function — no side effects, no DB writes. Used by preview UI and by
each adapter's send path."""
async def queue_dispatch(db: AsyncSession, comm_log_id: str) -> None:
"""Hand off a queued log entry to the async worker (Cloud Tasks / APScheduler).
Worker calls the adapter, updates status, writes ref_id."""
async def register_template(
db: AsyncSession,
*,
tenant_id: str,
name: str,
owner_module: str,
channels: list[Channel],
subject_jinja: str | None,
body_html_jinja: str | None,
body_text_jinja: str | None = None,
whatsapp_template_name: str | None = None,
locked: bool = False,
) -> "Template":
"""Idempotent on (tenant_id, name). Used by other modules at startup to
seed their owned templates (e.g. Vitta seeds 'donation_80g_receipt')."""
async def add_suppression(
db: AsyncSession, *, tenant_id: str, channel: Channel, address: str, reason: str,
) -> None: ...
async def is_suppressed(
db: AsyncSession, *, tenant_id: str, channel: Channel, address: str,
) -> bool: ...
Channel adapters
Each lives in aayojana.comms.channels.<channel>:
class ChannelAdapter(Protocol):
async def send(self, log: CommunicationLog, rendered: RenderedMessage) -> str:
"""Returns provider-side ref_id; raises on error so worker can retry."""
Concrete implementations:
- comms.channels.email.WorkspaceSMTPAdapter — wraps existing aiosmtplib code.
- comms.channels.email.ResendAdapter — for scale (>1k/day per tenant).
- comms.channels.whatsapp.MetaCloudAdapter — wraps existing httpx code.
- comms.channels.sms.MSG91Adapter — Indian DLT-compliant.
- comms.channels.postal.IndiaPostAdapter — generates PDF, writes dispatch_records,
surfaces letter to printer queue (manual handover today; Speed Post API later).
Worker
comms.worker runs the queue. Phase A: APScheduler in-process inside the Cloud
Run instance. Phase B: Cloud Tasks fan-out for >1k/day campaigns. Job picks the
oldest queued row whose scheduled_for <= now(), calls the adapter, updates
status, retries with exponential backoff (1m, 5m, 30m, 2h) up to 4 attempts
before moving to failed and emitting a dead-letter row.
6. UI / Templates
Lives in src/aayojana/templates/comms/. Reuses frame.html for chrome.
| Page | Purpose |
|---|---|
comms/dashboard.html |
Today's sends, this-week's deliverability, scheduled jobs. |
comms/template_list.html |
Filter + search + status. |
comms/template_edit.html |
Three-pane editor: source (textarea with Jinja highlight), vars panel, live preview tabs (Email HTML / Postal PDF / WhatsApp text / SMS text). |
comms/segment_list.html |
All segments + last resolution count. |
comms/segment_builder.html |
Visual: pick strategy → fill criteria → "Resolve dry-run" shows N recipients + sample 50. |
comms/job_create.html |
Pick template → pick segment → channels → schedule → confirm. |
comms/job_monitor.html |
Live status: queued/sent/delivered/bounced counts; recipient table with per-row state. |
comms/log.html |
Queryable communication log; deep-link from member detail. |
comms/suppressions.html |
Opt-out / bounce admin. |
comms/unsubscribe_public.html |
Public one-click unsubscribe landing. |
System Jinja templates (the body_html_jinja/subject_jinja content of
Template rows) are seeded from disk on first run from
src/aayojana/comms/templates_seed/<owner_module>/<name>.{subject,html,txt}.
7. Migration plan
| Rev | Slug | Tables / changes |
|---|---|---|
| 0029 | comms_core |
comms_templates, comms_template_versions, comms_audience_segments, comms_suppressions, comms_provider_credentials. |
| 0030 | comms_jobs_log |
comms_jobs, comms_job_recipients, communication_log, comms_delivery_events. Backfill: synthesise communication_log rows from existing dispatch_records so the log is complete from day-one. |
Other modules' migrations (Reports 0031, Newsletter 0032, Publications 0033, Outreach+Collaborations 0034) come AFTER comms.
8. Cross-module dependencies
Comms is consumed by: every other module. The exact call signature each module uses:
# Vitta — 80G annual receipt
await comms.send(
db, tenant_id=ten, channel="email",
recipient_type="member", recipient_id=member.id,
template_name="donation_80g_annual_receipt",
vars={"member": m, "fy": "2025-26", "total": 50000, "certificate_url": url},
idempotency_key=f"vitta:80g:{member.id}:{fy}",
)
# Compliance — T-30 renewal reminder
await comms.send_to_segment(
db, tenant_id=ten, segment_name="compliance_custodians",
template_name="compliance_renewal_t30",
channels=["email", "whatsapp"],
vars={"item": item, "due_date": due},
cron_expr="0 8 * * *", # daily 8am, idempotent on item_id
)
# Members Suite — onboarding email
await comms.send(
db, tenant_id=ten, channel="email",
recipient_type="member", recipient_id=m.id,
template_name="member_onboarding_welcome",
vars={"member": m, "tenant": tenant},
idempotency_key=f"members:onboarding:{m.id}",
)
# Events (Agent 4) — festival invitation
await comms.send_to_segment(
db, tenant_id=ten, segment_name="festival_devotees",
template_name="event_invitation_kalyanotsavam",
channels=["email", "whatsapp", "postal"],
vars={"event": ev}, scheduled_for=ev.invitation_send_at,
)
# Education (Agent 4) — certificate issued
await comms.send(
db, tenant_id=ten, channel="email",
recipient_type="member", recipient_id=scholar.id,
template_name="education_certificate_issued",
vars={"scholar": scholar, "course": course, "certificate_url": url},
idempotency_key=f"education:cert:{certificate.id}",
)
# Newsletter — issue dispatch
job = await comms.send_to_segment(
db, tenant_id=ten, segment_name=list.segment_name,
template_name=f"newsletter_issue_{issue.id}",
channels=["email"], vars={"issue": issue},
)
# Reports — scheduled trustee pack delivery
await comms.send_to_segment(
db, tenant_id=ten, segment_name="trustees",
template_name="reports_trustee_pack_quarterly",
channels=["email"],
vars={"quarter": q, "pack_url": run.output_url},
cron_expr="0 9 1 1,4,7,10 *",
)
Reports is consumed by Comms only via the pdf_service.render_pdf() import
once Reports lands — Comms postal adapter will switch from
aayojana.services.pdf_service to aayojana.reports.pdf_service in 0031.
9. Implementation phases
Phase A — Postal + Email (2 weeks):
1. Land migrations 0029 + 0030.
2. Move existing email_service and dispatch_service into adapters under
comms.channels.*. Backwards-compat shims keep old call sites working.
3. Implement comms.service.send and render_template.
4. Seed system templates: seva_reminder_email, seva_confirmation_email,
member_onboarding_welcome, dispatch_envelope, dispatch_letter.
5. Build /admin/comms/dashboard + template-editor + log viewer.
6. Migrate one consumer (Members Suite onboarding) end-to-end as proof.
Phase B — WhatsApp + Campaigns (2 weeks):
7. Move whatsapp_service into comms.channels.whatsapp.MetaCloudAdapter.
8. Implement send_to_segment + segment resolution.
9. APScheduler worker, retry, dead-letter.
10. Audience segment builder UI.
11. Job monitor UI.
12. Two-key approval for >500-recipient jobs.
Phase C — SMS + Analytics + Resend swap (2 weeks): 13. SMS adapter (MSG91 or KaleyraDLT). 14. Resend adapter; tenant config flag chooses Workspace vs Resend. 15. Delivery webhook receivers (Resend, Meta, BSP). 16. Open/click tracking pixels + UTM rewriting. 17. Bounce handling → automatic suppression after 3 hard bounces. 18. Per-channel deliverability dashboard.
10. Open questions
- Async worker — Cloud Tasks (managed, retries built in) vs APScheduler in-process (simpler, single Cloud Run instance only). Recommendation: APScheduler for Phase A/B; switch to Cloud Tasks at Phase C if any tenant crosses 1k sends/day.
- Email provider — Workspace SMTP only initially, or land Resend day-one? Recommendation: Workspace now (we already pay for it), Resend swap at Phase C when deliverability becomes audit-relevant.
- WhatsApp templates — pre-approve all in Meta dashboard, or build an in-app approval-flow that mirrors BSP submission? Recommendation: Manual pre-approval for v1. Build flow only after >20 templates.
- Per-tenant SMTP credentials — Secret Manager vs
comms_provider_credentialstable? Recommendation: Both, with table for Basic-tier and Secret Manager for Pro/Enterprise (env var per tenant:COMMS_SMTP_<TENANT>__USER). - Bounce handling threshold — auto-suppress after how many hard bounces? Recommendation: 1 hard bounce, 3 soft bounces.
- Idempotency key collision — what if two modules pick the same key by
accident? Recommendation: Convention enforced in lint:
<owner_module>:<event_kind>:<entity_id>[:<sub>]. - Postal physical print queue — handoff via PDF download to local printer only, or auto-email to print-shop? Recommendation: PDF download for Phase A; print-shop email integration is per-tenant config in Phase C.
- Template variable schema — JSON Schema validation at template-create
time? Recommendation: Yes, store
sample_varsand validatevarsagainst it on every send. - Multi-language — Sanskrit + English + regional rendered as separate
templates, or single template with
langvar? Recommendation: Separate templates per language; segment selects the right one based on member preference tag.