Skip to content
New site — Virtual Media Folders now has its own home. Visit vmf.soderlind.no →

Jev Mail Triage

Find spam and phishing in your Proton Mail inbox — locally, safely, and without touching your mail until you say so.

Jev Mail Triage runs on your Mac, reads new messages through Proton Mail Bridge, and asks TypeSafe.ai’s Jev model two questions about each one:

  1. Is this unsolicited spam?
  2. Is this a scam or phishing attempt?

By default it only records the answers (dry-run). Messages are moved to a Jev Review folder only if you explicitly turn that on. Nothing is ever deleted. Apple Mail shows any moves automatically, because it is connected to the same Bridge account.

[!IMPORTANT] Privacy: for each assessed message, the sender, subject, a shortened text body, and link addresses are sent to TypeSafe. That data leaves Proton’s end-to-end encrypted environment. Use this tool only if you are comfortable with that.

  • A Mac with Proton Mail Bridge installed, signed in, and running.
  • Python 3.11 or later.
  • A TypeSafe API key with access to a Jev model.

Your Mac and Bridge must stay running for the service to check mail.

Terminal window
git clone https://github.com/soderlind/jev-mail-triage.git
cd jev-mail-triage
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e '.[dev]'
Terminal window
mkdir -p ~/.config/jev-mail-triage
cp config.example.toml ~/.config/jev-mail-triage/config.toml

Open Proton Mail Bridge, select your account, and open Mailbox details → IMAP. You will see a host, port, security mode, username, and password.

Edit ~/.config/jev-mail-triage/config.toml and fill in the [bridge] section:

Config settingWhere to find it in Bridge
hostIMAP host (usually 127.0.0.1)
portIMAP port (often 1143)
security_modestarttls or implicit_tls, exactly as shown
usernameIMAP username (usually your email address)

Do not put the password in the config file. You will store it in Keychain in the next step.

Terminal window
.venv/bin/jev-mail-triage secret set bridge-password
.venv/bin/jev-mail-triage secret set typesafe-api-key

Each command asks you to paste the secret. Nothing is shown on screen.

  • Bridge password: copy it from Mailbox details → IMAP → Password in Bridge. This is a Bridge-generated password, not your Proton account password.
  • TypeSafe API key: copy it from your TypeSafe account.

Never share these in screenshots or chat. If one leaks, rotate it (see Troubleshooting).

[!TIP] The commands use ~/.config/jev-mail-triage/config.toml by default. To use another file, add --config /path/to/config.toml before the command, for example jev-mail-triage --config my.toml poll.

Terminal window
.venv/bin/jev-mail-triage poll

The first run records a baseline: it notes where your Inbox currently ends and skips existing messages. From then on, only new arrivals are assessed. poll never moves mail.

6. (Optional) Assess some existing messages

Section titled “6. (Optional) Assess some existing messages”
Terminal window
.venv/bin/jev-mail-triage backfill --limit 25 --dry-run

This assesses up to 25 of your newest existing messages without moving them. Each assessed message is sent to TypeSafe and may use API credits.

Terminal window
.venv/bin/jev-mail-triage serve

This checks for new mail every 60 seconds. Press Ctrl+C to stop. To run it in the background at login, see Run in the background.

Terminal window
.venv/bin/jev-mail-triage status
.venv/bin/jev-mail-triage candidates --headers

status shows counts. candidates --headers lists the messages Jev considers spam or phishing, with subject, sender, and date, so you can check whether it got them right.

CommandWhat it doesChanges mail?
pollChecks for new mail onceNever
serveChecks for new mail every 60 secondsOnly if moves are enabled
statusShows counts per status; no message contentNo
candidatesLists the 20 newest spam/phishing decisions (scores and IDs)No
candidates --headersSame, plus subject, sender, and date fetched from BridgeNo
backfill --limit NAssesses up to N existing messages, newest first (max 1,000)Only if moves are enabled
backfill --limit N --dry-runSame, but never moves anythingNever
retry --limit NTries failed assessments againNo (they are re-queued)
evaluate --labels FILE.csvMeasures accuracy against labelled test emailsNo
secret set NAMEStores a secret in KeychainNo

Prefix each command with .venv/bin/jev-mail-triage, or activate the virtual environment first (source .venv/bin/activate) and run jev-mail-triage.

Useful options:

  • status --json and candidates --json print machine-readable output.
  • candidates --limit 100 shows more results (maximum 1,000).

