docs/BENTO_EMAIL_PREFERENCES_SETUP.md
Rama main · réplica de lectura del repositorio original.
# Bento Configuration for Email Preferences
This document describes the Bento setup required to support the granular email notification preferences system.
## Overview
The email preferences system uses Bento tags to control which users receive specific email types. When a user disables a preference, a `_disabled` tag is added to their Bento profile. Bento automations should be configured to exclude users with these disabled tags.
### Two Levels of Preferences
1. **User-level preferences** (`users.email_preferences`): Controls which emails individual admin users receive
2. **Organization-level preferences** (`orgs.email_preferences`): Controls which emails are sent to the organization's management email (when different from admin emails)
## Disabled Tags
The following tags are automatically synced to Bento when users update their email preferences:
| Preference Key | Bento Tag (when disabled) | Description |
|----------------|---------------------------|-------------|
| `usage_limit` | `usage_limit_disabled` | Plan usage threshold alerts (50%, 70%, 90%) |
| `credit_usage` | `credit_usage_disabled` | Credit usage threshold alerts (50%, 75%, 90%, 100%) |
| `onboarding` | `onboarding_disabled` | Onboarding reminder emails |
| `weekly_stats` | `weekly_stats_disabled` | Weekly statistics emails |
| `monthly_stats` | `monthly_stats_disabled` | Monthly creation statistics |
| `billing_period_stats` | `billing_period_stats_disabled` | Billing period usage statistics with upgrade recommendations |
| `deploy_stats_24h` | `deploy_stats_24h_disabled` | 24-hour deploy install statistics |
| `bundle_created` | `bundle_created_disabled` | New bundle upload notifications |
| `bundle_deployed` | `bundle_deployed_disabled` | Bundle deployment notifications |
| `device_error` | `device_error_disabled` | Device update error notifications |
## How It Works
1. **When user DISABLES a preference**: The corresponding `_disabled` tag is ADDED to their Bento profile
2. **When user ENABLES a preference**: The corresponding `_disabled` tag is REMOVED from their Bento profile
3. **Default behavior**: All preferences default to enabled (no disabled tags)
## Bento Automation Configuration
For each email automation in Bento, you need to add a filter to exclude users with the corresponding disabled tag.
### Step-by-Step Setup
For each automation listed below, add a segment filter:
#### 1. Usage Limit Alerts (50%, 70%, 90%)
**Events**: `user:usage_50_percent_of_plan`, `user:usage_70_percent_of_plan`, `user:usage_90_percent_of_plan`, `user:upgrade_to_*`, `user:enterprise_above_plan`, `user:native_build_concurrency_limit`
**Filter to add**:
```text
Tag does NOT contain: usage_limit_disabled
```
`user:native_build_concurrency_limit` is emitted when an org tries to start more concurrent native builds than their plan allows. Event data includes `active_builds`, `limit`, `plan_name`, and `upgrade_url`. Wire a Bento automation to this event when the upgrade email is ready.
#### 2. Credit Usage Alerts
**Events**: `org:credits_usage_50_percent`, `org:credits_usage_75_percent`, `org:credits_usage_90_percent`, `org:credits_usage_100_percent`
**Filter to add**:
```text
Tag does NOT contain: credit_usage_disabled
```
#### 3. Onboarding Emails
**Events**: `user:need_onboarding`
**Filter to add**:
```text
Tag does NOT contain: onboarding_disabled
```
#### 3a. First-Organization Recovery
**Entry event**: `user:registered_without_org`
**Exit or goal event**: `user:joined_org`
Capgo adds `onboarding:awaiting_first_org` when a direct registration still has no active organization access. When Capgo observes active direct organization access, it removes that tag and permanently adds `onboarding:first_org_recovery_suppressed` in the same subscriber update. The permanent tag keeps recovery disabled even if Bento applies an older asynchronous registration import afterward. A normalized email-address change permanently adds the same suppression tag to both the old and new Bento aliases so asynchronous subscriber updates can never send this recovery email to a stale address. Deleting a user applies the same permanent suppression and unsubscribes the Bento subscriber before organization-dependent cleanup can return.
Configure the workflow to enroll each subscriber at most once. Queue delivery is at least once, so both lifecycle events can repeat for the same user. Treat `user:joined_org` as a fact used to exit or complete the workflow, not as another entry trigger; the subscriber-level enrollment rule makes repeated `user:registered_without_org` facts harmless.
After the workflow delay, add a final decision immediately before the send step. All three conditions must still be true:
```text
Tag contains: onboarding:awaiting_first_org
Tag does NOT contain: onboarding:first_org_recovery_suppressed
Tag does NOT contain: onboarding_disabled
```
Do not check the suppression tag only when the workflow starts. It is a monotonic safety opt-out that can be added while the workflow is waiting.
Keep this recovery email non-transactional so Bento's global unsubscribe state remains a delivery gate.
#### 3b. Invite-to-org reminders
Capgo sends the initial invitation itself (Cloudflare Email). After a successful send it emits one Bento event per invitation:
**Entry event**: `org:invite_sent`, with `details.invite_id`, `details.kind` (`new` or `existing`), `details.org_id`, `details.org_name`, `details.org_name_subject`, `details.org_admin_name`, `details.invited_first_name`, `details.invited_last_name`, and `details.invite_link`.
**Open-invites field**: `capgo_open_invites`, a space-separated list of the recipient's invite ids that are still open. The `org:invite_sent` call sets it, and Capgo rewrites it whenever an invitation is accepted, cancelled, rescinded, or removed.
The flow (kept as JSON in `Cap-go/bento-unofficial-api`, `bento-json-flows/capgo-invitation-reminders.json`) owns the timing and checks the field before each reminder:
```liquid
{% assign open_invites = visitor.capgo_open_invites | default: "" | split: " " %}{% if open_invites contains event.details.invite_id %}true{% else %}false{% endif %}
```
Deploy that flow alongside the application change. Until it is deployed, Bento accepts `org:invite_sent` events but sends no invitation reminders. Enable **Repeatable** in Bento so separate invitations for one recipient can each start a flow; JSON format version 1 does not manage this setting. Confirm that the field check reads the subscriber's current field after each delay.
Enrollment uses at-least-once delivery. Confirmed acceptance is recorded per invitation generation, but a lost Bento response or failed database commit can cause a retry to start a duplicate flow. The [Bento Events API](https://bentonow.com/docs/events_api) does not document idempotency for custom flow events; purchase `unique.key` is not a documented flow-enrollment guarantee. Both runs still stop when that invitation leaves `capgo_open_invites`.
Bento enrollment holds only a per-subscriber lock during the provider call. Invitation closing commits independently and queues subscriber reconciliation in the same transaction. That sync waits behind enrollment, then reads current invitation state before updating the field.
`org:invite_accepted` is still added on acceptance for older Bento segments, but the reminder flow no longer depends on it.
#### 3c. Public email preference footer link
Use the Capgo preference URL in every Bento footer. **Do not put the visitor email in the query string** — GET URLs leak via logs, history, and Referer.
Compliant URL (new sends):
```text
https://console.capgo.app/email-preferences?uuid={{ visitor.uuid }}
```
Legacy URL (already-sent emails; still works):
```text
https://console.capgo.app/email-preferences?email={{ visitor.email }}
```
Do **not** use `?email={{ visitor.uuid }}` — the console treats UUID-shaped `email` values as a Bento visitor id and resolves them server-side, but the Email field stays empty unless that lookup returns an address. Use the `uuid` query param for new footers.
- Prefills the address only — the visitor chooses what to disable
- `uuid` is resolved server-side via Bento `GET /fetch/subscribers` so the email never appears in the page URL
- Save returns an error when a Bento visitor id cannot be resolved; explicit email saves always return the same success shape (no Capgo account oracle)
- This public path is **opt-out only** (cannot re-enable prefs; use logged-in settings for that)
- “Unsubscribe from all” calls Bento unsubscribe for that address
- Cloudflare Turnstile required when `CAPTCHA_SECRET_KEY` / `VITE_CAPTCHA_KEY` are set (same as invite/login)
- Rate limits: ~20 / 15m per IP, ~10 / 15m per email or uuid hash
- If Bento subscriber lookup fails (outage or misconfiguration), GET/POST return `503` with `email_preferences_unavailable` instead of silently succeeding
- Update Bento footers to the `uuid` URL. Leave the `email` query working for mail already in inboxes.
#### 4. Weekly Statistics
**Events**: `user:weekly_stats`
**Filter to add**:
```text
Tag does NOT contain: weekly_stats_disabled
```
#### 5. Monthly Statistics
**Events**: `org:monthly_create_stats`
**Filter to add**:
```text
Tag does NOT contain: monthly_stats_disabled
```
#### 6. Billing Period Statistics
**Events**: `org:billing_period_stats`
**Filter to add**:
```text
Tag does NOT contain: billing_period_stats_disabled
```
#### 7. Deploy Install Statistics (24h)
**Events**: `bundle:install_stats_24h`
**Filter to add**:
```text
Tag does NOT contain: deploy_stats_24h_disabled
```
#### 8. Bundle Created Notifications
**Events**: `bundle:created`
**Filter to add**:
```text
Tag does NOT contain: bundle_created_disabled
```
#### 9. Bundle Deployed Notifications
**Events**: `bundle:deployed`
**Filter to add**:
```text
Tag does NOT contain: bundle_deployed_disabled
```
#### 10. Device Error Notifications
**Events**: `device:upgrade_blocked`, `device:downgrade_blocked`
Emitted from `/updates` when a device is blocked by channel auto-update version policy (upgrade: major/minor/patch/metadata; downgrade: under_native). Delivery uses `sendNotifToOrgMembersCached` with preference key `device_error` (Cloudflare plugin KV queue in production).
**Payload fields** (`event.details`):
| Field | Description |
|-------|-------------|
| `app_id` | App identifier |
| `app_id_url` | Same as `app_id` (for console URL segments) |
| `device_id` | Device UUID |
| `platform` | `ios` / `android` / `electron` |
| `channel_name` | Channel name |
| `channel_id` | Channel numeric id (console channel link) |
| `version` | Cloud/channel target bundle version name |
| `version_build` | Device native version used in the policy check |
| `version_name` | Device current OTA version name |
| `reason` | `major` \| `minor` \| `patch` \| `metadata` \| `under_native` |
Bundle / `version_id` numeric IDs are intentionally omitted — build console links from version names in the Bento template.
**Filter to add**:
```text
Tag does NOT contain: device_error_disabled
```
## Verification Checklist
After configuring Bento, verify the following:
- [ ] Each automation has the correct exclusion filter for its disabled tag
- [ ] The first-organization recovery workflow re-checks `onboarding:awaiting_first_org`, `onboarding:first_org_recovery_suppressed`, and `onboarding_disabled` immediately before sending
- [ ] Test by disabling a preference for a test user and confirming they don't receive that email type
- [ ] Test by re-enabling the preference and confirming they DO receive that email type
- [ ] Verify existing users without the `email_preferences` column still receive emails (tags default to not present = enabled)
## Legacy Tags (Still Active)
The following legacy tags continue to work alongside the new granular preferences:
| Tag | Description |
|-----|-------------|
| `notifications_opt_in` | General notifications toggle (from `enable_notifications` column) |
| `newsletter_opt_in` | Newsletter subscription (from `opt_for_newsletters` column) |
## Organization Management Email
The system also supports sending notifications to the organization's management email (billing email). This email receives notifications only when:
1. The management email is **different** from all admin user emails
2. The organization has the corresponding email preference **enabled**
### How Organization Preferences Work
- Organization preferences are stored in `orgs.email_preferences` (JSONB column)
- Admins can configure these in **Organization Settings > Notifications**
- When an org notification is sent, the system:
1. Sends to all admin/super_admin users who have the preference enabled
2. Also sends to the org's management email if it's different AND org preference is enabled
### Management Email vs User Emails
| Scenario | Who receives the email? |
|----------|------------------------|
| Management email = Admin email | Only the admin (not duplicated) |
| Management email ≠ Any admin email, org pref ON | Admin(s) + Management email |
| Management email ≠ Any admin email, org pref OFF | Admin(s) only |
## Technical Notes
- User tags are synced via `syncUserPreferenceTags()` in `user_preferences.ts`
- The sync happens whenever a user record is updated
- Tag operations use `syncBentoSubscriberTags()` which batches tag updates per subscriber
- The backend also checks preferences before sending emails as a fallback (defense in depth)
- Organization preferences are checked in `org_email_notifications.ts` via `getAllEligibleEmails()`
## Troubleshooting
### User not receiving emails they should receive
1. Check if the user has the `_disabled` tag in Bento
2. Verify the user's `email_preferences` in the database
3. Check the automation filter is set to "does NOT contain" (not "contains")
### User receiving emails they disabled
1. Verify the `_disabled` tag was added to their Bento profile
2. Check the automation has the correct exclusion filter
3. Ensure the filter is using the exact tag name (case-sensitive)
### Tags not syncing
1. Check the `syncUserPreferenceTags` function is being called on user updates
2. Verify Bento API credentials are correct
3. Check for errors in the cloudlog output