# System: Communications, Notifications & Webhook Gateway

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-monorepo`, `foundation-auth-rbac`  
**Referenced by:** `crm-support-center`, `tasks-detail-communication`, `invoices-core`, `ai-assistant`, `settings-module`

---

## Overview

Three subsystems:
1. **Communications adapters** — send and receive messages via Email, Telegram, Slack, WhatsApp
2. **In-app notifications** — per-user notification inbox with read/unread state
3. **Rate limiting middleware** — KV-backed rate limiter used by auth and API routes

Outbound webhook gateway: owned by `white-label-api` spec (spec 27). This spec emits events into the delivery queue; that spec owns schema, retry policy, and event catalog.

---

## 1. Communications Adapters

### Adapter Interface

```ts
// packages/types/src/comms-adapter.ts
interface CommsAdapter {
  id: string
  name: string
  send(message: OutboundMessage): Promise<void>
  receive?(payload: unknown): InboundMessage | null  // for bot adapters
}

interface OutboundMessage {
  to: string           // email address, chat_id, channel, etc.
  subject?: string     // email only
  body: string         // plain text fallback
  html?: string        // email: HTML body; Telegram: HTML parse mode
  attachments?: Attachment[]
}

interface InboundMessage {
  from: string
  text: string
  chatId: string
  metadata: Record<string, unknown>
}
```

### Notification Adapter Interface

Separate from `CommsAdapter` — `NotificationAdapter` is delivery-only (no receive), typed to `NotificationType`, and includes delivery eligibility check. Two real implementations exist: email and Telegram.

```ts
// packages/notifications/src/adapter.ts
interface NotificationAdapter {
  readonly id: 'email' | 'telegram' | 'push'
  /** Returns true if this adapter can reach this user for this event type */
  canDeliver(userId: string, type: NotificationType): Promise<boolean>
  /** Formats and sends the notification. Throws on hard failure; silently no-ops if canDeliver would return false. */
  deliver(userId: string, notification: DeliverableNotification): Promise<DeliveryResult>
}

interface DeliverableNotification {
  type: NotificationType
  title: string
  body: string
  entityType?: string
  entityId?: string
  actionButtons?: ActionButton[]   // adapter renders or drops as appropriate
}

interface ActionButton {
  label: string
  url?: string           // deep link (email + group/list Telegram)
  callbackAction?: string // encoded action for inline keyboard (DM Telegram only)
}

interface DeliveryResult {
  delivered: boolean
  error?: string
}
```

Delivery pipeline fans out across all registered adapters:
```ts
// packages/notifications/src/deliver.ts
const ADAPTERS: NotificationAdapter[] = [emailAdapter, telegramAdapter, webPushAdapter]

async function deliverNotification(userId: string, notification: DeliverableNotification) {
  await Promise.allSettled(
    ADAPTERS.map(async (adapter) => {
      if (await adapter.canDeliver(userId, notification.type)) {
        await adapter.deliver(userId, notification)
      }
    })
  )
}
```

`EmailNotificationAdapter`, `TelegramNotificationAdapter`, and `WebPushNotificationAdapter` each implement `NotificationAdapter`. Adding another (WhatsApp, Slack) requires implementing the interface only — zero changes to delivery pipeline.

### Web Push Adapter

Browser push notifications via the Web Push Protocol (RFC 8030) with VAPID authentication (RFC 8292). No third-party push service required — Cloudflare Workers send pushes directly to browser push endpoints.

**VAPID keys:**

```
VAPID_PRIVATE_KEY   -- base64url-encoded EC private key (P-256); generated once, stored as env secret
VAPID_PUBLIC_KEY    -- base64url-encoded EC public key; exposed to frontend via GET /api/push/vapid-public-key
```

Generate: `web-push generate-vapid-keys --json` (run once during infra setup; store private in Wrangler secrets).

**Schema:**

```sql
CREATE TABLE push_subscriptions (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  endpoint    TEXT NOT NULL UNIQUE,      -- browser push endpoint URL
  p256dh      TEXT NOT NULL,            -- client public key (base64url)
  auth        TEXT NOT NULL,            -- client auth secret (base64url)
  user_agent  TEXT,                     -- for display in preferences UI
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_push_subs_user ON push_subscriptions(user_id, tenant_id);
```

**API:**

```
GET  /api/push/vapid-public-key
     → { publicKey: string }   (public VAPID key for ServiceWorker registration)

POST /api/push/subscribe
     Auth: session
     body: { endpoint, keys: { p256dh, auth }, userAgent? }
     Action: upsert push_subscriptions row (endpoint is unique — re-subscribe if already exists)
     Response: 201

DELETE /api/push/subscribe
       Auth: session
       body: { endpoint }
       Action: delete matching push_subscriptions row
       Response: 204
```

**ServiceWorker integration (`apps/zync-app/public/sw.js`):**

```js
self.addEventListener('push', event => {
  const data = event.data.json()
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/zync-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url },
      tag: data.tag,   // collapses duplicate notifications of same type
    })
  )
})

