# Notifications and e-mail follow-up

When a visitor asks for a person, leaves a callback request or starts a conversation and nobody has the inbox open, Snakkbar tells people:

- **Operators**, each by their own choice, by **e-mail** and/or **push**: browser notifications from the hosted panel (web push) and the **Snakkbar mobile app** (iOS/Android). The `push` choice covers both.
- **The team**, in a **Slack** or **Microsoft Teams** channel (admin setting).
- **Visitors**, by **e-mail**: operator replies they missed, a copy of the conversation on request or when it is closed, and they can **reply by e-mail** straight into the conversation.

Everything runs on the domain event outbox (`server/events.ts`). Three handlers are registered there:

| Handler | File | Reacts to |
| --- | --- | --- |
| `notifications.operators` | `server/notifications.ts` | `conversation.requested`, `callback.created`, `conversation.created` (by a visitor), `message.created` (a visitor's e-mail reply) |
| `notifications.channels` | `server/notifications.ts` | `conversation.requested`, `callback.created`, `conversation.created` |
| `email.visitors` | `server/followup.ts` | `message.created` (operator messages), `conversation.closed` |

A failing handler is retried alone about two minutes later (up to 5 attempts). Events older than 30 minutes (a backlog after an outage) wake nobody.

## Who is told about what

The rules are pure functions in `server/notify-rules.ts` (unit tests: `npm run test:server`).

Each operator chooses, under **Innstillinger → Varsler når du er borte**, e-mail and/or browser for four categories:

| Category | Default | When |
| --- | --- | --- |
| `mine` — «Noen spør etter meg» | e-mail + push | A request names the operator or was routed to them (`conversation.requested.operatorIds`), and when a visitor replies by e-mail in a conversation they own or take part in. |
| `unattended` — «Forespørsler når ingen er pålogget» | e-mail + push | Any request while nobody on the team is online (checked in and heartbeat < 60 s). |
| `callback` — «Nye tilbakeringinger» | e-mail + push | Every new callback request. |
| `conversation` — «Nye samtaler når ingen er pålogget» | off | A visitor starts a conversation while nobody is online. |

Always:

- **Nobody in the panel** is notified: a heartbeat within the last 60 s (the panel beats every 25 s while visible) means they hear it there. This is presence, not the «available» switch.
- **Never the person who caused it**, never **deactivated** operators, and e-mail only for operators **with an e-mail address**.
- **Throttled**: at most one notification per operator per conversation (or callback) per 10 minutes, shared by all instances (`snakkbar_throttle`). Team channels likewise per channel, conversation and event. When nothing could be delivered the throttle is released, so the retry is not swallowed.

Links in e-mails and push notifications open the hosted panel on the conversation: `/panel?tenant=<id>&conversation=<id>` (or `&view=callbacks`). The panel keeps the link through the login (`/panel/login?next=…`). Operators provisioned by a customer's backend get the button too once they have signed in to the panel with their e-mail ([api.md](api.md#panel-sign-in-for-provisioned-operators)); until then the mail tells them to open their inbox.

## Web push

- Keys: VAPID (`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`). Encryption (RFC 8291, `aes128gcm`) and VAPID signing (RFC 8292) use Node's crypto only (`server/webpush.ts`).
- Subscriptions live in `push_subscriptions` (one per browser endpoint; whoever turns notifications on in a browser last owns it). Only the browsers' push services are accepted as endpoints (FCM, Mozilla, Apple, Windows). A `404`/`410` from the push service deletes the subscription.
- The service worker is `public/snakkbar-sw.js`, served from the panel's origin and registered with scope `/panel`. Clicking a notification focuses an open panel and opens the conversation without a reload (`postMessage` `snakkbar:open`), or opens `/panel?tenant=…&conversation=…`.
- In Settings the operator presses **«Slå på varsler i denne nettleseren»** (permission prompt → subscribe → `POST /operator/notifications/push`), can **send a test notification**, and turn it off again.
- iPhone/iPad: web push works only after «Legg til på Hjem-skjerm» (iOS 16.4+). Settings says so.

### Headless (embedded) admin

A service worker only works on the origin and path it is served from, so the hosted panel's worker cannot serve an admin embedded on `kunde.no`. To get browser notifications there, the customer:

1. Copies `https://<snakkbar>/snakkbar-sw.js` to their own site, e.g. `https://kunde.no/admin/snakkbar-sw.js`.
2. Passes it to the admin: `Snakkbar.mountAdmin(el, { …, push: { serviceWorker: "/admin/snakkbar-sw.js", scope: "/admin/" } })`.
3. Reads `?conversation=<id>` on the admin page (notification clicks open `<scope>?tenant=…&conversation=…`) and passes it as `mountAdmin({ conversationId })`.

Without `push`, Settings explains that browser notifications work in the Snakkbar panel; e-mail notifications work everywhere. Note that the VAPID key is the deployment's, so subscriptions made on the customer's origin are delivered by the same server.

## Mobile app (iOS/Android)

The Snakkbar operator app (React Native/Expo, separate repository) gets native push through **Expo's push service** (`server/expopush.ts`, no SDK): `POST https://exp.host/--/api/v2/push/send`, JSON arrays of at most 100 messages, 10 s timeout.

- **Registration**: after sign-in (and on every start, so the token stays fresh) the app calls `PUT /operator/notifications/devices` with `{ installationId, pushToken, platform }` (SDK: `notifications.registerDevice`). `installationId` is the app installation's own stable id (8–100 characters `[A-Za-z0-9_-]`), `pushToken` the `ExponentPushToken[…]` from `getExpoPushTokenAsync`, `platform` `ios` or `android`. Not allowed while impersonating.
- **One phone, every tenant**: signed in with Supabase, the phone is registered for **every active operator row** of the person, so it gets alerts from all their tenants (`data.tenantId` says which). With an operator token, only that operator.
- **Rows** live in `mobile_devices`, one per installation and operator (`unique (installation_id, operator_id)`). A phone belongs to whoever signed in on it last: registering removes the same token for other people and operators, this installation's rows for operators no longer in the set, and older installations with the same token.
- **Sign-out**: `DELETE /operator/notifications/devices/:installationId` (SDK: `notifications.unregisterDevice`) removes the installation for all of the person's operator rows (or the token's operator).
- **Delivery**: whenever an operator's `push` choice fires, `pushToOperator` sends to their browsers (only when VAPID is set up) and to their phones (no keys needed). The message has the same title and short quote as web push, `data: { tenantId, conversationId, view }` for the deep link, `sound: "default"`, `channelId: "customer-activity"` (the app creates that Android channel), `priority: "high"`, and `collapseId`/`tag`/`threadId` so repeat alerts for a conversation replace each other. A ticket with `details.error === "DeviceNotRegistered"` deletes the device row; a successful one sets `last_used_at`.
- **Expiry**: rows not re-registered (`updated_at`) in **90 days** are removed by `/internal/cleanup`.
- **Security**: `EXPO_ACCESS_TOKEN` is optional. Set it when *enhanced push security* is turned on for the Expo project; then it is sent as `Authorization: Bearer …`.
- **Test**: `POST /operator/notifications/test` `{ channel: "push" }` works with only phones and no VAPID.
- **Later**: Expo push *receipts* (`/push/getReceipts`, ~15 minutes after sending) are not polled yet; they would catch `DeviceNotRegistered` reported only by Apple/Google after the ticket and credential problems.

