---
title: Gmail integration (triage inbox inside Omniscio)
---

# Gmail integration (triage inbox inside Omniscio)

## What it is

Omniscio's Gmail integration pulls your email into the app as a virtual project (`__gmail__`) so you can triage, reply, and chain agents off email content without leaving the mission-control sidebar. Every message gets AI-suggested replies (the same Alt+1/2/3 chips used in sessions), DOMPurify-sanitized HTML rendering, and an inbound-security pipeline that prescreens every message body against prompt-injection attempts with Gemini 2.5 Flash-Lite via OpenRouter, falling back to Claude Haiku 4.5 before any agent ever sees the body. Under the hood, Omniscio talks to Gmail **directly over Google OAuth** using the official `googleapis` library: a one-time in-app consent flow ([google-auth-service.ts](/src/main/services/google/google-auth-service.ts)) stores an encrypted refresh token, and [gmail-api-service.ts](/src/main/services/email/gmail-api-service.ts) makes the REST calls. (An earlier design shelled out to a native `gog.exe` CLI; that path has been retired for the inbox. A CLI survives only for some _outbound_ sends — see **How it works** — where it is now backend-selectable between `gog` and the official Google Workspace CLI **`gws`**.)

## Where to find it

### How to use it

1. **Connect Gmail (in-app OAuth).** Omniscio authenticates to Gmail with a one-time in-app Google consent flow — no separate binary to install for the inbox. The connect action opens your browser for Google's consent screen; [google-auth-service.ts](/src/main/services/google/google-auth-service.ts) runs a short-lived localhost loopback server to catch the redirect and stores an encrypted refresh token at rest. Once connected, Omniscio detects the stored token on startup and the `__gmail__` virtual project appears in your left sidebar.
2. **Enable the integration.** Open Settings → **Gmail**. Toggle `gmailEnabled`. Defaults: `gmailDefaultQuery` is the Gmail query string used to fetch the inbox view (default `is:inbox`; you can override with something like `in:inbox -category:promotions`), `gmailPageSize` controls how many messages load per page, `gmailAutoMarkRead` auto-marks messages read when you open them.
3. **Triage in the sidebar.** Click the **Gmail** project to see your inbox as a session list. Each thread shows sender, subject, and a short preview. **On desktop, opening Gmail lands you straight on a message** — the thread you were last reading (remembered while the app stays open) or, if you haven't opened one yet, the first thread in the inbox — instead of an empty "Select an email" pane. The landing reuses the normal open path, so it honors your `gmailAutoMarkRead` setting exactly like clicking the thread yourself, and it fires once per Gmail entry: hitting **Back** out of a thread returns you to the empty pane and stays there (it won't snap you back to the first message). (On mobile the inbox stays a list you tap into.) Click a thread to open the `EmailViewer` panel — body is client-side DOMPurify-sanitized, images are proxied, inline scripts stripped. Each message you expand lists its **attachments** under the body as small chips showing the file name and size — click one to save the file (on a phone it downloads or opens the share sheet, and a file over 5 MB is refused with a note to save it from your computer). Omniscio saves attachments but never opens them for you, and an image already shown inside the email, like a signature logo, isn't listed a second time.
4. **Search by sender with autocomplete.** Click the three-dot menu in the Gmail header to reveal the search input. Type `from:` plus the start of a name or email — a dropdown of recent senders appears. **Tab** or **Enter** accepts the highlighted suggestion. Pressing Enter when nothing is highlighted submits the raw query. The autocomplete also supports `to:` for outbound messages. Recent senders come from a small contact cache built once at app start (200 messages from the last 90 days) and refreshed daily — so a sender you just emailed for the first time today won't appear until the next refresh or app restart. Sender filters combine with body-text search (`from:alice refund`) and survive category-tab switches.
5. **Reply with AI help.** Every open thread has a **single reply box pinned at the bottom** — **Reply / Reply All / Forward** tabs with a 5-second undo on send; there are no separate per-message reply buttons inside the email cards. Press `R` (or click the box) to reply and `F` to forward — both land in that one box. You get the same AI suggestion chips (Alt+1/2/3) and Quick Responses (Alt+S, Alt+Z) as inside any Claude session. **Emoji input** is available on both the reply box and the full compose dialog: type `:` followed by two characters (e.g. `:smi`) for a colon-autocomplete typeahead, or click the smiley-face button for a searchable emoji picker with categories and recents. Replies send over the same OAuth connection ([gmail-api-service.ts](/src/main/services/email/gmail-api-service.ts)) — not the CLI. A quick success toast ("Email sent", "Reply sent", or "Email forwarded") confirms the send went through.
6. **Let Omniscio filter the junk.** Turn on **Spam filter** (`spamFilterGmailEnabled`) plus optionally the AI-assisted filter (`spamFilterGmailAiEnabled`). Rule-based classification runs first; AI (Haiku) handles ambiguous cases. Flagged spam never spawns a session or fires an automation.
7. **Celebrate inbox zero.** `gmailInboxZeroCelebration` triggers a small confetti moment when you empty the inbox — on by default; turn it off in Settings if you find that sort of thing annoying.
8. **Filter by category.** The dropdown above the thread list ([GmailCategoryTabs.tsx](/src/renderer/src/features/gmail/GmailCategoryTabs.tsx)) narrows the inbox to All, Sent, Drafts, Primary, Updates, Social, Promos, or Forums; **Sent** shows the emails you've sent, handy for confirming a message actually went out; **Drafts** shows emails you've started but not yet sent.
9. **Filter by read state.** The **Read status** dropdown next to the category dropdown ([GmailReadFilter.tsx](/src/renderer/src/features/gmail/GmailReadFilter.tsx)) shows **All**, only **Unread**, or only **Read** email. It combines with the category, a Sent/Drafts view, and anything you typed in search — the choice is added to every search in one place ([gmail-read-filter.ts](/src/renderer/src/stores/gmail-read-filter.ts), applied in `searchInbox`), so the list always matches the dropdown. **All** leaves your search exactly as typed, so an `is:unread` you type yourself still works.

