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 sharedMultiRecipientPicker), loads saved recipients once on mount viaSETTINGS_GET(the tab unmounts on tab-switch, so a fresh mount always reflects the latest saved list — no push-subscribe needed), and callsQUICK_EMAIL_REQUEST_SENDwith arecipientsarray (each chip mapped to{ recipientId }if it matches a saved recipient, else{ email }) plus optionalcc/bccarrays (same mapping; collapsed behind "Add Cc/Bcc" —showCcBcc— and rendered as two moreMultiRecipientPickers with distinctariaLabels,ccEmails/bccEmailsstate, omitted from the payload when empty) and an optionalattachmentsarray.canSendneeds ≥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 sharedfilesToAttachmentspipeline + reusedQuickLaunchAttachmentChips; the UI caps are computed BEFOREsetState(planAttachmentAppend+attachmentsRef), and attachments join the in-memory draft (attachments-ride-the-send). AsubmittingRefblocks the Enter+Click double-fire race. On a successful schedule it callsonSuccess()(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_HEIGHTinquick-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 anyQUICK_LAUNCH_SET_WINDOW_HEIGHTinvoke in the QL folder.min-h-0on the tab/body/textarea keeps the footer reachable at any height. Two focus chords — a modal-internal windowkeydownlistener scoped to the tab (exempted in the keyboard-listener guard alongsideQuickLaunchModal) — 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-focusglobalHotkey(an OS-wideglobalShortcutthat swallows the chord before this listener runs), the Body-focus chord actually arrives via a main-side FORWARD —hotkeys.tsemitsQUICK_LAUNCH_FOCUS_EMAIL_BODYwhen 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 rebindsglobalHotkey. Auto-capitalize: the Body onChange +handleSendrun the shared@shared/smart-capitalize(new opt-inparagraphStartline-start rule) on a boundary char + at send, gated byautoCapitalizeQuickEmail(default on). Both are covered by quick-email-contract.mdfocus-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 samequickLaunchPreserveDraftsetting as the New-Session composer:QuickLaunchModalclears the store on a preserve-OFF close (bothresetComposerAfterHideanddecideDraftOnBlur(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 auseUndoStore.registerUndoundo (Ctrl+Z + a "Draft cleared · Undo" pill). Full behavior + the recipient-restore safety rules: quick-email-contract.mddraft-survives-non-submit-close. - Action registry: declared in src/shared/quick-launch-actions.ts with
id: 'quick-email',enabledSelectorgated onquickEmailEnabled(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-memoryMap<pendingId, entry>of scheduled sends, each guarded by a NodesetTimeout: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 forquickEmailUndoSecondslater, emits theQUICK_EMAIL_PENDINGpush (so the toast layer in the main window can render an Undo button), and returns{ pendingId, sendAt }. Recipients:resolveSendTargetsaccepts EITHER the back-compat singlerecipientId/email(CLI + older callers) OR arecipientsarray of 1..10 targets, resolves each, and aggregates to ONE deliverable —recipientEmailbecomes the,-joined To line (gog--tois comma-separated, so all ride one email) andrecipientNamea friendlysummarizeRecipientssummary ("Alice +2"); no push-schema change. Cc / Bcc: the optionalcc/bccarrays each resolve throughresolveOptionalRecipientListto a,-joined header line (orundefinedwhen empty ⇒ the header is omitted, a byte-identical no-cc/bcc send), carried asentry.ccEmail/bccEmailand passed tosendEmail's existing cc/bcc positional args (gog--cc/--bccare comma-separated too). A scheduled "send later" persists them via two nullablecc/bcccolumns added toquick_email_scheduled(migration20260731050257).- When the timer fires,
fire()calls the shared GmailsendEmail()and emitsQUICK_EMAIL_SENTon success orQUICK_EMAIL_ERRORon failure; either way the entry leaves the map.fire()first runs the body through the pureappendQuickEmailSignature(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 sharedgmail-service— replies / forwards / the Forward-Email automation — never gains it; see quick-email-contract.mdsignature-appended-at-send-time), then passessubject ?? '', that body, and the stagedattachmentsthrough — sogmail-service.sendEmailis 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) (thegog/gwsCLIs 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--htmlflag). 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.mdsend-email-builds-valid-cli-argsand the subjectless-send postmortem. cancelSend({ pendingId, reason })clears the timer, drops the entry, and emitsQUICK_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):
requestSendalso accepts an optionalscheduledSendAt(ISO). When present it takes a separate DURABLE path —scheduleDurableSendpersists the send to thequick_email_scheduledtable (queries-quick-email-scheduled.ts) and emitsQUICK_EMAIL_PENDINGwithscheduled: 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 samesendEmail()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 apendingId → { 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+Zregistration via an injectedShortcutBridge(a thin façade over Electron'sglobalShortcut.register/unregister). The bridge is injected —quickEmailService.setShortcutBridge({...})in src/main/index.ts afterapp.whenReady()— so unit tests passvi.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 iteratesquickEmailRecipientHotkeys, emitting oneHotkeyDefper recipient carrying itsrecipientId.quickEmailMasterOn = quickEmailEnabled && quickEmailHotkeysEnabledgates the whole recipient list. Empty accelerators are skipped asdisabled; 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 emitsQUICK_LAUNCH_OPEN_TO_EMAILwith therecipientId. - 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 thequick-emailtab. The Email tab consumespendingRecipientIdon 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 firesonPendingRecipientApplied()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_SETTINGSwith{ settingId: 'quick-email-recipients' }; the main-side handler (src/main/ipc/quick-launch-handlers.ts) hides the QL window, foregrounds the main window, and emitsSETTINGS_DEEP_LINKso the main window scrolls to the Recipients card.settingIdis 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 adocument.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.mdemail-tab-never-dead-ends. - Settings: seven
AppSettingsfields (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 theSETTINGS_UPDATEshape all derive from it), surfaced via src/shared/types.ts with defaults inDEFAULT_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. ThequickEmailSignaturefree-text field is applied at send time byappendQuickEmailSignature(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 (viaselectPersistablein quick-email-recipients-ops.ts) on blur/remove/hotkey — so a blank in-progress row never autosaves. The Zodnameis optional (emailis the only required field). Rows are drag-reorderable via the shareduseDragReorderhook wired to the purereorderRecipientsreducer — a drop commits the new order through the sameselectPersistablegate; the 50-recipient cap lives once inhotkeysQuickLaunchSettingsSchema(.max(50)), whichupdateSettingsSchemaderives 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/ERRORplusQUICK_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-anchorentries (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) inSTATIC_UI_ANCHORS(src/shared/ui-anchor-registry.ts) for App Tour spotlights and theGET /ui/snapshotCLI 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 APIconnections.listfor address-book names + a concurrent Gmailin:sent(≤250) andin:inbox(≤150) scan via the sharedtallyHeaderAddresses, weighted-combined so sent outranks received and automated senders are dropped, via the in-app googleapis OAuth in google-auth-service.ts, independent of thegog-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 callsrefreshContactsAfterSend()so a just-emailed person shows up on the next composer open. Exposed over the read-only invoke channelQUICK_EMAIL_SUGGEST_CONTACTS(handler in quick-email-handlers.ts); consumed by the sharedMultiRecipientPickerchip input — now used by BOTH the Quick Email composer (QuickLaunchEmailTab.tsx, which passes its saved recipients via thesavedRecipientsprop) and the Forward Email automation config (ForwardEmailConfig.tsx) + the Gmail compose dialog. (The single-selectRecipientPickerit replaced in the composer still exists but is no longer wired in.) Settings-card search (ContactSearchBoxinside QuickEmailCard.tsx) also uses the same channel. The received scan needs no new scope (in:inbox reads are covered by the existinggmail.modify); the address book needs the additivecontacts.readonlyscope (old tokens degrade to a re-auth inbox alert until re-consent). Full invariantscontact-ranking-is-pure→suggest-contacts-contract-validatedin quick-email-contract.md. - Live address-book typeahead (BOTH pickers):
searchAddressBookContacts(query)→ People APIsearchContacts(samecontacts.readonlyscope), exposed over the read-onlyQUICK_EMAIL_SEARCH_CONTACTSchannel. Warmed once per account (an empty-query priming call), per-query cached ~30 s, capped at 20. Both consumers of the sharedMultiRecipientPicker(the Quick Email composer + the Forward Email automation) drive it through ONE shared renderer hook,useLiveContactSearch(@building-block group=hooks), which owns the fetch +useDebouncedCallbackdebounce (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-blocksin 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