Watches your Gmail, pulls every posting out of job-alert digests, scores each one against your resume, and pings you the moment a human (recruiter, hiring manager, interview scheduler) actually writes to you. Everything runs on free tiers — no paid API is required at any point.
Gmail API ──> parse MIME ──> classify ──┬─ job_alert ─> extract posting links ─> scrape JD ─> score vs resume ─> SQLite/Postgres ─> dashboard
└─ recruiter / interview / assessment / offer ─> Outreach table ─> Telegram ping
gmail.readonly), incrementally, using a stored cursor so
each run only looks at new mail.job_alert, recruiter_outreach, interview_invite,
assessment, offer, rejection, application_update, or other. Rules first (free, instant);
an LLM only refines the ambiguous ones, and only if you enable one.schema.org/JobPosting JSON-LD → readable HTML → optional LLM
extraction from raw text. Bot walls and application forms are detected rather than stored as if
they were the job description, and each row records how it was fetched (ok, blocked,
empty) and by which extractor.config/profile.yaml: title fit (40%), weighted skill coverage
(35%), resume cosine similarity (25%), minus penalties for over-seniority and location mismatch.
Hard blockers (security clearance, excluded titles, …) reject outright.MIN_JOB_SCORE and shows them in a dashboard with save/apply/ignore.Full click-by-click setup for every integration: docs/SETUP.md.
Run python -m app.run doctor at any time and it tells you what is still missing.
git clone <your-fork> inbox-job-agent && cd inbox-job-agent
python -m venv .venv && .venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
copy .env.example .env # macOS/Linux: cp
copy config\profile.example.yaml config\profile.yaml
secrets/client_secret.json. Free, no billing account needed.python -m app.auth_setup — a browser opens, and secrets/token.json
appears. That file holds the refresh token; it is what you paste into a host later.config/profile.yaml: your target titles, weighted skills, and your resume text. This
file is the whole brain of the matcher — spend ten minutes here, it pays for itself.python -m app.run doctor # check Gmail, LLM, database, Telegram, scraping
python -m app.run poll # one pass over new mail
python -m app.run poll --days 1 # everything that arrived today, ignoring the cursor
python -m app.run report --days 1 # how many alerts / interviews / assessments / other
python -m app.run backfill --days 7 # rewind and re-read the last week
python -m app.run serve # dashboard at http://localhost:8000
python -m app.run loop # poll forever, every 5 min
The dashboard has these pages:
| Page | What it holds |
|---|---|
| Each email in time order, with the jobs and follow-ups from that message | |
| Matches | Roles that fit your profile, grouped by day, with the source email |
| Follow-ups | Mail an actual person sent you: recruiters, interview scheduling, assessments, offers |
| Applications | One row per role you applied to, with its status and mail timeline |
| Flags | Mail you marked as the wrong category |
| Misses | Emails whose extraction looked wrong — download JSON for extractor work |
An application appears automatically when a confirmation email arrives (“thank you for applying to X at Y”), or the moment you press applied on a posting. After that, every interview invite, assessment, rejection or update from that company is matched back to it and pushes the status forward — never backwards, so a late “we received your application” cannot undo an interview.
No Gmail set up yet and just want to see the UI? python -m app.run demo seeds the database from a
sample alert email.
python -m app.run match --title "Machine Learning Engineer" --file some_job.txt
Prints the score breakdown, matched skills, and missing skills so you can calibrate weights and
MIN_JOB_SCORE before trusting it.
This app runs on Google Cloud Run. Push to main → Cloud Build → new Cloud Run revision.
Do not deploy from the laptop and from GitHub at the same time. Secrets stay in GCP.
Gmail on Cloud Run is polled every 5 minutes by Cloud Scheduler (POST /api/run).
A new revision starts the cursor at deploy time (old mail is skipped).
First-time steps: docs/deploy.md.
Test the same image locally:
docker build -t inbox-job-agent .
docker run --rm -p 8080:8080 -e PORT=8080 -e API_TOKEN=dev inbox-job-agent
config/profile.yaml is gitignored. After you edit it locally:
gcloud secrets versions add profile-yaml --data-file=config\profile.yaml
gcloud run services update inbox-job-agent --region us-east1 --update-secrets PROFILE_YAML=profile-yaml:latest
On Cloud Run, PROFILE_YAML and GMAIL_TOKEN_JSON replace the local files.
Nothing is hard-coded and nothing but these two files (plus secrets/) is personal.
| What | Local file | Hosted equivalent |
|---|---|---|
| Gmail OAuth client | secrets/client_secret.json (downloaded from Google Cloud) |
not needed after step 2 |
| Gmail refresh token | secrets/token.json (created by python -m app.auth_setup) |
GMAIL_TOKEN_JSON secret = the file’s contents |
| Which mail to read | GMAIL_QUERY in .env |
env var / Actions variable |
| Your resume, target roles, skills | config/profile.yaml |
PROFILE_YAML secret = the file’s contents |
| Score cutoff | MIN_JOB_SCORE in .env |
env var |
| Gemini key (aistudio.google.com) | GEMINI_API_KEY |
GEMINI_API_KEY secret |
| Groq key (console.groq.com) | GROQ_API_KEY |
GROQ_API_KEY secret |
| NVIDIA NIM (build.nvidia.com) | NVIDIA_API_KEY |
NVIDIA_API_KEY secret |
| DeepSeek / OpenRouter | DEEPSEEK_API_KEY, OPENROUTER_API_KEY |
same names |
| Local model instead | LLM_PROVIDER=ollama, OLLAMA_MODEL |
n/a |
| Telegram alerts | TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID |
same names as secrets |
| Dashboard password | API_TOKEN |
same name on Cloud Run |
| Database | DATABASE_URL (SQLite by default) |
DATABASE_URL secret for free Postgres |
.env (see .env.example) controls plumbing; config/profile.yaml controls judgment.
| Key | Meaning |
|---|---|
GMAIL_QUERY |
Gmail search used each poll. Narrow it, e.g. in:inbox -category:promotions -from:me. |
MIN_JOB_SCORE |
0–1 cutoff for storing a posting. Start at 0.45, raise once you see the noise level. |
SCRAPE_JOB_PAGES |
Fetch each posting page. Off = faster and quieter, but scores rely on the email snippet only. |
LLM_PROVIDER |
none disables models. Any other value turns on failover across every key you set. |
GMAIL_APPLY_LABEL |
Label processed mail in Gmail. Needs the gmail.modify scope — re-run app.auth_setup after enabling. |
API_TOKEN |
Dashboard/API key. Leave as change-me for local-only, set it before exposing the app. |
app/
auth_setup.py one-time OAuth flow -> secrets/token.json
gmail_client.py Gmail API wrapper (list, get, label)
email_parse.py MIME -> text/html/links
extract_jobs.py digest -> distinct postings, canonical de-dupe keys
scrape.py job page -> title/company/location/description
classify.py rules + optional LLM triage
applications.py company/role extraction, application matching, status transitions
matcher.py resume/profile scoring
pipeline.py the whole run, transactional per message
reporting.py category breakdown shared by the CLI and the Overview page
notify.py Telegram
main.py FastAPI dashboard + JSON API
doctor.py integration self-check behind `run doctor`
run.py CLI: doctor | poll | report | loop | backfill | serve | demo | match
config/profile.example.yaml
tests/
Mail is read from your own account with your own OAuth client; nothing is sent anywhere unless you
enable an LLM provider (then only sender, subject and the first 6k characters of ambiguous emails
are sent) or Telegram. The database is yours. Keep secrets/ and .env out of git — .gitignore
already does that.
python -m pytest -q
Covers digest extraction and de-duplication, scoring tiers and hard blockers, classifier rules, JSON-LD scraping, and the end-to-end pipeline against a fixture email (no network, no Gmail).