Building an n8n job tracker

What this workflow does

  1. Pulls new Gmail messages

  2. Normalizes each email into clean text fields

  3. Drops bulky headers and keeps only what the model needs

  4. Filters out non-job emails with a light, rule-based gate

  5. 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

  6. Finalizes a minimal, sheet-ready record and builds a tracking key = company|role

  7. Upserts into Google Sheets:

    • If a row with the same tracking key exists → update columns A..F

    • Otherwise → append a new row (A..G)

  8. 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 (multi-item)
// Output: item.json.subject, item.json.date (ISO), item.json.body_text, item.json.from.text (if available)
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 (multi-item) — “Slim unnecessary fields”
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 (multi-item) — “Filter out non-job”
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 (multi-item) — “Finalize for Sheets (minimal)”
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 => {
  // Handle agent outputs that put JSON inside a string property
  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; // current item from Finalize node (2nd input)
const rows = items[0].json; // rows from Read (1st input)
let rowNumber = -1;

// detect header
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; } // 1-based
}
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.

© Copyright 2025. All rights Reserved.

Made by

Abishek Girish

© Copyright 2025. All rights Reserved.

Made by

Abishek Girish

© Copyright 2025. All rights Reserved.

Made by

Abishek Girish

Create a free website with Framer, the website builder loved by startups, designers and agencies.