# Marketing — Catalogs, Proposals & Campaigns — Implementation Plan

**Spec:** docs/specs/2026-05-30-marketing-catalogs-campaigns.md  ·  **Slug:** marketing-catalogs-campaigns  ·  **Wave:** 9
**Depends on:** billing-module, calendar-module, customers-module, foundation-auth-rbac, marketing-leads-pipeline, system-communications-notifications

## Goal
Deliver two marketing sub-modules on top of the existing leads pipeline: (1) Catalogs & Proposals — section-based catalog templates rendered as public UTM-tracked share links (`/c/:token`) and recipient-specific interactive proposals (`/p/:token`) with accept/reject; (2) Campaigns & Mailing Lists — a credit-based email broadcast system with CSV-importable lists, a Tiptap HTML campaign builder, batched queue delivery via Resend, HMAC unsubscribe, and a Cloudflare Analytics Engine funnel dashboard. Acceptance of a proposal is a non-binding acknowledgment that notifies staff; it never auto-charges or auto-signs.

## Architecture
- **Worker:** `apps/zync-api` (Hono) hosts all `/api/*` authenticated routes, the `campaigns-send` cron, and the `campaign.send_batch` queue consumer. Public unauthenticated pages (`/c/:token`, `/p/:token`, `/unsubscribe`) are served from the same API worker (no separate portal worker needed; spec mentions a portal worker but all handlers are stateless token lookups served from `apps/zync-api`).
- **App UI:** `apps/zync-app` (Vite+React) renders `/marketing/catalogs`, `/marketing/lists`, `/marketing/campaigns`, `/marketing/analytics` and the `/settings/integrations/marketing` config page. Proposal list is **superseded by spec 156 (`/proposals`)** — this module builds the `proposals` table + API but links out to `/proposals` instead of rendering its own table.
- **Upstream tables consumed:** `tenants(id)`, `users(id)`, `customers(id)`, `leads(id)` (from marketing-leads-pipeline), `lead_forms(id)` (embedded catalog form attachment), `customer_communications` (system events), `payments` (billing top-up).
- **Upstream exports consumed:** `tenantQuery` / `systemQuery` (tenant-scoped Drizzle), `requirePermission`, `requireModuleEnabled`, `requireTier`, `authMiddleware`, `sendEmail` + `SendEmailOptions` (Resend adapter), `createNotification`, `recordSystemCommunication`, `rateLimit`, `timingSafeEqual`, `generateOpaqueToken`, `buildPaginated` / `clampLimit`, `getPaymentAdapter` (billing top-up), `webhook.deliver` (outbound webhooks), `ANALYTICS_ENGINE` binding, `QUEUE` binding.
- **New bindings:** `ANALYTICS_ENGINE` (shared with invoices worker — `invoice_paid` event emitted there), `campaign.send_batch` queue, `campaigns-send` cron, secret `UNSUBSCRIBE_HMAC_KEY`. `RATE_LIMITER_LEAD_FORM` already exists (locked) and is reused for catalog embedded forms.
- **Data flow (funnel):** catalog view (`/c/:token`) → optional embedded lead form → lead captured → proposal sent → proposal accepted → invoice paid. Each step emits one AE event; conversion is always `step_N / step_(N-1)`.

## Tech Stack
- **Packages:** `@zync/db` (Drizzle schema + queries), `@zync/types` (shared types), `@zync/ui` (DataTable, Dialog, Tabs, Form, Button, Badge, Input, Select, Toast), `@zync/notifications` (`sendEmail`, `createNotification`), `@zync/auth` (`requirePermission`, `timingSafeEqual`, `generateOpaqueToken`).
- **Apps:** `apps/zync-api` (Hono on Workers), `apps/zync-app` (React/Vite).
- **Libraries:** Tiptap (campaign + about-section rich text), Zod (route validation), `@tanstack/react-query` (UI data).
- **Cloudflare bindings:** `ANALYTICS_ENGINE` (Analytics Engine dataset), `QUEUE` (`campaign.send_batch`), `STORAGE` (R2 — hero/logo image uploads), `RATE_LIMITER_LEAD_FORM`, secret `UNSUBSCRIBE_HMAC_KEY`, secret `RESEND_API_KEY` (existing).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 9a — schema + perms | 1, 2 | `packages/db/src/schema/marketing.ts`, migration, permission seed | No (1 blocks all) |
| 9b — query layer | 3, 4, 5 | `packages/db/src/queries/*`, `packages/types` | After 1; parallel among themselves |
| 9c — catalog/proposal API | 6, 7, 8 | `apps/zync-api/src/routes/catalog-*`, `proposals.ts` | After 3; parallel |
| 9d — campaign API + credits + delivery | 9, 10, 11, 12 | `apps/zync-api/src/routes/campaigns.ts`, `mailing-lists.ts`, cron, queue consumer | After 4,5; 11/12 after 9/10 |
| 9e — public + webhooks + analytics | 13, 14, 15, 16 | public pages, resend webhook, AE helper, analytics route | After 6,9; parallel |
| 9f — UI | 17, 18, 19, 20, 21 | `apps/zync-app/src/pages/marketing/*` | After respective APIs; parallel |
| 9g — config + tier gate | 22 | settings page + module gate | After 17–21 |

