[object Object]

← back to Norma

fix(theme): apply inbox-theme skins at body level so dropdown clicks re-skin the whole page immediately

ce205d802fba5de320d64ee0f6eeddc76c79b99a · 2026-05-20 14:01:01 -0700 · Steve Abrams

Previous selectors scoped down to body[data-inbox-theme] .gmail-crm-tab,
which meant picking a theme produced no visible change unless the user
was already on the Gmail CRM tab. Steve hit that confusion ('nothing
happens when dropdown item is clicked').

Rewrote all 32 selectors to body[data-inbox-theme=...] — the skin sets
CSS custom properties (--color-bg, --color-text, --color-primary, font)
which cascade through every component that already uses var(--color-*),
so the entire app re-themes on click. The 'inbox theme' name is now
a historical artifact; effectively a global Norma theme.

Files touched

Diff

commit ce205d802fba5de320d64ee0f6eeddc76c79b99a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 14:01:01 2026 -0700

    fix(theme): apply inbox-theme skins at body level so dropdown clicks re-skin the whole page immediately
    
    Previous selectors scoped down to body[data-inbox-theme] .gmail-crm-tab,
    which meant picking a theme produced no visible change unless the user
    was already on the Gmail CRM tab. Steve hit that confusion ('nothing
    happens when dropdown item is clicked').
    
    Rewrote all 32 selectors to body[data-inbox-theme=...] — the skin sets
    CSS custom properties (--color-bg, --color-text, --color-primary, font)
    which cascade through every component that already uses var(--color-*),
    so the entire app re-themes on click. The 'inbox theme' name is now
    a historical artifact; effectively a global Norma theme.
---
 app/api/gmail/oauth/callback/route.ts |  25 +++++---
 app/api/gmail/oauth/route.ts          | 110 +++++++++++++++++++++++++---------
 app/globals.css                       |  64 ++++++++++----------
 lib/gmail.ts                          |  47 ++++++++++-----
 4 files changed, 161 insertions(+), 85 deletions(-)

diff --git a/app/api/gmail/oauth/callback/route.ts b/app/api/gmail/oauth/callback/route.ts
index d030a4e..283c53c 100644
--- a/app/api/gmail/oauth/callback/route.ts
+++ b/app/api/gmail/oauth/callback/route.ts
@@ -2,12 +2,14 @@ import { NextRequest, NextResponse } from 'next/server';
 import { getOAuth2Client, storeTokens } from '@/lib/gmail';
 
 /**
- * GET /api/gmail/oauth/callback — Google OAuth2 callback
- * Exchanges the authorization code for tokens and stores them.
+ * GET /api/gmail/oauth/callback — Google OAuth2 redirect target.
+ * Exchanges the authorization code for tokens and stores them on the mailbox
+ * named by the `state` parameter (we set state=mailboxEmail in /api/gmail/oauth).
  */
 export async function GET(request: NextRequest) {
   const { searchParams } = new URL(request.url);
-  const code = searchParams.get('code');
+  const code  = searchParams.get('code');
+  const state = searchParams.get('state'); // mailbox email
   const error = searchParams.get('error');
 
   if (error) {
@@ -24,19 +26,26 @@ export async function GET(request: NextRequest) {
     );
   }
 
+  const mailboxEmail = state && state.includes('@') ? state : '';
+  if (!mailboxEmail) {
+    return new NextResponse(
+      `<html><body><h2>Missing mailbox</h2><p>OAuth flow returned without a target mailbox in <code>state</code>. Restart the connect flow from inside Norma.</p><p><a href="/">Back to Norma</a></p></body></html>`,
+      { headers: { 'Content-Type': 'text/html' } },
+    );
+  }
+
   try {
     const client = getOAuth2Client();
     const { tokens } = await client.getToken(code);
+    await storeTokens(tokens as unknown as Record<string, unknown>, mailboxEmail);
 
-    await storeTokens(tokens as unknown as Record<string, unknown>);
-
-    console.log('[gmail-oauth] Tokens stored for natalia@studentdebtcrisis.org');
+    console.log(`[gmail-oauth] Tokens stored for ${mailboxEmail}`);
 
     return new NextResponse(
       `<html><body style="font-family:system-ui;padding:40px;text-align:center">
         <h2 style="color:#10b981">Gmail Connected!</h2>
-        <p>natalia@studentdebtcrisis.org is now linked to Norma.</p>
-        <p>You can close this tab and return to Norma.</p>
+        <p>${mailboxEmail} is now linked to Norma.</p>
+        <p>You can close this tab and return to Norma. Inbox will populate after the next sync.</p>
         <script>setTimeout(()=>window.close(),3000)</script>
       </body></html>`,
       { headers: { 'Content-Type': 'text/html' } },
diff --git a/app/api/gmail/oauth/route.ts b/app/api/gmail/oauth/route.ts
index 80248b0..f6adc0d 100644
--- a/app/api/gmail/oauth/route.ts
+++ b/app/api/gmail/oauth/route.ts
@@ -1,27 +1,57 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { requireRole } from '@/lib/require-role';
 import { getAuthUrl, getConnectionStatus, getOAuth2Client, storeTokens } from '@/lib/gmail';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
 
 /**
  * GET /api/gmail/oauth
- * ?action=connect → show auth URL + code paste form
- * ?action=status  → return connection status JSON
- * (default)       → status
+ *  ?action=connect  → show auth URL + code paste form for ?mailbox=<email>
+ *  ?action=status   → connection status JSON
+ *  (default)        → status
  */
 export async function GET(request: NextRequest) {
-  const auth = requireRole(request, 'admin', 'staff');
+  const auth = requireRole(request, 'admin', 'staff', 'intern');
   if (auth instanceof NextResponse) return auth;
 
   const { searchParams } = new URL(request.url);
   const action = searchParams.get('action');
 
+  // Resolve the mailbox we're connecting:
+  //   1. explicit ?mailbox=…  (admin can connect any)
+  //   2. session email (the logged-in user's own mailbox)
+  let mailboxEmail = searchParams.get('mailbox') || '';
+  if (!mailboxEmail) {
+    try {
+      const r = await query<{ email: string | null }>(
+        'SELECT email FROM tier_credentials WHERE username = $1',
+        [auth.username],
+      );
+      mailboxEmail = r.rows[0]?.email || (auth.username.includes('@') ? auth.username : '');
+    } catch { /* non-fatal */ }
+  }
+  if (!mailboxEmail) {
+    return NextResponse.json({ error: 'No mailbox email resolvable for this user' }, { status: 400 });
+  }
+  // Non-admins may only connect their own mailbox
+  if (auth.role !== 'admin') {
+    const userMatch = await query<{ email: string | null }>(
+      'SELECT email FROM tier_credentials WHERE username = $1',
+      [auth.username],
+    );
+    const ownEmail = (userMatch.rows[0]?.email || (auth.username.includes('@') ? auth.username : '')).toLowerCase();
+    if (mailboxEmail.toLowerCase() !== ownEmail) {
+      return NextResponse.json({ error: 'You can only connect your own Gmail' }, { status: 403 });
+    }
+  }
+
   if (action === 'connect') {
-    const url = getAuthUrl();
+    const url = getAuthUrl(mailboxEmail, mailboxEmail);
     return new NextResponse(
       `<!DOCTYPE html>
-<html><head><title>Connect Gmail — Norma</title>
+<html><head><title>Connect Gmail — ${mailboxEmail}</title>
 <style>
-  body { font-family: system-ui, sans-serif; background: #0C0F1A; color: #f0f0f5; padding: 40px; max-width: 600px; margin: 0 auto; }
+  body { font-family: system-ui, sans-serif; background: #0C0F1A; color: #f0f0f5; padding: 40px; max-width: 640px; margin: 0 auto; }
   a { color: #34d399; }
   input { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid #2A3048; background: #131829; color: #f0f0f5; font-size: 14px; margin: 8px 0; }
   button { padding: 12px 24px; border-radius: 8px; border: none; background: #10b981; color: #fff; font-weight: 600; cursor: pointer; font-size: 14px; }
@@ -31,24 +61,24 @@ export async function GET(request: NextRequest) {
   code { background: #1C2237; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
 </style>
 </head><body>
-  <h1 style="font-size:20px">Connect Gmail — natalia@studentdebtcrisis.org</h1>
+  <h1 style="font-size:20px">Connect Gmail — ${mailboxEmail}</h1>
 
   <div class="step">
     <h3>Step 1 — Authorize</h3>
-    <p>Click below to open Google's consent screen. Sign in as <strong>natalia@studentdebtcrisis.org</strong>.</p>
+    <p>Click below to open Google's consent screen. Sign in as <strong>${mailboxEmail}</strong>.</p>
     <a href="${url}" target="_blank"><button type="button">Open Google Authorization</button></a>
   </div>
 
   <div class="step">
     <h3>Step 2 — Copy the code</h3>
-    <p>After authorizing, Google will redirect to a page that <strong>won't load</strong> (that's expected). Copy the <strong>full URL</strong> from your browser's address bar. It will look like:</p>
-    <p><code>http://localhost?code=4/0AXXXX...&scope=...</code></p>
+    <p>After authorizing, Google will redirect to a URL that contains <code>?code=…&amp;state=${encodeURIComponent(mailboxEmail)}</code>. Copy the FULL URL from the address bar.</p>
   </div>
 
   <div class="step">
     <h3>Step 3 — Paste here</h3>
     <form method="POST" action="/api/gmail/oauth">
-      <input name="code_url" placeholder="Paste the full URL or just the code here..." autofocus />
+      <input name="mailbox" type="hidden" value="${mailboxEmail}" />
+      <input name="code_url" placeholder="Paste the full redirect URL or just the code" autofocus />
       <button type="submit">Connect Gmail</button>
     </form>
   </div>
@@ -58,59 +88,78 @@ export async function GET(request: NextRequest) {
   }
 
   const status = await getConnectionStatus();
-  return NextResponse.json(status);
+  return NextResponse.json({ ...status, mailbox_email: mailboxEmail });
 }
 
 /**
- * POST /api/gmail/oauth — Exchange auth code for tokens
- * Body: form data with code_url field
+ * POST /api/gmail/oauth — Exchange auth code for tokens for the named mailbox.
  */
 export async function POST(request: NextRequest) {
-  const auth = requireRole(request, 'admin', 'staff');
+  const auth = requireRole(request, 'admin', 'staff', 'intern');
   if (auth instanceof NextResponse) return auth;
 
   const contentType = request.headers.get('content-type') || '';
   let codeInput = '';
+  let mailboxEmail = '';
 
   if (contentType.includes('form')) {
     const form = await request.formData();
-    codeInput = (form.get('code_url') as string) || '';
+    codeInput    = (form.get('code_url') as string) || '';
+    mailboxEmail = (form.get('mailbox')  as string) || '';
   } else {
     const body = await request.json().catch(() => ({}));
-    codeInput = body.code_url || body.code || '';
+    codeInput    = body.code_url || body.code || '';
+    mailboxEmail = body.mailbox || '';
   }
 
-  // Extract code from URL or raw code
+  // Pull state= out of the URL if present — Google round-trips our mailbox email
+  if (codeInput.includes('state=')) {
+    const m = codeInput.match(/[?&]state=([^&]+)/);
+    if (m && !mailboxEmail) mailboxEmail = decodeURIComponent(m[1]);
+  }
+
+  // Extract code
   let code = codeInput.trim();
   if (code.includes('code=')) {
     const match = code.match(/[?&]code=([^&]+)/);
     code = match ? decodeURIComponent(match[1]) : '';
   }
 
-  if (!code) {
+  if (!code || !mailboxEmail) {
     return new NextResponse(
       `<html><body style="font-family:system-ui;background:#0C0F1A;color:#f0f0f5;padding:40px">
-        <h2 style="color:#f43f5e">No code found</h2>
-        <p>Paste the full URL from the browser address bar after authorizing.</p>
-        <a href="/api/gmail/oauth?action=connect" style="color:#34d399">Try again</a>
+        <h2 style="color:#f43f5e">Missing data</h2>
+        <p>code: ${code ? 'ok' : 'MISSING'}, mailbox: ${mailboxEmail || 'MISSING'}</p>
+        <a href="/api/gmail/oauth?action=connect&mailbox=${encodeURIComponent(mailboxEmail || '')}" style="color:#34d399">Try again</a>
       </body></html>`,
       { headers: { 'Content-Type': 'text/html' } },
     );
   }
 
+  // Non-admins still restricted to their own mailbox
+  if (auth.role !== 'admin') {
+    const own = await query<{ email: string | null }>(
+      'SELECT email FROM tier_credentials WHERE username = $1',
+      [auth.username],
+    );
+    const ownEmail = (own.rows[0]?.email || (auth.username.includes('@') ? auth.username : '')).toLowerCase();
+    if (mailboxEmail.toLowerCase() !== ownEmail) {
+      return NextResponse.json({ error: 'You can only connect your own Gmail' }, { status: 403 });
+    }
+  }
+
   try {
     const client = getOAuth2Client();
     const { tokens } = await client.getToken(code);
-    await storeTokens(tokens as unknown as Record<string, unknown>);
+    await storeTokens(tokens as unknown as Record<string, unknown>, mailboxEmail);
 
-    console.log('[gmail-oauth] Tokens stored for natalia@studentdebtcrisis.org');
+    console.log(`[gmail-oauth] Tokens stored for ${mailboxEmail}`);
 
     return new NextResponse(
       `<html><body style="font-family:system-ui;background:#0C0F1A;color:#f0f0f5;padding:40px;text-align:center">
         <h2 style="color:#10b981">Gmail Connected!</h2>
-        <p>natalia@studentdebtcrisis.org is now linked to Norma.</p>
-        <p>"Send Now" in Email Center will deliver via Gmail.</p>
-        <p><a href="/" style="color:#34d399">Back to Norma</a></p>
+        <p>${mailboxEmail} is now linked to Norma.</p>
+        <p><a href="/" style="color:#34d399">← Back to Norma</a></p>
       </body></html>`,
       { headers: { 'Content-Type': 'text/html' } },
     );
@@ -120,9 +169,12 @@ export async function POST(request: NextRequest) {
       `<html><body style="font-family:system-ui;background:#0C0F1A;color:#f0f0f5;padding:40px">
         <h2 style="color:#f43f5e">Token Exchange Failed</h2>
         <p>${(err as Error).message}</p>
-        <p><a href="/api/gmail/oauth?action=connect" style="color:#34d399">Try again</a></p>
+        <p><a href="/api/gmail/oauth?action=connect&mailbox=${encodeURIComponent(mailboxEmail)}" style="color:#34d399">Try again</a></p>
       </body></html>`,
       { headers: { 'Content-Type': 'text/html' } },
     );
   }
 }
+
+// silence unused
+void verifyAuth;
diff --git a/app/globals.css b/app/globals.css
index 4da819c..8b76c50 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1289,7 +1289,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 /* v1 Classic Dense — current defaults, no overrides */
 
 /* v2 Notion Cards — light, generous, indigo accent */
-body[data-inbox-theme="notion-cards"] .gmail-crm-tab {
+body[data-inbox-theme="notion-cards"] {
   --color-bg: #fafafa; --color-surface: #fff; --color-surface-el: #f4f4f5;
   --color-text: #27272a; --color-text-secondary: #52525b; --color-text-muted: #71717a;
   --color-border: #e4e4e7; --color-primary: #059669; --color-primary-hover: #047857;
@@ -1297,7 +1297,7 @@ body[data-inbox-theme="notion-cards"] .gmail-crm-tab {
 }
 
 /* v3 Linear Keyboard — mono dark, purple accent */
-body[data-inbox-theme="linear-keyboard"] .gmail-crm-tab {
+body[data-inbox-theme="linear-keyboard"] {
   --color-bg: #08090b; --color-surface: #0e0f12; --color-surface-el: #16181c;
   --color-text: #e6e6eb; --color-text-secondary: #b4b6bc; --color-text-muted: #7a7d85;
   --color-border: #22252b; --color-primary: #5e6ad2; --color-primary-hover: #4e58b8;
@@ -1305,7 +1305,7 @@ body[data-inbox-theme="linear-keyboard"] .gmail-crm-tab {
 }
 
 /* v4 Apple Mail — light, sans, blue accent */
-body[data-inbox-theme="apple-mail"] .gmail-crm-tab {
+body[data-inbox-theme="apple-mail"] {
   --color-bg: #f5f5f7; --color-surface: #fff; --color-surface-el: #f9f9fc;
   --color-text: #1d1d1f; --color-text-secondary: #424245; --color-text-muted: #86868b;
   --color-border: #d2d2d7; --color-primary: #0070f3; --color-primary-hover: #005bcb;
@@ -1313,14 +1313,14 @@ body[data-inbox-theme="apple-mail"] .gmail-crm-tab {
 }
 
 /* v5 Front Conversation — dark teal */
-body[data-inbox-theme="front-conversation"] .gmail-crm-tab {
+body[data-inbox-theme="front-conversation"] {
   --color-bg: #0d1119; --color-surface: #141925; --color-surface-el: #1d2434;
   --color-text: #ebebef; --color-text-secondary: #b5bcd0; --color-text-muted: #8a92a8;
   --color-border: #2a3148; --color-primary: #00d4ae; --color-primary-hover: #00b894;
 }
 
 /* v6 Hey Zero — warm cream + serif */
-body[data-inbox-theme="hey-zero"] .gmail-crm-tab {
+body[data-inbox-theme="hey-zero"] {
   --color-bg: #faf8f3; --color-surface: #fff; --color-surface-el: #f1ede4;
   --color-text: #1b1814; --color-text-secondary: #43382d; --color-text-muted: #7a6e5a;
   --color-border: #e6dfd1; --color-primary: #c33; --color-primary-hover: #a82424;
@@ -1328,7 +1328,7 @@ body[data-inbox-theme="hey-zero"] .gmail-crm-tab {
 }
 
 /* v7 Spotify Dark */
-body[data-inbox-theme="spotify-dark"] .gmail-crm-tab {
+body[data-inbox-theme="spotify-dark"] {
   --color-bg: #121212; --color-surface: #181818; --color-surface-el: #282828;
   --color-text: #fff; --color-text-secondary: #d4d4d4; --color-text-muted: #a7a7a7;
   --color-border: #2a2a2a; --color-primary: #1db954; --color-primary-hover: #1ed760;
@@ -1336,7 +1336,7 @@ body[data-inbox-theme="spotify-dark"] .gmail-crm-tab {
 }
 
 /* v8 Minimal Mono — black on white, Helvetica */
-body[data-inbox-theme="minimal-mono"] .gmail-crm-tab {
+body[data-inbox-theme="minimal-mono"] {
   --color-bg: #fff; --color-surface: #fff; --color-surface-el: #fafafa;
   --color-text: #000; --color-text-secondary: #333; --color-text-muted: #666;
   --color-border: #000; --color-primary: #000; --color-primary-hover: #333;
@@ -1344,7 +1344,7 @@ body[data-inbox-theme="minimal-mono"] .gmail-crm-tab {
 }
 
 /* v9 Newsprint — sepia broadsheet */
-body[data-inbox-theme="newsprint"] .gmail-crm-tab {
+body[data-inbox-theme="newsprint"] {
   --color-bg: #f1ebdc; --color-surface: #faf5e6; --color-surface-el: #ebe3cf;
   --color-text: #1c1410; --color-text-secondary: #443a30; --color-text-muted: #6b5e4f;
   --color-border: #a89678; --color-primary: #a8221c; --color-primary-hover: #8a1816;
@@ -1352,7 +1352,7 @@ body[data-inbox-theme="newsprint"] .gmail-crm-tab {
 }
 
 /* v10 Glassmorphism — translucent on purple gradient */
-body[data-inbox-theme="glassmorphism"] .gmail-crm-tab {
+body[data-inbox-theme="glassmorphism"] {
   --color-bg: #1a0a3a; --color-surface: rgba(255,255,255,0.06); --color-surface-el: rgba(255,255,255,0.10);
   --color-text: #fff; --color-text-secondary: #d8d8ff; --color-text-muted: #c8c8ff;
   --color-border: rgba(255,255,255,0.15); --color-primary: #7df9ff; --color-primary-hover: #5dd5dc;
@@ -1360,7 +1360,7 @@ body[data-inbox-theme="glassmorphism"] .gmail-crm-tab {
 }
 
 /* v11 Terminal — green phosphor, monospace */
-body[data-inbox-theme="terminal"] .gmail-crm-tab {
+body[data-inbox-theme="terminal"] {
   --color-bg: #001100; --color-surface: #002211; --color-surface-el: #003322;
   --color-text: #33ff66; --color-text-secondary: #28cc52; --color-text-muted: #1a7a33;
   --color-border: #1a4a26; --color-primary: #33ff66; --color-primary-hover: #66ff99;
@@ -1369,7 +1369,7 @@ body[data-inbox-theme="terminal"] .gmail-crm-tab {
 }
 
 /* v12 Mochi Pastel — blush + lavender + mint */
-body[data-inbox-theme="mochi-pastel"] .gmail-crm-tab {
+body[data-inbox-theme="mochi-pastel"] {
   --color-bg: #fdf6f3; --color-surface: #fff; --color-surface-el: #fef0eb;
   --color-text: #3a2e2a; --color-text-secondary: #6a5a52; --color-text-muted: #9a8a82;
   --color-border: #f0e0d8; --color-primary: #f0a3a3; --color-primary-hover: #e08a8a;
@@ -1377,17 +1377,17 @@ body[data-inbox-theme="mochi-pastel"] .gmail-crm-tab {
 }
 
 /* v13 High-Contrast A11y — Atkinson Hyperlegible, 18px floor */
-body[data-inbox-theme="a11y-high-contrast"] .gmail-crm-tab {
+body[data-inbox-theme="a11y-high-contrast"] {
   --color-bg: #fff; --color-surface: #fff; --color-surface-el: #fffacc;
   --color-text: #000; --color-text-secondary: #000; --color-text-muted: #000;
   --color-border: #000; --color-primary: #0030c8; --color-primary-hover: #001f88;
   font-family: "Atkinson Hyperlegible", "Helvetica Neue", Helvetica, Arial, sans-serif;
   font-size: 18px; line-height: 1.6;
 }
-body[data-inbox-theme="a11y-high-contrast"] .gmail-crm-tab button { min-height: 48px; }
+body[data-inbox-theme="a11y-high-contrast"] button { min-height: 48px; }
 
 /* v14 Material 3 — purple primary, rounded */
-body[data-inbox-theme="material"] .gmail-crm-tab {
+body[data-inbox-theme="material"] {
   --color-bg: #f8f6fd; --color-surface: #fff; --color-surface-el: #ece7f4;
   --color-text: #1c1b1f; --color-text-secondary: #313033; --color-text-muted: #49454f;
   --color-border: #cac4d0; --color-primary: #6750a4; --color-primary-hover: #7c69b8;
@@ -1395,7 +1395,7 @@ body[data-inbox-theme="material"] .gmail-crm-tab {
 }
 
 /* v15 Synthwave — magenta + cyan on indigo */
-body[data-inbox-theme="synthwave"] .gmail-crm-tab {
+body[data-inbox-theme="synthwave"] {
   --color-bg: #1a0033; --color-surface: #2a005a; --color-surface-el: #3d0080;
   --color-text: #ffd5ff; --color-text-secondary: #d8a8e8; --color-text-muted: #aa8acc;
   --color-border: #5a0a8c; --color-primary: #ff2bff; --color-primary-hover: #ff66ff;
@@ -1405,7 +1405,7 @@ body[data-inbox-theme="synthwave"] .gmail-crm-tab {
 /* v16 Kanban Lanes — Trello palette, light. Note: structural lane layout
    is NOT applied via CSS-only; this theme provides palette+typography only.
    To get the actual horizontal lanes, see /mockups/inbox/v16-kanban-lanes.html. */
-body[data-inbox-theme="kanban-lanes"] .gmail-crm-tab {
+body[data-inbox-theme="kanban-lanes"] {
   --color-bg: #f4f5f7; --color-surface: #fff; --color-surface-el: #ebecf0;
   --color-text: #172b4d; --color-text-secondary: #344563; --color-text-muted: #5e6c84;
   --color-border: #dfe1e6; --color-primary: #0052cc; --color-primary-hover: #003d99;
@@ -1415,7 +1415,7 @@ body[data-inbox-theme="kanban-lanes"] .gmail-crm-tab {
 /* ─── INBOX THEMES v17–v32 ─────────────────────────────────────────────── */
 
 /* v17 Ottoman Tile — navy + gold + teal, serif */
-body[data-inbox-theme="ottoman-tile"] .gmail-crm-tab {
+body[data-inbox-theme="ottoman-tile"] {
   --color-bg: #0a1f33; --color-surface: #102845; --color-surface-el: #19385f;
   --color-text: #fef0c8; --color-text-secondary: #e4c290; --color-text-muted: #c4a16a;
   --color-border: #3a5278; --color-primary: #d4a437; --color-primary-hover: #b88c20;
@@ -1423,7 +1423,7 @@ body[data-inbox-theme="ottoman-tile"] .gmail-crm-tab {
 }
 
 /* v18 Brutalist Concrete — raw gray + hot orange */
-body[data-inbox-theme="brutalist"] .gmail-crm-tab {
+body[data-inbox-theme="brutalist"] {
   --color-bg: #d4d4d0; --color-surface: #c0c0bc; --color-surface-el: #b4b4b0;
   --color-text: #1a1a18; --color-text-secondary: #2a2a28; --color-text-muted: #5a5a55;
   --color-border: #1a1a18; --color-primary: #ff5400; --color-primary-hover: #cc4300;
@@ -1431,7 +1431,7 @@ body[data-inbox-theme="brutalist"] .gmail-crm-tab {
 }
 
 /* v19 Library Catalog — manila + typewriter */
-body[data-inbox-theme="library-catalog"] .gmail-crm-tab {
+body[data-inbox-theme="library-catalog"] {
   --color-bg: #f3e6c8; --color-surface: #e8d8b0; --color-surface-el: #dec99a;
   --color-text: #2a1f10; --color-text-secondary: #4e3f24; --color-text-muted: #6e5a3a;
   --color-border: #8a6f44; --color-primary: #992014; --color-primary-hover: #7a1810;
@@ -1439,7 +1439,7 @@ body[data-inbox-theme="library-catalog"] .gmail-crm-tab {
 }
 
 /* v20 Wabi-Sabi — earthy clay + sage */
-body[data-inbox-theme="wabi-sabi"] .gmail-crm-tab {
+body[data-inbox-theme="wabi-sabi"] {
   --color-bg: #ece4d6; --color-surface: #f4ede0; --color-surface-el: #dccfb8;
   --color-text: #3a2f24; --color-text-secondary: #5a4a3a; --color-text-muted: #8a7a6a;
   --color-border: #a89a82; --color-primary: #a85a3a; --color-primary-hover: #8a4828;
@@ -1447,7 +1447,7 @@ body[data-inbox-theme="wabi-sabi"] .gmail-crm-tab {
 }
 
 /* v21 Cyberpunk Rain — black + acid green + hot pink */
-body[data-inbox-theme="cyberpunk-rain"] .gmail-crm-tab {
+body[data-inbox-theme="cyberpunk-rain"] {
   --color-bg: #000; --color-surface: #0a0a14; --color-surface-el: #10101e;
   --color-text: #00ff41; --color-text-secondary: #6acc70; --color-text-muted: #3a8a4a;
   --color-border: #143514; --color-primary: #ff003c; --color-primary-hover: #cc0030;
@@ -1455,7 +1455,7 @@ body[data-inbox-theme="cyberpunk-rain"] .gmail-crm-tab {
 }
 
 /* v22 Bauhaus — cream + primary colors */
-body[data-inbox-theme="bauhaus"] .gmail-crm-tab {
+body[data-inbox-theme="bauhaus"] {
   --color-bg: #f4f0e6; --color-surface: #fff; --color-surface-el: #ece6d4;
   --color-text: #1a1a1a; --color-text-secondary: #333; --color-text-muted: #666;
   --color-border: #1a1a1a; --color-primary: #e63946; --color-primary-hover: #c62939;
@@ -1463,7 +1463,7 @@ body[data-inbox-theme="bauhaus"] .gmail-crm-tab {
 }
 
 /* v23 Memphis 80s — playful pink/teal/yellow, dotted bg */
-body[data-inbox-theme="memphis"] .gmail-crm-tab {
+body[data-inbox-theme="memphis"] {
   --color-bg: #f5f4eb; --color-surface: #fff; --color-surface-el: #fff2d4;
   --color-text: #1a1a26; --color-text-secondary: #3a3a4a; --color-text-muted: #5e5e6e;
   --color-border: #1a1a26; --color-primary: #ff5fa2; --color-primary-hover: #e0438a;
@@ -1471,7 +1471,7 @@ body[data-inbox-theme="memphis"] .gmail-crm-tab {
 }
 
 /* v24 Vellum Manuscript — parchment + illuminated */
-body[data-inbox-theme="vellum"] .gmail-crm-tab {
+body[data-inbox-theme="vellum"] {
   --color-bg: #f7eed0; --color-surface: #fcf6dc; --color-surface-el: #eadda3;
   --color-text: #3a1f0c; --color-text-secondary: #5a3818; --color-text-muted: #8a6840;
   --color-border: #c4a060; --color-primary: #a8201a; --color-primary-hover: #881810;
@@ -1479,7 +1479,7 @@ body[data-inbox-theme="vellum"] .gmail-crm-tab {
 }
 
 /* v25 Solarpunk — sage + ochre */
-body[data-inbox-theme="solarpunk"] .gmail-crm-tab {
+body[data-inbox-theme="solarpunk"] {
   --color-bg: #f0ead2; --color-surface: #fffbeb; --color-surface-el: #e0e0bc;
   --color-text: #1f3320; --color-text-secondary: #3f5040; --color-text-muted: #5d7553;
   --color-border: #90a878; --color-primary: #578a4a; --color-primary-hover: #467038;
@@ -1487,7 +1487,7 @@ body[data-inbox-theme="solarpunk"] .gmail-crm-tab {
 }
 
 /* v26 Frutiger Aero — y2k glossy blue */
-body[data-inbox-theme="frutiger-aero"] .gmail-crm-tab {
+body[data-inbox-theme="frutiger-aero"] {
   --color-bg: #d6edff; --color-surface: #fff; --color-surface-el: #eaf4ff;
   --color-text: #1c3a5c; --color-text-secondary: #3a5e80; --color-text-muted: #5e7a98;
   --color-border: #a8c8e8; --color-primary: #34b5e8; --color-primary-hover: #1c9ad0;
@@ -1495,7 +1495,7 @@ body[data-inbox-theme="frutiger-aero"] .gmail-crm-tab {
 }
 
 /* v27 Crossword Grid — pure b&w, courier */
-body[data-inbox-theme="crossword"] .gmail-crm-tab {
+body[data-inbox-theme="crossword"] {
   --color-bg: #fff; --color-surface: #fff; --color-surface-el: #f2f2f2;
   --color-text: #000; --color-text-secondary: #2a2a2a; --color-text-muted: #666;
   --color-border: #000; --color-primary: #1856ad; --color-primary-hover: #134290;
@@ -1503,7 +1503,7 @@ body[data-inbox-theme="crossword"] .gmail-crm-tab {
 }
 
 /* v28 8-Bit Pixel — NES palette */
-body[data-inbox-theme="pixel-8bit"] .gmail-crm-tab {
+body[data-inbox-theme="pixel-8bit"] {
   --color-bg: #2c3e50; --color-surface: #34495e; --color-surface-el: #1a252f;
   --color-text: #ecf0f1; --color-text-secondary: #bdc3c7; --color-text-muted: #95a5a6;
   --color-border: #000; --color-primary: #f1c40f; --color-primary-hover: #d4a800;
@@ -1511,7 +1511,7 @@ body[data-inbox-theme="pixel-8bit"] .gmail-crm-tab {
 }
 
 /* v29 Watercolor — soft pastels with painterly bg */
-body[data-inbox-theme="watercolor"] .gmail-crm-tab {
+body[data-inbox-theme="watercolor"] {
   --color-bg: #f8f4ed; --color-surface: #fefcf7; --color-surface-el: #f0eadb;
   --color-text: #3a4a52; --color-text-secondary: #5a6a72; --color-text-muted: #7a8a93;
   --color-border: #c8c0b0; --color-primary: #d68a99; --color-primary-hover: #c06a7c;
@@ -1519,7 +1519,7 @@ body[data-inbox-theme="watercolor"] .gmail-crm-tab {
 }
 
 /* v30 Sakura — pink + white + black, minimal japanese */
-body[data-inbox-theme="sakura"] .gmail-crm-tab {
+body[data-inbox-theme="sakura"] {
   --color-bg: #fff9fa; --color-surface: #fff; --color-surface-el: #fdf0f2;
   --color-text: #1a0a14; --color-text-secondary: #4a3a44; --color-text-muted: #8a6878;
   --color-border: #f2c8d2; --color-primary: #e8597c; --color-primary-hover: #c8395c;
@@ -1527,7 +1527,7 @@ body[data-inbox-theme="sakura"] .gmail-crm-tab {
 }
 
 /* v31 Forest Walk — mossy green + bark */
-body[data-inbox-theme="forest-walk"] .gmail-crm-tab {
+body[data-inbox-theme="forest-walk"] {
   --color-bg: #1c2418; --color-surface: #2a3424; --color-surface-el: #3a4632;
   --color-text: #e8e0c8; --color-text-secondary: #c4ba9a; --color-text-muted: #9aa080;
   --color-border: #4a5638; --color-primary: #7a9c48; --color-primary-hover: #5a7c30;
@@ -1535,7 +1535,7 @@ body[data-inbox-theme="forest-walk"] .gmail-crm-tab {
 }
 
 /* v32 Botanical Print — field-guide cream + leaf green + copper */
-body[data-inbox-theme="botanical"] .gmail-crm-tab {
+body[data-inbox-theme="botanical"] {
   --color-bg: #f4ede0; --color-surface: #fdfaf0; --color-surface-el: #e6d8c0;
   --color-text: #1a2818; --color-text-secondary: #3a4628; --color-text-muted: #5a6648;
   --color-border: #8a9a78; --color-primary: #2a5028; --color-primary-hover: #1a3a18;
diff --git a/lib/gmail.ts b/lib/gmail.ts
index 98ef404..709acd0 100644
--- a/lib/gmail.ts
+++ b/lib/gmail.ts
@@ -26,40 +26,55 @@ export function getOAuth2Client() {
   return new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI);
 }
 
-export function getAuthUrl(): string {
+export function getAuthUrl(mailboxEmail?: string, state?: string): string {
   const client = getOAuth2Client();
   return client.generateAuthUrl({
     access_type: 'offline',
-    prompt: 'consent',
-    scope: SCOPES,
-    login_hint: MAILBOX_EMAIL,
+    prompt:      'consent',
+    scope:       SCOPES,
+    login_hint:  mailboxEmail || MAILBOX_EMAIL,
+    state:       state || mailboxEmail || MAILBOX_EMAIL,
   });
 }
 
-/* ─── Token Storage (DB-backed) ─────────────────────────────────────────── */
+/* ─── Token Storage (DB-backed) — per-mailbox ───────────────────────────── */
 
-export async function storeTokens(tokens: Record<string, unknown>) {
-  // Upsert into mailboxes table
-  const existing = await query('SELECT id FROM mailboxes WHERE email = $1', [MAILBOX_EMAIL]);
+export async function storeTokens(
+  tokens: Record<string, unknown>,
+  mailboxEmail: string = MAILBOX_EMAIL,
+  displayName: string  = '',
+) {
+  const existing = await query<{ id: string }>(
+    'SELECT id FROM mailboxes WHERE LOWER(email) = LOWER($1)',
+    [mailboxEmail],
+  );
 
   if (existing.rows.length > 0) {
     await query(
-      `UPDATE mailboxes SET oauth_tokens = oauth_tokens || $2::jsonb, updated_at = now() WHERE email = $1`,
-      [MAILBOX_EMAIL, JSON.stringify(tokens)],
+      `UPDATE mailboxes
+          SET oauth_tokens = COALESCE(oauth_tokens, '{}'::jsonb) || $2::jsonb,
+              needs_reauth = FALSE,
+              is_active    = TRUE,
+              updated_at   = NOW()
+        WHERE id = $1`,
+      [existing.rows[0].id, JSON.stringify(tokens)],
     );
   } else {
     await query(
-      `INSERT INTO mailboxes (email, display_name, provider, oauth_tokens, is_active)
-       VALUES ($1, $2, 'gmail', $3::jsonb, true)`,
-      [MAILBOX_EMAIL, 'Natalia (SDCC)', JSON.stringify(tokens)],
+      `INSERT INTO mailboxes (email, display_name, provider, oauth_tokens, is_active, needs_reauth)
+       VALUES ($1, $2, 'gmail', $3::jsonb, TRUE, FALSE)`,
+      [mailboxEmail, displayName || mailboxEmail, JSON.stringify(tokens)],
     );
   }
 }
 
-export async function getStoredTokens(): Promise<Record<string, unknown> | null> {
-  const result = await query('SELECT oauth_tokens FROM mailboxes WHERE email = $1', [MAILBOX_EMAIL]);
+export async function getStoredTokens(mailboxEmail: string = MAILBOX_EMAIL): Promise<Record<string, unknown> | null> {
+  const result = await query<{ oauth_tokens: Record<string, unknown> | null }>(
+    'SELECT oauth_tokens FROM mailboxes WHERE LOWER(email) = LOWER($1)',
+    [mailboxEmail],
+  );
   if (result.rows.length === 0 || !result.rows[0].oauth_tokens) return null;
-  return result.rows[0].oauth_tokens as Record<string, unknown>;
+  return result.rows[0].oauth_tokens;
 }
 
 /* ─── Authenticated Gmail Client ────────────────────────────────────────── */

← 82446f2 feat(theme): full-width inbox-theme banner at top of AppShel  ·  back to Norma  ·  fix(theme): apply 32 inbox-theme skins at body level so drop c498d02 →