name: doccorretor-gmail-oauth
description: Gmail OAuth integration for DocCorretor (TanStack Start + Supabase) β OAuth flow, callback pattern, and known pitfalls.
Gmail OAuth β DocCorretor Pattern
Overview
Full Gmail OAuth integration using Google OAuth 2.0 with refresh tokens, stored in Supabase.
TanStack Start server-side callback + frontend redirect pattern.
Architecture
`
Frontend (admin.tsx)
β getGmailAuthUrl() [server fn]
β window.open(url, "_blank") β opens Google consent in new tab
β on return: /admin?gmail_connected=1 or ?gmail_error=...
Callback (gmail-oauth-callback.ts)
β exchanges code at https://oauth2.googleapis.com/token
β gets user email via https://www.googleapis.com/oauth2/v2/userinfo
β validates user via Supabase JWT (state param)
β upserts into gmail_connections table
β Response.redirect() to /admin?gmail_connected=1
gmail.functions.ts
β sendGmailWithAttachments: uses stored refresh_token to get access_token,
then sends via Gmail API with base64 attachments
`
Files
- -
src/routes/admin.tsxβ connect/disconnect button, email send UI - -
src/routes/api/public/gmail-oauth-callback.tsβ OAuth callback handler - -
src/server/gmail.functions.tsβ server fns: getGmailConnection, getGmailAuthUrl, disconnectGmail, sendGmailWithAttachments - - User enters 16-char Gmail App Password in admin UI
- - Stored in
app_settingstable per user (gmail_app_password_{user_id}) - - nodemailer sends via
smtp.gmail.com:465with the stored app password - - Works in ALL browsers including Opera, no popup, no redirect, no OAuth issues
- -
gmail.app_passwordβ shared/global App Password (all users) - -
gmail_app_password_{user_id}β per-user App Password (individual)
Required .env vars
`env
SUPABASE_URL=https://lzoiuxulhnvtmadoymrb.supabase.co
SUPABASE_PUBLISHABLE_KEY=
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://documentos.rochasalesseguros.com.br/api/public/gmail-oauth-callback
`
Key Patterns
1. OAuth URL generation (server fn)
`typescript
export const getGmailAuthUrl = createServerFn({ method: "POST" })
.middleware([requireSupabaseAuth])
.handler(async ({ data }) => {
const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID!;
const redirectUri = ${data.origin}/api/public/gmail-oauth-callback;
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/userinfo.email",
access_type: "offline",
prompt: "consent",
state: data.userToken, // Supabase access_token JWT
});
return { url: https://accounts.google.com/o/oauth2/v2/auth?${params.toString()} };
});
`
2. Frontend β open OAuth in new tab (NOT window.open with popup name)
`typescript
async function connectGmail() {
const { data: { session } } = await supabase.auth.getSession();
if (!session) { toast.error("FaΓ§a login novamente."); return; }
const { url } = await getAuthUrlFn({ data: { origin: window.location.origin, userToken: session.access_token } });
if (!url) { toast.error("Erro ao gerar URL de autorizaΓ§Γ£o."); return; }
window.open(url, "_blank"); // _blank = new tab, NOT blocked by popup blocker
}
// Listen for ?gmail_connected=1 on mount
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("gmail_connected") === "1") {
refreshGmailConn();
toast.success("Gmail conectado!");
window.history.replaceState({}, "", window.location.pathname); // clean URL
}
const err = params.get("gmail_error");
if (err) {
toast.error("Erro Gmail: " + decodeURIComponent(err));
window.history.replaceState({}, "", window.location.pathname);
}
}, []);
`
3. OAuth callback β redirect pattern (NOT postMessage)
`typescript
GET: async ({ request }) => {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state"); // Supabase access_token
const error = url.searchParams.get("error");
const adminUrl = url.origin + "/admin";
if (error) return Response.redirect(adminUrl + "?gmail_error=" + encodeURIComponent(error), 302);
if (!code || !state) return Response.redirect(adminUrl + "?gmail_error=no_code", 302);
// Exchange code for tokens
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ code, client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!,
client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!, redirect_uri: url.origin + "/api/public/gmail-oauth-callback",
grant_type: "authorization_code" }),
});
const tokenJson = await tokenRes.json();
if (!tokenRes.ok || !tokenJson.refresh_token) {
return Response.redirect(adminUrl + "?gmail_error=" + encodeURIComponent(tokenJson.error_description || "refresh_token_missing"), 302);
}
// Validate user + upsert connection...
return Response.redirect(adminUrl + "?gmail_connected=1", 302);
}
`
4. Sending email with attachments (Gmail API)
`typescript
// Get fresh access token from refresh_token
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: new URLSearchParams({
client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!,
client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!,
refresh_token: conn.refresh_token,
grant_type: "refresh_token",
}),
});
const tokenJson = await tokenRes.json();
const accessToken = tokenJson.access_token;
// Build MIME multipart, then send
const sendRes = await fetch(
"https://gmail.googleapis.com/upload/gmail/v1/users/me/messages/send?uploadType=media",
{ method: "POST", headers: { Authorization: Bearer ${accessToken}, "Content-Type": "message/rfc822" }, body: rawB64 }
);
`
Gmail connections table (Supabase)
`sql
CREATE TABLE public.gmail_connections (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
google_email text NOT NULL,
refresh_token text NOT NULL,
created_at timestamptz DEFAULT now()
);
CREATE UNIQUE INDEX ON public.gmail_connections(user_id);
`
Known Pitfalls
window.open blocked by popup blocker
window.open(url, "gmail-oauth", "width=520,height=640") β named window with dimensions is blocked.
Fix: use window.open(url, "_blank") β standard new tab, not blocked.
postMessage requires popup
window.opener.postMessage(...) only works when the child was opened via window.open().
Fix: use Response.redirect() back to /admin?gmail_connected=1 β no popup needed.
App Password SMTP (fallback when OAuth fails)
If Google OAuth is blocked (Opera browser, headless, popups, etc.), the app already has a fully functional App Password SMTP route as fallback β no code changes needed.
Endpoint: POST /api/gmail/gmail-app-password β save app password
Endpoint: POST /api/gmail/send-gmail β send email with attachments
Endpoint: GET /api/gmail/gmail-app-password β check if configured
How it works:
User flow: Login β Admin β Gmail section β "Conectar Gmail" β Enter App Password β Save
The app password for tecrochasales@gmail.com: dkjp zbis xubt cjpt
User flow: Login β Admin β Gmail section β "Conectar Gmail" β Enter App Password β Save
The app password for tecrochasales@gmail.com: dkjp zbis xubt cjpt
Shared/global App Password (all users share one Gmail sender)
To avoid requiring every user to configure their own Gmail App Password, use a shared/global App Password stored in app_settings under gmail.app_password (NOT per-user). All email then sent from the shared account (tecrochasales@gmail.com).
`sql
-- Insert shared App Password via Supabase REST API (anon key works for reads)
INSERT INTO public.app_settings (key, value) VALUES ('gmail.app_password', '"dkjp zbis xubt cjpt"')
ON CONFLICT (key) DO UPDATE SET value = '"dkjp zbis xubt cjpt"';
`
Code change in gmail-app-password.ts β send-gmail handler fallback chain:
`typescript
// Get app password β per-user first, then shared fallback (gmail.app_password)
const pwdKey = gmail_app_password_${user.id};
let appPassword = await getAppSettings(pwdKey);
if (!appPassword) {
appPassword = await getAppSettings("gmail.app_password"); // β shared fallback
}
if (!appPassword) {
return Response.json({ error: { message: "Gmail nΓ£o configurado." } }, { status: 400 });
}
// All users send from the same Gmail account
senderEmail = "tecrochasales@gmail.com";
`
Known keys in app_settings:
No code changes needed β App Password SMTP is already implemented. Just direct user to use it.
Known Pitfalls
Google blocks headless browsers
Google OAuth returns "This browser or app may not be secure" in headless/automated browsers.
Fix: Use App Password SMTP instead (no OAuth, no browser needed).
Anonymous auth may be disabled
Supabase Auth can have anonymous sign-ins disabled ("anonymous_users": false).
Fix: OAuth flow uses the user's actual session token as state, not anonymous auth.
Missing GOOGLE env vars
.env often missing GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_REDIRECT_URI.
Fix: Add them β they are NOT in the original Lovable-generated .env.
Node 18 ESM build breaks
@lovable.dev/vite-tanstack-config β lovable-tagger fails with Node 18 (ESM module resolution error).
Fix: use Node 20 via /root/.nvm/versions/node/v20.20.2/bin/node
`bash
cd /var/www/documentos/deploy-vps-nodejs/app
/root/.nvm/versions/node/v20.20.2/bin/node node_modules/vite/bin/vite.js build
`
Server default is Node 18 β always switch before building.
RLS blocks insert/update on app_settings
RLS policies on app_settings table block write operations from the app's anon key.
Fix: Use Supabase Management API with service role key for database operations:
`bash
curl -X POST 'https://api.supabase.com/v1/projects/dauftiqcvgaydddoxqhh/query' \
-H 'apikey:
-H 'Content-Type: application/json' \
-d '{"sql": "INSERT INTO public.app_settings (key, value) VALUES (...)"}'
`
Supabase project ref in .env
DocCorretor and ComercialRS share the same Supabase Cloud project (dauftiqcvgaydddoxqhh).
The .env had wrong ref (lzoiuxulhnvtmadoymrb). Fix to dauftiqcvgaydddoxqhh if tables are empty.
Anonymous auth may be disabled
If user previously authorized but refresh_token is missing: refresh_token: Unknown error.
Google only returns refresh_token on FIRST consent or after revoking access at myaccount.google.com/permissions.
Fix: User must revoke prior access, then re-authorize.
Google Cloud Console Setup
1. Go to https://console.cloud.google.com/apis/credentials
2. Select project (or create new)
3. Create OAuth 2.0 Client ID (Web application type)
4. Add Authorized redirect URI: https://documentos.rochasalesseguros.com.br/api/public/gmail-oauth-callback
5. Enable Gmail API at https://console.cloud.google.com/apis/library/gmail.googleapis.com
6. Scopes needed: https://www.googleapis.com/auth/gmail.send, https://www.googleapis.com/auth/userinfo.email