## Tasks

### Task 1: Marketing schema (Drizzle) + migration
**Blocks:** 2, 3, 4, 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/marketing.ts`
- Modify: `packages/db/src/schema/index.ts` (export marketing tables)
- Create: `packages/db/migrations/00XX_marketing_catalogs_campaigns.sql`
**Steps:**
- [ ] Define all 9 tables in Drizzle `pgTable` matching the DDL below; export them.
- [ ] Add FK clauses the spec omitted (all `tenant_id`→`tenants(id)`, `created_by`→`users(id)`, plus the per-table FKs).
- [ ] Convert every enum comment to an inline `CHECK (col IN (...))`.
- [ ] Add `settings JSONB` to `catalog_shares` (spec §Public Catalog Share references `catalog_shares.settings.lead_form_id` but the DDL omits the column — reconcile by adding it).
- [ ] Add supporting indexes: `proposals(public_token)`, `catalog_shares(public_token)`, `mailing_list_subscribers(list_id, status)`, `proposal_view_events(proposal_id)`, `campaigns(status, scheduled_at)`, `email_credit_transactions(tenant_id, created_at)`.
- [ ] Generate the SQL migration via drizzle-kit; verify it is Postgres dialect (gen_random_uuid, TIMESTAMPTZ, BOOLEAN, JSONB).
**Schema / Interfaces:**
```sql
CREATE TABLE catalog_templates (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id),
  name        TEXT NOT NULL,
  content     JSONB NOT NULL,
  is_default  BOOLEAN NOT NULL DEFAULT false,
  created_by  UUID NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE proposals (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id        UUID NOT NULL REFERENCES tenants(id),
  lead_id          UUID REFERENCES leads(id),
  customer_id      UUID REFERENCES customers(id),
  template_id      UUID REFERENCES catalog_templates(id),
  name             TEXT NOT NULL,
  content          JSONB NOT NULL,
  status           TEXT NOT NULL DEFAULT 'DRAFT'
                     CHECK (status IN ('DRAFT','SENT','VIEWED','ACCEPTED','REJECTED','EXPIRED')),
  public_token     TEXT NOT NULL UNIQUE,
  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 NOT NULL DEFAULT 0,
  accepted_at      TIMESTAMPTZ,
  accepted_by_name TEXT,
  customer_email   TEXT,
  rejected_at      TIMESTAMPTZ,
  created_by       UUID NOT NULL REFERENCES users(id),
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE proposal_view_events (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    UUID NOT NULL REFERENCES tenants(id),
  proposal_id  UUID NOT NULL REFERENCES proposals(id),
  viewed_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  ip           TEXT,
  user_agent   TEXT,
  referrer     TEXT,
  utm_source   TEXT,
  utm_medium   TEXT,
  utm_campaign TEXT
);

CREATE TABLE mailing_lists (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id        UUID NOT NULL REFERENCES tenants(id),
  name             TEXT NOT NULL,
  description      TEXT,
  subscriber_count INTEGER NOT NULL DEFAULT 0,
  created_by       UUID NOT NULL REFERENCES users(id),
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE mailing_list_subscribers (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id),
  list_id         UUID NOT NULL REFERENCES mailing_lists(id),
  email           TEXT NOT NULL,
  name            TEXT,
  customer_id     UUID REFERENCES customers(id),
  status          TEXT NOT NULL DEFAULT 'SUBSCRIBED'
                    CHECK (status IN ('SUBSCRIBED','UNSUBSCRIBED','BOUNCED','COMPLAINED')),
  source          TEXT,
  subscribed_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  unsubscribed_at TIMESTAMPTZ,
  UNIQUE (list_id, email)
);

CREATE TABLE campaigns (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id          UUID NOT NULL REFERENCES tenants(id),
  name               TEXT NOT NULL,
  subject            TEXT NOT NULL,
  body_html          TEXT NOT NULL,
  list_id            UUID NOT NULL REFERENCES mailing_lists(id),
  status             TEXT NOT NULL DEFAULT 'DRAFT'
                       CHECK (status IN ('DRAFT','SCHEDULED','SENDING','SENT','FAILED')),
  scheduled_at       TIMESTAMPTZ,
  sent_at            TIMESTAMPTZ,
  recipient_count    INTEGER,
  credits_used       INTEGER,
  batches_total      INTEGER NOT NULL DEFAULT 0,
  batches_completed  INTEGER NOT NULL DEFAULT 0,
  utm_source         TEXT NOT NULL DEFAULT 'email',
  utm_medium         TEXT NOT NULL DEFAULT 'campaign',
  utm_campaign       TEXT,
  created_by         UUID NOT NULL REFERENCES users(id),
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE email_credits (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id  UUID NOT NULL REFERENCES tenants(id),
  credits    INTEGER NOT NULL DEFAULT 0,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE email_credit_transactions (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id),
  delta       INTEGER NOT NULL,
  reason      TEXT CHECK (reason IN ('plan_allotment','top_up_purchase','campaign_send')),
  campaign_id UUID REFERENCES campaigns(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE catalog_shares (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    UUID NOT NULL REFERENCES tenants(id),
  template_id  UUID NOT NULL REFERENCES catalog_templates(id),
  name         TEXT NOT NULL,
  public_token TEXT NOT NULL UNIQUE,
  settings     JSONB,
  utm_source   TEXT,
  utm_medium   TEXT,
  utm_campaign TEXT,
  is_active    BOOLEAN NOT NULL DEFAULT true,
  view_count   INTEGER NOT NULL DEFAULT 0,
  created_by   UUID NOT NULL REFERENCES users(id),
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
**Acceptance:**
- [ ] Migration applies cleanly to Neon Postgres; all 9 tables exist with FKs and CHECK constraints.
- [ ] `drizzle-kit` types compile; tables exported from `@zync/db`.

### Task 2: Seed `marketing:read` / `marketing:write` permissions
**Blocks:** 6, 9  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/db/src/seed/permissions.ts` (the `seedPermissions` source list)
**Steps:**
- [ ] Add `marketing:read` and `marketing:write` permission rows if not already seeded by marketing-leads-pipeline (idempotent upsert).
- [ ] Grant both to admin/owner roles; grant `marketing:write` to manager role per existing role matrix.
**Acceptance:**
- [ ] `seedPermissions` is idempotent and inserts both permissions exactly once.
- [ ] Permission checks in later tasks resolve against these rows.

### Task 3: Catalog & proposal query layer
**Blocks:** 6, 7, 8  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/catalogs.ts`
- Create: `packages/db/src/queries/proposals.ts`
- Modify: `packages/db/src/queries/index.ts`
**Steps:**
- [ ] `listCatalogTemplates`, `createCatalogTemplate`, `updateCatalogTemplate`, `deleteCatalogTemplate` (all via `tenantQuery`).
- [ ] `listCatalogShares(tenantId)`, `createCatalogShare`, `updateCatalogShare`, `getCatalogShareByToken(token)` (token lookup is tenant-agnostic via `systemQuery` then scoped), `incrementCatalogShareViews(id)`.
- [ ] `listProposals(filters)`, `createProposal`, `getProposal(id)`, `getProposalByToken(token)`, `updateProposal` (draft-only guard), `deleteProposal` (draft-only), `recordProposalView(...)` (inserts `proposal_view_events`, bumps `view_count`, sets `first_viewed_at`/`last_viewed_at`), `acceptProposal`, `rejectProposal`.
- [ ] `recordProposalView` and accept/reject set the documented timestamp columns and `status`.
**Schema / Interfaces:**
```typescript
function getCatalogShareByToken(token: string): Promise<CatalogShare | null>;
function getProposalByToken(token: string): Promise<Proposal | null>;
function acceptProposal(token: string, args: { name?: string; email?: string }): Promise<Proposal>;
function incrementCatalogShareViews(id: string): Promise<void>;
```
**Acceptance:**
- [ ] Draft-only mutation guards reject non-DRAFT proposals.
- [ ] Token lookups return null for unknown/inactive tokens.

### Task 4: Mailing list & subscriber query layer
**Blocks:** 10, 12  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/mailing-lists.ts`
**Steps:**
- [ ] `listMailingLists(tenantId)` returning per-list counts (SUBSCRIBED/UNSUBSCRIBED/BOUNCED) computed via grouped query.
- [ ] `createMailingList`, `listSubscribers(listId, filters)`, `addSubscriber` (upsert on `UNIQUE(list_id,email)`), `removeSubscriber`, `bulkImportSubscribers(listId, rows)` (dedupe on conflict do nothing, recompute `subscriber_count`), `cleanList(listId)` (remove BOUNCED/COMPLAINED).
- [ ] `setSubscriberStatus(subscriberId, status)` used by webhook + unsubscribe.
- [ ] `getSubscriberById(id)` for HMAC unsubscribe lookup.
- [ ] `listSubscribedRecipients(listId)` returns only `status = 'SUBSCRIBED'` rows.
**Acceptance:**
- [ ] Import dedupes and never throws on duplicate emails; `subscriber_count` stays accurate.
- [ ] `listSubscribedRecipients` excludes all non-SUBSCRIBED statuses.

### Task 5: Campaign & email-credit query layer
**Blocks:** 9, 11, 12  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/campaigns.ts`
- Create: `packages/db/src/queries/email-credits.ts`
**Steps:**
- [ ] `listCampaigns`, `createCampaign`, `getCampaign`, `updateCampaign` (draft-only), `setCampaignStatus`, `setCampaignSending(id, batchesTotal, recipientCount)`, `incrementBatchesCompleted(id)` returning new `{batches_completed, batches_total}` for completion detection.
- [ ] `getEmailCredits(tenantId)` (creates row at 0 if missing), `applyCreditDelta(tenantId, delta, reason, campaignId?)` — single transaction: insert `email_credit_transactions` and update `email_credits.credits` (with `require-audit-in-transaction`). Guard balance never goes below 0 on deduct.
- [ ] `refundCampaignCredits(campaignId, amount)` wraps `applyCreditDelta` with positive delta + reason `campaign_send`.
**Schema / Interfaces:**
```typescript
function applyCreditDelta(tenantId: string, delta: number,
  reason: 'plan_allotment'|'top_up_purchase'|'campaign_send', campaignId?: string): Promise<number>;
function incrementBatchesCompleted(campaignId: string): Promise<{ completed: number; total: number }>;
```
**Acceptance:**
- [ ] Credit deduction and transaction insert are atomic (single Drizzle transaction).
- [ ] `incrementBatchesCompleted` uses an atomic SQL `SET batches_completed = batches_completed + 1 RETURNING`.

### Task 6: Catalog templates + shares API routes
**Blocks:** 13, 17  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/catalog-templates.ts`
- Create: `apps/zync-api/src/routes/catalog-shares.ts`
- Modify: `apps/zync-api/src/index.ts` (mount routes, require module enabled)
**Steps:**
- [ ] `GET/POST /api/catalog-templates`, `PATCH/DELETE /api/catalog-templates/:id` — `requirePermission('marketing:write')` for writes, `marketing:read` for list; Zod-validated bodies; `requireModuleEnabled('marketing')`.
- [ ] `GET /api/catalog-shares` (list), `POST /api/catalog-shares` (generate `public_token` via `generateOpaqueToken`, accept UTM + optional `settings.lead_form_id`), `PATCH /api/catalog-shares/:id` (update name/UTM, toggle `is_active`).
- [ ] Hero/about image upload: presign or proxy upload to `STORAGE` R2; store R2 key inside template `content` JSONB.
**Acceptance:**
- [ ] All routes use `requireZodValidationInRoutes` (no unvalidated bodies) and `no-raw-drizzle-from-routes` (queries via query layer).
- [ ] Deactivating a share flips `is_active`; reachable from UI Shares tab.

### Task 7: Proposals API routes (authenticated)
**Blocks:** 14, 18  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/proposals.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `GET /api/proposals` (filter by status/lead/customer), `POST /api/proposals` (snapshot template `content` into `proposals.content`, generate `public_token`, status `DRAFT`), `GET /api/proposals/:id`, `PATCH /api/proposals/:id` (draft only), `DELETE /api/proposals/:id` (draft only).
- [ ] Send flow inside `POST /api/proposals` (or a `:id/send` action): set `status='SENT'`, `sent_at=now()`, enqueue Resend email via `sendEmail` with public `/p/{token}` link; email HTML must include `dir="rtl"` wrapper for Hebrew locale (`sendEmail` `locale` param).
- [ ] Staff accept/reject: `POST /api/proposals/:id/accept` (`marketing:write`; body `{accepted_by_name?, customer_email?}`, falls back to proposal recipient), `POST /api/proposals/:id/reject`.
- [ ] On accept (staff or public): `createNotification` to staff + `recordSystemCommunication` on linked customer; fire `proposal.accepted` outbound webhook via `webhook.deliver`; **no auto-charge/sign**.
**Acceptance:**
- [ ] Sending snapshots content immutably; later template edits don't change sent proposals.
- [ ] Accept produces staff notification and webhook only — no invoice/payment side effects.

### Task 8: Public proposal accept/reject domain logic
**Blocks:** 14  ·  **Blocked by:** 3
**Files:**
- Modify: `packages/db/src/queries/proposals.ts` (accept/reject helpers if not in Task 3)
- Create: `apps/zync-api/src/lib/proposal-public.ts` (shared accept/reject used by public token routes)
**Steps:**
- [ ] Implement token-based accept: verify not expired and status not already ACCEPTED/REJECTED; set `status='ACCEPTED'`, `accepted_at`, `accepted_by_name` (from public `{name}`), optional `customer_email`.
- [ ] Token-based reject: set `status='REJECTED'`, `rejected_at`.
- [ ] Both fire the same notification + `proposal.accepted`/`proposal.viewed` webhooks and emit `proposal_accepted` AE event (Task 15 helper).
**Acceptance:**
- [ ] Expired or already-decided proposals reject accept/reject with 409.
- [ ] Acceptance is non-binding: no charge, contract, or invoice is created.

### Task 9: Campaigns API routes
**Blocks:** 11, 19  ·  **Blocked by:** 2, 5
**Files:**
- Create: `apps/zync-api/src/routes/campaigns.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `GET /api/campaigns`, `POST /api/campaigns` (Zod: name, subject, body_html, list_id, UTM), `GET /api/campaigns/:id`, `PATCH /api/campaigns/:id` (draft only).
- [ ] `POST /api/campaigns/:id/send` — resolve recipient_count from `listSubscribedRecipients`; check `getEmailCredits >= recipient_count`; if insufficient return 402 with top-up prompt payload; else hand off to send orchestration (Task 11 shared function), set `status='SENDING'`.
- [ ] `POST /api/campaigns/:id/schedule` — set `scheduled_at`, `status='SCHEDULED'`.
- [ ] `requirePermission('marketing:write')`, `requireTier('business')` (Business+ gate for campaigns), `requireModuleEnabled('marketing')`.
**Acceptance:**
- [ ] Insufficient credits returns 402 without changing campaign status.
- [ ] Starter tier is blocked from creating/sending campaigns.

### Task 10: Mailing lists & subscribers API routes
**Blocks:** 19  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-api/src/routes/mailing-lists.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `GET/POST /api/mailing-lists`, `GET/POST /api/mailing-lists/:id/subscribers`, `DELETE /api/mailing-lists/:id/subscribers/:sid`, `POST /api/mailing-lists/:id/import` (multipart CSV: email, name columns; dedupe).
- [ ] CSV import streams rows, validates email format, calls `bulkImportSubscribers`; returns counts (added, skipped, invalid).
- [ ] `marketing:write` for all; `marketing:read` for GETs.
**Acceptance:**
- [ ] Import dedupes on `(list_id, email)` and reports skipped duplicates.
- [ ] Removing a subscriber decrements list counts.

### Task 11: `campaigns-send` cron + send orchestration
**Blocks:** —  ·  **Blocked by:** 5, 9
**Files:**
- Create: `apps/zync-api/src/cron/campaigns-send.ts`
- Create: `apps/zync-api/src/lib/campaign-send.ts` (shared orchestration used by cron + manual send)
- Modify: `apps/zync-api/wrangler.toml` (cron `*/5 * * * *`, `[[analytics_engine_datasets]]`, queue producer binding, `UNSUBSCRIBE_HMAC_KEY`)
- Modify: `apps/zync-api/src/index.ts` (`POST /api/cron/campaigns-send` + scheduled handler)
**Steps:**
- [ ] Cron selects campaigns where `status='SCHEDULED' AND scheduled_at <= now()`.
- [ ] Orchestration: (1) recheck credit balance ≥ recipient_count, abort if short; (2) set `status='SENDING'`; (3) fetch SUBSCRIBED recipients; (4) deduct credits via `applyCreditDelta(-recipient_count, 'campaign_send', campaignId)`; (5) chunk recipients into batches of 50, `setCampaignSending(id, batches_total=ceil(n/50), recipient_count)`, enqueue one `campaign.send_batch` job per chunk.
- [ ] Idempotency guard: skip campaigns already in `SENDING`/`SENT`.
**Acceptance:**
- [ ] Credits deducted exactly once before any batch is enqueued.
- [ ] `batches_total = ceil(recipient_count / 50)`.

### Task 12: `campaign.send_batch` queue consumer + completion/refund
**Blocks:** —  ·  **Blocked by:** 4, 5, 11
**Files:**
- Create: `apps/zync-api/src/queue/campaign-send-batch.ts`
- Modify: `apps/zync-api/src/index.ts` (queue handler), `apps/zync-api/wrangler.toml` (queue consumer)
**Steps:**
- [ ] For each recipient in the batch: render `body_html` with merge tags `{{name}}`, `{{unsubscribe_url}}`; append UTM (`?utm_source={utm_source}&utm_medium=email&utm_campaign={slug}`) to all links; inject HMAC unsubscribe URL; call `sendEmail` (Resend) with `dir="rtl"` for Hebrew locale.
- [ ] On batch success: `incrementBatchesCompleted`; when `completed === total` set `status='SENT'`, `sent_at=now()`.
- [ ] On batch hard failure (retries exhausted): set `status='FAILED'`; if `batches_completed === 0` refund full `recipient_count` credits (`refundCampaignCredits`); partial delivery → no refund; log failure count.
- [ ] Unsubscribe URL: `https://zync.is/unsubscribe?s={hmacToken}`, `hmacToken = base64url(HMAC-SHA256(UNSUBSCRIBE_HMAC_KEY, "{subscriberId}:{tenantId}"))`. No tenant slug in URL.
**Acceptance:**
- [ ] Completion only flips to SENT when all batches finish.
- [ ] Full refund only when zero batches delivered; partial delivery keeps credits consumed.
- [ ] Every email contains an HMAC unsubscribe link and RTL wrapper.

### Task 13: Public catalog page `GET /c/:token`
**Blocks:** —  ·  **Blocked by:** 6, 15
**Files:**
- Create: `apps/zync-api/src/routes/public-catalog.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] No auth. Look up share by `public_token`; if missing or `is_active=false` render "link expired" page.
- [ ] Render catalog HTML from template `content` JSONB (server-rendered, CSP-safe — no inline event handlers; nonce-based or external script only).
- [ ] `incrementCatalogShareViews`; emit AE `catalog_view` event `{ tenantId, catalogShareId, utmSource, utmMedium, utmCampaign }` (Task 15).
- [ ] If `settings.lead_form_id` set, embed the lead form (renders `lead_forms` config); submission hits the existing public form endpoint guarded by `RATE_LIMITER_LEAD_FORM`, propagating the share's UTM → `lead_captured` AE event.
**Acceptance:**
- [ ] Inactive share token returns the expired page (HTTP 410/200 page, not 500).
- [ ] `catalog_view` AE event emitted once per render with correct UTM.
- [ ] Page response carries CSP header (no inline JS).

### Task 14: Public proposal page `/p/:token` + accept/reject + `/unsubscribe`
**Blocks:** —  ·  **Blocked by:** 7, 8, 15
**Files:**
- Create: `apps/zync-api/src/routes/public-proposal.ts`
- Create: `apps/zync-api/src/routes/unsubscribe.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `GET /p/:token` (no auth): render `proposals.content`; call `recordProposalView` (inserts `proposal_view_events`, bumps counters); fire `proposal.viewed` webhook; set status `VIEWED` if currently SENT; emit AE `proposal_view`. Show Accept/Reject buttons only if not expired and status not ACCEPTED/REJECTED.
- [ ] `POST /api/proposals/:token/accept` (no auth, body `{name?}`) → `acceptProposal`; `POST /api/proposals/:token/reject` (no auth) → reject. Both via Task 8 logic; emit `proposal_accepted` AE on accept.
- [ ] `GET /unsubscribe?s={hmacToken}` (no auth): recompute HMAC over `{subscriberId}:{tenantId}` and compare with `timingSafeEqual` (NEVER `===`); on match `setSubscriberStatus('UNSUBSCRIBED')` + set `unsubscribed_at`; render confirmation. Invalid/forged token → generic error page, no tenant disclosure.
**Acceptance:**
- [ ] Unsubscribe token verification uses `timingSafeEqual`; forged tokens are rejected.
- [ ] Accept/reject hidden for expired or already-decided proposals.
- [ ] `proposal.viewed`/`proposal.accepted` webhooks fire with documented payloads.

### Task 15: Analytics Engine funnel helper + Resend webhook
**Blocks:** 13, 14, 16  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/lib/funnel-ae.ts`
- Create: `apps/zync-api/src/routes/webhooks-resend.ts`
- Modify: `apps/zync-api/src/index.ts`, `apps/zync-api/wrangler.toml`
**Steps:**
- [ ] `emitFunnelEvent(event, fields)` writes to `ANALYTICS_ENGINE` with blobs `{ event, utmSource, utmMedium, utmCampaign }` and indexes `{ tenantId }`, plus optional `catalogShareId/leadId/proposalId/invoiceId`. Events: `catalog_view`, `lead_captured`, `proposal_accepted`, `invoice_paid`. (Export so invoices-core can emit `invoice_paid` via the shared binding.)
- [ ] `POST /api/webhooks/resend` (no auth, verify Resend signature): on `email.bounced` → `setSubscriberStatus('BOUNCED')`; on `email.complained` → `setSubscriberStatus('COMPLAINED')`, matched by recipient email + tenant.
**Schema / Interfaces:**
```typescript
type FunnelEvent = 'catalog_view'|'lead_captured'|'proposal_accepted'|'invoice_paid';
function emitFunnelEvent(env: Env, event: FunnelEvent, fields: {
  tenantId: string; utmSource?: string; utmMedium?: string; utmCampaign?: string;
  catalogShareId?: string; leadId?: string; proposalId?: string; invoiceId?: string;
}): void;
```
**Acceptance:**
- [ ] `emitFunnelEvent` exported and consumable by the invoices worker (shared `ANALYTICS_ENGINE`).
- [ ] Resend bounce/complaint webhooks transition subscriber status; signature verified.

### Task 16: Marketing analytics API `GET /api/marketing/analytics`
**Blocks:** 20  ·  **Blocked by:** 15
**Files:**
- Create: `apps/zync-api/src/routes/marketing-analytics.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] Query AE (SQL API): `SELECT SUM(_sample_interval) ... WHERE tenantId = ? AND event = ? AND timestamp >= ?` grouped by utm_campaign/utm_source for each of the 4 funnel events, filtered by period + optional campaign.
- [ ] Compute conversion rates **step-relative**: leads/catalog_views, accepted/leads, paid/accepted. NEVER divide by step_1 for step_3/step_4.
- [ ] Return funnel counts, traffic-by-source breakdown, top campaigns (views/leads/conv), and per-campaign drilldown when `?campaign=` set.
- [ ] `requirePermission('marketing:read')`.
**Acceptance:**
- [ ] Each conversion rate's denominator is the immediately prior step's count.
- [ ] Drilldown returns UTM breakdown per source/medium for a campaign.

### Task 17: Catalogs UI — templates, editor, Shares tab
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/pages/marketing/catalogs/CatalogListPage.tsx`
- Create: `apps/zync-app/src/pages/marketing/catalogs/CatalogTemplateEditor.tsx`
- Create: `apps/zync-app/src/pages/marketing/catalogs/CatalogSharesTab.tsx`
- Create: `apps/zync-app/src/hooks/useCatalogTemplates.ts`, `useCatalogShares.ts`
**Steps:**
- [ ] Template list (name, last edited, New template). Section-based editor: Hero (heading/subheading/image R2 upload), About (Tiptap rich text), Items table (name/description/price/image), Pricing summary (total/discount/VAT), CTA (label+URL, external link only — V2 booking deferred), Footer (logo/contact/legal). Inherit brand colors/fonts from tenant settings.
- [ ] `[Editor] | [Shares]` tabs on `/marketing/catalogs/:templateId`. Shares tab: table (Name, Public URL `/c/{token}` with copy-to-clipboard, UTM, Views, Active toggle). Row actions: Copy, Deactivate/Reactivate via `PATCH /api/catalog-shares/:id`, edit name/UTM, `+ New share`.
- [ ] Use `@zync/ui` DataTable, Tabs, Dialog, Form, Button, Badge; `no-raw-html-in-pages`, `no-hardcoded-colors/spacing`.
**Acceptance:**
- [ ] Shares tab lists shares, copies links, and toggles `is_active`.
- [ ] Editor saves section JSONB; CTA is external-link only.

### Task 18: Proposals UI hooks + send dialog (link out to `/proposals`)
**Blocks:** —  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-app/src/components/marketing/SendProposalDialog.tsx`
- Create: `apps/zync-app/src/hooks/useProposals.ts`
- Modify: `apps/zync-app/src/pages/marketing/catalogs/CatalogListPage.tsx` (link to `/proposals`)
**Steps:**
- [ ] Send Proposal dialog (from lead detail or standalone): pick template, override recipient name/items/pricing, set UTM (auto-suggested from `lead.utm_source`), optional expiry; on Send call `POST /api/proposals` then send action.
- [ ] **Do NOT build a proposal list table** — `/proposals` is owned by spec 156 (`proposals-list`). `/marketing/catalogs` links out to `/proposals`.
**Acceptance:**
- [ ] Dialog creates and sends a proposal; UTM pre-fills from lead.
- [ ] No standalone proposal table is rendered here (avoids dead surface superseded by spec 156).

### Task 19: Campaigns + Mailing Lists UI
**Blocks:** —  ·  **Blocked by:** 9, 10
**Files:**
- Create: `apps/zync-app/src/pages/marketing/lists/ListIndexPage.tsx`, `ListDetailPage.tsx`
- Create: `apps/zync-app/src/pages/marketing/campaigns/CampaignListPage.tsx`, `CampaignBuilder.tsx`
- Create: `apps/zync-app/src/hooks/useMailingLists.ts`, `useCampaigns.ts`, `useEmailCredits.ts`
**Steps:**
- [ ] `/marketing/lists` index (Name, Subscribed, Unsub, Bounced, Updated; New list, Import). `/marketing/lists/:id` detail: subscriber table (email, name, status, source, added; search + status filter), import/export CSV, manual add/remove, "clean list" (bulk remove bounced/complained), named segments.
- [ ] Campaign builder: name, subject, mailing-list/segment picker, Tiptap HTML body with merge tags `{{name}}`/`{{unsubscribe_url}}`, UTM auto-append note, preview with sample data. Show credit balance before send. Send now (402 → top-up prompt via billing) or Schedule (date/time picker).
- [ ] `useEmailCredits` reads `GET /api/email-credits`; top-up via `POST /api/email-credits/topup` (routes through billing `getPaymentAdapter`).
**Acceptance:**
- [ ] Builder shows live credit balance and blocks send when insufficient (offers top-up).
- [ ] List detail supports CSV import/export and list cleaning.

### Task 20: Marketing analytics UI `/marketing/analytics`
**Blocks:** —  ·  **Blocked by:** 16
**Files:**
- Create: `apps/zync-app/src/pages/marketing/analytics/MarketingAnalyticsPage.tsx`
- Create: `apps/zync-app/src/hooks/useMarketingAnalytics.ts`
**Steps:**
- [ ] Period + campaign filters. Funnel widget (Catalog Views → Leads → Proposals Acc. → Paid) showing counts AND step-relative rates with explicit `(n/prev)` denominators.
- [ ] Traffic-by-source bar, Top Campaigns table (Views/Leads/Conv), campaign-row click → UTM drilldown.
- [ ] Use `StatCard`, `DataTable`, charts; respect `prefers-reduced-motion` on animated transitions.
**Acceptance:**
- [ ] Displayed conversion rates use step-relative denominators (e.g. 34/89, not 34/1240).
- [ ] Drilldown opens per-campaign UTM breakdown.

### Task 21: Top-up credits via billing integration
**Blocks:** —  ·  **Blocked by:** 5, 9
**Files:**
- Create: `apps/zync-api/src/routes/email-credits.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `GET /api/email-credits` → `getEmailCredits` balance (`marketing:read`).
- [ ] `POST /api/email-credits/topup` (`billing:write`): create a one-time payment via billing `getPaymentAdapter`; on success `applyCreditDelta(+credits, 'top_up_purchase')`.
- [ ] Plan allotment: a billing/subscription hook grants monthly credits (`applyCreditDelta(+allotment, 'plan_allotment')`) — Starter 0, Business 500, Enterprise 2000.
**Acceptance:**
- [ ] Successful top-up payment increments balance with `top_up_purchase` transaction.
- [ ] Monthly plan allotment grants the tier-correct credit amount.

### Task 22: Tenant marketing config + module/tier gate
**Blocks:** —  ·  **Blocked by:** 17, 18, 19, 20, 21
**Files:**
- Create: `apps/zync-app/src/pages/settings/integrations/MarketingSettingsPage.tsx`
- Create: `apps/zync-api/src/routes/marketing-settings.ts`
- Modify: `packages/config/src/modules.ts` (register `marketing` module definition if not present), `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `/settings/integrations/marketing`: enable/disable module (Business+ for forms + campaigns), default UTM source/medium prefix, new-lead notification recipient email.
- [ ] Enforce Business+ gate: forms + campaign routes call `requireTier('business')`; manual pipeline/catalog read stays available to lower tiers.
- [ ] Persist config in tenant settings (existing tenant settings store); `requireModuleEnabled('marketing')` on all marketing routes.
**Acceptance:**
- [ ] Module toggle on/off gates marketing routes via `requireModuleEnabled`.
- [ ] Campaign + form features hard-gated to Business+ with upgrade prompt on lower tiers.
