[object Object]

← back to Norma

fix(gmail): scope Gmail CRM to current user's mailbox, not hardcoded natalia

8f71b143924c0a61dd5abb60ffdfa08bce11aa6f · 2026-05-20 13:46:46 -0700 · Steve Abrams

The Gmail CRM header read 'Gmail CRM — natalia@studentdebtcrisis.org'
for EVERY logged-in user, and /api/gmail/messages always returned
natalia's mailbox regardless of who was logged in. So info@ saw natalia's
inbox, which is the wrong sharing model.

- /api/auth/session now returns fullName + email pulled from
  tier_credentials (with username fallback when the username itself is
  email-shaped).
- /api/gmail/messages now resolves the mailbox via the authed user's
  email (case-insensitive). When the user has no connected mailbox it
  returns {messages: [], needs_connect: true} instead of leaking another
  user's mail. Admin can opt-out via ?mailbox=all to see the union.
- GmailCRMTab header now shows the logged-in user's email + a
  "Gmail not connected" badge when needs_connect=true.

Also added placeholder mailbox rows for info@ + SabrinaAshley@ with
needs_reauth=true so the upcoming Connect-Gmail flow has a target row.

Files touched

Diff

commit 8f71b143924c0a61dd5abb60ffdfa08bce11aa6f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 13:46:46 2026 -0700

    fix(gmail): scope Gmail CRM to current user's mailbox, not hardcoded natalia
    
    The Gmail CRM header read 'Gmail CRM — natalia@studentdebtcrisis.org'
    for EVERY logged-in user, and /api/gmail/messages always returned
    natalia's mailbox regardless of who was logged in. So info@ saw natalia's
    inbox, which is the wrong sharing model.
    
    - /api/auth/session now returns fullName + email pulled from
      tier_credentials (with username fallback when the username itself is
      email-shaped).
    - /api/gmail/messages now resolves the mailbox via the authed user's
      email (case-insensitive). When the user has no connected mailbox it
      returns {messages: [], needs_connect: true} instead of leaking another
      user's mail. Admin can opt-out via ?mailbox=all to see the union.
    - GmailCRMTab header now shows the logged-in user's email + a
      "Gmail not connected" badge when needs_connect=true.
    
    Also added placeholder mailbox rows for info@ + SabrinaAshley@ with
    needs_reauth=true so the upcoming Connect-Gmail flow has a target row.
---
 app/api/auth/session/route.ts    | 21 +++++++++++---
 app/api/gmail/messages/route.ts  | 62 +++++++++++++++++++++++++++++++++-------
 components/gmail/GmailCRMTab.tsx | 33 +++++++++++++++++++--
 3 files changed, 100 insertions(+), 16 deletions(-)

diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index de66685..d302985 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -6,28 +6,41 @@ export async function GET(request: NextRequest) {
   const session = verifyAuth(request);
 
   if (session) {
-    // Fetch display_name from tier_credentials
+    // Fetch full identity from tier_credentials so the UI never has to guess
+    // who's logged in (header chrome, Gmail CRM, audit log, etc.)
     let displayName: string | null = null;
-    let clientType: string | null = null;
+    let clientType:  string | null = null;
+    let fullName:    string | null = null;
+    let email:       string | null = null;
     try {
       const res = await query(
-        'SELECT display_name, client_type FROM tier_credentials WHERE username = $1',
+        'SELECT display_name, client_type, full_name, email FROM tier_credentials WHERE username = $1',
         [session.username],
       );
       if (res.rows.length > 0) {
         displayName = res.rows[0].display_name;
-        clientType = res.rows[0].client_type;
+        clientType  = res.rows[0].client_type;
+        fullName    = res.rows[0].full_name;
+        email       = res.rows[0].email;
       }
     } catch {
       // Non-fatal — proceed without display name
     }
 
+    // Fallback: if `email` column wasn't populated, infer from the username
+    // when it's already an email-shaped string (e.g. 'info@studentdebtcrisis.org').
+    if (!email && session.username.includes('@')) {
+      email = session.username;
+    }
+
     return NextResponse.json({
       authenticated: true,
       user: session.username,
       role: session.role,
       orgId: session.orgId,
       displayName,
+      fullName,
+      email,
       clientType,
       isInteriorDesigner: clientType === 'trade',
     });
diff --git a/app/api/gmail/messages/route.ts b/app/api/gmail/messages/route.ts
index 672ea84..b722624 100644
--- a/app/api/gmail/messages/route.ts
+++ b/app/api/gmail/messages/route.ts
@@ -56,11 +56,51 @@ export async function GET(request: NextRequest) {
   const params: unknown[] = [];
   let idx = 1;
 
-  // Only show natalia@studentdebtcrisis.org messages
-  const nataliaMailbox = await query(`SELECT id FROM mailboxes WHERE email = 'natalia@studentdebtcrisis.org'`);
-  if (nataliaMailbox.rows.length > 0) {
+  // Filter to the currently-logged-in user's own mailbox.
+  // Resolution order: explicit ?mailbox=<id> override → user's tier_credentials.email → username (when shaped like an email).
+  // Admin role can pass ?mailbox=all to see the union (no mailbox filter).
+  const explicitMailbox = sp.get('mailbox');
+  if (explicitMailbox === 'all' && auth.role === 'admin') {
+    // intentional no-op — admin opted out of mailbox scoping
+  } else if (explicitMailbox) {
     conditions.push(`mailbox_id = $${idx++}`);
-    params.push(nataliaMailbox.rows[0].id);
+    params.push(explicitMailbox);
+  } else {
+    // Resolve the user's email and look up their mailbox
+    let userEmail: string | null = null;
+    try {
+      const u = await query<{ email: string | null }>(
+        'SELECT email FROM tier_credentials WHERE username = $1',
+        [auth.username],
+      );
+      userEmail = u.rows[0]?.email || null;
+    } catch { /* non-fatal */ }
+    if (!userEmail && auth.username.includes('@')) {
+      userEmail = auth.username;
+    }
+    if (userEmail) {
+      const mb = await query<{ id: string }>(
+        'SELECT id FROM mailboxes WHERE LOWER(email) = LOWER($1) AND is_active = true',
+        [userEmail],
+      );
+      if (mb.rows.length > 0) {
+        conditions.push(`mailbox_id = $${idx++}`);
+        params.push(mb.rows[0].id);
+      } else {
+        // User has no connected mailbox — return zero rows.
+        return NextResponse.json({
+          messages:        [],
+          total:           0,
+          limit,
+          offset,
+          mailbox_email:   userEmail,
+          needs_connect:   true,
+        });
+      }
+    } else {
+      // No identifiable email at all — empty list (no leakage from other users).
+      return NextResponse.json({ messages: [], total: 0, limit, offset, needs_connect: true });
+    }
   }
 
   // Exclude trashed messages
@@ -194,24 +234,26 @@ export async function GET(request: NextRequest) {
     m.read_by           = readsByMessageId[m.id] || [];
   }
 
-  // Get unique labels/topics for filter dropdown (natalia only)
-  const nmbId = nataliaMailbox.rows[0]?.id;
+  // Get unique labels/topics for filter dropdown — scoped to the same mailbox
+  // we filtered messages by (if any). Pulled out of the first WHERE param.
+  const mailboxClauseIdx = params.findIndex((_, i) => conditions[i]?.startsWith('mailbox_id = '));
+  const mbId = mailboxClauseIdx >= 0 ? (params[mailboxClauseIdx] as string) : null;
   const topicsRes = await query(
     `SELECT DISTINCT unnest(labels) AS topic
      FROM gmail_messages
-     WHERE NOT (labels @> ARRAY['TRASH'])${nmbId ? ` AND mailbox_id = $1` : ''}
+     WHERE NOT (labels @> ARRAY['TRASH'])${mbId ? ` AND mailbox_id = $1` : ''}
      ORDER BY topic`,
-    nmbId ? [nmbId] : [],
+    mbId ? [mbId] : [],
   );
 
   // Get unique senders for "From" dropdown (natalia only)
   const sendersRes = await query(
     `SELECT DISTINCT from_address
      FROM gmail_messages
-     WHERE from_address IS NOT NULL AND from_address != ''${nmbId ? ` AND mailbox_id = $1` : ''}
+     WHERE from_address IS NOT NULL AND from_address != ''${mbId ? ` AND mailbox_id = $1` : ''}
      ORDER BY from_address
      LIMIT 100`,
-    nmbId ? [nmbId] : [],
+    mbId ? [mbId] : [],
   );
 
   return NextResponse.json({
diff --git a/components/gmail/GmailCRMTab.tsx b/components/gmail/GmailCRMTab.tsx
index 1e22489..3059544 100644
--- a/components/gmail/GmailCRMTab.tsx
+++ b/components/gmail/GmailCRMTab.tsx
@@ -244,6 +244,29 @@ export default function GmailCRMTab() {
 
   const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
   const [showSyncDetails, setShowSyncDetails] = useState(false);
+
+  // Currently-logged-in user identity — used for the header chrome + to know
+  // whether this user has a connected Gmail mailbox. Filled from /api/auth/session.
+  const [sessionEmail, setSessionEmail] = useState<string | null>(null);
+  const [sessionName,  setSessionName]  = useState<string | null>(null);
+  const [needsConnect, setNeedsConnect] = useState(false);
+
+  useEffect(() => {
+    let cancelled = false;
+    (async () => {
+      try {
+        const r = await fetch('/api/auth/session');
+        if (!r.ok) return;
+        const s = await r.json();
+        if (cancelled) return;
+        if (s?.authenticated) {
+          setSessionEmail(s.email || s.user || null);
+          setSessionName(s.fullName || s.displayName || null);
+        }
+      } catch { /* non-fatal */ }
+    })();
+    return () => { cancelled = true; };
+  }, []);
   // Mail first: land with the full-width inbox visible. The Contacts sidebar
   // pre-filters the list to one sender (onSelectJournalist → fromFilter), which
   // is useful for outreach drilldowns but the wrong default for "what came in
@@ -406,6 +429,7 @@ export default function GmailCRMTab() {
       setTotal(data.total || 0);
       setTopics(data.topics || []);
       setSenders(data.senders || []);
+      setNeedsConnect(!!data.needs_connect);
     } catch (err) {
       toast.addToast('Failed to load messages', 'error');
     } finally {
@@ -495,7 +519,7 @@ export default function GmailCRMTab() {
     }
   }, [selectedId, toast]);
 
-  /* ── Sync Gmail (natalia@studentdebtcrisis.org via OAuth) ────────────── */
+  /* ── Sync Gmail for the current user's connected mailbox ─────────────── */
   const syncGmail = useCallback(async () => {
     setSyncing(true);
     try {
@@ -600,7 +624,12 @@ export default function GmailCRMTab() {
               Gmail CRM
             </h2>
             <span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
-              natalia@studentdebtcrisis.org
+              {sessionEmail || 'Not signed in'}
+              {needsConnect && (
+                <span style={{ marginLeft: 8, color: '#f59e0b', fontWeight: 600 }}>
+                  · Gmail not connected
+                </span>
+              )}
             </span>
           </div>
           <span className="badge" style={{ fontSize: 11 }}>

← ee9135a feat(modals): fan out ResizableModal to 22 modals across 15  ·  back to Norma  ·  feat(theme): hoist inbox theme switcher to global AppShell h 70fa5dc →