How we reviewed this: we ran a real IMAP server locally (GreenMail with an stunnel TLS front end, because the client always uses implicit TLS), seeded it with five messages we wrote — an invoice, a production outage, an obvious scam, a lunch invitation, and a contract note with a phone number and an IBAN in it — and pointed the classifier at a local stand-in in place of the API by patching the one endpoint constant, which we then reverted and diffed. We made no Jev calls.
What it does
Poll an IMAP folder, ask one noul question per configured category about each unprocessed message, and run the actions of every category whose probability clears its threshold: tag, move, flag, unflag, mark read or unread, or post a webhook. There is a Textual TUI for setting it up and a config.yaml you can write by hand. Secrets live in .env and are referenced as ${VAR}.
The design decisions are sensible. One request per email carries every category at once, so five categories cost one call rather than five. A message is marked with a custom IMAP keyword, $JevProcessed, so a re-run skips it. max_emails_per_run caps a single pass, and the fetch takes the newest unprocessed mail first — a fix the author made the day before we looked, and the right way round for a backlog.
It works, and we watched it work
Against our seeded mailbox, the dry run classified and touched nothing:
[dry-run] 'You have WON a free iPhone!!!': spam (0.97) -> move
[dry-run] 'Production outage - card payments failing': urgent (0.97) -> flag
[dry-run] 'Production outage - card payments failing': urgent (0.97) -> webhook
[dry-run] 'Invoice INV-2291 for August': invoice (0.95) -> tag
[dry-run] 'Invoice INV-2291 for August': invoice (0.95) -> move
[dry-run] 'Lunch on Thursday?': no category matched
We checked the mailbox afterwards: no flags, no moves, no $JevProcessed. A dry run really is one.
With a working configuration, a real run did all of it — Invoice tag applied and the message moved into a folder it created, the outage flagged and a webhook posted, the scam moved to Spam — and the third run found nothing left to do and exited 0. The keyword mechanism works on a server that supports custom keywords, which Gmail does.
One small thing we noticed: a message that gets moved does not end up with $JevProcessed, because the mark is applied to a UID that has just left the folder. It doesn’t matter while the message stays moved; move it back and it will be classified again.
The shipped webhook config stops the run
config.example.yaml — the file the README points at — configures the webhook like this:
urgent:
actions:
- type: flag
- type: webhook
url: ${SLACK_WEBHOOK_URL}
${VAR} interpolation is only applied to three fields. config.py:97-106 expands mailbox.host, mailbox.username and mailbox.password; Action.from_dict takes value, folder and url straight out of the YAML. So the webhook URL stays the literal string ${SLACK_WEBHOOK_URL}, and the run ends like this:
httpx.UnsupportedProtocol: Request URL is missing an 'http://' or 'https://' protocol.
The traceback is the visible part. The state it leaves behind is the part that matters:
| message |
what happened |
marked processed? |
| contract note |
classified, no category matched |
yes |
| lunch invitation |
classified, no category matched |
yes |
| scam |
moved to Spam |
no |
| production outage |
flagged, then the webhook raised |
no |
| invoice |
never reached |
no |
Actions are applied one at a time and mark_processed only runs after all of them, so an exception in the middle leaves the message actioned but unprocessed — and the invoice, further down the queue, was never classified at all. We ran it twice: the second run repeated the same partial work and died in the same place.
Nothing here is dangerous on its own — a re-flag is idempotent, and a re-move usually is. But a webhook that fires twice is not, and the fix is one line in the config loader: run the same interpolation over action fields. Until then, put the literal URL in config.yaml and keep that file out of version control, which is exactly what the ${VAR} convention was there to avoid. We’ve reported it.
What leaves your machine
One request per email. The state is Subject: … followed by the whole plain-text body, and every category rides along as a noul in the same request:
{"model": "jev-latest",
"state": "Subject: Re: contract draft - my notes\n\nA few notes on clause 7. My mobile is
+44 7700 900123 and the account is GB29 NWBK 6016 1331 9268 19.",
"questions": {"invoice": {"type": "noul", "instructions": "Does this apply: Invoice, …"}, …}}
The phone number and the IBAN we planted went out with it, as they would from any mail classifier — the point is that there is no redaction step and no length cap, so the body of every unprocessed message reaches the provider in full. Treat your inbox as something you are handing to a third party, or run it against a folder you have chosen deliberately rather than INBOX.
The webhook is better behaved: it posts {"category", "probability", "subject"} and no body. Three providers are supported — TypeSafe direct, OpenRouter and the Vercel gateway — with the key read from .env and sent as a bearer token. The IMAP connection is always implicit TLS with no plaintext or STARTTLS switch, which is why our local server needed a TLS front end: a good default that costs a little convenience.
Maintenance
MIT, 66 tests passing, an installer script, a TUI with screenshots, and a fix history that reads like someone using their own tool (clearing a key field now removes it; long HTTP errors no longer hide a button). There is no .github directory, so nothing runs those 66 tests.
Worth crediting: providers/typesafe_direct.py carries the comment “Documented endpoint, not yet live-tested by us — flagged in the README.” Saying which of your own code paths you have not exercised is rare. For what it is worth, we drove that path against our stand-in and its request and response handling are correct.
Verdict
A tidy, honest tool that does what it says, with one config bug between it and a clean first run. Fix the webhook URL by hand, use --dry-run first on a real mailbox, and point it at a folder rather than your whole inbox until you have watched a few decisions.
For the read-only version of the same idea, see jevmail; for the same pattern on Reddit rather than mail, Lurk.
See how it compares with other tools in Best Jev tools, tested hands-on.
Review updated Sep 21, 2026. Numbers quoted from the project are its author's own; we don't publish our own measurements of Jev.