# Marketing — Catalogs, Proposals & Campaigns

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `marketing-leads-pipeline`, `customers-module`, `billing-module`, `calendar-module`, `system-communications-notifications`  
**Referenced by:** `reports-analytics`, `white-label-api`

---

## Overview

Two sub-modules: (1) Catalogs & Proposals — web-based interactive proposals and static PDF catalogs with UTM-tracked share links; (2) Campaigns & Mailing Lists — credit-based email broadcast system with UTM tracking and funnel analytics via Cloudflare Analytics Engine.

### Mobile route behavior

At phone widths, Marketing proposal headers and controls wrap while retaining the list, pipeline, and create actions.

Rationale: preserve proposal workflows inside the mobile app frame without horizontal overflow.

---

## Data Model

```sql
-- Catalogs & Proposals
catalog_templates (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  content JSONB NOT NULL,               -- structured sections: hero, items, pricing, CTA
  is_default BOOLEAN DEFAULT false,
  created_by UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
)

proposals (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  lead_id UUID,                         -- FK to leads (nullable — can send to existing customer)
  customer_id UUID,                     -- FK to customers (nullable)
  template_id UUID,                     -- FK to catalog_templates
  name TEXT NOT NULL,
  content JSONB NOT NULL,               -- snapshot of content at time of send
  status TEXT DEFAULT 'DRAFT',          -- 'DRAFT' | 'SENT' | 'VIEWED' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED'
  public_token TEXT NOT NULL UNIQUE,    -- URL-safe random token for public view link
  utm_source TEXT,
  utm_medium TEXT,
  utm_campaign TEXT,
  expires_at TIMESTAMPTZ,
  sent_at TIMESTAMPTZ,
  first_viewed_at TIMESTAMPTZ,
  last_viewed_at TIMESTAMPTZ,
  view_count INTEGER DEFAULT 0,
  accepted_at TIMESTAMPTZ,
  accepted_by_name TEXT,                -- name of the person who accepted (public {name} or staff-entered); seeds contract signatory (spec 78)
  customer_email TEXT,                  -- recipient email captured at send/accept; seeds contract signatory + statement (spec 78)
  rejected_at TIMESTAMPTZ,
  created_by UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
)

proposal_view_events (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  proposal_id UUID NOT NULL,
  viewed_at TIMESTAMPTZ DEFAULT now(),
  ip TEXT,
  user_agent TEXT,
  referrer TEXT,
  utm_source TEXT,
  utm_medium TEXT,
  utm_campaign TEXT
)

-- Campaigns & Mailing Lists
mailing_lists (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  description TEXT,
  subscriber_count INTEGER DEFAULT 0,   -- denormalized for display
  created_by UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
)

mailing_list_subscribers (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  list_id UUID NOT NULL,
  email TEXT NOT NULL,
  name TEXT,
  customer_id UUID,                     -- FK to customers if known
  status TEXT DEFAULT 'SUBSCRIBED',     -- 'SUBSCRIBED' | 'UNSUBSCRIBED' | 'BOUNCED' | 'COMPLAINED'
  subscribed_at TIMESTAMPTZ DEFAULT now(),
  unsubscribed_at TIMESTAMPTZ,
  UNIQUE (list_id, email)
)

campaigns (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  subject TEXT NOT NULL,
  body_html TEXT NOT NULL,              -- email HTML content
  list_id UUID NOT NULL,               -- FK to mailing_lists
  status TEXT DEFAULT 'DRAFT',          -- 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'SENT' | 'FAILED'
  scheduled_at TIMESTAMPTZ,
  sent_at TIMESTAMPTZ,
  recipient_count INTEGER,
  credits_used INTEGER,
  batches_total INTEGER DEFAULT 0,      -- total enqueued batch jobs
  batches_completed INTEGER DEFAULT 0,  -- incremented by each batch consumer on finish
  utm_source TEXT DEFAULT 'email',
  utm_medium TEXT DEFAULT 'campaign',
  utm_campaign TEXT,
  created_by UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
)

email_credits (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  credits INTEGER NOT NULL DEFAULT 0,   -- current balance
  updated_at TIMESTAMPTZ DEFAULT now()
)

email_credit_transactions (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  delta INTEGER NOT NULL,               -- positive = top-up, negative = consumed
  reason TEXT,                          -- 'plan_allotment' | 'top_up_purchase' | 'campaign_send'
  campaign_id UUID,
  created_at TIMESTAMPTZ DEFAULT now()
)

catalog_shares (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  template_id UUID NOT NULL,            -- FK to catalog_templates
  name TEXT NOT NULL,
  public_token TEXT NOT NULL UNIQUE,    -- URL-safe random token; public URL: /c/{token}
  utm_source TEXT,
  utm_medium TEXT,
  utm_campaign TEXT,
  is_active BOOLEAN DEFAULT true,
  view_count INTEGER DEFAULT 0,
  created_by UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
)
```