self.addEventListener('notificationclick', event => {
  event.notification.close()
  event.waitUntil(clients.openWindow(event.notification.data.url))
})
```

**WebPushNotificationAdapter delivery:**

```ts
// packages/notifications/src/adapters/web-push.ts
class WebPushNotificationAdapter implements NotificationAdapter {
  readonly id = 'push'

  async canDeliver(userId: string, type: NotificationType): Promise<boolean> {
    // Check user has at least one push_subscriptions row
    // AND the notification type is in user_preferences.notification_channels.push (if user has opted specific types)
    const count = await db.query('SELECT COUNT(*) FROM push_subscriptions WHERE user_id = $1', [userId])
    return count > 0
  }

  async deliver(userId: string, notification: DeliverableNotification): Promise<DeliveryResult> {
    const subs = await db.query('SELECT * FROM push_subscriptions WHERE user_id = $1', [userId])
    const results = await Promise.allSettled(
      subs.map(sub => sendWebPush(sub, notification, env.VAPID_PRIVATE_KEY, env.VAPID_PUBLIC_KEY))
    )
    // Prune subscriptions that returned 410 Gone (browser unsubscribed)
    await pruneExpiredSubscriptions(results, subs)
    return { delivered: results.some(r => r.status === 'fulfilled') }
  }
}
```

**Web push payload format:**

```json
{
  "title": "Invoice INV-0042 paid",
  "body": "Acme Corp paid ₪12,700",
  "url": "https://app.zync.is/invoices/uuid-...",
  "tag": "invoice_paid_uuid-...",
  "type": "invoice_paid"
}
```

**Opt-in UX:** Browser push requires explicit user permission. On first login, after 30 seconds, a soft prompt appears: "Enable desktop notifications for invoices and tasks?" → [Enable] [Not now]. [Enable] calls `Notification.requestPermission()` then `POST /api/push/subscribe`. Permission state cached in `localStorage`; no re-prompt if denied.

### Email adapters

v1: transactional email via **Resend** (REST API, Cloudflare Workers native, no SMTP needed for system emails). Tenant-configurable SMTP as bring-your-own option.

| Adapter | Use case | Config location |
|---------|---------|----------------|
| Resend | System emails (verification, invites, password reset, notifications) | Env secret `RESEND_API_KEY` |
| SMTP | Tenant outbound (custom from-address for CRM replies) | Tenant settings (encrypted in DB) |
| Gmail OAuth | Tenant inbound + outbound (read tickets from Gmail inbox) | Tenant settings, OAuth flow |
| Outlook 365 | Same as Gmail | Tenant settings, OAuth flow |

Gmail/Outlook OAuth: initiated from `/settings/integrations/communications`. Access token + refresh token stored encrypted in `adapter_credentials` table (`adapter_id: 'gmail'` / `'outlook'`; AES-256-GCM, `INTEGRATION_ENCRYPTION_KEY` env secret).

### Telegram bot adapter

Each tenant creates their own bot via BotFather and provides the token in Zync settings. No global Zync bot.

**Setup flow:**
1. Tenant creates bot at `t.me/BotFather` → `/newbot` → copies token
2. Pastes token at `/settings/integrations/communications` → Zync validates via `getMe`, then saves encrypted in `adapter_credentials` (`adapter_id: 'telegram'`)
3. Zync auto-calls `setWebhook`: `https://api.telegram.org/bot{TOKEN}/setWebhook?url=https://zync.is/api/webhooks/telegram/{tenantId}`
4. On disconnect: Zync calls `deleteWebhook` and removes token from `adapter_credentials`

Inbound (webhook from Telegram):
- `POST /api/webhooks/telegram/{tenantId}` — tenant-scoped URL; token looked up from `adapter_credentials`
- Message → parsed → dispatched to `inbound_message_handler` queue entry
- Queue consumer: routes to CRM ticket creation, AI assistant, or support flow based on tenant config

Outbound:
- Telegram Bot API `sendMessage` / `sendDocument` using tenant's own bot token

Gated: `requireTier('business')` — Telegram integration available on Business tier and above.

