Runbook: Email Cutover Rollback (Gmail β Legacy Resend/Cloudflare)
P1. If per-org Gmail email routing (#1438) breaks outbound email for an organization, Specialists in that org cannot send messages to customers. Rolling back to the legacy Resend/Cloudflare path restores service within 30s. See
OWNERS.mdfor on-call contact.
β οΈ PREREQUISITE: This runbook documents the rollback procedure for the
use_workspace_emailflag implemented in #1438. Do not use this runbook until #1438 is merged and deployed. If that flag is not yet in production, the SQL commands in this runbook will silently succeed without affecting email routing behavior.
This runbook covers rolling back per-organization email routing from Gmail to the legacy Resend/Cloudflare path when the per-org cutover feature (organizations.metadata.use_workspace_email) causes email delivery failures. The legacy path (Resend for transactional, Cloudflare Email Routing for inbound) remains available during cutover for exactly this case. Architecture: ADR-0002 Phase 2 β Specialist Email (channels.md Β§System 3). Related issue: #688 (parent), #1438 (flag implementation).
Symptomsβ
- Customer complaints: "Bob sent a message but I never got it." Concentrated within minutes of cutover, in one organization.
- No outbound audit log rows in
email_audit_logfor the affected organization in the last N minutes (where N starts at "minute of cutover"). - Sentry errors β
Gmail API: send failedorWorkspace email: unauthorized / quota exceededfor a specificorg_id. - Outbound email dispatcher logs β
EmailService: Gmail path failed for org=<id>, fallback unavailable. - Specialist reports β "I clicked Send but the customer says they got nothing."
Step 1 β Confirm the failure is cutover-related, not application-wideβ
Before rolling back the flag (org-scoped, requires DB write), rule out:
1a. Is email working for organizations NOT on Gmail cutover?β
SELECT org_id, COUNT(*) AS sent_last_10m
FROM email_audit_log
WHERE direction = 'outbound'
AND created_at > NOW() - INTERVAL '10 minutes'
AND org_id != '<suspected_org_id>'
GROUP BY org_id
ORDER BY sent_last_10m DESC
LIMIT 10;
If other orgs are sending normally β failure is cutover-specific. Continue to Step 2.
If ALL orgs are silent β application-wide outage (Resend API down, API pods down). Stop, do not roll back the flag. Go to incident-response.md.
1b. Is the affected org actually using Gmail cutover?β
SELECT id, name, metadata->'use_workspace_email' AS use_workspace_email
FROM organizations
WHERE id = '<org_id>';
Expected: "true" (as JSON boolean or string). If null or "false" β cutover flag is NOT the root cause. Investigate Gmail API credentials or workspace provisioning.
1c. Are Gmail API credentials present for this org?β
SELECT org_id, COUNT(*) AS osa_count,
SUM(CASE WHEN oauth_refresh_token_encrypted IS NOT NULL THEN 1 ELSE 0 END) AS with_token
FROM org_specialist_assignments
WHERE org_id = '<org_id>'
GROUP BY org_id;
If with_token = 0 β OAuth never completed. Go to oauth-token-refresh.md.
If 1a + 1b + 1c all check out and email still isn't flowing β cutover flag is the problem. Continue to Step 2.
Step 2 β Roll back the cutover flagβ
2a. Flip the flag to falseβ
-- Roll back org to legacy Resend/Cloudflare path
UPDATE organizations
SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb),
'{use_workspace_email}',
'false'::jsonb
),
updated_at = NOW()
WHERE id = '<org_id>';
What happens to in-flight emails during the flag flip?
- Emails already in the outbound dispatcher queue when you run the UPDATE will complete via the path they started on (Gmail). If Gmail is broken, they will fail and retry.
- The outbound dispatcher checks the flag at dispatch time (per message), not at queue time.
- After the UPDATE commits, new messages enqueued will route via the legacy Resend/Cloudflare path.
- In-flight emails in the Gmail path that fail will be retried. On retry (typically 30s - 2min later), they will read the new flag value and route via Resend.
No lost messages β BullMQ retries failed sends. The worst case is a 2-minute delay for in-flight messages that were mid-send when the flag flipped.
2b. Verify the rollback took effectβ
SELECT id, name, metadata->'use_workspace_email' AS use_workspace_email, updated_at
FROM organizations
WHERE id = '<org_id>';
Expected: "false" (or false as JSON boolean). If still "true" β transaction didn't commit; check DB connection and retry.
Step 3 β Verify email flow resumedβ
3a. Check outbound dispatcher logsβ
# Example: Kubernetes logs for outbound-dispatcher pod
kubectl logs -l app=outbound-dispatcher --tail=50 --timestamps | grep "org=<org_id>"
# Expected log line:
# [timestamp] EmailService: routing org=<org_id> via legacy Resend (use_workspace_email=false)
3b. Smoke test: trigger an outbound sendβ
If safe (non-production, or production with AM approval):
curl -X POST https://api.<env>.h.work/messages/send \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"org_id": "<org_id>",
"to": "test+rollback@humanity.org",
"subject": "Rollback smoke test",
"text": "Testing legacy path after rollback"
}'
Expected:
- 200 response
email_audit_logrow withdirection = 'outbound'created within 5s- Resend dashboard shows delivery within 30s
3c. Check metrics dashboardβ
- Grafana (or your observability tool) β Email Routing dashboard:
- Panel: "Emails by routing path" β should show uptick in
resendpath, drop ingmailpath for the affected org - Panel: "Email send errors by org" β should show error rate drop to 0 for
org_id=<org_id>within 1 minute
- Panel: "Emails by routing path" β should show uptick in
- Sentry β Issues β filter by
org_id=<org_id>andEmailServiceβ error rate should drop to 0 after rollback
3d. Monitoring command: per-org email routing statusβ
To check email routing status for any organization at any time:
-- Check routing status and recent email activity
SELECT
o.id AS org_id,
o.name AS org_name,
o.metadata->'use_workspace_email' AS use_workspace_email,
COUNT(eal.id) FILTER (WHERE eal.created_at > NOW() - INTERVAL '10 minutes') AS emails_last_10m,
MAX(eal.created_at) AS last_email_sent
FROM organizations o
LEFT JOIN email_audit_log eal ON eal.org_id = o.id
AND eal.direction = 'outbound'
WHERE o.id = '<org_id>'
GROUP BY o.id, o.name, o.metadata;
To list ALL orgs currently on Gmail cutover:
SELECT id, name, metadata->'use_workspace_email' AS use_workspace_email, updated_at
FROM organizations
WHERE metadata->>'use_workspace_email' = 'true'
ORDER BY updated_at DESC;
Step 4 β Communicateβ
- Slack
#ops-incidents: "Rolled back email cutover for org<org_name>(<org_id>) at<UTC time>due to. Outbound resumed via legacy Resend path. Investigating Gmail cutover root cause." - Email impacted AM / org admin: "Some outbound emails between
<window>may have been delayed. We've restored the previous email path. All queued messages will be delivered within 2 minutes." - Capture in incident channel:
- Cutover start time (when
use_workspace_emailwas set totrue) - Rollback start time (now)
- Rollback complete time (when Step 3 verification passed)
- Number of failed sends in the window (from Sentry or
email_audit_logerror logs)
- Cutover start time (when
Step 5 β Troubleshooting: rollback complete but emails still not flowingβ
If you completed Step 2 (flag flipped to false) and Step 3 verification (query confirms use_workspace_email=false), but emails STILL aren't sending:
5a. Check legacy Resend API credentialsβ
# Check environment variable is set
kubectl exec -it deploy/api -- env | grep RESEND_API_KEY
# Expected: RESEND_API_KEY=re_...
If missing β check Secrets Manager / Kubernetes secret:
kubectl get secret humanwork-api-secrets -o jsonpath='{.data.RESEND_API_KEY}' | base64 -d
If still missing or revoked β regenerate in Resend dashboard, update secret, restart API pods.
5b. Check Resend API statusβ
curl -s https://status.resend.com/api/v2/status.json | jq .
If status.indicator != "none" β Resend is degraded/down. Check their status page; wait for resolution or escalate to Platform Lead for manual SMTP fallback.
5c. Check Cloudflare Email Routing worker (inbound path)β
# Cloudflare dashboard β Email Routing β Overview
# Should show "Active" status
# If "Paused" or "Error" β click Activate
Worker code is in scripts/local/legacy-cloudflare-worker/. If worker was accidentally deleted β redeploy from repo.
5d. Check BullMQ outbound queueβ
The outbound dispatcher uses BullMQ. If messages are enqueued but not sending:
# Redis CLI or BullMQ dashboard
# Check queue: "channels-outbound"
# Count waiting jobs:
redis-cli LLEN bull:channels-outbound:wait
# If > 100 β queue is backed up. Check dispatcher pod health.
kubectl get pods -l app=outbound-dispatcher
# If pod CrashLoopBackOff β check logs for error, fix, restart
See bullmq-queue-backlog.md for detailed BullMQ troubleshooting.
5e. DNS MX records (inbound only)β
If OUTBOUND is working but INBOUND is not β this runbook is for outbound cutover rollback. For inbound MX issues, see dns-mx-rollback.md.
Rollback the rollback (re-attempt Gmail cutover)β
After the Gmail cutover root cause is fixed (OAuth tokens, Gmail API quota, workspace provisioning):
- Pre-cutover smoke: Verify Gmail Send works for at least one OSA in the org (see oauth-token-refresh.md Step 4).
- Re-flip flag:
UPDATE organizations
SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb),
'{use_workspace_email}',
'true'::jsonb
),
updated_at = NOW()
WHERE id = '<org_id>'; - Monitor for 1 hour: Check
email_audit_log, Sentry, and Grafana. Watch for send failures. - Declare success only when 100% of outbound for the org lands (no errors for 1 hour).
Step 6 β Escalateβ
- Rollback didn't restore email flow after 5 minutes (Step 5 troubleshooting exhausted) β page Platform Lead + on-call SRE. Possible cascading failure in Resend or DNS.
- Multiple orgs affected simultaneously β P0, do NOT roll back one-by-one. Page Platform Lead immediately; likely infrastructure-wide issue (Gmail API key revoked, Resend account suspended).
- Customer SLA breach (e.g., financial services client with 15-min reply SLA) β page CS + Platform Lead together.
Relatedβ
- oauth-token-refresh.md β OAuth token issues after cutover
- dns-mx-rollback.md β MX rollback for inbound email (different from this runbook's outbound scope)
- workspace-provisioning-failure.md β provisioning must succeed before cutover
- gsuite-tenant-suspended.md β if Gmail cutover fails because tenant is suspended
- #688 β parent issue for email cutover project
- #1438 β implementation of
use_workspace_emailflag