Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications59
  4. Projects & Tasks95
  5. Automation & Scheduling75
  6. Knowledge & Memory26
  7. AI Features60
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization58
  12. Account & Billing28
  13. Troubleshooting84
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

Quick Email (Control Space → Email tab) (part 2)

The second half of the Quick Email page: the machinery behind it — the tab component and its fixed window, the main-side send pipeline and its undo timer, the durable send-later path, contact suggestion ranking and live typeahead, the global Ctrl+Z shortcut bridge, the settings schema and the key files.

What it is

This is part 2 of the Quick Email (Control Space → Email tab) page. That page covers what Quick Email is, where to find it, and everything a sender does with it — recipients, the body, Cc and Bcc, attachments, send-later, drafts, the Ctrl+Z undo, the settings card, and the defaults. This half carries the machinery behind it: the tab component, the send pipeline that actually delivers the email, the shortcut plumbing, the contact suggestions, and the code files an engineer needs.

Where to find it

Nothing in this half has a surface of its own. Every piece of it runs behind the same two places the main page describes — the Email tab of the Quick Launch / Control Space composer, and the Quick Email card under Settings → System. What follows is what happens in the background while a sender uses those two.

How it behaves

A sender never sees any of this directly — it is what the app does on their behalf, and it is deliberately kept out of the way. Nothing in this half changes what a sender experiences; the behaviour they do see is described on the main page. What follows is where the implementation is written out in full, file by file.

For agents