### Slack adapter

- OAuth app installation from `/settings/integrations/communications`
- Inbound: slash commands + event subscriptions → `POST /api/webhooks/slack`
- Outbound: `chat.postMessage` via Bot token

### WhatsApp adapter (Enterprise only)

- WhatsApp Business API (Meta) — tenant provides their own WABA token
- Inbound: webhook `POST /api/webhooks/whatsapp/{tenantId}`
- Outbound: `POST https://graph.facebook.com/v*/messages`
- Gated: `requireTier('enterprise')` middleware

### Inbound message routing (`packages/db/src/queries/inbound.ts`)

```ts
// Queue consumer resolves what to do with an inbound message
async function routeInboundMessage(msg: InboundMessage, tenantConfig: TenantCommsConfig) {
  if (tenantConfig.autoCreateTickets) → createSupportTicket(msg)
  if (tenantConfig.aiAssistantEnabled) → dispatchToAiAssistant(msg)
}
```

---

## 2. In-App Notifications

### Data model

```sql
notifications (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  user_id UUID NOT NULL,
  type TEXT NOT NULL,         -- see NotificationType enum
  title_key TEXT NOT NULL,    -- i18n key; rendered client-side (see i18n section)
  body_key TEXT,              -- i18n key
  params JSONB DEFAULT '{}',  -- interpolation params for the keys
  entity_type TEXT,           -- 'task' | 'invoice' | 'ticket' | etc.
  entity_id UUID,
  read_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT now()
)

-- Index for inbox query:
CREATE INDEX notifications_inbox ON notifications (tenant_id, user_id, read_at, created_at DESC);
```

### Notification types

**Notification types:** see spec 97 (`notification-preferences`) for the canonical `NotificationType` union. This spec does **not** re-define the enum — it imports it. The delivery layer here (`NotificationAdapter`) is typed against that union and dispatches every type.

The following types are emitted **into** this delivery layer (this spec dispatches them; the listed types originate in the modules noted):

```ts
import type { NotificationType } from '@zync/types' // canonical union — spec 97

// Routed through this delivery layer:
//   'task_assigned' | 'task_comment'   — originate in the tasks module (project-tasks)
//   'user_approved' | 'user_frozen'    — originate in this spec's own account-lifecycle handlers
//                                        (membership approval / freeze)
// Every NotificationType value flows through this delivery layer regardless of origin;
// see spec 97 for the full taxonomy and each type's owning module.
```

### Delivery

Notifications are created by API route handlers (synchronously — no queue for in-app). They are *read* by the frontend via polling or WebSocket push.

**WebSocket delivery**: when a notification is created, if the target user has an active WebSocket connection (tracked in KV: `ws_session:{userId}:{tenantId}` → Worker Durable Object ID), push the notification immediately.

WebSocket infrastructure: Cloudflare Durable Objects. One DO per tenant for real-time multiplexing (tasks + notifications share the same DO connection). See `tasks-detail-communication` spec for the DO architecture — notifications piggyback on it.

### API endpoints

```
GET  /api/notifications          → list unread (+ last 20 read)
POST /api/notifications/read-all → mark all read
PATCH /api/notifications/:id/read → mark one read
```

### Frontend notification dropdown

- Bell icon in Header with unread badge count
- Dropdown shows last 20 notifications, grouped by today / earlier
- Each item: icon (by type), title, body, entity link, timestamp
- "Mark all read" action
- Unread count refreshed every 30s (polling fallback) + WebSocket push

### Delivery preferences (`user_preferences.notification_channels`)

```ts
interface NotificationPreferences {
  email: NotificationType[]       // which types to also email
  telegram: NotificationType[]    // which types to also send via Telegram
}
```

User-configurable from Profile → Alert settings.

---

## Locale & Content Localization

### Email locale resolution

System emails (invitations, password reset, verification) and notification emails must be sent in the correct language. Resolution order:

| Email type | Locale source |
|------------|---------------|
| System emails to staff (e.g. task assigned, invoice paid) | Tenant default locale (`tenants.locale`) |
| Transactional emails to customers (e.g. invoice sent, proposal viewed) | Customer's portal locale preference (`portal_users.locale`), fallback to tenant default |
| Auth emails (password reset, verification — before tenant context exists) | Browser `Accept-Language` header, fallback to `he` |

**RTL emails:** For `locale = 'he'`, the email HTML body must include `dir="rtl"` on `<body>`:

```html
<!-- Resend / SMTP email templates -->
<body dir="rtl" lang="he" style="font-family: 'Heebo', Arial, sans-serif;">
```

