Sort Your Gmail Inbox with the Jev API

One Jev call per email, a small Node script, and a simple rule: label first, review, then delete.

Velu S Gautam8 min read

I wrote a small Node script that reads my inbox, asks Jev to decide what each email is, and labels it in Gmail. Nothing gets deleted until I've looked at the results.

My inbox had years of shop promos, old flight bookings and "your order has shipped" emails, sitting next to bank alerts and documents I actually need. Gmail's filters can't separate them, because the difference is what an email means, not who sent it.

So I let a model read each email and decide, and I kept the final say. This post covers the whole thing: getting Gmail API access, classifying with the Jev API, labelling in Gmail, and trashing safely once you trust the results.

How it works

The script asks Gmail for a page of message IDs, reads each message's headers, sends them to Jev one email at a time, and writes the answer to a CSV. Only when you pass --apply does it touch Gmail.

yes

no

no

yes

npm start

Gmail: list message IDs
matching your search query

Already
processed?

Gmail: read subject, sender,
date and snippet

Wait for a free slot
rate limit / concurrency

Jev API: one email,
one choice question

Category + confidence

results.csv

--apply?

Dry run: Gmail untouched

Gmail: add a label
like AI/Flagged for Delete

You review the labels

Trash what you approve
recoverable for 30 days

I set one rule for myself: label first, review, then delete. The model never deletes anything.

What Jev is (and isn't)

Jev isn't a chat model. You give it some text (state) and one or more typed questions, and it gives back typed answers with probabilities. It never writes prose, so there's nothing to parse.

For sorting email that's a good fit. The question is "which of these five buckets is this in?" and the answer is one of five keys plus a confidence number.

The bits of the API we need:

  • Endpoint: POST https://api.beatapi.io/v1/systemone
  • Auth: Authorization: Bearer <your key>
  • Question type we use: choice. You pass named options with a description of each, and you get back the chosen option, a probability for every option, and a confidence.
  • Models: jev-1.13-free and jev-1.13 (paid).
  • Not OpenAI-compatible. There's no /v1/chat/completions, so there's no SDK to install. A plain fetch does the job.

Free or paid


ModelPriceLimits
Freejev-1.13-free$01 successful request a minute until your first top-up, 1,000 a day
Paidjev-1.13$0.042 per 1M input tokens, output isn't billed10 requests a minute after first top-up

I built this for the paid model and sent one email per request. It keeps the code simple, and one bad response only costs you one email. An email plus the category descriptions is a few hundred tokens, so by my rough maths 1,000 emails cost a couple of cents. Check the pricing page before a big run.

The free model works too, but at one request a minute you'll get about 60 emails an hour. The script has two settings for that, covered below.

What you need

  • Node.js 20 or newer
  • The Gmail account you want to clean up
  • A BeatAPI account and API key

Step 1: Get access to the Gmail API

  1. Open the Google Cloud Console and create a project.
  2. Go to APIs & Services → Library, search for Gmail API and enable it.
  3. Go to APIs & Services → OAuth consent screen.
    • User type: External (or Internal on Workspace).
    • Fill in an app name and your email.
    • Under Test users, add your own Gmail address. Google doesn't need to verify the app for accounts on this list.
  4. Go to APIs & Services → Credentials → Create credentials → OAuth client ID.
    • Application type: Desktop app.
    • Download the JSON and save it as credentials.json in your project folder.

Ask for the smallest scope you can

This is the decision I care about most:

TS
const SCOPES = ["https://www.googleapis.com/auth/gmail.modify"];

gmail.modify can read mail, create and apply labels, and move messages to Trash. It cannot delete anything permanently. That takes the full https://mail.google.com/ scope, which I never asked for. If my script has a bug, the worst outcome is mail sitting in Trash for 30 days.

While your OAuth app is in "Testing" status, Google expires refresh tokens after 7 days. If the script suddenly asks you to log in again, that's why.

Step 2: Get a Jev API key

  1. Sign up at beatapi.io.
  2. Open API keys in the dashboard (/dashboard/apikeys) and create one.
  3. Create a .env file:
SHELL
BEATAPI_API_KEY=your-key-here

# jev-1.13 is the paid model, jev-1.13-free is the free one
JEV_MODEL=jev-1.13

# How many emails to classify at the same time
CONCURRENCY=5
# Minimum gap between requests, in milliseconds (0 = no limit)
REQUEST_DELAY_MS=0

LABEL_PREFIX=AI
GMAIL_QUERY=in:inbox -is:important
MAX_MESSAGES=100

Add .env, credentials.json and token.json to .gitignore. Those three files are the keys to your mailbox and your account.

If you're on the free tier, change two lines and leave the rest alone:

BASH
JEV_MODEL=jev-1.13-free
CONCURRENCY=1
REQUEST_DELAY_MS=61000

Step 3: Set up the project

BASH
mkdir gmail-triage && cd gmail-triage
npm init -y
npm i googleapis dotenv
npm i -D typescript tsx @types/node

Set "type": "module" in package.json and add a start script:

JSON
{
  "type": "module",
  "scripts": { "start": "tsx src/index.ts" }
}

Step 4: Log in to Gmail

src/auth.ts runs a one-time OAuth login using a loopback redirect, which is Google's recommended method for desktop apps. It prints a URL, waits for Google to redirect back to 127.0.0.1, and saves the result in token.json. After that you don't log in again.

TS
import fs from "node:fs";
import http from "node:http";
import { google } from "googleapis";
import type { OAuth2Client } from "google-auth-library";

const CREDENTIALS_PATH = "credentials.json";
const TOKEN_PATH = "token.json";
const SCOPES = ["https://www.googleapis.com/auth/gmail.modify"];
const PORT = 51823;
const REDIRECT_URI = `http://127.0.0.1:${PORT}`;

export async function getAuthorizedClient(): Promise<OAuth2Client> {
  const { client_id, client_secret } = JSON.parse(
    fs.readFileSync(CREDENTIALS_PATH, "utf-8")
  ).installed;
  const client = new google.auth.OAuth2(client_id, client_secret, REDIRECT_URI);

  if (fs.existsSync(TOKEN_PATH)) {
    client.setCredentials(JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8")));
    return client;
  }

  const authUrl = client.generateAuthUrl({
    access_type: "offline", // so we get a refresh token
    scope: SCOPES,
    prompt: "consent",
  });
  console.log(`Open this URL in a browser signed into Gmail:\n\n${authUrl}\n`);

  const code = await new Promise<string>((resolve, reject) => {
    const server = http.createServer((req, res) => {
      const url = new URL(req.url!, REDIRECT_URI);
      const code = url.searchParams.get("code");
      const error = url.searchParams.get("error");
      res.end("You can close this tab and go back to the terminal.");
      server.close();
      if (code) resolve(code);
      else reject(new Error(`OAuth error: ${error}`));
    });
    server.listen(PORT);
  });

  const { tokens } = await client.getToken(code);
  client.setCredentials(tokens);
  fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2));
  return client;
}

