← 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
M app/api/auth/session/route.tsM app/api/gmail/messages/route.tsM components/gmail/GmailCRMTab.tsx
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 →