How it works (for repo-aware readers)

  • Tab component: src/renderer/src/features/quick-launch/QuickLaunchEmailTab.tsx — owns the recipients/subject/body/attachments state (recipients as a toEmails: string[] chip list, via the shared MultiRecipientPicker), loads saved recipients once on mount via SETTINGS_GET (the tab unmounts on tab-switch, so a fresh mount always reflects the latest saved list — no push-subscribe needed), and calls QUICK_EMAIL_REQUEST_SEND with a recipients array (each chip mapped to { recipientId } if it matches a saved recipient, else { email }) plus optional cc / bcc arrays (same mapping; collapsed behind "Add Cc/Bcc" — showCcBcc — and rendered as two more MultiRecipientPickers with distinct ariaLabels, ccEmails / bccEmails state, omitted from the payload when empty) and an optional attachments array. canSend needs ≥1 To recipient (Cc/Bcc never satisfy it) plus a non-empty body, subject, or ≥1 attachment (only a fully-blank send is blocked). Attachments stage from paste (extractClipboardFiles), the paperclip (DIALOG_PICK_ATTACHMENTS), and drag-drop (useFileDropZone), all through the shared filesToAttachments pipeline + reused QuickLaunchAttachmentChips; the UI caps are computed BEFORE setState (planAttachmentAppend + attachmentsRef), and attachments join the in-memory draft (attachments-ride-the-send). A submittingRef blocks the Enter+Click double-fire race. On a successful schedule it calls onSuccess() (the modal's exit animation); failures keep the tab open with an inline hint. The Control Space window is one FIXED 720×660 size for every tab — sized around this composer (WINDOW_HEIGHT in quick-launch-window.ts; entrance-animation contract I21): the Email tab requests NO window resize on mount, Subject/Cc-Bcc reveal, or unmount — a runtime grow exposed a strip the compositor cleared white for a frame (the recurring flash bug), so the resize lever was deleted repo-wide and a lint guard forbids any QUICK_LAUNCH_SET_WINDOW_HEIGHT invoke in the QL folder. min-h-0 on the tab/body/textarea keeps the footer reachable at any height. Two focus chords — a modal-internal window keydown listener scoped to the tab (exempted in the keyboard-listener guard alongside QuickLaunchModal) — jump between fields: Ctrl/Cmd+Shift+S focuses the Subject (revealing it first when collapsed), Ctrl/Cmd+Shift+M focuses the Body. Because Ctrl+Shift+M is ALSO the default app-focus globalHotkey (an OS-wide globalShortcut that swallows the chord before this listener runs), the Body-focus chord actually arrives via a main-side FORWARD — hotkeys.ts emits QUICK_LAUNCH_FOCUS_EMAIL_BODY when the QL window is focused and the Email tab focuses the Body on receipt; the window listener above still handles the chord directly if the user rebinds globalHotkey. Auto-capitalize: the Body onChange + handleSend run the shared @shared/smart-capitalize (new opt-in paragraphStart line-start rule) on a boundary char + at send, gated by autoCapitalizeQuickEmail (default on). Both are covered by quick-email-contract.md focus-body-forward-and-autocap. Unsent-draft persistence: because the tab remounts on every open (key={openEpoch}), it keeps the compose (recipient + subject + body + subject-visibility) in a module-scope store, quick-launch-email-draft.ts — seeded on mount, mirrored on edit, in-memory only. It honors the same quickLaunchPreserveDraft setting as the New-Session composer: QuickLaunchModal clears the store on a preserve-OFF close (both resetComposerAfterHide and decideDraftOnBlur (features/quick-launch/decide-draft-on-blur.ts), gated on !preserveDraftRef.current); a successful send clears it on schedule; the Clear/eraser button (quick-launch-email-clear-draft) wipes it with a useUndoStore.registerUndo undo (Ctrl+Z + a "Draft cleared · Undo" pill). Full behavior + the recipient-restore safety rules: quick-email-contract.md draft-survives-non-submit-close.
  • Action registry: declared in src/shared/quick-launch-actions.ts with id: 'quick-email', enabledSelector gated on quickEmailEnabled (and presence of recipients). Component map entry in src/renderer/src/features/quick-launch/quick-launch-action-components.ts.
  • Send pipeline (the heart of the feature): src/main/services/quick-email-service.ts — a factory (createQuickEmailService()) plus a module singleton. It holds an in-memory Map<pendingId, entry> of scheduled sends, each guarded by a Node setTimeout:
    • requestSend({ recipientId | email | recipients[], cc?, bcc?, body, subject?, attachments? }) validates (feature enabled, every recipient resolves, and a non-empty subject OR body OR ≥1 attachment — subject-only and attachment-only sends are allowed), schedules a timer for quickEmailUndoSeconds later, emits the QUICK_EMAIL_PENDING push (so the toast layer in the main window can render an Undo button), and returns { pendingId, sendAt }. Recipients: resolveSendTargets accepts EITHER the back-compat single recipientId/email (CLI + older callers) OR a recipients array of 1..10 targets, resolves each, and aggregates to ONE deliverable — recipientEmail becomes the , -joined To line (gog --to is comma-separated, so all ride one email) and recipientName a friendly summarizeRecipients summary ("Alice +2"); no push-schema change. Cc / Bcc: the optional cc/bcc arrays each resolve through resolveOptionalRecipientList to a , -joined header line (or undefined when empty ⇒ the header is omitted, a byte-identical no-cc/bcc send), carried as entry.ccEmail/bccEmail and passed to sendEmail's existing cc/bcc positional args (gog --cc/--bcc are comma-separated too). A scheduled "send later" persists them via two nullable cc/bcc columns added to quick_email_scheduled (migration 20260731050257).
    • When the timer fires, fire() calls the shared Gmail sendEmail() and emits QUICK_EMAIL_SENT on success or QUICK_EMAIL_ERROR on failure; either way the entry leaves the map. fire() first runs the body through the pure appendQuickEmailSignature(body, getSettings().quickEmailSignature) (the optional signature, appended after a blank line — a blank signature is a byte-for-byte no-op; applied ONLY here, so the shared gmail-service — replies / forwards / the Forward-Email automation — never gains it; see quick-email-contract.md signature-appended-at-send-time), then passes subject ?? '', that body, and the staged attachments through — so gmail-service.sendEmail is responsible for turning a blank subject/body into a valid CLI command (and for delivering attachments: it writes each to a unique temp dir and hands gog its repeatable --attach <path> flag, cleaned up after the send — attachments-ride-the-send) (the gog/gws CLIs reject an empty --subject/--body): a blank subject becomes -, a blank body sends an empty --body-html, and a markdown body renders to --body-html (never the invalid --html flag). Before that fix, every body-only, subject-only, or formatted quick email failed with the generic "Couldn't send the email right now" toast — see quick-email-contract.md send-email-builds-valid-cli-args and the subjectless-send postmortem.
    • cancelSend({ pendingId, reason }) clears the timer, drops the entry, and emits QUICK_EMAIL_CANCELLED (reason ∈ 'user' | 'settings-disabled' | 'recipient-removed'). It also handles a scheduled send — soft-deleting its row so the checker never fires it.
    • "Send later" (durable scheduled send): requestSend also accepts an optional scheduledSendAt (ISO). When present it takes a separate DURABLE path — scheduleDurableSend persists the send to the quick_email_scheduled table (queries-quick-email-scheduled.ts) and emits QUICK_EMAIL_PENDING with scheduled: true — instead of the in-memory undo timer. A periodic checker (startScheduledProcessor, wired at app-ready in src/main/index.ts, 20s) atomically claims + fires due rows via the same sendEmail() path, so a far-future send survives an app restart; delivery is bounded-retry, and a crash-orphaned row is reconciled on boot (at-most-once). The UI picker is SendLaterPicker.tsx (presets via the shared snooze presets + parseSnoozeTime). Full invariants: quick-email-scheduled-send-contract.md.
  • IPC handlers: src/main/ipc/quick-email-handlers.ts registers three wrapHandler-gated invoke channels — QUICK_EMAIL_REQUEST_SEND ('quick-email:request-send'), QUICK_EMAIL_CANCEL_SEND ('quick-email:cancel-send'), QUICK_EMAIL_LIST_PENDING ('quick-email:list-pending', used to rehydrate the toast layer after a reload). The three push channels (QUICK_EMAIL_SENT / CANCELLED / ERROR) are fired by the service, not by handlers. Zod schemas in src/shared/ipc-schemas/quick-email.ts.
  • Toast wiring: src/renderer/src/hooks/useQuickEmailPushEvents.ts — a top-level hook mounted from App.tsx (not from the composer, because the composer closes on submit). It subscribes to the four push channels and maps them to toasts, keeping a pendingId → { toastId, recipientName } map so SENT/CANCELLED/ERROR can clear the right pending toast and name the recipient (only PENDING carries the display name).
  • Global Ctrl+Z: the service owns a single CommandOrControl+Z registration via an injected ShortcutBridge (a thin façade over Electron's globalShortcut.register/unregister). The bridge is injected — quickEmailService.setShortcutBridge({...}) in src/main/index.ts after app.whenReady() — so unit tests pass vi.fn() pairs and never touch the OS registrar. The hotkey is claimed when the pending map goes 0→1 and released when it drains 1→0. handleGlobalUndo() cancels the last-inserted entry (LIFO via Map iteration order).
  • Per-recipient hotkey planner: src/shared/global-hotkeys.ts planHotkeyRegistrations(config) — a pure function (no Electron) that decides which accelerators register and in what order. The fixed four (focus / Omni / Quick Launch / KMS window) come first; then it iterates quickEmailRecipientHotkeys, emitting one HotkeyDef per recipient carrying its recipientId. quickEmailMasterOn = quickEmailEnabled && quickEmailHotkeysEnabled gates the whole recipient list. Empty accelerators are skipped as disabled; duplicates lose to whoever claimed first (conflict). The registration loop in src/main/index.ts binds each recipient hotkey to a handler that shows Quick Launch then emits QUICK_LAUNCH_OPEN_TO_EMAIL with the recipientId.
  • OPEN_TO_EMAIL handoff: src/renderer/src/features/quick-launch/QuickLaunchModal.tsx listens for QUICK_LAUNCH_OPEN_TO_EMAIL, sets the pending recipient id, and switches to the quick-email tab. The Email tab consumes pendingRecipientId on mount, preselects that recipient (falling back to blank if the id was deleted between hotkey registration and mount — never to another row; composer-picker-never-defaults), and fires onPendingRecipientApplied() exactly once so a later plain Ctrl+Space re-open doesn't pull the stale id. A hotkey preselect also wins over any restored unsent-draft target.
  • Empty-state deep-link: with no saved recipients, the Add a recipient in Settings button invokes QUICK_LAUNCH_OPEN_SETTINGS with { settingId: 'quick-email-recipients' }; the main-side handler (src/main/ipc/quick-launch-handlers.ts) hides the QL window, foregrounds the main window, and emits SETTINGS_DEEP_LINK so the main window scrolls to the Recipients card. settingId is an enum-bounded allow-list ('quick-launch-tabs' is the gear icon's default; 'quick-email-recipients' is the Email empty-state target) — the value reaches a document.querySelector('[data-setting-id=…]') in the trusted main window, so the enum keeps the untrusted QL renderer from injecting an arbitrary selector. See quick-email-contract.md email-tab-never-dead-ends.
  • Settings: seven AppSettings fields (quickEmailEnabled, quickEmailRecipients, quickEmailHotkeysEnabled, quickEmailUndoSeconds, quickEmailShowSubjectByDefault, autoCapitalizeQuickEmail — the one default-ON field, gating the body auto-capitalization — quickEmailSignature) — the single-source Zod slice is hotkeys-quicklaunch-settings.ts (type, defaults, and the SETTINGS_UPDATE shape all derive from it), surfaced via src/shared/types.ts with defaults in DEFAULT_SETTINGS; the settings card is src/renderer/src/features/settings/sections/quick-email/QuickEmailCard.tsx rendered from the General settings section; six search-index entries in GeneralSettings-search.ts. The quickEmailSignature free-text field is applied at send time by appendQuickEmailSignature (signature-appended-at-send-time), NOT persisted per-recipient. The recipients sub-card is not fully controlled: it edits a local draft and commits only the valid subset (via selectPersistable in quick-email-recipients-ops.ts) on blur/remove/hotkey — so a blank in-progress row never autosaves. The Zod name is optional (email is the only required field). Rows are drag-reorderable via the shared useDragReorder hook wired to the pure reorderRecipients reducer — a drop commits the new order through the same selectPersistable gate; the 50-recipient cap lives once in hotkeysQuickLaunchSettingsSchema (.max(50)), which updateSettingsSchema derives from via .partial().shape. Why this shape matters: quick-email-add-recipient-cant-add-postmortem.md.
  • Push-listener scope: all five push channels (QUICK_EMAIL_PENDING / SENT / CANCELLED / ERROR plus QUICK_LAUNCH_OPEN_TO_EMAIL) are registered as desktop-only in the mobile-push-listener-coverage lint (tests/unit/lint/mobile-push-listener-coverage.test.ts) — mobile/web has no Quick Launch surface and therefore no Quick Email entry point.
  • UI anchors: the Email tab registers data-ui-anchor entries (tab body, recipient select, subject input + toggle, body textarea, send button, and the empty-state Add a recipient in Settings button — quick-launch-email-empty-add-recipient) in STATIC_UI_ANCHORS (src/shared/ui-anchor-registry.ts) for App Tour spotlights and the GET /ui/snapshot CLI route.
  • Multi-source contact suggestions: pure merge/rank + error-classifier in src/main/services/google-contacts-ranking.ts (rankContactCandidates — frequency desc → address-book contacts alpha → rest, de-duped by lowercased email, address-book names win; combineFrequency — weighted sent+received merge; isLikelyAutomatedSender — no-reply/mailer-daemon filter; classifyContactsFetchError), fetched + cached by src/main/services/google-contacts-service.ts (suggestQuickEmailContacts — People API connections.list for address-book names + a concurrent Gmail in:sent (≤250) and in:inbox (≤150) scan via the shared tallyHeaderAddresses, weighted-combined so sent outranks received and automated senders are dropped, via the in-app googleapis OAuth in google-auth-service.ts, independent of the gog-CLI send path; every source degrades independently — a failure status surfaces only when ALL produce nothing and one failed); the result is cached stale-while-revalidate (CONTACTS_FRESH_MS ≈ 2 min — opens stay instant, and once stale the list refreshes in a single coalesced background scan), and a successful Quick Email send calls refreshContactsAfterSend() so a just-emailed person shows up on the next composer open. Exposed over the read-only invoke channel QUICK_EMAIL_SUGGEST_CONTACTS (handler in quick-email-handlers.ts); consumed by the shared MultiRecipientPicker chip input — now used by BOTH the Quick Email composer (QuickLaunchEmailTab.tsx, which passes its saved recipients via the savedRecipients prop) and the Forward Email automation config (ForwardEmailConfig.tsx) + the Gmail compose dialog. (The single-select RecipientPicker it replaced in the composer still exists but is no longer wired in.) Settings-card search (ContactSearchBox inside QuickEmailCard.tsx) also uses the same channel. The received scan needs no new scope (in:inbox reads are covered by the existing gmail.modify); the address book needs the additive contacts.readonly scope (old tokens degrade to a re-auth inbox alert until re-consent). Full invariants contact-ranking-is-pure → suggest-contacts-contract-validated in quick-email-contract.md.
  • Live address-book typeahead (BOTH pickers): searchAddressBookContacts(query) → People API searchContacts (same contacts.readonly scope), exposed over the read-only QUICK_EMAIL_SEARCH_CONTACTS channel. Warmed once per account (an empty-query priming call), per-query cached ~30 s, capped at 20. Both consumers of the shared MultiRecipientPicker (the Quick Email composer + the Forward Email automation) drive it through ONE shared renderer hook, useLiveContactSearch (@building-block group=hooks), which owns the fetch + useDebouncedCallback debounce (250 ms, min 2 chars) + state + the stale-response guard. The hook is dedupe-agnostic — each picker keeps its own merge/dedupe, rendering live matches BELOW the instant local rows (the multi-select ALSO dedupes against the selected chips) so a saved contact beyond the pre-loaded cap still surfaces without blocking the local list. live-typeahead-never-blocks in quick-email-contract.md.

Related

Quick Email (Control Space → Email tab) is the user-facing half of this feature — what a sender does, and every setting that changes it. The contracts and key files named above hold the invariants behind what this half describes.

Last verified 2026-10-06