Step 5: Talk to Gmail

src/gmail.ts does four jobs: list message IDs, read one message's metadata, make sure a label exists, and add a label to a message.

The script only reads headers and Gmail's snippet, never the full body. That's faster, cheaper, and keeps most of your email content off the network.

TS
import { google, type gmail_v1 } from "googleapis";
import type { OAuth2Client } from "google-auth-library";

export const getGmailClient = (auth: OAuth2Client) =>
  google.gmail({ version: "v1", auth });

export interface EmailMeta {
  id: string;
  subject: string;
  from: string;
  date: string;
  snippet: string;
}

export async function listMessageIds(
  gmail: gmail_v1.Gmail,
  query: string,
  maxResults: number,
  pageToken?: string
) {
  const res = await gmail.users.messages.list({
    userId: "me",
    q: query, // any search you'd type into Gmail works here
    maxResults,
    pageToken,
  });
  return {
    ids: (res.data.messages ?? []).map((m) => m.id!),
    nextPageToken: res.data.nextPageToken,
  };
}

const header = (h: gmail_v1.Schema$MessagePartHeader[] | undefined, name: string) =>
  h?.find((x) => x.name?.toLowerCase() === name.toLowerCase())?.value ?? "";

export async function getMessageMeta(gmail: gmail_v1.Gmail, id: string): Promise<EmailMeta> {
  const res = await gmail.users.messages.get({
    userId: "me",
    id,
    format: "metadata",
    metadataHeaders: ["Subject", "From", "Date"],
  });
  const h = res.data.payload?.headers;
  return {
    id,
    subject: header(h, "Subject"),
    from: header(h, "From"),
    date: header(h, "Date"),
    snippet: res.data.snippet ?? "",
  };
}

/** Finds a label by name or creates it. "Parent/Child" names nest in Gmail. */
export async function ensureLabel(gmail: gmail_v1.Gmail, name: string): Promise<string> {
  const { data } = await gmail.users.labels.list({ userId: "me" });
  const existing = data.labels?.find((l) => l.name === name);
  if (existing?.id) return existing.id;

  const created = await gmail.users.labels.create({
    userId: "me",
    requestBody: {
      name,
      labelListVisibility: "labelShow",
      messageListVisibility: "show",
    },
  });
  return created.data.id!;
}