### Keyboard triage (Superhuman-style)

Triage shortcuts work on the focused thread without requiring an open detail pane — that's the point of inbox-zeroing. With the cursor anywhere outside a text input, press:

| Key           | Action                                                 |
| ------------- | ------------------------------------------------------ |
| `J` / `K`     | Move down / up the thread list (also opens the thread) |
| `E`           | Archive                                                |
| `S`           | Toggle star                                            |
| `U`           | Mark unread (also closes the open detail)              |
| `!` (Shift+1) | Report spam                                            |
| `H`           | Snooze                                                 |
| `X`           | Toggle multi-select                                    |
| `R`           | Reply (composes into the open detail)                  |
| `F`           | Forward (composes into the open detail, forward mode)  |
| `G > D`       | Go to Drafts                                           |

**No-selection fallback.** If you've not opened any thread (cold list), pressing `E` / `S` / `U` / `!` / `H` / `X` acts on the topmost thread in the list — same as Superhuman. `R` and `F` need a loaded thread detail, so the first press auto-selects (and loads) the topmost thread; press `R` or `F` again to compose. If the list is empty the keys do nothing.

**Middle-click to archive (mouse).** Middle-clicking a thread row in the list archives that email on the spot — the mouse equivalent of `E` — and pops the same **Undo** toast. Handy for fast desk triage without the keyboard. (Desktop gesture only; touch has no middle button.)

**Select several emails (multi-select).** Select emails to act on them together: **Ctrl-click** (**Cmd-click** on macOS) a row to add or remove it, **Shift-click** another row to select every email between the two, or use the small select box that appears at the left of a row when you hover it (it stays visible on every row once anything is selected). `X` still toggles the focused row. Selected rows are highlighted, and a bar above the list shows how many are selected with **Archive**, **Star**, **Read**, **Unread**, **Cancel**, and **Select all** (selects every email loaded in the list). A plain click still opens the email. Selection arithmetic lives in [gmail-selection.ts](/src/renderer/src/stores/gmail-selection.ts); the row control in [GmailThreadItem.tsx](/src/renderer/src/features/gmail/GmailThreadItem.tsx); the bar in [BulkActionBar.tsx](/src/renderer/src/features/gmail/BulkActionBar.tsx).

**Auto-Focus Reply Box toggle.** The setting `gmailAutoFocusReply` (Settings → Gmail → "Auto-Focus Reply Box") defaults to **off**. Off matches Gmail/Superhuman: the reply textarea does NOT take focus when you open a thread, so triage shortcuts (`E`, `S`, `U`, `!`, `X`, `H`) keep working while the email is open — press `R` to start typing a reply. Turn it on if you prefer the cursor pre-placed in the reply box (sacrifices triage shortcuts while a thread is open). The AI-suggestion chips (Alt+1/2/3) always focus the textarea after they fill it, regardless of this setting, because clicking a suggestion is an explicit "I'm replying now" gesture.

