Fetches recent emails from Gmail and tags each with a category using a local LLM (Ollama). Applies the category as a Gmail label
- Fetch – Connects to Gmail (OAuth), lists recent messages in the inbox, and downloads each message’s headers and body.
- Tag – Sends subject + body to Ollama (OpenAI-compatible API) and gets a single category and short reason per email.
- Save – Writes a raw CSV of fetched emails and a tagged CSV with
categoryandreason. - Apply – For each tagged email, adds the category as a Gmail label
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt- In Google Cloud Console, create a project and enable the Gmail API.
- Create OAuth 2.0 credentials (Desktop app), download as
credentials.json, and place it in the project root. - On the first run, a browser window will open for you to sign in. After authorising, a
token.jsonis created automatically — this stores your access token so you don't need to sign in again.
credentials.jsonis the app identity (safe to keep private, never changes).
token.jsonis your personal access token (auto-refreshed when it expires).
Both are listed in.gitignoreand will never be committed.
Install Ollama, then pull the default model:
ollama pull qwen2.5:14bIn Gmail Settings → Labels, create labels whose names exactly match the category names defined in utils/prompts.py (e.g. TRANSACTION_ALERT, OTP_AND_VERIFICATION, ADS). The script prints which labels are missing on startup — any missing categories are still classified but the label won't be applied in Gmail.
python run.pyFetches Primary messages that still lack any of your triage category labels (read or unread), classifies with Ollama, applies labels. By default only the last 90 days are searched (newer_than:90d) so untagged mode does not pull your entire archive; use --since-days 0 for no limit. Pass --unread-only for the old behavior (unread Primary only). Pass --mark-read to clear UNREAD after labeling (the Airflow DAG uses this).
| Flag | Default | Description |
|---|---|---|
--since-days N |
90 |
Only messages newer than N days (0 = no date limit) |
--max N |
None (all) | Cap how many messages Gmail returns for this run |
--unread-only |
off | Only unread messages (legacy Primary, or all tabs with --all-tabs) |
--dry-run |
off | Classify only, skip Gmail updates |
--mark-read |
off | After labeling, mark each message as read |
--all-tabs |
off | Untagged (or unread with --unread-only) across the whole inbox, not only Primary |
--cache |
off | Reuse today’s CSV instead of re-fetching |
--model MODEL |
qwen2.5:14b |
Ollama model to use |
Usually no.
- Changes to
run.py,utils/, etc.: each task runs a newpython … run.pyprocess, so the next DAG run always uses the latest files on disk. Keep scheduler + webserver running; no restart. - Changes to
dags/*.py: the scheduler re-parses DAG files periodically (often about every 30 seconds). Wait one cycle or trigger a new run. Still no need to kill both processes unless something is stuck. - Restart when: the scheduler/webserver crashed, you changed
airflow.cfg, or the UI shows the scheduler heartbeat warning and logs confirm it died.
Optional helper (from WSL, project root):
chmod +x scripts/airflow_wsl.sh
./scripts/airflow_wsl.sh restart # stop stale PIDs, start scheduler + webserver in background
./scripts/airflow_wsl.sh stop