santifer/career-ops · error · Error
gmail: missing GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET / GMAIL
Error message
gmail: missing GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET / GMAIL_REFRESH_TOKEN in .env
What it means
The Gmail ingest plugin requires three OAuth credentials to authenticate against the Gmail API: a client ID, client secret, and a long-lived refresh token. This error fires at the very start of ingest() when any of the three is absent or empty in ctx.env (populated from .env). The check is a flat OR across all three, so the message names all of them regardless of which one is actually missing.
Source
Thrown at plugins/gmail/index.mjs:77
}
function saveProcessedIds(ids) {
try {
mkdirSync('data', { recursive: true });
writeFileSync(STATE_PATH, JSON.stringify({ processed_message_ids: [...ids] }, null, 2), 'utf-8');
} catch (err) {
console.warn(`gmail: could not persist processed-id state — ${err.message}`);
}
}
/** @type {{ ingest: (ctx: any) => Promise<object[]> }} */
export default {
async ingest(ctx) {
const clientId = ctx?.env?.GMAIL_CLIENT_ID;
const clientSecret = ctx?.env?.GMAIL_CLIENT_SECRET;
const refreshToken = ctx?.env?.GMAIL_REFRESH_TOKEN;
if (!clientId || !clientSecret || !refreshToken) {
throw new Error('gmail: missing GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET / GMAIL_REFRESH_TOKEN in .env');
}
const label = ctx?.settings?.label || 'Job Leads';
const daysBack = Number(ctx?.settings?.days_back ?? 7);
if (!Number.isInteger(daysBack) || daysBack <= 0) {
throw new Error(`gmail: invalid days_back "${ctx?.settings?.days_back}" (must be a positive integer)`);
}
const token = await getAccessToken({ clientId, clientSecret, refreshToken }, ctx.fetch);
const auth = { Authorization: `Bearer ${token}` };
const query = `label:"${label}" newer_than:${daysBack}d`;
ctx.log(`gmail: querying ${query}`);
// List message ids (paginated). ctx.fetch throws on a non-2xx (with the body
// in the message), so a failed page surfaces a clear error.
const messages = [];
let pageToken = null;
do {View on GitHub (pinned to 9b17a8ac97)
Solutions
- Add all three vars to .env: GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN — get them from a Google Cloud Console OAuth 2.0 client (Desktop/app type) where Gmail API is enabled and the user has authorized the https://mail.google.com/ scope to obtain the refresh token.
- Confirm the engine is loading that .env — the plugin reads ctx.env, so the vars must reach the plugin context, not just process.env of a different process.
- Re-run `node plugins.mjs run gmail`.
Example fix
# before (.env) GMAIL_CLIENT_ID= GMAIL_CLIENT_SECRET=xxxxx GMAIL_REFRESH_TOKEN=yyyyy # after (.env) GMAIL_CLIENT_ID=123456-abc.apps.googleusercontent.com GMAIL_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxx GMAIL_REFRESH_TOKEN=1//0eXXXXXXXXXXXXXXXX
Defensive patterns
Strategy: validation
Validate before calling
// Before calling the plugin's ingest, verify the env is populated.
import { existsSync, readFileSync } from 'fs';
function gmailEnvReady(env = process.env) {
const need = ['GMAIL_CLIENT_ID', 'GMAIL_CLIENT_SECRET', 'GMAIL_REFRESH_TOKEN'];
const missing = need.filter((k) => !env[k]);
if (missing.length) {
return { ready: false, missing };
}
return { ready: true, missing: [] };
}
const { ready, missing } = gmailEnvReady();
if (!ready) console.error(`gmail not configured; set in .env: ${missing.join(', ')}`); Type guard
// Narrow plugin ctx.env before passing to ingest.
/** @typedef {{ GMAIL_CLIENT_ID: string, GMAIL_CLIENT_SECRET: string, GMAIL_REFRESH_TOKEN: string }} GmailEnv */
/** @param {unknown} e
* @returns {e is GmailEnv} */
function isGmailEnv(e) {
const env = /** @type {Record<string, unknown>} */ (e ?? {});
return typeof env.GMAIL_CLIENT_ID === 'string' && env.GMAIL_CLIENT_ID.length > 0
&& typeof env.GMAIL_CLIENT_SECRET === 'string' && env.GMAIL_CLIENT_SECRET.length > 0
&& typeof env.GMAIL_REFRESH_TOKEN === 'string' && env.GMAIL_REFRESH_TOKEN.length > 0;
} Try / catch
try {
const jobs = await gmailPlugin.ingest(ctx);
} catch (err) {
if (err instanceof Error && err.message.startsWith('gmail: missing GMAIL_CLIENT_ID')) {
console.warn('Skipping gmail ingest — configure credentials in .env');
// non-fatal: continue without gmail leads
} else throw err;
} Prevention
- Validate required env vars at startup, before any plugin runs — fail with a clear setup message, not mid-ingest.
- Keep a single source of truth for env (.env) and a loader the engine always runs through.
- Document the OAuth scope (https://mail.google.com/) the refresh token must cover, so tokens aren't silently scope-limited.
When it happens
Trigger: Calling `node plugins.mjs run gmail` (or any engine entry that drives the gmail plugin's ingest hook) when .env lacks one or more of GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN. Also fires if the vars are set but empty (e.g. `GMAIL_CLIENT_ID=`), since the guard is falsy-checking (!clientId || ...).
Common situations: First-time plugin setup (OAuth credentials never created in Google Cloud Console); .env not loaded by the runner; credentials added to a different .env than the one the engine reads; refresh token revoked or never generated (OAuth app in testing without a granted refresh token).
Related errors
- NOTION_ACCESS_TOKEN is not set (.env) — the Notion plugin ne
- Gmail token refresh failed: ${res.status} ${(await res.text(
- Gmail token refresh returned no access_token
- gmail: invalid days_back "${ctx?.settings?.days_back}" (must
- Set NOTION_PARENT_PAGE_ID in .env (the "Career Ops" parent p
AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13).
Data as JSON: /api/errors/5660e633ace70ba5.
Report an issue: GitHub.