# Scheduled Inbox Polling with himalaya — reference pattern

Use this when you want Hermes to check a mailbox on a schedule and only
alert on **new** mail matching a known set of senders (or containing specific
keywords). The motivating use case: job-application status mail — there are
~10 firms you applied to, you want a WhatsApp ping when one of them
replies, and you don't want pings for every recruiter spam that lands in
your inbox.

## Components

1. **Poller script** — Python; calls `himalaya envelope list`, parses the
   table, filters by sender/subject, persists a state file so each message
   is reported exactly once.
2. **State file** — JSON list of message IDs already reported. Trim
   periodically so it doesn't grow unbounded.
3. **Cron job** — runs the poller on a schedule (e.g., Mo–Fr 8–17, hourly).
4. **Notifier** — the cron job's final response is auto-delivered to the
   chat that scheduled it (default), so just `print()` the alert.

## Pitfalls (the cheap ones)

- **State file must include the *scan* envelope IDs, not just the matched
  ones.** Otherwise the poller will re-scan the same 100 envelopes every
  hour and re-fire alerts every time one of them matches. Track "envelopes
  I've seen" separately from "envelopes that matched".
- **Trim the state file.** If you bind it to message IDs (numeric), keep
  at most the most recent ~1000 — old IDs scroll out of the IMAP window
  and re-appearing them would cause noise.
- **Don't fetch the full message body for every envelope.** Only call
  `himalaya message read <id>` when the truncated subject is suspicious
  (i.e., the row's SUBJECT column is ≥ 85 chars). Reading N envelopes N
  times for N cron runs gets expensive fast.
- **Page size 50, scan 2 pages.** For a typical inbox this covers current
  activity. Bumping to 3+ pages is fine but ten pages is overkill — if
  you have 500 unread mail, you don't want to re-scan them every hour.
- **The matcher should match on subject AND sender.** A firm like Stepstone
  is a platform — the *sender* is "Lisa Stein von Stepstone" but the
  *real* company is the one whose vacancy they're alerting about. Decide
  which signal you trust and stick with it.

## Working example (job-application poller)

Skeleton (see `scripts/check_bewerbungen.py` in the v2 skill for the full
implementation):

```python
import json, subprocess
from pathlib import Path

HIMALAYA = Path.home() / ".local" / "bin" / "himalaya.exe"
STATE_FILE = Path.home() / ".hermes" / "scripts" / "state.json"

# (needle, display_name, domain) — match needle in sender OR subject
FIRMS = [
    ("g&s", "G&S IT Group", "gs-it-group.com"),
    ("stepstone", "Stepstone", "email.stepstone.de"),
    # ...
]

def classify(subject: str) -> str:
    s = subject.lower()
    if any(k in s for k in ["absage", "nicht weiter", "abgelehnt"]):
        return "ABSAGE"
    if "news" in s and "bewerbung" in s:
        return "STATUS-UPDATE"   # Stepstone "News zu deiner Bewerbung" — usually a polite decline
    if any(k in s for k in ["interview", "einladung", "vorstellungsgespräch"]):
        return "EINLADUNG"
    if any(k in s for k in ["erhalten", "eingang", "bestätig"]):
        return "EMPFANGSBESTÄTIGUNG"
    if any(k in s for k in ["verzöger", "zwischeninfo"]):
        return "ZWISCHENINFO"
    return "BEWERBUNGS-MAIL"

def load_state() -> set[str]:
    return set(json.loads(STATE_FILE.read_text())) if STATE_FILE.exists() else set()

def save_state(state: set[str]) -> None:
    STATE_FILE.write_text(json.dumps(sorted(state, key=int), indent=2))

def main():
    state = load_state()
    new_hits = []
    seen_this_run = set()

    for page in (1, 2):
        result = subprocess.run(
            [str(HIMALAYA), "envelope", "list",
             "--page", str(page), "--page-size", "50",
             "--max-width", "200"],
            capture_output=True, text=True, timeout=30,
        )
        for env in parse_envelope_table(result.stdout):
            env_id = env["id"]
            if env_id in state or env_id in seen_this_run:
                continue
            seen_this_run.add(env_id)

            sender = env["from"].lower()
            subject = env["subject"].lower()
            firm = next(
                ((name, dom) for n, name, dom in FIRMS
                 if n in sender or n in subject),
                None,
            )
            if not firm:
                continue

            new_hits.append({
                "id": env_id,
                "firm": firm[0],
                "category": classify(env["subject"]),
                "subject": env["subject"],
                "from": env["from"],
                "date": env["date"],
            })

    # Persist EVERY scanned ID, not just matches — so we don't re-fire.
    new_state = state | seen_this_run
    if len(new_state) > 1000:
        new_state = set(sorted(new_state, key=int)[-1000:])
    save_state(new_state)

    print(json.dumps({"new": new_hits, "scanned": len(seen_this_run)}, ensure_ascii=False, indent=2))
```

## Cron wiring

```python
cronjob(
    action="create",
    name="Bewerbungs-Mail-Check",
    schedule="0 8-17 * * 1-5",   # every hour, Mon–Fri, 8–17
    prompt=(
        "Führe `python C:\\Users\\server\\.hermes\\scripts\\check_bewerbungen.py` "
        "aus. Wenn die JSON-Ausgabe `new` ≠ [] ist, fasse die Treffer als "
        "deutsche WhatsApp-Benachrichtigung zusammen — eine Zeile pro Mail, "
        "Format: '<Firma> — <Kategorie>: <Subject>'. Wenn `new` == [], "
        "gib NICHT aus (silent watchdog)."
    ),
    skill="himalaya-v2",
)
```

## Anti-patterns

- **Don't fire on every subject that contains "Bewerbung".** That double-counts
  Stepstone-style "Wir haben deine Bewerbung erhalten" forwarders. Prefer
  the firm-name match and classify the subject independently.
- **Don't notify on the *envelope*, notify on the *event*.** Multiple
  envelopes about the same application (e.g., one Empfangsbestätigung +
  one Zwischeninfo) should be separate events, not collapsed.
- **Don't suppress duplicates acroos runs.** If you flag the same ID as
  seen and then the user replies "show me the body", your script should
  still be able to fetch it on demand. Don't delete the message from the
  mailbox as a "dedupe" — himalaya's `flag add --flag seen` is the right
  primitive for mailbox-side dedupe.
