Guide

Bulk-Archive Old Emails from the CLI

You have years of read newsletters and old receipts clogging your inbox. This guide selects aged mail with a --before cutoff date, then archives each message in a guarded loop that is idempotent and stays under provider rate limits, so you can re-run it without double-processing anything.

Written by Caleb Geene Director, Site Reliability Engineering

Reviewed by Qasim Muhammad

VerifiedCLI 3.1.17 · Gmail, Outlook · last tested June 9, 2026

Command references used in this guide: nylas email search, nylas email list, nylas email folders, and nylas auth show.

Why bulk-archive old emails instead of deleting them?

Archiving old emails moves them out of the inbox while keeping them searchable, unlike deletion which sends messages to Trash for permanent removal after about 30 days on most providers. The average professional receives 121 emails per day according to the Radicati Group, so a two-year-old inbox can hold 80,000 messages. Archiving clears that backlog without losing a single receipt.

Bulk-archiving from the command line beats clicking through a web client because it scales. A mouse-driven cleanup of 5,000 messages takes hours; a guarded loop processes the same set in minutes and leaves an auditable log of every ID it touched. The pattern in this guide selects mail older than a fixed cutoff date, archives each message, and skips anything it already handled, so an interrupted run resumes cleanly. The CLI normalizes Gmail labels and Outlook folders behind one interface, which means the same script works whether your archive is a Gmail label or an Outlook folder.

How do I select emails older than a cutoff date?

Select aged mail with nylas email search and the --before flag, which takes a YYYY-MM-DD date and returns every message received before it. Pair it with a * query to match any subject. This is the selection step that feeds the archive loop.

The search command accepts a query string plus filters. The --before flag bounds the upper end of the date range, --after bounds the lower end, and --from narrows to one sender. The --limit flag defaults to 20 but auto-paginates past 200, so set it high enough to cover the full backlog. The --json flag emits structured output you can pipe into jq. The command below counts how many messages predate January 1, 2024 before you touch anything.

# Count how many messages are older than the cutoff
nylas email search "*" --before 2024-01-01 --json --limit 1000 | \
  jq 'length'

# Preview the oldest mail: id, date, and subject
nylas email search "*" --before 2024-01-01 --json --limit 50 | \
  jq -r '.[] | "\(.id)  \(.date)  \(.subject)"'

# Scope it tighter: newsletters from one sender, older than the cutoff
nylas email search "*" --from newsletter@example.com \
  --before 2024-01-01 --json --limit 500 | jq -r '.[].id'

Run the count first. If jq 'length' returns 4,812, you know the loop has 4,812 messages to process and can estimate the runtime. Always confirm the date math: --before 2024-01-01 archives mail received in 2023 and earlier, leaving everything from 2024 onward in place.

What do I need before the archive loop runs?

Before the loop runs you need two values: your grant ID and the target archive folder ID. The grant ID identifies the connected mailbox, and the folder ID names where archived messages land. On Gmail the archive action removes the INBOX label; on Outlook it moves the message to the Archive folder, which is one of roughly 12 default folders.

Get the grant ID from nylas auth show --json, which prints details about the active grant. List your folders or labels with nylas email folders list to find the archive target's ID. Gmail exposes about 12 system labels including INBOX, SENT, SPAM, and TRASH — but no ARCHIVE label, since archiving a Gmail message just removes the INBOX label. Outlook ships a named Archive folder among its default set. Capture both values into shell variables so the loop can reference them without a lookup on every iteration.

# Grab the active grant ID
GRANT_ID=$(nylas auth show --json | jq -r '.id')

# Find the archive folder/label ID
nylas email folders list --json | \
  jq -r '.[] | "\(.id)  \(.name)"'

# Capture the archive folder ID (name varies by provider)
ARCHIVE_ID=$(nylas email folders list --json | \
  jq -r '.[] | select(.name | test("archive"; "i")) | .id' | head -1)

echo "grant=$GRANT_ID archive=$ARCHIVE_ID"

Verify both variables print non-empty values before going further. If ARCHIVE_ID comes back blank, your provider may name the folder differently (Gmail's archive is the absence of the INBOX label rather than a named folder), so inspect the full folders list output and pick the right ID by hand.

How do I archive each message in a guarded, idempotent loop?

A guarded loop reads the selected message IDs, archives each one through the Nylas API, and records every processed ID to a file so a re-run skips work it already finished. Idempotent means running it twice produces the same end state with no double-processing. This is the payoff teased in the TL;DR: a small ledger file is what makes the loop safe to re-run.

The loop pipes search results through jq to get IDs, checks each ID against a done.log ledger, and sends a PATCH to the message endpoint that removes the INBOX folder. The sleep 0.3 between calls keeps you under provider rate limits: at 0.3 seconds per request the loop makes about 200 calls per minute, well under the Gmail API's default ceiling. Appending each ID to the ledger after a successful call is what makes the run resumable.

#!/usr/bin/env bash
set -euo pipefail

GRANT_ID=$(nylas auth show --json | jq -r '.id')
CUTOFF="2024-01-01"
LEDGER="archive-done.log"
touch "$LEDGER"

nylas email search "*" --before "$CUTOFF" --json --limit 1000 \
  | jq -r '.[].id' | while read -r MSG_ID; do
    # Idempotency guard: skip anything already archived
    if grep -qxF "$MSG_ID" "$LEDGER"; then
      echo "skip $MSG_ID (already archived)"
      continue
    fi

    # Archive: replace the message's folders with the archive target (swap ARCHIVE for your folder ID)
    curl -sf -X PATCH \
      "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/$MSG_ID" \
      -H "Authorization: Bearer $NYLAS_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"folders": ["ARCHIVE"]}' > /dev/null

    echo "$MSG_ID" >> "$LEDGER"   # record success
    echo "archived $MSG_ID"
    sleep 0.3                      # rate-aware pacing
done

The set -euo pipefail line stops the script on the first error so a failed PATCH does not silently skip messages. Because each ID is written to the ledger only after curl -sf succeeds, an interrupted run leaves the ledger accurate: re-running picks up exactly where it stopped. Swap ARCHIVE for your provider's folder ID from the previous section if the literal name does not resolve.

How do I verify the archive ran and tune the rate?

Verify the archive by re-running the same search and confirming the count dropped to zero, then check the ledger line count matches the number of messages you expected to process. Tuning the rate means adjusting the sleep interval: the Gmail API enforces a per-user limit measured in quota units, and pacing at 0.3 seconds per call keeps a single run comfortably inside it.

After the loop finishes, the selection query should return an empty set because the archived messages no longer carry the INBOX folder. If a 429 status appears in the curl output, the provider throttled you; raise the sleep to 0.6 seconds to halve the request rate to about 100 calls per minute. The ledger doubles as an audit trail: wc -l archive-done.log tells you exactly how many messages moved, and you can diff it across runs.

# Confirm the inbox no longer holds aged mail
nylas email search "*" --before 2024-01-01 --in INBOX --json --limit 1000 \
  | jq 'length'   # expect 0

# How many messages did the loop archive?
wc -l archive-done.log

# Spot-check one archived message left the inbox
nylas email list --folder INBOX --json --limit 5 \
  | jq -r '.[] | "\(.date)  \(.subject)"'

Gmail and Outlook differ on what archiving means under the hood. Per Google's documentation, archiving in Gmail only removes the INBOX label and keeps the message in All Mail, so a search across all folders still finds it. On Outlook the message moves into the Archive folder. Scoping the verify search with --in INBOX handles both: it asks only whether the inbox is clear, which is the outcome you wanted.

Next steps