---
title: Quick Email (Control Space → Email tab) (part 2)
---

# Quick Email (Control Space → Email tab) (part 2)

## What it is

This is part 2 of the [Quick Email (Control Space → Email tab)](quick-email.md) 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](quick-email.md) 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](quick-email.md). 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](../../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 `MultiRecipientPicker`s with distinct `ariaLabel`s, `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](../../.claude/memory/contracts/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](../../src/renderer/src/features/quick-launch/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 `applyDraftPolicyOnBlur`, 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](../../.claude/memory/contracts/quick-email-contract.md) `draft-survives-non-submit-close`.
- **Action registry**: declared in [src/shared/quick-launch-actions.ts](../../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](../../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](../../src/main/services/quick/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](../../.claude/memory/contracts/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](../../.claude/memory/contracts/quick-email-contract.md) `send-email-builds-valid-cli-args` and the [subjectless-send postmortem](../../.claude/memory/postmortems/quick-email-blank-subject-send-postmortem.md).
  - `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](../../src/main/db/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](../../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](../../src/renderer/src/features/quick-launch/SendLaterPicker.tsx) (presets via the shared snooze presets + `parseSnoozeTime`). Full invariants: [quick-email-scheduled-send-contract.md](../../.claude/memory/contracts/quick-email-scheduled-send-contract.md).
- **IPC handlers**: [src/main/ipc/quick-email-handlers.ts](../../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](../../src/shared/ipc-schemas/quick-email.ts).
- **Toast wiring**: [src/renderer/src/hooks/useQuickEmailPushEvents.ts](../../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](../../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](../../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](../../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](../../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](../../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](../../.claude/memory/contracts/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](../../src/shared/types/settings/hotkeys-quicklaunch-settings.ts) (type, defaults, and the `SETTINGS_UPDATE` shape all derive from it), surfaced via [src/shared/types.ts](../../src/shared/types.ts) with defaults in `DEFAULT_SETTINGS`; the settings card is [src/renderer/src/features/settings/sections/quick-email/QuickEmailCard.tsx](../../src/renderer/src/features/settings/sections/quick-email/QuickEmailCard.tsx) rendered from the General settings section; six search-index entries in [GeneralSettings-search.ts](../../src/renderer/src/features/settings/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](../../src/renderer/src/features/settings/sections/quick-email/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](../../.claude/memory/postmortems/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](../../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](../../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](../../src/main/services/google/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](../../src/main/services/google/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](../../src/main/services/google/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](../../src/main/ipc/quick-email-handlers.ts)); consumed by the shared **`MultiRecipientPicker`** chip input — now used by BOTH the Quick Email composer ([QuickLaunchEmailTab.tsx](../../src/renderer/src/features/quick-launch/QuickLaunchEmailTab.tsx), which passes its saved recipients via the `savedRecipients` prop) and the Forward Email automation config ([ForwardEmailConfig.tsx](../../src/renderer/src/features/automations/action-configs/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](../../src/renderer/src/features/settings/sections/quick-email/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](../../.claude/memory/contracts/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](../../.claude/memory/contracts/quick-email-contract.md).

## Related

[Quick Email (Control Space → Email tab)](quick-email.md) 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.
