Gmail integration (triage inbox inside Omniscio)
Your Gmail inbox shows up inside Omniscio as its own project, so you can read, triage and reply without switching apps. Every message gets AI reply suggestions, and anything that looks like a prompt-injection attempt is screened out before an agent ever sees it.
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 Google consent in your own web browser (google-auth-service.ts) stores an encrypted refresh token, and 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
- Connect Gmail (browser sign-in). Omniscio authenticates to Gmail with a one-time Google consent in your normal web browser — no separate binary to install for the inbox. The connect action opens your default browser at Google's sign-in, where every passkey kind works (iCloud Keychain / Touch ID, a phone, a security key); google-auth-loopback.ts runs a short-lived localhost loopback server to catch the redirect, and the refresh token is stored encrypted at rest. If you close the browser tab without finishing, Connect waits up to 5 minutes and then says it timed out — just click Connect again. (Google sign-in never runs inside an Omniscio window: Google's OAuth policy forbids embedded sign-in, and passkeys cannot work there — see google-account-connection-contract.md I6.) Once connected, Omniscio detects the stored token on startup and the
__gmail__virtual project appears in your left sidebar. - Enable the integration. Open Settings → Gmail. Toggle
gmailEnabled. Defaults:gmailDefaultQueryis the Gmail query string used to fetch the inbox view (defaultis:inbox; you can override with something likein:inbox -category:promotions),gmailPageSizecontrols how many messages load per page,gmailAutoMarkReadauto-marks messages read when you open them. - 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
gmailAutoMarkReadsetting 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 theEmailViewerpanel — 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. - 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 supportsto: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. - 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 andFto 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) — not the CLI. A quick success toast ("Email sent", "Reply sent", or "Email forwarded") confirms the send went through. - 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. - Celebrate inbox zero.
gmailInboxZeroCelebrationtriggers 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. - Filter by category. The dropdown above the thread list (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.
- Filter by read state. The Read status dropdown next to the category dropdown (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, applied in
searchInbox), so the list always matches the dropdown. All leaves your search exactly as typed, so anis:unreadyou 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; the row control in GmailThreadItem.tsx; the bar in 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; invariants + tests in 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 owns the consent flow and the encrypted refresh token, and 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 — wrapped by 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, default gog) and dispatches to either gog gmail send or the official Google Workspace CLI's gws gmail +send --format json (gws-runner.ts). To switch, install the gws binary, run gws auth login, then flip the googleCliBackend setting. Either backend is only tried when it can actually serve the call. If no CLI is installed, or the CLI refuses before transmitting (not signed in, or its keyring is locked), the send falls back to the account you connected with Connect Google — the app's own gmail.modify grant already covers sending, so nothing needs a command-line tool to work. A quota, rate-limit, recipient or network failure is never retried on the other path, because a Gmail send is non-idempotent and the message may already have left. That fallback is what keeps Quick Email, the team-chat notice, workflow emails, the legacy automation send and Setup Backup working on a machine with no CLI at all; see google-cli-fallback-contract.md. Inbound security is a 4-layer defense designed in /docs/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 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 (client-side DOMPurify on top of server-side sanitization — defense in depth). IPC handlers in /src/main/ipc/email-inbound-handlers.ts. The virtual project constant GMAIL_PROJECT_ID = '__gmail__' lives in /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.
Layer 4 details (email-triggered sessions). When an email passes prescreen and spawns a Claude session, four things happen:
- The session name always starts with
Email:and gets an AI-generated suffix. At create time the row is namedEmail: <subject>(orEmail: (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 toEmail: <generated title>and aSESSION_RENAMEDpush updates any open Omniscio window. The whole name is capped at 80 characters; longer names get truncated with a…while preserving theEmail: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 withEmail:, the AI rename is skipped. Helper + flow live in /src/main/services/email/email-inbound-service.ts (formatEmailSessionName+generateEmailTitle). - The email body becomes the first user message. It's persisted as an
operatormessage withmetadata.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 whatbodyyou 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 inside the same DB transaction that creates the session. - A short hard-rule list is appended via
buildSessionPrompt()in /src/main/services/agentmail-client.ts. Currently: nogit push/git remote/ code publishing, no file deletion outside/tmpor 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 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. - Refusals are silent. If the agent decides the request violates the rules (or just smells wrong) it includes the literal token
EMAIL_REFUSE_v1somewhere in its reply.sendReply()in /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 preemptively callsmarkRepliedBySessionId()so the agent's post-deny refusal text can't be reverse-walked into an email byhandleSessionStatusChanged.
Sender autocomplete in 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), parsed with the shared RFC 5322 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 — 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). 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 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 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.
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:
- 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
INBOXlabel), so this is fully reversible. - 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) ormailto: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. - 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, 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, GmailCleanupView.tsx, store gmail-cleanup-store.ts. Service + parser: gmail-cleanup-service.ts, gmail-list-unsubscribe.ts. IPC: gmail:scan-subscriptions · gmail:bulk-archive · gmail:unsubscribe · gmail:create-archive-filter in gmail-handlers.ts. Invariants + the test that catches each: gmail-inbox-cleanup-contract.md.
Related
- set-up-sms-integration.md — SMS is the parallel virtual project for phone messages
- automations-and-auto-replies.md — auto-label, archive, or forward Gmail based on rules
Last verified 2026-10-06