> **Funnel topology note:** `catalog_shares` are the top-of-funnel public surface (pre-lead). A lead form on the catalog page captures leads. Proposals are sent *after* lead capture to specific recipients. Causal order: catalog view → lead captured → proposal accepted → invoice paid. Each step's population is a subset of the previous — denominators must match their own step, not the top-line view count.

---

## Catalogs & Proposals (`/marketing/catalogs`)

### Template Builder

Template list: name, last edited, "New template" button.

Template editor — section-based (not free-form):
- **Hero section:** heading, subheading, image (R2 upload)
- **About section:** rich text
- **Items/Services:** table of items (name, description, price, image)
- **Pricing summary:** total, optional discount, optional VAT
- **CTA section:** button label + URL (or "Book meeting" → calendar integration)
- **Footer:** logo, contact, legal

Style: brand colors, fonts (inherited from tenant settings if set).

### Public Catalog Share (`/c/{token}`)

A `catalog_share` links a template to a shareable public URL with UTM params. Used as the **top of funnel** — shared via social media, email signature, ad campaigns.

Public page `GET /c/{token}`:
- Renders catalog from template JSONB
- Increments `view_count`
- Emits AE event `catalog_view` with `{ tenantId, catalogShareId, utmSource, utmMedium, utmCampaign }`
- Optionally includes an embedded lead form (configurable per share — `lead_form_id` JSONB field in `catalog_shares.settings`)
  - Form submission → `lead_captured` AE event + lead record with UTM from the catalog share

> **V2 scope:** catalog integration with payment adapters ("mini service ecommerce") and calendar booking CTA are deferred. V1 CTA section is a configurable external link only.

Staff create shares from: `/marketing/catalogs` → template detail → "Create share link". Form: UTM params, optional lead form attachment, name. Generates `public_token`.

#### Shares tab (manage existing share links)

`catalog_shares` are created and deactivated via API but had no surface to list or manage them. The template detail (`/marketing/catalogs/:templateId`) gains a **[Shares]** tab listing every share link for that template:

```
┌──────────────────────────────────────────────────────────────┐
│  Summer Catalog            [Editor]  [Shares]                │
│                                                  [+ New share]│
│                                                              │
│  Name            Public URL          UTM            Views  •  │
│  ─────────────────────────────────────────────────────────── │
│  Instagram bio   /c/k3f9…  [Copy]   ig/social      1,204  ● │
│  Email signature /c/p7m2…  [Copy]   email/sig         318  ● │
│  Old promo       /c/x1a8…  [Copy]   fb/ad             42   ○ │
│                                                              │
│  ● Active   ○ Inactive                                       │
└──────────────────────────────────────────────────────────────┘
```

**Columns:** Name, Public URL (`/c/{token}` with copy-to-clipboard), UTM (source/medium/campaign), Views (`view_count`), Active toggle (`is_active`).

**Per-row actions:** Copy link; Deactivate / Reactivate (toggles `is_active` via `PATCH /api/catalog-shares/:id`); inactive shares return a "link expired" page on `GET /c/{token}`. Editing a share's name/UTM uses the same `PATCH`.

This makes the existing `GET /api/catalog-shares` and `PATCH …/:id` endpoints reachable from the UI.

### Send Proposal

From lead detail panel or standalone "New proposal" button:
1. Pick template (or create ad-hoc)
2. Fill in / override: recipient name, items, pricing
3. Set UTM params (auto-suggested from lead.utm_source if lead has them)
4. Set expiry date (optional)
5. On "Send": create `proposals` record (`status = 'SENT'`); enqueue email via Resend with public link

### Public Proposal View

`GET /p/{publicToken}` — unauthenticated, served from portal worker:
- Renders proposal content from `proposals.content` JSONB
- Logs view event: creates `proposal_view_events` record; fires `proposal.viewed` webhook; updates `proposals.view_count`, `first_viewed_at`, `last_viewed_at`
- Accept / Reject buttons visible if not expired and status != accepted/rejected
  - Accept: `POST /api/proposals/{token}/accept` → status → 'ACCEPTED', persists `accepted_by_name` (from the `{name}` body) + `accepted_at`, fires `proposal.accepted`. **Acceptance is a non-binding acknowledgment only** — no automatic charge, contract signing, or invoice creation. Staff receives notification and follows up manually (or links to invoice/payment plan).
  - Reject: `POST /api/proposals/{token}/reject` → status → 'REJECTED'

