Files
broccolini-bot/docs/superpowers/specs/2026-06-03-email-flow-toggle-design.md
indifferentketchup 2ccdbf72aa Email ticketing fixes, comms polish, and .env cleanup
Inbound:
- Gmail poll query is:unread in:inbox (was category:primary, which matched
  nothing on a no-tabs Workspace inbox)

Outbound email:
- Closed/escalation auto-emails editable via TICKET_CLOSE_MESSAGE and new
  TICKET_ESCALATION_EMAIL_MESSAGE; drop the staff signature from closing emails
- Replies quote the customer's latest message (gmail_quote markup so clients
  collapse it), embed custom emoji inline via CID attachment, and strip Discord
  role mentions
- Tagline spacing fix in the company signature

Discord side:
- Suppress all mentions in log + transcript posts (no more pinging on close)
- Drop the staff-role ping from new-ticket and follow-up notifications
- Ticket channels inherit category permissions instead of setting per-channel
  overwrites (removes the Manage Roles requirement)

Gmail folders:
- Folder/label routing (gmailLabels.js) with /folder; close files to Complete

Config:
- Remove ~56 stale .env keys for long-removed features; refresh stale copy

Docs:
- Design specs for folder routing, email-flow toggle, and per-staff metrics
2026-06-04 22:05:20 +00:00

125 lines
7.0 KiB
Markdown

