Hopp til innholdet

DokumentasjonVarsler og e-postMarkdown

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:

HandlerFileReacts to
notifications.operatorsserver/notifications.tsconversation.requested, callback.created, conversation.created (by a visitor), message.created (a visitor's e-mail reply)
notifications.channelsserver/notifications.tsconversation.requested, callback.created, conversation.created
email.visitorsserver/followup.tsmessage.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:

CategoryDefaultWhen
mine — «Noen spør etter meg»e-mail + pushA 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 + pushAny request while nobody on the team is online (checked in and heartbeat < 60 s).
callback — «Nye tilbakeringinger»e-mail + pushEvery new callback request.
conversation — «Nye samtaler når ingen er pålogget»offA 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); 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

MethodPathWho
GET / PATCH/operator/notificationsEvery operator: own preferences, { preferences: { mine: { email, push } } } (partial)
POST / DELETE/operator/notifications/pushStore / remove this browser's PushSubscription.toJSON()
PUT/operator/notifications/devicesThe mobile app: { installationId, pushToken, platform } → settings
DELETE/operator/notifications/devices/:installationIdThe mobile app on sign-out → 204
POST/operator/notifications/test{ channel: "push" | "email" }
GET/operator/channelsAdmin
PUT/operator/channels/:kindAdmin, kind = slack | teams; { url, events, enabled }; url: "" removes
POST/operator/channels/:kind/testAdmin
POST/visitor/conversations/:id/transcriptVisitor; answers { email }
POST/webhooks/resendResend (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.