15
✉️

Communications Service — Blueprint

Postal · Email · WhatsApp · SMS — one ledger

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

  1. 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.
  2. 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.
  3. 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.
  4. Per-tenant SMTP credentials — Secret Manager vs comms_provider_credentials table? Recommendation: Both, with table for Basic-tier and Secret Manager for Pro/Enterprise (env var per tenant: COMMS_SMTP_<TENANT>__USER).
  5. Bounce handling threshold — auto-suppress after how many hard bounces? Recommendation: 1 hard bounce, 3 soft bounces.
  6. 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>].
  7. 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.
  8. Template variable schema — JSON Schema validation at template-create time? Recommendation: Yes, store sample_vars and validate vars against it on every send.
  9. Multi-language — Sanskrit + English + regional rendered as separate templates, or single template with lang var? Recommendation: Separate templates per language; segment selects the right one based on member preference tag.