[!NOTE] --dry-run prevents mailbox changes, but messages are still sent to TypeSafe for assessment.

  • assessed — the message was checked.
  • move_decision — Jev scored it at or above a threshold. In dry-run mode this means “would have moved”, not that it was moved.
  • baseline / rebaseline — the service recorded a new starting point and skipped existing mail. A rebaseline happens when the mailbox identity changes, for example after removing and re-adding the account in Bridge.

candidates reads only the local database and can run while serve is running. Only --headers contacts Bridge. It opens the Inbox read-only, so messages stay unread. Headers are printed on your screen only; they are not stored or sent to TypeSafe.

Start in dry-run mode, review candidates --headers for a while, and only enable moves once you trust the results.

  1. Review the thresholds in the [policy] section of your config:

    move_enabled = false
    spam_threshold = 0.98
    scam_threshold = 0.95

    A message is a candidate when either score is at or above its threshold. Lower values catch more messages but increase the risk of false positives. These defaults are provisional, not validated guarantees. Calibrate them with labelled test emails if you can.

  2. Set move_enabled = true.

  3. Restart serve.

When moves are enabled:

  • Only high-scoring messages are moved, into a Jev Review folder (configurable with review_folder). Apple Mail shows it under your Proton account.
  • Uncertain and clean messages stay where they are.
  • Messages whose content was too large to send in full are never moved.
  • Nothing is ever deleted. Check Jev Review and move any mistakes back.

[!NOTE] Changing thresholds affects future assessments only. Messages that were already assessed are not rescored.

“Could not connect to Proton Mail Bridge” Make sure Bridge is open and signed in. Check that host, port, and security_mode match Mailbox details → IMAP exactly.

Login fails, or Bridge reports “no such user” The username or password does not match Bridge. Copy both again from Mailbox details → IMAP, then run secret set bridge-password.

My Bridge password was exposed In Bridge, remove the account (trash icon) and add it again; this creates a new password. Run secret set bridge-password again. Expect a rebaseline on the next run.

Certificate or TLS errors Certificate checks are always on and cannot be disabled. If macOS does not trust the Bridge certificate, export it from Bridge as a PEM file and set tls_ca_file in the [bridge] section. Do not install a global trust bypass.

Keychain error The program only stores secrets in macOS Keychain and refuses other storage backends.

Messages stuck as failed Temporary TypeSafe or Bridge errors are retried automatically with increasing delays. If a message still fails, run retry --limit 20. Moves with an unclear outcome are never retried automatically; check those messages by hand.

move_decision is greater than zero, but nothing moved That is expected in dry-run mode. See Turning on automatic moves.

The best way to keep the service running is a macOS LaunchAgent. It starts jev-mail-triage serve when you log in, restarts it if it exits, and runs in your login session, so it can read your Keychain secrets. A sample is included at launchd/com.soderlind.jev-mail-triage.plist. This project never installs or activates it for you.

Only do this after you have reviewed the dry-run results.

  1. Install the LaunchAgent with your paths filled in. Run this from the project folder. The log-path substitutions must come before the executable path substitution.

    Terminal window
    mkdir -p ~/Library/LaunchAgents ~/Library/Logs
    sed -e "s#/absolute/path/to/jev-mail-triage.log#$HOME/Library/Logs/jev-mail-triage.log#" \
    -e "s#/absolute/path/to/jev-mail-triage-error.log#$HOME/Library/Logs/jev-mail-triage-error.log#" \
    -e "s#/absolute/path/to/jev-mail-triage#$PWD/.venv/bin/jev-mail-triage#" \
    launchd/com.soderlind.jev-mail-triage.plist \
    > ~/Library/LaunchAgents/com.soderlind.jev-mail-triage.plist
    plutil -lint ~/Library/LaunchAgents/com.soderlind.jev-mail-triage.plist
  2. Start it:

    Terminal window
    launchctl bootstrap "gui/$(id -u)" \
    ~/Library/LaunchAgents/com.soderlind.jev-mail-triage.plist
  3. Check that it is running:

    Terminal window
    launchctl print "gui/$(id -u)/com.soderlind.jev-mail-triage" | grep -E "state|pid"
    tail -f ~/Library/Logs/jev-mail-triage*.log
    .venv/bin/jev-mail-triage status
  4. Restart it after changing the config. The service reads config.toml only at startup, so changes to thresholds or move_enabled need a restart:

    Terminal window
    launchctl kickstart -k "gui/$(id -u)/com.soderlind.jev-mail-triage"
  5. Stop and unload it:

    Terminal window
    launchctl bootout "gui/$(id -u)/com.soderlind.jev-mail-triage"