## Team channels (Slack, Microsoft Teams)

Admins add, under **Innstillinger → Teamkanaler**, an incoming-webhook address per service and pick what to post: requests for a person, callback requests, new conversations. **«Send test»** posts a test message. Addresses are validated (https only, the services' own hosts: `hooks.slack.com/services/…` for Slack; `*.webhook.office.com`, `*.logic.azure.com`, `*.api.powerplatform.com` for Teams) and are only ever shown to admins (anyone with the URL can post). Posts are in Norwegian and link to the panel. Teams gets an Adaptive Card (works with Workflows webhooks and classic incoming webhooks); Slack gets Block Kit.

## E-mail with visitors

All of it needs the visitor's e-mail address (from the site via `setUser`/`PATCH /visitor/me`, a callback form, or the transcript form).

### Missed replies («Send svar på e-post når kunden har gått», on by default)

1. An operator message (`message.created`, role operator) queues a follow-up for the conversation (`email_followups`): due 2 minutes after the latest operator message, at most 10 minutes after the first — several quick messages become one mail.
2. The events cron (every minute) sends what is due (`sendDueFollowUps`). It mails only operator messages the visitor has **not read** (after `visitor_read_at`; an open chat marks what it shows as read), and only when the visitor has **not been seen for 3 minutes**. Still on the site: checked again 3 minutes later (given up after 12 hours).
3. The mail is in the visitor's language (Norwegian or English, with the stored translations of the messages), from the tenant's name, with a button back to the page the conversation was on, and a reply-to address (below).

### A copy of the conversation

- Visitors: the chats drawer (☰) in the widget has **«Send samtalen på e-post»** when e-mail is set up (`PublicConfig.emailTranscript`). The form asks for the address (prefilled when known), saves it to the visitor's profile with the existing profile API, then calls `POST /visitor/conversations/:id/transcript` (SDK `sendTranscript`). Identified visitors keep the address their site set. Limited to 3 per conversation per hour.
- Admins can turn on **«Send en kopi av samtalen når den avsluttes»** (`transcriptOnClose`): closing a conversation mails the visitor a copy (once per 10 minutes, and pending follow-ups for it are dropped).

### Replies by e-mail

Every visitor mail has a reply-to address that cannot be guessed: `c-<conversationId>.<hmac>@<SNAKKBAR_INBOUND_DOMAIN>` (HMAC-SHA256 with `SNAKKBAR_TOKEN_SECRET`, 80 bits, lowercase). Resend receives the reply and calls `POST /api/v1/webhooks/resend` (Svix signature checked with `RESEND_WEBHOOK_SECRET`, 5 minutes skew). The server then:

1. Finds the conversation from the address (`to`, `cc`, `received_for`); anything else is ignored with `200`.
2. Fetches the full mail from Resend (`GET /emails/receiving/:id`; the webhook carries only metadata), drops automatic replies (`Auto-Submitted`, `Precedence: bulk`, «Automatisk svar», «Fravær», …), and keeps only the new text: quoted history («On … wrote:», «Den … skrev:», Outlook headers, `>` lines), signatures and «Sendt fra min iPhone» are stripped.
3. Stores it as a visitor message with `meta.channel = "email"` (idempotent on the Resend e-mail id), translates it for staff, and reopens a closed conversation.
4. The admin transcript marks it **«Via e-post»** (`Message.channel`).
5. Who hears about it:
   - **A person owns the conversation** (`status = human`): the owner and participants are notified (category `mine`). The assistant does not answer.
   - **AI-owned (or reopened) conversation**: the reply is routed to people (`requestPeople`, which writes «… har fått beskjed.» and emits `conversation.requested`). **The assistant never answers e-mail replies**: its answers would go out unreviewed by e-mail, and two mail robots could answer each other forever. A person answers in the inbox; if the visitor is gone, the answer goes back by e-mail through the follow-up above, so the thread continues.

Replies are limited to 30 per conversation per hour.

## API

| Method | Path | Who |
| --- | --- | --- |
| `GET` / `PATCH` | `/operator/notifications` | Every operator: own preferences, `{ preferences: { mine: { email, push } } }` (partial) |
| `POST` / `DELETE` | `/operator/notifications/push` | Store / remove this browser's `PushSubscription.toJSON()` |
| `PUT` | `/operator/notifications/devices` | The mobile app: `{ installationId, pushToken, platform }` → settings |
| `DELETE` | `/operator/notifications/devices/:installationId` | The mobile app on sign-out → `204` |
| `POST` | `/operator/notifications/test` | `{ channel: "push" \| "email" }` |
| `GET` | `/operator/channels` | Admin |
| `PUT` | `/operator/channels/:kind` | Admin, `kind` = `slack` \| `teams`; `{ url, events, enabled }`; `url: ""` removes |
| `POST` | `/operator/channels/:kind/test` | Admin |
| `POST` | `/visitor/conversations/:id/transcript` | Visitor; answers `{ email }` |
| `POST` | `/webhooks/resend` | Resend (signed) |

Tenant settings (`PATCH /operator/tenant`): `emailFollowUp` (default on), `transcriptOnClose` (default off).

## Owner setup

Everything is optional: without the variables, nothing is mailed or pushed, and the UI says what is missing.

### 1. Sending e-mail (Resend)

1. Create an account at resend.com and add a sending domain, e.g. `snakkbar.app` (or a subdomain like `mail.snakkbar.app`): **Domains → Add domain**, then add the DNS records Resend shows (an MX and a TXT/SPF record on `send.<domain>`, and the DKIM TXT record `resend._domainkey`). Optionally add DMARC: `_dmarc` TXT `v=DMARC1; p=none;`. Wait until the domain is verified.
2. **API Keys → Create API key** with *Full access* (sending and reading received mail).
3. Set in Vercel (Production, and Preview if wanted):
   - `RESEND_API_KEY=re_…`
   - `SNAKKBAR_EMAIL_FROM="Snakkbar <varsler@snakkbar.app>"` — the address must be on the verified domain. Mails to visitors use the tenant's name with this address.
   - `SNAKKBAR_URL=https://snakkbar.app` if links should not use Vercel's production URL.
4. Test: in the panel, **Innstillinger → Varsler når du er borte → Send test-e-post**.

### 2. Receiving replies (Resend inbound)

1. Pick a subdomain only for replies, e.g. `svar.snakkbar.app` (do not use a domain that already receives mail).
2. In Resend: **Domains → (the domain) → enable Receiving** (or add `svar.snakkbar.app` as a domain with receiving), and add the **MX record** Resend shows for that subdomain (e.g. `svar` MX `10 inbound-smtp.<region>.amazonaws.com`, exactly as displayed). Wait for verification.
3. **Webhooks → Add endpoint**: URL `https://snakkbar.app/api/v1/webhooks/resend`, event **`email.received`**. Copy the signing secret (`whsec_…`).
4. Set `SNAKKBAR_INBOUND_DOMAIN=svar.snakkbar.app` and `RESEND_WEBHOOK_SECRET=whsec_…`, then redeploy.
5. Test: start a chat with an e-mail in the visitor profile, ask for a copy («Send samtalen på e-post»), reply to the mail; the reply shows up «Via e-post» in the inbox.

### 3. Web push (VAPID)

1. `node scripts/generate-vapid-keys.mjs` prints a key pair. Generate it **once**: new keys invalidate every browser's subscription (operators must turn notifications on again).
2. Set `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` and `VAPID_SUBJECT=mailto:drift@snakkbar.app` (a contact for the push services), then redeploy.
3. Test: in the panel, **Slå på varsler i denne nettleseren → Send testvarsel**.

### 4. Mobile app (Expo)

Nothing is required: Expo's push service delivers to the app's tokens. If *enhanced push security* is enabled for the Expo project, create an access token at expo.dev (**Account settings → Access tokens**) and set `EXPO_ACCESS_TOKEN`. Test: sign in in the app, allow notifications, **Send testvarsel**.

### 5. Slack

1. In Slack: **Apps → Incoming Webhooks** (or api.slack.com/apps → *Create New App → Incoming Webhooks → Activate*), **Add New Webhook to Workspace**, pick the channel.
2. Copy the address (`https://hooks.slack.com/services/…`) into **Innstillinger → Teamkanaler → Slack**, choose the events, **Lagre**, then **Send test**.

### 6. Microsoft Teams

1. In the Teams channel: **··· → Workflows (Arbeidsflyter) → «Post to a channel when a webhook request is received» / «Send webhook-varsler til en kanal»**, choose team and channel, and copy the address the workflow gives (Office 365 *Incoming Webhook* connector addresses on `*.webhook.office.com` work too, while Microsoft still runs them).
2. Paste it into **Innstillinger → Teamkanaler → Microsoft Teams**, **Lagre**, **Send test**.

## Data and retention

- New tables (RLS on, no policies; server only): `notification_preferences`, `push_subscriptions`, `notification_channels`, `notification_throttle`, `email_followups`. New tenant columns: `email_follow_up`, `transcript_on_close`. Migration: `supabase/migrations/20261017110000_notifications.sql`.
- `mobile_devices` (same rules; `supabase/migrations/20261018090000_mobile_devices.sql`) goes with its operator and tenant; rows not re-registered in 90 days are removed by `/internal/cleanup`.
- Preferences and push subscriptions go with their operator, channels with the tenant, follow-ups with their conversation (all `on delete cascade`). `snakkbar_throttle` sweeps keys older than two days itself, so no cleanup job is needed.
- The e-mail reply's sender and subject are stored in the message's `meta.email`; the mail itself stays at Resend.