Resend supports `dir="rtl"` in HTML emails. Test with Gmail, Outlook, and Apple Mail — all three render RTL Hebrew correctly when `dir="rtl"` is set on `<body>`.

Email templates live in `packages/notifications/src/templates/` — one file per locale per type (e.g. `invoice-sent.he.mjml`, `invoice-sent.en.mjml`). The `EmailNotificationAdapter` selects template by `locale`.

#### Locale Parameter in sendEmail

The `sendEmail` function must accept an explicit `locale` parameter. The adapter uses it to select the correct template and to set `<html lang>` and `dir` on the generated email HTML independently of the template key lookup.

```ts
interface SendEmailOptions {
  to: string
  templateKey: string  // e.g., 'invoice_sent_he', 'invoice_sent_en'
  vars: Record<string, string>
  locale: 'he-IL' | 'en-US'
}
// Default when not provided: tenant's settings.locale
// Never default to 'en-US' — IL-first market is Hebrew
```

### Notification body localization

In-app notifications **never store pre-rendered text** — the `notifications` table carries `title_key`, `body_key`, and `params` (see Data Model), rendered in the viewer's locale at read time.

Example: instead of `"Invoice INV-0042 was paid"`, the row holds:

```json
{
  "title_key": "notification.invoice_paid.title",
  "body_key": "notification.invoice_paid.body",
  "params": { "invoiceNumber": "INV-0042", "amount": "₪1,200" }
}
```

Frontend renders: `t(notification.title_key, notification.params)` and `t(notification.body_key, notification.params)` via `react-i18next`. `body_key` may be null (title-only notifications).

This approach enables locale switching without re-sending notifications. All 30+ `NotificationType` values must have matching i18n keys in `packages/ui/src/locales/en.json` and `he.json`.

## 3. Rate Limiting Middleware

Used by auth routes and (optionally) module API routes. Uses CF native `RateLimiter` binding (Workers Paid, no KV quota consumed).

```toml
# wrangler.toml — add per env
[[unsafe.bindings]]
name = "RATE_LIMITER_AUTH"
type = "ratelimit"
namespace_id = "1001"
simple = { limit = 10, period = 60 }

[[unsafe.bindings]]
name = "RATE_LIMITER_WEBHOOK"
type = "ratelimit"
namespace_id = "1002"
simple = { limit = 100, period = 60 }
```

```ts
// packages/auth/src/rate-limit.ts
export function rateLimit(binding: RateLimit, key: string) {
  return async (c: Context) => {
    const { success } = await binding.limit({ key })
    if (!success) return c.json({ error: 'Too many requests' }, 429)
  }
}
```

Applied to:
- `POST /api/auth/login` — 10 attempts / 15min / IP (`RATE_LIMITER_AUTH`)
- `POST /api/auth/signup` — 5 / 1h / IP (`RATE_LIMITER_AUTH`)
- `POST /api/auth/reset-password` — 3 / 1h / email (`RATE_LIMITER_AUTH`)
- `POST /api/webhooks/*` — 100 / 1min / IP (`RATE_LIMITER_WEBHOOK`)

Native rate limiting is purpose-built: atomic sliding window, no KV reads/writes, no quota impact.

---

## Secrets

| Secret | Purpose |
|--------|---------|
| `RESEND_API_KEY` | Transactional email |
| `INTEGRATION_ENCRYPTION_KEY` | AES-256-GCM for all per-tenant adapter credentials (Gmail/Outlook OAuth tokens, Telegram bot tokens, SMTP passwords) — stored in `adapter_credentials` table |

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| System email | Resend | Native REST API, Workers-compatible, simple pricing |
| Inbound routing | Cloudflare Queues | Decouples webhook receipt from processing; retryable |
| WebSocket infra | Durable Objects | CF-native; shared with tasks real-time (one connection per tenant) |
| Notification delivery | In-app sync + WS push + `NotificationAdapter` fanout | Simple; email/Telegram as opt-in per type; `NotificationAdapter` interface encapsulates per-channel delivery — two real implementations (email, Telegram) make the seam load-bearing |
| Rate limit store | CF native RateLimiter | Workers Paid built-in; atomic sliding window; zero KV quota impact |
| Telegram BYOT | Per-tenant bot token, no global bot | Enables tenant-branded bot identity; avoids single global token as SPoF; aligns with WhatsApp/Slack BYOT pattern |
| Webhook gateway | Owned by `white-label-api` (spec 27) | Prevents dual-ownership drift on schema and retry policy |