export async function applyLabel(gmail: gmail_v1.Gmail, messageId: string, labelId: string) {
  await gmail.users.messages.modify({
    userId: "me",
    id: messageId,
    requestBody: { addLabelIds: [labelId] },
  });
}

Step 6: Ask Jev what each email is

This is the short part. For each email we send one request with:

  • a state: today's date and the email's subject, sender, date and snippet
  • one choice question called category, with five options and a plain-English description of each

Jev replies with the option it picked and how confident it is. The descriptions do the same job a prompt would, so they're worth writing carefully.

TS
import type { EmailMeta } from "./gmail.js";

const ENDPOINT = "https://api.beatapi.io/v1/systemone";
export const JEV_MODEL = process.env.JEV_MODEL?.trim() || "jev-1.13";

export const CATEGORIES = [
  "fraud",
  "marketing",
  "important",
  "old_or_obsolete",
  "no_longer_needed",
] as const;
export type Category = (typeof CATEGORIES)[number];

export const CATEGORY_LABELS: Record<Category, string> = {
  fraud: "Fraud",
  marketing: "Flagged for Delete",
  important: "Important",
  old_or_obsolete: "Old-Obsolete",
  no_longer_needed: "No-Longer-Needed",
};

const CRITERIA: Record<Category, string> = {
  fraud: "Phishing, scams, impersonation, or anything that looks suspicious.",
  marketing: "Promotions, newsletters, sales pitches and other bulk marketing.",
  important: "Personal, financial, legal or work mail the user probably still needs.",
  old_or_obsolete: "Time-sensitive mail (an event, offer, code or deadline) whose date has passed.",
  no_longer_needed: "Routine notifications, receipts or confirmations with no reason to keep them.",
};

export interface Classification {
  category: Category;
  confidence: number;
}

export async function classifyEmail(meta: EmailMeta): Promise<Classification> {
  const today = new Date().toISOString().slice(0, 10);

  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.BEATAPI_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: JEV_MODEL,
      state:
        `A personal Gmail message. Today is ${today}.\n` +
        `Subject: ${meta.subject}\nFrom: ${meta.from}\n` +
        `Date: ${meta.date}\nSnippet: ${meta.snippet}`,
      questions: {
        category: {
          type: "choice",
          instructions: "Which category does this email belong in?",
          criteria: CRITERIA,
        },
      },
    }),
  });

  if (!res.ok) {
    const err: any = new Error(`Jev API ${res.status}: ${await res.text()}`);
    err.status = res.status;
    err.retryAfter = Number(res.headers.get("retry-after")) || undefined;
    throw err;
  }

  const body = (await res.json()) as {
    answers: { category: { choice: Category; confidence: number } };
  };
  const { choice, confidence } = body.answers.category;
  return { category: choice, confidence };
}

Here's a real response, from a made-up "Flash Sale: up to 40% off everything" email that I sent to jev-1.13-free:

JSON
{
  "answers": {
    "category": {
      "type": "choice",
      "choice": "old_or_obsolete",
      "confidence": 0.72,
      "probabilities": {
        "fraud": 0,
        "important": 0,
        "marketing": 0.22,
        "no_longer_needed": 0,
        "old_or_obsolete": 0.78
      }
    }
  },
  "id": "task_DnjwUg31...",
  "model": "jev-1.13-free",
  "usage": { "input_tokens": 493, "output_tokens": 64 }
}

I expected marketing. Jev picked old_or_obsolete because the sale said "this weekend" and the email was eight days old. That's a fair call, and it's why the date matters. It's also a reminder to review before deleting.

Two things to notice. confidence (0.72) isn't the same as the top probability (0.78), so use confidence when you set a threshold. And the request used 493 input tokens, mostly the category descriptions, which are repeated on every call. At the paid price that works out to roughly two cents per 1,000 emails.

A few choices in there that matter:

  • Send today's date along with the email's date. "Old or obsolete" is impossible to judge without both.
  • The confidence comes for free. You'll lean on it during review. The emails Jev is unsure about are the ones you should read yourself.
  • No SDK, no JSON parsing tricks. The answer is already typed.

Rate limits: concurrency and delay

I wanted two knobs, and they do different things.

  • CONCURRENCY is how many emails are in flight at once. On the paid model, 5 is a sensible start.
  • REQUEST_DELAY_MS is the minimum time between the start of any two requests. 0 means no limit. On the free tier, set it to 61000 and CONCURRENCY to 1.