Analytics Engine event: emit `proposal_view` event with `{ tenantId, proposalId, utmSource, utmMedium, utmCampaign, leadId }` for funnel analytics.

### Proposal List & Status

> **Superseded by `/proposals` (spec 156 `proposals-list`).** This embedded proposal table was the original list surface; the canonical proposal-list screen is now `/proposals`, owned by spec 156, with full table + pipeline views, filters, and quick actions. The `/marketing/catalogs` view links out to `/proposals` rather than rendering its own table. The columns below are retained only as the historical description of what `/proposals` standardized.

Table: Recipient, Proposal name, Status, Views, Sent date, Expiry.

Status badges: DRAFT (grey), SENT (blue), VIEWED (yellow), ACCEPTED (green), REJECTED (red), EXPIRED (grey).

---

## Campaigns & Mailing Lists (`/marketing/campaigns`)

### Mailing Lists (`/marketing/lists`)

Mailing lists get their **own dedicated route and nav entry** — `/marketing/lists` (list index) + `/marketing/lists/:id` (list detail) — rather than living only as a panel inside the campaign builder. A campaign references an audience that is built and maintained here; without a first-class screen there is no place to import/segment/clean an audience before sending.

**List index (`/marketing/lists`):**

```
┌──────────────────────────────────────────────────────────────┐
│  Mailing Lists                          [+ New list] [Import] │
│                                                              │
│  Name                Subscribed  Unsub  Bounced  Updated     │
│  ─────────────────────────────────────────────────────────── │
│  Newsletter          1,204       42     7        2d ago      │
│  Existing customers  318         3      0        1w ago      │
│  Webinar 2026        566         11     2        Jun 01      │
│                                                              │
│  3 lists · 2,088 subscribed                                  │
└──────────────────────────────────────────────────────────────┘
```

**List detail (`/marketing/lists/:id`):**
- Subscriber table: email, name, status (SUBSCRIBED / UNSUBSCRIBED / BOUNCED / COMPLAINED), source, added date; search + status filter.
- Actions: import CSV (email, name columns; dedupes on `UNIQUE(list_id, email)`), export CSV, manual add, manual remove, bulk remove bounced/complained ("clean list").
- **Segments:** save a filter (status + source + custom field) as a named segment usable as a campaign audience.
- Header counts feed the campaign builder's audience picker.

Subscriber status transitions:
- UNSUBSCRIBED: on click of `{unsubscribe_url}` in email footer (auto-injected)
- BOUNCED: on Resend webhook `email.bounced`
- COMPLAINED: on Resend webhook `email.complained`

All outbound campaigns skip non-SUBSCRIBED recipients. The campaign builder's "mailing list" field selects a list or a saved segment from this screen.

### Campaign Builder

Form: name, subject, mailing list, body (HTML editor — Tiptap, with merge tags: `{{name}}`, `{{unsubscribe_url}}`).

UTM params: auto-append to all links in body: `?utm_source={value}&utm_medium=email&utm_campaign={slug}`.

Preview: renders merged email with sample data.

Send options:
- **Send now** — check credit balance; if insufficient: prompt top-up; else enqueue send
- **Schedule** — date/time picker → sets `scheduled_at`

### Credit System

Credits consumed: 1 credit per recipient per send.

**Plan allotment:**
- Starter: 0 credits/month
- Business: 500 credits/month
- Enterprise: 2000 credits/month

**Top-up:** tenant buys extra credits via billing module (treated as one-time purchase, creates payment + credit_transaction).

Credit balance shown in campaign builder before send. Deducted on send start (refunded on hard failure before any delivery).

### Campaign Send Flow

Cron or queue consumer: `POST /api/cron/campaigns-send` runs every 5 minutes.

For each campaign where `status = 'SCHEDULED' AND scheduled_at <= now()`, or manually triggered:
1. Check `email_credits` balance ≥ recipient_count; abort if insufficient
2. Set status = 'SENDING'
3. Fetch SUBSCRIBED subscribers for `list_id`
4. Deduct credits: INSERT `email_credit_transactions` (delta = -recipient_count)
5. Enqueue `campaign.send_batch` jobs (batches of 50); set `batches_total = ceil(recipient_count / 50)`
6. Consumer: for each recipient, call Resend `sendEmail` with merged content + UTM links + unsubscribe link
7. On batch success: `UPDATE campaigns SET batches_completed = batches_completed + 1`; when `batches_completed = batches_total`: set `status = 'SENT'`, `sent_at = now()`
8. On batch hard failure (all retries exhausted): set `status = 'FAILED'`; refund credits for undelivered recipients only if `batches_completed = 0` (no delivery at all → full refund). Partial-delivery: credits not refunded (some were delivered). Log failure count for staff review.