**Superhuman reminders collapse out of the way.** If you use Superhuman's **Remind me**, each reminder arrives as a boilerplate email — _"This is a reminder from Superhuman Mail. Do not reply to this email."_ — that threads on top of the message it resurfaces. Opening such a thread used to feature that boilerplate (the viewer expands the most-recent message) and hide the email you actually wanted. Omniscio now treats the reminder as noise: it is **always collapsed** into a tidy **🕐 Superhuman reminder** row, and the latest **real** (non-reminder) message is the one that opens expanded — so you land on what the reminder was about, not the do-not-reply notice. The reminder row is still one click away if you want it, and threads without a Superhuman reminder open exactly as before. Detection + the "what starts collapsed" logic live in one pure module [email-collapse-state.ts](/src/renderer/src/features/gmail/email-collapse-state.ts); invariants + tests in [gmail-reminder-collapse-contract.md](/.claude/memory/contracts/gmail-reminder-collapse-contract.md).

## How it behaves

### Compose draft persistence (never lose an unsent email)

Any message you're writing — a new email, a reply, or a forward — saves itself automatically as you type, so a crash, an accidental close, or switching away mid-thought never costs you a half-written email. Drafts save in two layers: first almost instantly to your own device (so the draft survives even if Omniscio or your computer restarts before the next save reaches Google), and shortly after to your Gmail account's own Drafts folder (so it's there if you pick the conversation back up from another device). Each connected Gmail account keeps its own separate draft — switching accounts never touches another account's in-progress email.

If you close the compose window (or Omniscio itself) with something unsent, a small **"You have an unsaved draft"** banner appears at the top of your Gmail inbox the next time you're there, with two options: **Resume** reopens the message exactly where you left off, and **Discard** clears it for good — including the backing copy in your Gmail Drafts folder. While you're actively typing, a quiet **"Saving…" / "Saved"** note in the compose window's header confirms your draft is protected in real time.

Your draft is only cleared once you actually send the email or explicitly discard it. Closing the window, navigating elsewhere in Omniscio, restarting the app, or switching accounts all leave it exactly as you left it, ready to resume.

### How it works

The live inbox path is **direct OAuth**: [google-auth-service.ts](/src/main/services/google/google-auth-service.ts) owns the consent flow and the encrypted refresh token, and [gmail-api-service.ts](/src/main/services/email/gmail-api-service.ts) makes the `googleapis` REST calls that build queries, fetch messages, mark read, label, archive, and send replies. The legacy gog CLI wrapper [gmail-service.ts](/src/main/services/email/gmail-service.ts) — wrapped by [gog-runner.ts](/src/main/services/gog-runner.ts), whose `resolveGogBinary()` searches Go/Conda/Python paths for a native Windows tzdata (Windows lacks IANA timezone data) and whose `runGog()` adds `--format json --no-input` so responses parse cleanly — now survives only for one outbound function: `sendEmail` (used by the automation-action executor and Quick Email). That single function is **backend-selectable**: it reads `getActiveGoogleBackend()` ([google-cli-backend.ts](/src/main/services/google/google-cli-backend.ts), default `gog`) and dispatches to either `gog gmail send` or the official Google Workspace CLI's `gws gmail +send --format json` ([gws-runner.ts](/src/main/services/gws-runner.ts)). To switch, install the `gws` binary, run `gws auth login`, then flip the `googleCliBackend` setting — until then everything stays on gog and nothing breaks. Inbound security is a 4-layer defense designed in [/docs/plans/2026-04-16-email-inbound-security-design.md](../plans/2026-04-16-email-inbound-security-design.md): (1) loop-token validation on reply-targeting links, (2) DKIM/SPF/DMARC verification, (3) [/src/main/services/email/email-prescreen.ts](/src/main/services/email/email-prescreen.ts) runs the message body through Gemini 2.5 Flash-Lite via OpenRouter (`PRESCREEN_PROVIDER` / `PRESCREEN_MODEL`) with a structured-JSON prompt that classifies prompt-injection / data-exfiltration / obfuscation attempts, falling back to Claude Haiku 4.5 and, as a last resort, the Groq relay, (4) prompt hardening + a runtime permission filter on any session downstream that receives the email. Fail-closed design — any prescreen failure returns a generic auto-reply and **does not** spawn a session. The UI is [/src/renderer/src/features/gmail/EmailViewer.tsx](/src/renderer/src/features/gmail/EmailViewer.tsx) (client-side DOMPurify on top of server-side sanitization — defense in depth). IPC handlers in [/src/main/ipc/email-inbound-handlers.ts](/src/main/ipc/email-inbound-handlers.ts). The virtual project constant `GMAIL_PROJECT_ID = '__gmail__'` lives in [/src/shared/types.ts](/src/shared/types.ts). Spam classification, hybrid rules + AI, hand-tuned per user, and the Gmail-specific toggle hierarchy is in [feature-inventory-communications.md](/.claude/memory/feature-inventory-communications.md).

**Layer 4 details (email-triggered sessions).** When an email passes prescreen and spawns a Claude session, four things happen:

1. **The session name always starts with `Email: ` and gets an AI-generated suffix.** At create time the row is named `Email: <subject>` (or `Email: (no subject)` if the message had no subject) so the sidebar shows something useful immediately. As soon as the session is launched, Omniscio fires a fire-and-forget call to the same title-generation service used by regular sessions (`aiSuggestionService.generateSessionTitle`, using the same hard-coded title prompt as regular sessions). When the model returns, the row is renamed in place to `Email: <generated title>` and a `SESSION_RENAMED` push updates any open Omniscio window. The whole name is capped at 80 characters; longer names get truncated with a `…` while preserving the `Email: ` prefix. Two safety rails: (a) if the model returns null / empty / whitespace, the initial subject-based name is kept (no regression); (b) if the user (or any other flow) renames the session before the model returns and the new name no longer starts with `Email: `, the AI rename is skipped. Helper + flow live in [/src/main/services/email/email-inbound-service.ts](/src/main/services/email/email-inbound-service.ts) (`formatEmailSessionName` + `generateEmailTitle`).
2. **The email body becomes the first user message.** It's persisted as an `operator` message with `metadata.source = 'email_inbound'` so it shows up at the top of the session's chat history exactly like a message you'd have typed yourself. This is what `body` you see on the conversation pane when you open the session — no more "where did this prompt come from?" guessing. Inserted in [/src/main/services/email/email-inbound-service.ts](/src/main/services/email/email-inbound-service.ts) inside the same DB transaction that creates the session.
3. **A short hard-rule list is appended via `buildSessionPrompt()` in [/src/main/services/agentmail-client.ts](/src/main/services/agentmail-client.ts).** Currently: no `git push` / `git remote` / code publishing, no file deletion outside `/tmp` or scratch, no writes into `.claude/` / `.git/config` / config files. HTTP requests, credential reads, and outbound emails are **allowed** — those used to be blocked but it locked out legitimate tasks (e.g. uploading a YouTube video to Vimeo). The runtime permission filter [/src/main/services/email/email-permission-filter.ts](/src/main/services/email/email-permission-filter.ts) mirrors the same short rule set for Bash patterns and file paths so prompt-only restrictions can't be talked out of by a clever email.
4. **Refusals are silent.** If the agent decides the request violates the rules (or just smells wrong) it includes the literal token `EMAIL_REFUSE_v1` somewhere in its reply. `sendReply()` in [/src/main/services/email/email-inbound-service.ts](/src/main/services/email/email-inbound-service.ts) drops any reply containing the token without sending it back to the inbox — the user sees the refusal in the Omniscio session UI, but a (potentially spoofed) sender learns nothing about why or whether the email even reached an agent. There's a second layer: when the runtime filter denies a permission request via control_response, [/src/main/process/ndjson-permission-handler.ts](/src/main/process/ndjson-permission-handler.ts) preemptively calls `markRepliedBySessionId()` so the agent's post-deny refusal text can't be reverse-walked into an email by `handleSessionStatusChanged`.

Sender autocomplete in [GmailSearchInput.tsx](/src/renderer/src/features/gmail/GmailSearchInput.tsx) is an ARIA combobox that draws suggestions from a renderer-side contact cache on `useGmailStore`. The cache is built by an IPC call to `gmail:list-contacts` (handled in [gmail-handlers.ts](/src/main/ipc/gmail-handlers.ts)), parsed with the shared RFC 5322 [email-address-parser.ts](/src/shared/email-address-parser.ts), persisted to localStorage (`amc:gmail:contacts:v1`), and refreshed every 24h. Caret-position detection is handled by [sender-token-detector.ts](/src/renderer/src/features/gmail/sender-token-detector.ts) — a pure function with table-driven tests. **Caveat:** Gmail returns thread-grouped results, so `from:X` returns threads where any message matched, but the displayed sender on each row is the LAST message's sender. This is a Gmail API limitation.

**Attachments.** Parsing marks every MIME part with its part id and whether it is **inline** — a Content-ID image the sanitized body actually displays ([gmail-api-parse.ts](/src/main/services/email/gmail/gmail-api-parse.ts)). Inline images stay in the message's attachment list, so bug intake and every other reader still see them; only the viewer skips them. [MessageAttachmentChips.tsx](/src/renderer/src/features/gmail/MessageAttachmentChips.tsx) renders the rest on an expanded message card. A click calls `gmail:get-attachment` with the message id and **part id** — never the attachment id alone, because Gmail gives a small attachment stored inline on its part no attachment id at all — and [gmail-read.ts](/src/main/services/email/gmail/gmail-read.ts) returns the bytes as base64. The file is always saved as `application/octet-stream` under a cleaned name: the sender's declared type is never trusted (a phone can open a saved file inside the app's own page), and hidden characters that disguise a file's type are stripped. From the phone the request needs the same account verification as other data leaving the computer, and a file over the phone connection's 5 MB limit is refused before any download starts. Invariants + tests: [gmail-message-attachments-contract.md](/.claude/memory/contracts/gmail-message-attachments-contract.md).

### Clean up — mass unsubscribe + bulk archive (inbox zero)

The **Clean up** view turns "I have 800 emails and most are newsletters" into a few clicks. Open it from the sparkles (**Clean up inbox**) icon in the Gmail inbox toolbar, beside Refresh and Compose — available on every viewport (the rows use standard buttons + a confirm dialog, so it works on touch too). Omniscio scans your most recent inbox mail (bounded — it tells you "scanned your most recent N" and lets you scan again for more) and **groups it by sender address**, so you act per sender instead of selecting hundreds of messages one at a time.

Each sender row shows: the sender, how many of their emails are in your inbox, a **"Subscription"** pill when that sender advertises a standard unsubscribe option, and a sample subject. If a sender has threads you have **replied to or starred**, those are counted separately and **kept by default** — bulk actions never silently hide mail you actually engaged with.

Three per-sender actions, each `ConfirmDialog`-gated:

1. **Archive all** — archives that sender's inbox mail (skips your replied-to / starred threads by default). **Undoable** — a toast offers Undo (it re-adds the `INBOX` label), so this is fully reversible.
2. **Unsubscribe + Archive** — requests an unsubscribe through **safe, standardized channels only**, then archives. Omniscio uses the sender's advertised one-click (`List-Unsubscribe-Post: List-Unsubscribe=One-Click`, RFC 8058) or `mailto:` unsubscribe. For a sender that offers only a plain web link, Omniscio **opens the link in your browser for you to finish** — it never auto-fetches it (that can confirm your address to spammers / follow an untrusted link). The button says unsubscribe **"requested"**, never "unsubscribed": many senders ignore the request, and **unsubscribe is not undoable** (the UI says so). The archiving is what reliably clears the mail, and the archiving _is_ undoable.
3. **Never see this again** — archives now **and** creates a Gmail filter so future mail from that sender auto-archives (skips your inbox). This is the only action that needs a **new Google permission** (manage filters, `gmail.settings.basic`): the first time you use it, Omniscio asks you to **reconnect Google once** with clear "manage filters" wording (distinct from an "expired session" prompt). Filters are deduped — using it twice on the same sender won't pile up duplicate rules.

**Safety under the hood.** The one-click unsubscribe request is sent from Omniscio's privileged main process to a URL the _sender_ controls, so it is guarded against SSRF: HTTPS-only, with the hostname resolved and every resolved IP re-checked to reject private / loopback / link-local / CGNAT / cloud-metadata ranges (IPv4 and IPv6), and redirects are not followed ([ssrf-guard.ts](/src/main/utils/ssrf-guard.ts), exhaustive allow/deny IP test matrix). A residual DNS-rebinding TOCTOU is documented and bounded by "safe standardized only" being the default. Unsubscribe attempts are logged by host + method + result only — never the tokenized URL.

**Code references.** Entry point + view: [GmailInboxPane.tsx](/src/renderer/src/features/gmail/GmailInboxPane.tsx), [GmailCleanupView.tsx](/src/renderer/src/features/gmail/GmailCleanupView.tsx), store [gmail-cleanup-store.ts](/src/renderer/src/stores/gmail-cleanup-store.ts). Service + parser: [gmail-cleanup-service.ts](/src/main/services/email/gmail-cleanup-service.ts), [gmail-list-unsubscribe.ts](/src/main/services/email/gmail-list-unsubscribe.ts). IPC: `gmail:scan-subscriptions` · `gmail:bulk-archive` · `gmail:unsubscribe` · `gmail:create-archive-filter` in [gmail-handlers.ts](/src/main/ipc/gmail-handlers.ts). Invariants + the test that catches each: [gmail-inbox-cleanup-contract.md](/.claude/memory/contracts/gmail-inbox-cleanup-contract.md).

## Related

- [set-up-sms-integration.md](set-up-sms-integration.md) — SMS is the parallel virtual project for phone messages
- [automations-and-auto-replies.md](automations-and-auto-replies.md) — auto-label, archive, or forward Gmail based on rules