src/throttle.ts handles both, plus retries. If Jev answers 429 it waits for the Retry-After value (or 61 seconds if there isn't one), and it retries 502/503 after a short pause. It gives up straight away on 402 because that means your balance is empty, and retrying won't fix that.

TS
const DELAY_MS = Number(process.env.REQUEST_DELAY_MS ?? 0);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// Every request reserves the next free slot, so the gap holds even with several workers.
let nextSlot = 0;
async function waitForSlot() {
  const now = Date.now();
  const at = Math.max(now, nextSlot);
  nextSlot = at + DELAY_MS;
  if (at > now) await sleep(at - now);
}

/** Runs `worker` over `items` with at most `n` running at once. */
export async function pool<T>(items: T[], n: number, worker: (item: T) => Promise<void>) {
  let next = 0;
  const lane = async () => {
    while (next < items.length) await worker(items[next++]);
  };
  await Promise.all(Array.from({ length: Math.min(n, items.length) }, lane));
}

/** Waits for a slot, calls `fn`, and retries the errors that are worth retrying. */
export async function withJevLimits<T>(fn: () => Promise<T>, attempts = 5): Promise<T> {
  for (let i = 1; ; i++) {
    await waitForSlot();
    try {
      return await fn();
    } catch (err: any) {
      if (err.status === 402) throw new Error("Jev says your balance is empty (402). Top up and re-run.");
      const retryable = err.status === 429 || err.status === 502 || err.status === 503;
      if (!retryable || i >= attempts) throw err;
      const wait = err.status === 429 ? (err.retryAfter ?? 61) * 1000 : 3000 * i;
      console.log(`  Jev returned ${err.status}, retrying in ${Math.round(wait / 1000)}s (${i}/${attempts})`);
      await sleep(wait);
    }
  }
}

Step 7: Put it together, dry run first

src/index.ts walks through your inbox and classifies each new email. By default it only prints results and writes results.csv. Gmail isn't touched until you pass --apply.

It also remembers which message IDs it has handled in processed-ids.json, so running it again only picks up new mail.

TS
import "dotenv/config";
import fs from "node:fs";
import { getAuthorizedClient } from "./auth.js";
import { getGmailClient, listMessageIds, getMessageMeta, ensureLabel, applyLabel } from "./gmail.js";
import { classifyEmail, CATEGORY_LABELS, JEV_MODEL, type Category } from "./classify.js";
import { pool, withJevLimits } from "./throttle.js";

const APPLY = process.argv.includes("--apply"); // default is a dry run
const PREFIX = process.env.LABEL_PREFIX?.trim() || "AI";
const QUERY = process.env.GMAIL_QUERY?.trim() || "in:inbox -is:important";
const MAX = Number(process.env.MAX_MESSAGES ?? 100);
const CONCURRENCY = Number(process.env.CONCURRENCY ?? 5);

const PROCESSED = "processed-ids.json";
const processed = new Set<string>(
  fs.existsSync(PROCESSED) ? JSON.parse(fs.readFileSync(PROCESSED, "utf-8")) : []
);

const gmail = getGmailClient(await getAuthorizedClient());
console.log(
  `${APPLY ? "APPLY" : "DRY RUN"} | model ${JEV_MODEL} | concurrency ${CONCURRENCY} | ` +
    `delay ${process.env.REQUEST_DELAY_MS ?? 0}ms | query: ${QUERY}\n`
);

const labelIds = new Map<Category, string>();
if (APPLY) {
  for (const [cat, name] of Object.entries(CATEGORY_LABELS) as [Category, string][]) {
    labelIds.set(cat, await ensureLabel(gmail, `${PREFIX}/${name}`));
  }
}

const esc = (s: string) => `"${s.replace(/"/g, '""')}"`;
if (!fs.existsSync("results.csv")) {
  fs.writeFileSync("results.csv", "message_id,date,from,subject,category,confidence,label_applied\n");
}

let classified = 0;
let failed = 0;
let pageToken: string | undefined;

while (classified < MAX) {
  const { ids, nextPageToken } = await listMessageIds(gmail, QUERY, Math.min(50, MAX - classified), pageToken);
  if (ids.length === 0) break;

  const todo = ids.filter((id) => !processed.has(id)).slice(0, MAX - classified);

  await pool(todo, CONCURRENCY, async (id) => {
    try {
      const meta = await getMessageMeta(gmail, id);
      const { category, confidence } = await withJevLimits(() => classifyEmail(meta));
      classified++;

      if (APPLY) await applyLabel(gmail, id, labelIds.get(category)!);

      fs.appendFileSync(
        "results.csv",
        [meta.id, meta.date, meta.from, meta.subject, category].map(esc).join(",") +
          `,${confidence.toFixed(2)},${APPLY ? "yes" : "no"}\n`
      );
      console.log(`[${category.padEnd(16)}] ${confidence.toFixed(2)}  ${meta.from} - ${meta.subject}`);
      processed.add(id);
    } catch (err: any) {
      failed++;
      console.error(`  failed on ${id}: ${err.message ?? err}`);
    }
  });

  fs.writeFileSync(PROCESSED, JSON.stringify([...processed]));
  if (!nextPageToken) break;
  pageToken = nextPageToken;
}

console.log(`\nClassified ${classified}, failed ${failed}.`);
console.log(APPLY ? "Labels applied in Gmail." : "Dry run: nothing was written to Gmail.");

Step 8: Run it

BASH
npm start              # dry run: classify, print, write results.csv
npm start -- --apply   # create the labels and apply them

The first run prints a login URL. Open it, approve access, and token.json is saved.

Open results.csv before you use --apply, sort by confidence, and read the low ones. Treat the labels as suggestions. Near-identical emails, like two bank debit alerts, can end up in different categories, which is exactly why I don't let this delete anything by itself.

After --apply, Gmail's sidebar shows nested labels (AI/Flagged for Delete, AI/Fraud, and so on). Click through each one and look.

Step 9: Deleting, carefully

When you trust a category, deleting is a small change. Instead of adding your own label, you add Gmail's built-in TRASH label. That moves the mail to Trash, and with the gmail.modify scope it's as far as this script can go.

batchModify handles up to 1,000 messages per call. Write an undo list before you touch anything:

TS
import fs from "node:fs";
import type { gmail_v1 } from "googleapis";

export async function trash(gmail: gmail_v1.Gmail, ids: string[]) {
  const manifest = `trashed-${new Date().toISOString().slice(0, 19).replace(/:/g, "")}.csv`;
  fs.writeFileSync(manifest, "message_id\n");

  for (let i = 0; i < ids.length; i += 1000) {
    const chunk = ids.slice(i, i + 1000);
    await gmail.users.messages.batchModify({
      userId: "me",
      requestBody: { ids: chunk, addLabelIds: ["TRASH"] },
    });
    fs.appendFileSync(manifest, chunk.join("\n") + "\n");
  }
  console.log(`Trashed ${ids.length}. Undo list: ${manifest} (Trash keeps mail for 30 days)`);
}

To restore a batch, run the same call with removeLabelIds: ["TRASH"] over the IDs in that file.

Rules I'd add before trashing anything

  1. Skip starred mail, and anything you've filed by hand in folders like documents, tax, medical or tickets.
  2. Set a confidence threshold. Only trash marketing, old_or_obsolete and no_longer_needed above a level you've checked against results.csv. Everything else goes to a Review label.
  3. Keep a list of senders that are never deleted. Banks, tax offices and government mail should survive whatever the model says. A plain rules list is more reliable than a confidence score here.
  4. Keep anything with an attachment, unless it's a category where attachments are normal, like old statements.
  5. Mind the threads. In Gmail's conversation view, deleting one message trashes the whole thread. Only trash a message if every message in its thread is also marked for deletion.
  6. Dry-run every destructive step. Print counts and a few sample subjects per rule before applying.

One small gotcha: Important is a reserved Gmail label name, so you can't create a top-level label with it. AI/Important is fine because it's nested. If you want a top-level "keep" label, call it something like Priority.

Where I'd go next

  • Let Gmail do the deleting. Once you know a sender is pure promo, from:(news.example.com) is free and instant, and no model is involved. Use Jev to find the patterns, then write them as plain Gmail searches with the rules above.
  • Add a yes/no question. Jev's noul type returns a 0 to 1 likelihood. "Is this safe to delete?" next to the category gives you a second number to threshold on.
  • Read the full body for unsure emails only. If confidence is low, fetch the whole message and ask again.
  • Give it more to go on. Attachment names, whether the message is starred, Gmail's own CATEGORY_PROMOTIONS label and thread size all help.

Wrapping up

The whole thing is a few hundred lines: a login, a handful of Gmail calls, one Jev call per email, and a small throttle. Most of the value is in the dull parts around it: a dry run, a narrow OAuth scope, a review CSV, an undo list, and a refusal to delete when the model isn't sure.

If you try it, start with MAX_MESSAGES=50, read every row, and only raise the number once the labels match what you would have picked yourself.

Full source: github.com/velusgautam/gmail-jev-triage. Copy .env.example to .env, add your key, and you can run it as it is.

Comments