What this workflow does
Pulls new Gmail messages
Normalizes each email into clean text fields
Drops bulky headers and keeps only what the model needs
Filters out non-job emails with a light, rule-based gate
Sends the rest to a local LLM (Ollama) via the AI Chat Model / AI Agent node to extract:
Company, Role, Current Status, Date Applied, Exact Subject
Finalizes a minimal, sheet-ready record and builds a tracking key = company|role
Upserts into Google Sheets:
If a row with the same tracking key exists → update columns A..F
Otherwise → append a new row (A..G)
Optionally notifies me on Telegram when a row is added/updated
Prerequisites
n8n (Docker) running locally
Gmail credentials (OAuth or App Password)
Google Sheets credentials
Ollama + a small instruction-tuned model (e.g. llama3.2:3b-instruct-q4_K_M)
(Optional) Telegram bot token + chat id
1) Gmail: Get many messages
Node: Gmail → Get Many (or “Search” if you prefer queries)
Polling: run hourly (n8n Cron node) or set Gmail node to fetch “Since last run”
Return the essentials (snippet, headers, internalDate, etc.).
2) Normalizer (Function)
Goal: make each item consistent: subject, date (ISO), and a plain body_text. If you’re using Gmail’s snippet, that’s fine.
function clean(s=""){ return String(s).replace(/\s+/g," ").trim(); }
for (const item of items) {
const j = item.json || {};
const subject = clean(j.Subject || j.subject || "");
const ms = Number(j.internalDate || 0);
const dateIso = (isNaN(ms) || !ms) ? new Date().toISOString() : new Date(ms).toISOString();
const body_text = clean(j.body_text || j.snippet || "");
const fromText = clean(j.From || j.from || "");
item.json = { ...j, subject, date: dateIso, body_text, from: { text: fromText } };
}
return items;
3) Kill unnecessary fields / keep only what the model needs
We’ll keep subject, date, a compact body, a list of links, and from_domain for ATS/provider hints.
function clean(s=""){ return String(s).replace(/\s+/g," ").trim(); }
function pickSentences(text=""){
const sents = text.split(/(?<=[.!?])\s+/);
const re = /(thank you|application|applied|submitted|assessment|challenge|test|game|interview|schedule|invite|availability|under review|offer|congratulations|unfortunately|not selected|reject|decline|regret)/i;
const kept = sents.filter(x => re.test(x)).slice(0, 12).join(" ");
return kept || text.slice(0, 2000);
}
function domainOf(fromText=""){
const m = (fromText || "").match(/@([A-Za-z0-9.-]+\.[A-Za-z]{2,})/);
return m ? m[1].toLowerCase() : "";
}
for (const item of items) {
const j = item.json || {};
const subject = clean(j.subject || "");
const dateISO = j.date || new Date().toISOString();
const body_text = clean(j.body_text || "");
const body_compact = pickSentences(body_text);
const links = (body_text.match(/https?:\/\/[^\s"'>)]+/g) || []).slice(0, 30);
const links_str = links.join(" ");
const from_domain = domainOf(j.from?.text || "");
item.json = { subject, date: dateISO, body_compact, links_str, from_domain };
}
return items;
4) Filter out non-job mail (Gate + IF)
A tiny, deterministic gate prevents the model from force-fitting everything into “recruiting.”
function has(re, s) { return re.test(s || ""); }
const POS_STATUS = /(application (received|submitted)|thank you for applying|under review|assessment|interview|offer|unfortunately|not selected|reject)/i;
const NEG_MARKETING = /(newsletter|digest|apply now|new jobs posted|career site|talent community|upcoming deadlines|webinar|subscribe|unsubscribe)/i;
const NEG_SECURITY = /(security alert|2-step verification|password|authenticator|sign[- ]?in|login|account activity)/i;
const ATS_POS = /(boards\.greenhouse\.io|jobs\.lever\.co|smartrecruiters\.com|icims\.com|oraclecloud\.com|taleo|successfactors\.com|myworkday|myworkdayjobs|\.wd\d*\.)/i;
for (const item of items) {
const j = item.json || {};
const subject = (j.subject || "").toLowerCase();
const body = (j.body_compact || "").toLowerCase();
const links = (j.links_str || "").toLowerCase();
const domain = (j.from_domain || "").toLowerCase();
let score = 0;
if (has(POS_STATUS, subject) || has(POS_STATUS, body)) score += 3;
if (has(ATS_POS, links) || has(ATS_POS, domain)) score += 3;
if (has(NEG_MARKETING, subject) || has(NEG_MARKETING, body)) score -= 3;
if (has(NEG_SECURITY, subject) || has(NEG_SECURITY, body)) score -= 4;
item.json.gate_is_status = score >= 2;
item.json.gate_score = score;
}
return items;
IF node:
Condition (Expression): {{ $json.gate_is_status === true }}
true → go to AI
false → end (or route to a “Skipped” branch)
5) Decode Mail (AI Chat Model / AI Agent)
Provider: OpenAI-compatible credentials pointing to http://localhost:11434/v1
Model: llama3.2:3b-instruct-q4_K_M (fast, good enough for extraction)
Temperature: 0
Response format: JSON object (or force JSON via HTTP with "format":"json")
Instructions / System Prompt
You are an extraction engine. Output ONLY a single JSON object matching the schema.
Use empty string for unknowns. No commentary.
Schema:
{
"company": string,
"role": string,
"current_status": "Applied"|"Complete Assessment"|"Interview"|"Under Review"|"Offer"|"Rejected"|"Update",
"exact_subject_line": string,
"date_applied": "YYYY-MM-DD"
}User Prompt (use the slim fields)
subject: {{$json["subject"] || ""}}
date: {{$json["date"] || ""}}
body: {{$json["body_compact"] || ""}}
links: {{$json["links_str"] || ""}}
from_domain: {{$json["from_domain"] || ""}}Typical output per item in my setup:
{
"company": "Chobani",
"role": "Manufacturing Engineering Intern",
"current_status": "Rejected",
"exact_subject_line": "Manufacturing Engineering Intern at Chobani",
"date_applied": "2025-09-23"
}6) Finalize for Sheets (minimal)
Only the 7 columns you want, with tracking_key = company|role.
function parseObj(x){ try { return (typeof x === 'string') ? JSON.parse(x) : x || {}; } catch { return {}; } }
function toYMD(d){
if (!d) return new Date().toISOString().slice(0,10);
const t = Date.parse(d);
return isNaN(t) ? String(d).slice(0,10) : new Date(t).toISOString().slice(0,10);
}
function clampStatus(s){
const ok = new Set(["Applied","Complete Assessment","Interview","Under Review","Offer","Rejected","Update"]);
if (ok.has(s)) return s;
const l = (s||"").toLowerCase();
if (/thank you|received|submitted/.test(l)) return "Applied";
if (/assessment|challenge|test|game/.test(l)) return "Complete Assessment";
if (/interview|schedule|invite|availability/.test(l)) return "Interview";
if (/under review|in review|being reviewed/.test(l)) return "Under Review";
if (/offer|congratulations/.test(l)) return "Offer";
if (/unfortunately|not selected|reject|decline|regret/.test(l)) return "Rejected";
return "Update";
}
return items.map(it => {
const src = it.json || {};
const ai = parseObj(src.output ?? src.content ?? src.message?.content ?? src);
const company = (ai.company || "").trim();
const role = (ai.role || "").trim();
const dateApplied = toYMD(ai.date_applied || src.date);
const status = clampStatus((ai.current_status || "").trim());
const subject = (ai.exact_subject_line || src.subject || "").trim();
const tracking_key = `${company}|${role}`;
return {
json: {
Company: company,
Role: role,
"Date Applied": dateApplied,
"Current Status": status,
"Last updated": new Date().toISOString(),
"Exact subject line": subject,
tracking_key
}
};
});
7) Sheets upsert (lookup → update/append)
7.1 Lookup by tracking_key (G)
Node: Google Sheets → Read A:G
Function “Find row by tracking_key”:
const key = $items(0,1,0).json.tracking_key;
const rows = items[0].json;
let rowNumber = -1;
const hasHeader = Array.isArray(rows?.[0]) && rows[0][6] === 'tracking_key';
for (let i = 0; i < rows.length; i++) {
if (hasHeader && i === 0) continue;
if ((rows[i]?.[6] || "") === key) { rowNumber = i + 1; break; }
}
return [{ json: { rowNumber } }];IF: rowNumber > 0
true → Update
false → Append
7.2 Update row (A..F)
Set node (values A..F from Finalize)
Sheets → Update with range expression:
={{ "A" + $json.rowNumber + ":F" + $json.rowNumber }}7.3 Append row (A..G)
Set node (values A..G from Finalize)
Sheets → Append to A:G
7.4 (Optional) Telegram notify
Send a short message on append/update with Company, Role, and new Current Status.