Unsubscribe link: `https://zync.is/unsubscribe?s={hmacToken}` where `hmacToken = HMAC-SHA256(key=UNSUBSCRIBE_HMAC_KEY, msg={subscriberId}:{tenantId})` base64url-encoded. Handler decodes, verifies HMAC, looks up subscriber by ID, sets status = 'UNSUBSCRIBED'. No slug in URL (avoids leaking tenant identity; subscriber ID is opaque).

---

## UTM Analytics (`/marketing/analytics`)

### Funnel Dashboard

CF Analytics Engine powers all funnel metrics. Events written throughout the system:

| Step | Population | Emitted by | AE event |
|------|-----------|-----------|----------|
| 1. Catalog viewed | Anyone with link | `GET /c/{token}` handler | `catalog_view` |
| 2. Lead captured | Subset who submitted form | Lead form handler (spec 22) | `lead_captured` |
| 3. Proposal accepted | Subset who received + accepted proposal | Accept handler | `proposal_accepted` |
| 4. Invoice paid | Subset with paid invoice | Invoices-core record-payment handler | `invoice_paid` |

Each AE event: `{ tenantId, utmSource, utmMedium, utmCampaign, catalogShareId?, leadId?, proposalId?, invoiceId? }`

**Denominator rule:** each step's conversion rate is `step_N / step_(N-1)` — NOT step_N / step_1. Each step's population is already a strict subset of the prior. Never use step_1 (catalog views) as denominator for step_3 (proposals accepted) — populations differ and causality is broken.

### Analytics Page Layout

```
┌─────────────────────────────────────────────────────┐
│  Period: [Last 30d ▾]  Campaign: [All ▾]            │
├─────────────────────────────────────────────────────┤
│  FUNNEL (each step = subset of prior)               │
│  Catalog Views → Leads → Proposals Acc. → Paid     │
│     1,240    →   89   →      34        →   22       │
│                 7.2%         38.2%       64.7%       │
│                 (89/1240)   (34/89)     (22/34)      │
├─────────────────────────────────────────────────────┤
│  TRAFFIC BY SOURCE                                  │
│  Email 45%  |  Direct 30%  |  Facebook 15%  | ...  │
├─────────────────────────────────────────────────────┤
│  TOP CAMPAIGNS                  Views  Leads  Conv. │
│  Summer_2026                     420    31     18   │
│  ProductLaunch_May               315    22     12   │
└─────────────────────────────────────────────────────┘
```

Queries: `SELECT SUM(count) FROM analytics_engine WHERE tenantId = ? AND event = ? AND date >= ?` grouped by utm_campaign / utm_source.

### Campaign-Level Drilldown

Click campaign row → UTM breakdown: views, leads, accepted, paid per utm_source/medium.

---

## Tenant Configuration (`/settings/integrations/marketing`)

- Enable/disable marketing module per tenant tier (Business+ only for forms + campaigns)
- Default UTM source/medium prefix
- Notification settings: new lead email alert recipient

---

## Permissions

| Action | Required permission |
|--------|-------------------|
| View pipeline / leads | `marketing:read` |
| Manage catalogs + proposals | `marketing:write` |
| View/manage mailing lists | `marketing:write` |
| Create/send campaigns | `marketing:write` |
| View analytics | `marketing:read` |
| Buy credits | `billing:write` |

---

## API Endpoints

