TLDR: Airtable wrapped a Gmail label ID in a JSON array. The migration copied it as-is. Gmail said no. Thirteen matters showed a scary red API error. The fix was two things — a data repair AND a code change that means bad data can never look like a system failure again.
The Setup
I've been building a practice-management app for my business partner at a law firm — custom matters (the legal term for a client case or file), timelines, Gmail sync, Xero billing, the works. We migrated her historical data out of Airtable and into the new system.
One of the fields we moved: gmail_label_id. Every matter links to a Gmail label so the email timeline populates automatically.
The migration script said, right there in PLAN.md line 101: "copied as-is."
That should have been a flag. It wasn't.
The Wall
My business partner sends me a Loom screenshot. Red error banner on a client's business formation matter: "Email sync: Google API error (400)."
My first instinct was to look at the live sync code. Something in syncMatterEmail, maybe a broken API call, maybe a header issue.
Wrong direction.
The tell was this: matters my business partner had created natively in the new app were fine. Only the Airtable imports were broken. That split points straight at the migration, not the running code.
What I Found
I replayed the exact Gmail call with a read-only .mjs script — no guessing, just reproduce it:
400 Invalid label: ["Label_683"]
There it was. Airtable's devGmailLabelID field stored the value as a JSON array: ["Label_683"]. The migration dutifully copied ["Label_683"] — brackets, quotes, and all — straight into Postgres. Gmail's messages.list expected a bare label ID. It got a stringified array and rejected it instantly.
13 of 44 labeled matters were corrupted. All of them Airtable imports. Every one.
The Fix — Two Parts
Part one was the data repair. Migration 023_fix_gmail_label_ids.sql: unwrap all 13 records with gmail_label_id::jsonb ->> 0. Three of those bare IDs had since gone dead (Gmail label renamed), so I re-linked them by matching client name and matter title to their current live label. One had no confident match — cleared it, shows the normal re-link prompt.
Post-migration probe: zero bracket-wrapped IDs remaining. ✅
Part two is the one that actually matters for next time.
I hardened syncMatterEmail to catch the invalid-label 400 from Gmail and return { labelInvalid: true } instead of throwing. The ActivityTimeline component threads that flag through and shows a quiet "re-link your Gmail label" hint — no red banner, no scary error message.
So even when a label goes stale, gets renamed, or arrives corrupted from the next import… it cannot look like the system is broken. It just says: hey, the label needs updating. Which is true!
Why This Matters
You can't control what Airtable (or any other platform) hands you when you migrate. The bracket-wrapped field is their internal representation, not a bug — just a format mismatch nobody caught until it hit production.
But here's the thing: data repair only fixes what you know about. The code hardening is what protects you against the next one.
Every time a third-party system hands your app garbage and it surfaces as a system error rather than a data hint, you've failed your user twice — once with the bad data, once by making it look like your software is the problem.
The goal is always: degrade gracefully, surface the right message, give the user a path forward. My business partner shouldn't see a Google API error. She should see "this matter needs a Gmail label."
That's the version I shipped. Commit 7fe1a3e.
P.S. The lesson I'll carry into every future migration: "copied as-is" is never safe for foreign-key or external-ID fields. Normalize at import time, or write a validator that screams before the data hits prod. Future me is reading this. Don't skip that step.