# Email Flow Toggle — Design
**Date:** 2026-06-03
**Status:** Approved (design); pending implementation plan
## Goal
A staff slash command to turn the **inbound** email flow on and off at runtime, with the state surviving container restarts.
- **ON** — Gmail polling runs as today: reads the inbox, creates/append ticket channels, runs all downstream features.
- **OFF** — Polling stops entirely. The mailbox is **never read** (inbox untouched). No new tickets are created from email.
- **Outbound is unaffected** in both states — ticket-close emails, Gmail replies, and notification emails still send when staff act on existing tickets. (Decision: "off" scopes to inbound polling only.)
- **Persists across restarts** — the off-state is honored on the next boot. (Decision: persisted, not runtime-only.)
Discord-originated tickets are independent of email polling and are unaffected by this toggle.
## Decisions (locked)
| Decision | Choice |
|----------|--------|
| Scope of OFF | Inbound polling only; outbound email still sends |
| Persistence | Persist to `.env` via existing config-persistence path; honored on boot |
| Command shape | New dedicated `/email on\|off\|status` command (Approach A) |
| Existing `/gmailpoll` | Guarded so it cannot silently re-enable polling while flow is OFF |
Rejected: folding into `/gmailpoll` subcommands (needless churn to a working command); sentinel interval `/gmailpoll 0` (poor discoverability).
## Architecture
Single source of truth: a new boolean config flag `GMAIL_POLL_ENABLED` (default **true**). The live poll timer (`gmailPollInterval` in `broccolini-discord.js`) is started/stopped to match the flag.
### 1. Config flag — `GMAIL_POLL_ENABLED`
- **`config.js`** — add:
```js
GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false',
```
Undefined/absent → `true`, so existing deployments keep polling with no `.env` change required.
- **`services/configSchema.js`** — add `'GMAIL_POLL_ENABLED'` to `ALLOWED_CONFIG_KEYS`. The existing `/ENABLED$/` rule in `inferType()` already classifies it as a boolean validator, so the settings site can also toggle it (bonus, no extra work).
### 2. Boot gate
In `broccolini-discord.js` (`client.once('ready')`, currently ~lines 210-211), only start the poll when enabled:
```js
if (CONFIG.GMAIL_POLL_ENABLED) {
gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS));
poll(client);
} else {
console.log('Gmail poll disabled by config (GMAIL_POLL_ENABLED=false)');
}
```
When disabled, `gmailPollInterval` stays `null` — no timer is registered in `activeIntervals`, nothing reads the inbox.
### 3. `/email` command
- **Registration** — `commands/register.js`: a `SlashCommandBuilder` named `email` with three subcommands (`on`, `off`, `status`), `setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)` to match sibling commands.
- **Dispatch** — add `email: handleEmail` to `COMMAND_HANDLERS` in `handlers/commands/index.js`. Staff-gated automatically via `requireStaffRole()` at the dispatcher entry.
- **Handler** — `handleEmail(interaction)`:
- `on`:
1. `applyConfigUpdates({ GMAIL_POLL_ENABLED: true })` (updates runtime `CONFIG` **and** writes `.env`).
2. Clear the auth-suspend latch via `require('../../gmail-poll').setPollSuspended(false)` so a prior `invalid_grant` suspend doesn't keep polling dead. If auth is still broken, the next cycle re-suspends and DMs admin, exactly as today.
3. `setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS)` to start the live timer.
4. Reply (ephemeral): "Email flow is now **on**."
- `off`:
1. `applyConfigUpdates({ GMAIL_POLL_ENABLED: false })`.
2. `clearGmailPollInterval()`.
3. Reply (ephemeral): "Email flow is now **off** — the inbox will not be polled. Outbound emails still send."
- `status`:
- Report `CONFIG.GMAIL_POLL_ENABLED`, the current interval (`CONFIG.GMAIL_POLL_INTERVAL_MS / 1000`s), and whether polling is currently suspended by an auth error.
- On `on`/`off`, fire `logTicketEvent('Email flow toggled', [...], interaction).catch(() => {})` — fire-and-forget, matching `/gmailpoll`.
`applyConfigUpdates` is called in-process (the command runs inside the bot), reusing the same path the internal API uses — no HTTP round-trip.
### 4. Guard `/gmailpoll` against silent re-enable
`handleGmailPoll` currently calls `setGmailPollInterval(ms)`, which *starts* the timer. While flow is OFF that would silently re-enable polling. Change it so that when `CONFIG.GMAIL_POLL_ENABLED` is false:
- update the interval in memory only (`CONFIG.GMAIL_POLL_INTERVAL_MS = ms`) — matching `/gmailpoll`'s existing runtime-only model (it has never persisted to `.env`), but
- do **not** start the live timer, and
- reply: "Interval saved (`<n>`s), but the inbound email flow is currently **off** — it will apply when you run `/email on`."
When flow is ON, `/gmailpoll` behaves exactly as today.
## Data flow
```
/email off ──> applyConfigUpdates({GMAIL_POLL_ENABLED:false}) ──> CONFIG updated + .env written
└─> clearGmailPollInterval() ──> live timer stopped, gmailPollInterval=null
(no inbox reads)
restart ──> config.js reads GMAIL_POLL_ENABLED=false ──> ready gate skips poll start ──> stays off
/email on ──> applyConfigUpdates({GMAIL_POLL_ENABLED:true}) ──> CONFIG updated + .env written
├─> setPollSuspended(false) ──> clear prior auth-suspend latch
└─> setGmailPollInterval(interval) ──> live timer started, immediate poll
```
## Error handling
- Command runs through `runHandler`, so any throw is logged and the user gets an ephemeral "Something went wrong."
- `applyConfigUpdates` returns `{ applied, errors }`; if `GMAIL_POLL_ENABLED` lands in `errors` (should not, given the boolean validator), reply with the error rather than claiming success, and do not flip the live timer.
- Logging stays fire-and-forget (`.catch(() => {})`), per Hard Rule 4.
## Files touched
| File | Change |
|------|--------|
| `config.js` | Add `GMAIL_POLL_ENABLED` (default true) |
| `services/configSchema.js` | Add `'GMAIL_POLL_ENABLED'` to `ALLOWED_CONFIG_KEYS` |
| `broccolini-discord.js` | Gate poll start in `ready` on the flag |
| `commands/register.js` | Register `/email on\|off\|status` |
| `handlers/commands/index.js` | Add `handleEmail`; guard `handleGmailPoll` |
| `.env.example` | Document `GMAIL_POLL_ENABLED` (optional, default true) |
No DB schema changes. No destructive data ops. The mailbox is never read while OFF and is never written by this feature.
## Verification
- `/email off` → logs show no further poll cycles; `.env` contains `GMAIL_POLL_ENABLED=false`.
- Restart container → no polling on boot; `/email status` reports off.
- `/email on` → poll resumes (immediate cycle), `.env` flips to `true`.
- While OFF, `/gmailpoll 60` → interval saved, no polling starts.
- `npm test` (covers `services/configSchema.js`); `node --check` on every edited file.