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

7.0 KiB

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:
    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:

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

  • Registrationcommands/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.
  • HandlerhandleEmail(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 / 1000s), 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.