Things to know:

  • Bridge must be running. Turn on “Start Bridge on startup” in Proton Mail Bridge settings. If Bridge is not running, the service retries with backoff.
  • Keychain prompt. The first time the service runs in the background, macOS may ask whether Python can access your Keychain items. Choose Always Allow, otherwise the service cannot log in while you are away.
  • Sleep. Nothing is checked while the Mac is asleep. Mail that arrived in the meantime is picked up on the first poll after it wakes.
  • Crash loops. If the service keeps exiting, for example because of a config error, launchd restarts it about every 10 seconds. If status stops changing, check ~/Library/Logs/jev-mail-triage-error.log.
  • Moving the project folder breaks the absolute path in the LaunchAgent. Repeat step 1 after moving it.
  • Dry-run first. While move_enabled = false, the service only records decisions. Let it run for a while and review candidates before you turn on moves.

You can measure how well Jev and your thresholds perform using labelled .eml files. Create a CSV file next to them:

path,spam,scam_phishing
newsletter.eml,false,false
phishing.eml,false,true

Paths are relative to the CSV. Then run:

Terminal window
.venv/bin/jev-mail-triage evaluate --labels ./fixtures/labels.csv

The report shows false positives, precision, recall (for spam, scam/phishing, and combined), and how many messages would be sent for review. It uses your configured thresholds and model.

Each file is sent to TypeSafe and may use API credits. Use synthetic or otherwise approved emails, not real inbox messages.

Your mail

  • Dry-run by default; moves require move_enabled = true.
  • Never deletes mail.
  • Reads messages with BODY.PEEK, so unread mail stays unread.
  • Before moving a message, it checks that the message still exists and is unchanged. It prefers IMAP UID MOVE. Otherwise, it copies the message, verifies the copy, and removes only that one message. It never runs a broad EXPUNGE; if a move cannot be completed safely, it stops and flags the message.
  • Only one worker can run at a time.

Message content

  • Emails are parsed locally. Plain text is preferred; HTML is converted to text, with scripts and styles removed.
  • Links are listed but never opened. Remote images and other resources are never loaded, and message content is never executed.
  • Attachments are never sent.
  • Input is limited: up to 2 MiB is read from Bridge and up to 64 KiB is sent to TypeSafe by default (max_input_bytes). Truncated messages are never moved.
  • Email content is treated as untrusted data in the Jev prompt. The criteria separate spam from subscribed newsletters, receipts, and genuine correspondence. A link alone is not considered malicious. Norwegian and English are supported.

Local storage

  • The database (~/Library/Application Support/jev-mail-triage/state.sqlite3) stores message identifiers, a content fingerprint, Message-ID, scores, model/policy versions, retry state, and move history. It does not store bodies, subjects, or senders.
  • Database and lock files are readable only by your user account.
  • The Bridge password and TypeSafe API key are stored only in macOS Keychain.
  • Logs contain status information and message numbers only, never credentials or message content.
SectionSettingDefaultMeaning
(top)database_pathApplication SupportSQLite state file
[bridge]account_idprimaryName used to find the Bridge password in Keychain
host, port127.0.0.1, 1143Bridge IMAP address
security_modestarttlsstarttls or implicit_tls, as Bridge shows
username—Bridge IMAP username
mailboxINBOXFolder to check
tls_ca_file(unset)PEM certificate if macOS does not trust Bridge
[service]poll_interval_seconds60Time between checks
batch_size20Messages per check
max_attempts5Attempts before a message is marked as failed
backoff_max_seconds300Longest delay between attempts
[policy]move_enabledfalseAllow moves in serve and backfill
spam_threshold0.98 (provisional)Minimum spam score for review
scam_threshold0.95 (provisional)Minimum scam/phishing score for review
max_input_bytes65536Maximum text sent to TypeSafe
review_folderJev ReviewDestination folder for moves
modeljev-latestTypeSafe model
allowlist[]Trusted senders; see below
trusted_authserv_id(unset)Required for the allowlist

The allowlist prevents a move only when the sender address or domain matches and a trusted Authentication-Results header (from trusted_authserv_id) reports DMARC pass. Proton does not document that it adds or cleans these headers, so configure this only if you have independently verified that the named receiver is trusted and replaces forged headers. Missing or untrusted authentication evidence never prevents a move.

If the mailbox identity (IMAP UIDVALIDITY) changes, the service records a new baseline instead of reprocessing or moving old messages. Use a bounded backfill if you want to assess messages that arrived during the reset.

Tests use synthetic .eml files and fake IMAP/TypeSafe clients only:

Terminal window
python -m pytest
python -m ruff check src tests

The tests do not connect to Proton Mail Bridge, access a real mailbox, call TypeSafe, move messages, or activate a LaunchAgent.

MIT