```
GET    /api/catalog-templates             → list
POST   /api/catalog-templates             → create
PATCH  /api/catalog-templates/:id         → update
DELETE /api/catalog-templates/:id         → delete

GET    /api/catalog-shares                → list shares (staff)
POST   /api/catalog-shares                → create share (with UTM + optional form)
PATCH  /api/catalog-shares/:id            → update / deactivate
GET    /c/:token                          → public catalog view (no auth); emits catalog_view AE event

GET    /api/proposals                     → list (filterable by status, lead, customer)
POST   /api/proposals                     → create from template
GET    /api/proposals/:id                 → detail
PATCH  /api/proposals/:id                 → update (draft only)
DELETE /api/proposals/:id                 → delete (draft only)

GET    /p/:token                          → public proposal view (no auth)
POST   /api/proposals/:token/accept       → accept (no auth); body { name? } persists accepted_by_name + accepted_at
POST   /api/proposals/:token/reject       → reject (no auth)
POST   /api/proposals/:id/accept          → staff "Mark accepted" (auth, marketing:write); body { accepted_by_name?, customer_email? }; status→ACCEPTED, sets accepted_at + accepted_by_name (falls back to proposal recipient when omitted)
POST   /api/proposals/:id/reject          → staff mark rejected (auth, marketing:write); status→REJECTED

GET    /api/mailing-lists                 → list
POST   /api/mailing-lists                 → create
GET    /api/mailing-lists/:id/subscribers → list subscribers
POST   /api/mailing-lists/:id/subscribers → add subscriber
DELETE /api/mailing-lists/:id/subscribers/:sid → remove
POST   /api/mailing-lists/:id/import      → bulk CSV import

GET    /api/campaigns                     → list
POST   /api/campaigns                     → create
GET    /api/campaigns/:id                 → detail
PATCH  /api/campaigns/:id                 → update (draft only)
POST   /api/campaigns/:id/send            → send now (checks credits)
POST   /api/campaigns/:id/schedule        → schedule

GET    /api/email-credits                 → current balance
POST   /api/email-credits/topup           → purchase top-up (routes through billing)

GET    /api/marketing/analytics           → funnel metrics (AE query)
GET    /unsubscribe                       → unsubscribe handler (no auth, HMAC verified token)

POST   /api/webhooks/resend               → Resend delivery webhook (bounce/complained → update subscriber status)
```

---

## Outbound Webhook Events

| Event | Payload |
|-------|---------|
| `proposal.viewed` | `{ proposalId, leadId, viewCount, tenantId }` |
| `proposal.accepted` | `{ proposalId, leadId, tenantId }` |

---

## Foundation Deltas

**New binding:** `RATE_LIMITER_LEAD_FORM` — 20 submissions/min per form (CF native RateLimiter). Belongs to spec 22 but captured here as marketing layer.

**New binding:** `ANALYTICS_ENGINE` (`[[analytics_engine_datasets]]`) — CF Analytics Engine for funnel events (`catalog_view`, `lead_captured`, `proposal_accepted`, `invoice_paid`). **Cross-cutting:** the `invoice_paid` event is emitted by invoices-core (spec 15); the `lead_captured` event by spec 22. This binding must be available to both the marketing Worker and the invoices Worker. Configure as a shared binding in `wrangler.toml`.

**New cron:** `campaigns-send` — every 5 minutes, processes due + manually triggered campaigns.

**New queue:** `campaign.send_batch` — batched email delivery per campaign (50 recipients/job).

**New secret:** `UNSUBSCRIBE_HMAC_KEY` — secret key for HMAC-signed unsubscribe tokens. Never logged or exposed.

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Proposals use public token | Not auth-required view | Leads/external recipients aren't Zync users; token is sufficient auth for a proposal |
| Proposal accept is non-binding acknowledgment | Not auto-charge or auto-sign | Acceptance triggers staff notification + manual follow-up; auto-executing financial actions from an unauthenticated endpoint is unsafe |
| Catalog shares as top-of-funnel | Separate `catalog_shares` table, not proposals | Proposals are recipient-specific (post-lead); catalog shares are anonymous public links (pre-lead); different population = different AE event |
| Funnel step denominators are step-relative | `step_N / step_(N-1)`, not `/ step_1` | Each step's population is a strict subset of prior; using catalog views as denominator for proposal conversions yields meaningless ratio across different populations |
| Unsubscribe HMAC uses subscriber ID | `HMAC(subscriberId:tenantId)`, no slug in URL | Stateless verification; slug in URL leaks tenant identity; HMAC prevents crafting unsubscribe tokens for other subscribers |
| `batches_total` / `batches_completed` counters | Atomic increments | Enables observable completion without a separate batch-tracking table; partial-failure state is explicit |
| Payment/calendar catalog integration | V2 deferred | "Mini service ecommerce" (Zync.txt 402) and calendar booking CTA (403) require significant additional scope; V1 CTA is external link only |
| Content snapshot in proposals.content | Not live template reference | Template may change after send; proposal must be immutable record of what was sent |
| AE for funnel analytics | Not Postgres aggregates | AE handles high-volume event writes efficiently; queried by tenant+campaign+period without full table scan |
| UTM auto-appended to all campaign links | Not per-link manual | Ensures tracking coverage; power users can override per-link if needed |
| Credit deduction before first send | Not after | Prevents overspend when batches fail mid-way; refund on hard failure before any delivery |
| Unsubscribe via HMAC-signed URL | Not DB token | Stateless; no lookup table needed; HMAC over `{subscriberId}:{tenantId}` prevents unsubscribing others |
| Business+ tier only for forms + campaigns | Not all tiers | Forms create public endpoints (abuse surface); campaigns consume Resend quota — cost must be tier-gated |
