gws-cache keeps a local copy of your Gmail conversations and Google Calendar events so you can search, read and export them instantly, without waiting on the network.
- Groups related emails into named topics, such as newsletters, billing or a particular project.
- Keeps each topic up to date by fetching only what has changed since the last run.
- Searches everything saved locally by words, sender, topic or date, with no network round trip.
- Writes a topic's history out as a Markdown or JSON timeline.
- Saves a copy of recent mailbox conversations, skipping anything already stored.
- Keeps a local copy of your calendar, including events that were later cancelled.
- Separates automatic notifications and receipts from mail a person actually wrote.
- You name a topic and give it an email search, for example everything from your billing address.
- Each time you sync,
gws-cacheasks Google for the mail matching that search and stores it on your machine. You can also fetch a one-off search without tracking it. - On later syncs it only asks for mail newer than what it already has, so repeat runs are quick.
- Anything already saved is left alone, so syncing twice costs almost nothing.
- Each run downloads a bounded number of new conversations. If more are waiting, it says so and the next run picks up where it left off, so nothing is ever quietly left behind.
- Searching, listing and exporting read the local copy only, so they return immediately and work offline.
- Calendar events are copied the same way, and events removed from your calendar stay in the local copy marked as cancelled.
- Every command prints a readable table by default, and switches to compact machine-readable lines when an automated tool runs it.
- All state lives in one SQLite database, created at
$XDG_CACHE_HOME/gws-cache/cache.dband opened through the CGO-freemodernc.org/sqlitedriver. - Gmail and Google Calendar are reached by invoking the
gwsCLI as a subprocess and parsing its JSON, sogws-cachenever handles your OAuth credentials itself. - Thread and event listings follow
nextPageTokento the end. Gmail pages at 100 threads and Calendar at 250 events, so reading a single page would silently truncate the window. - Topic sync keeps a high-watermark per topic: the newest cached message
internalDate, minus a 24 hour overlap, translated into a Gmailafter:term. Passing--sinceexplicitly overrides that watermark for the run. - Before fetching a conversation, the cached
historyIdis compared with the one Gmail reports; a match skips the fetch entirely. --max-threadsis a per-run budget on downloads, not a cap on the listing. Cached conversations cost nothing against it, anything it does not reach is counted and reported, and the watermark is held back until the window is fully cached — so a bounded run can never strand mail behind the mark.- Calendar sync stores the
nextSyncTokenGoogle returns and replays it for delta ingestion, falling back to a boundedtimeMin/timeMaxwindow when the token is rejected. - Calendar events are matched to topics by whole-word search over the event summary and description, with longer topic names taking precedence, so the same event always resolves to the same topic.
- Classification is rule based: the RFC 3834
Auto-Submittedand RFC 2369List-Unsubscribeheaders decide first, then regular expressions over the sender and snippet. - Every subprocess call runs under a context deadline set by
--timeout, andCtrl-Ccancels the run in progress.
gws, the Google Workspace CLI, installed and on yourPATH:npm install -g @googleworkspace/cli.- A Google Cloud project with the Gmail API and the Google Calendar API enabled. Running
gws auth setupwill do this for you. - An authenticated
gwssession covering both services:gws auth login -s gmail,calendar. The read-only scopeshttps://www.googleapis.com/auth/gmail.readonlyandhttps://www.googleapis.com/auth/calendar.readonlyare enough.
Download the archive for your platform from the latest release, unpack it and move the binary onto your PATH. For example, on macOS with Apple Silicon:
curl -L https://github.com/alexgorbatchev/gws-cache-cli/releases/download/v2.0.0/gws-cache_2.0.0_darwin_arm64.tar.gz | tar -xz
sudo mv gws-cache /usr/local/bin/Commands are named after the thing they act on: topic for the searches you
want kept up to date, message and event for fetching and reading each kind
of content, and cache for the local copy itself.
# Name a search you want kept up to date
gws-cache topic add newsletters --name "Tech Newsletters" --query "category:promotions"
# Fetch its mail into the local copy
gws-cache message sync newsletters
# Fetch a one-off search without tracking it
gws-cache message sync --query "is:unread"
# Search everything saved locally
gws-cache message search "digest"
# Write a topic out as a timeline
gws-cache message export newsletters --format markdown
# Copy your calendar and read it back
gws-cache event sync
gws-cache event search "standup"
# See what the local copy holds
gws-cache cache statusGlobal flags apply to every command.
| Flag | Short | Default | Description |
|---|---|---|---|
--db <path> |
$XDG_CACHE_HOME/gws-cache/cache.db |
Path to the local cache file | |
--timeout <duration> |
2m0s |
How long to wait for a single Google Workspace request | |
--version |
-v |
Print the version and exit | |
--help |
-h |
Show the command tree |
topic add <name>
| Flag | Short | Default | Description |
|---|---|---|---|
--name <text> |
slug | Friendly name to show in listings | |
--query <search> |
Email search to collect for this topic |
message sync [topic]
| Flag | Short | Default | Description |
|---|---|---|---|
--all |
false |
Sync every topic you track | |
--query <search> |
Fetch a one-off search instead of a topic | ||
--force-full |
false |
Fetch everything again instead of only what changed | |
--since <window> |
4w |
How far back to look: a span, a date, or all |
|
--max-threads <n> |
25 |
New conversations to download in one run; the rest are reported and collected next run | |
--format <style> |
table |
Output style: table or json |
message search [words]
| Flag | Short | Default | Description |
|---|---|---|---|
--topic <name> |
Only look inside this topic | ||
--since <window> |
14d |
How far back to search: a span, a date, or all |
|
--human-only |
false |
Leave out automatic notifications and receipts | |
--format <style> |
table |
Output style: table or json |
message export <topic>
| Flag | Short | Default | Description |
|---|---|---|---|
--human-only |
true |
Leave out automatic notifications and receipts | |
--format <style> |
markdown |
Output style: markdown or json |
|
--since <window> |
4w |
How far back to include: a span, a date, or all |
event sync
| Flag | Short | Default | Description |
|---|---|---|---|
--calendar-id <id> |
primary |
Which calendar to copy | |
--past <window> |
4w |
How far back to include: a span, a date, or all |
|
--future <window> |
4w |
How far ahead to include: a span, a date, or all |
|
--force-full |
false |
Fetch the whole window again instead of only what changed | |
--format <style> |
table |
Output style: table or json |
event search [words]
| Flag | Short | Default | Description |
|---|---|---|---|
--calendar-id <id> |
primary |
Which calendar to read | |
--topic <name> |
Only show entries matched to this topic | ||
--since <window> |
4w |
How far back to include: a span, a date, or all |
|
--format <style> |
table |
Output style: table or json |
--max-threads bounds how many new conversations a single run downloads, because each one is a separate request to Google. Already-cached conversations do not count against it.
When a run cannot finish the window it says so on stdout and leaves the sync watermark where it was, so the next run continues from the same point instead of skipping ahead:
$ gws-cache message sync newsletters
[WARN] newsletters: 25 of 118 new conversations fetched; 93 remaining, run again or raise --max-threads
[OK] newsletters: 25 threads, 63 messages
$ gws-cache message sync newsletters # picks up where the last run stopped
[WARN] newsletters: 25 of 93 new conversations fetched; 68 remaining, run again or raise --max-threads
[OK] newsletters: 25 threads, 71 messagesRepeat the command until the warning stops, or pass a larger --max-threads to finish in one run.
A partial run still exits 0, because it succeeded at what it attempted. Rather than matching on the warning text, an automated caller should pass --format json and read threads_remaining:
$ gws-cache message sync newsletters --max-threads 25 --format json
{
"target": "newsletters",
"threads_listed": 118,
"threads_fetched": 25,
"threads_remaining": 93,
"messages_ingested": 63,
"max_history_id": "4412098"
}threads_remaining above zero means the cache is not yet complete: run the command again. --all always returns an array of these, one per topic, each carrying an error field if that topic failed. In JSON mode nothing else is written to stdout, and progress lines stay on stderr, so the output is safe to pipe straight into a parser.
Every --since, --past and --future flag accepts the same three forms:
- A span counted from now:
24h,30d,4w,2m,1y. - A calendar date:
2026-06-01. all, which applies no bound in that direction.
On a topic that has been synced before, message sync resumes from where it left off and ignores the default --since. Passing --since yourself overrides that and reaches back as far as you asked.
Setting AGENT=1 switches every command to token-conservative output for automated callers: no tables, no dividers, no alignment padding, and full untruncated values as tab-separated key:value pairs.
AGENT=1 gws-cache topic list
# slug:newsletters name:Tech Newsletters search:category:promotions last_synced:neverOut of the box, mail is treated as automatic when it declares the Auto-Submitted or List-Unsubscribe header, or when the sender looks like no-reply@, do-not-reply@, notifications@, mailer-daemon@ or postmaster@.
To add your own rules, create $XDG_CONFIG_HOME/gws-cache/classifier.json. Its patterns are Go regular expressions and are applied in addition to the built-in ones:
{
"sender_patterns": ["(?i)no-reply@ashbyhq\\.com"],
"snippet_patterns": ["(?i)has requested a signature", "(?i)upcoming interview in 1 hour"]
}