Inbox
Multi-account email client — Gmail & Outlook, merged inbox, Gmail-style floating compose with rich text, attachments, scheduled send, signatures, and calendar inserts.
Inbox is Project88's email client. It connects to Gmail and Outlook, supports multiple accounts in parallel, and ships with a Gmail-style floating compose surface complete with rich text, attachments, schedule send, signatures, and inline calendar invites.
Inbox opens as a native shell surface — full-bleed, no React Flow
canvas underneath — mounted by EmailSurface. See
Canvases and widgets → native apps vs canvases.
The dashboard-style email widgets (EmailInboxWidget,
EmailComposeWidget, EmailStatsWidget, EmailAccountSelectorWidget,
…) remain canvas-addable for embedded views on Home and your own
boards.
Multiple accounts and merged views
Connected accounts live in the integrations table with OAuth tokens
encrypted in Vault (see Gmail). The Inbox
store (src/store/email.jsx) holds them as a connections array, and the
active view is a discriminated union:
activeView | Shows |
|---|---|
{ type: 'all' } | Every account merged into one feed |
{ type: 'account', connectionId } | A single account |
{ type: 'group', groupId } | A user-defined account group |
For merged and group views, the store fetches each account's mail in
parallel and reconciles into one timestamp-sorted feed. Each email keeps
an _integrationId so the right account answers when you reply.
Default From account
Configured in Settings → Inbox → Defaults, stored per-user and
per-org at
profile.preferences.inbox.default_from_by_org[orgId]. Org-keyed
because integrations rows are unique per
(org_id, user_id, provider), so a user in two orgs has two
disjoint connection sets and a single scalar would dangle in
whichever org didn't set it.
Every compose window shows a From row when more than one
account is connected — static text on replies, which can only be
threaded by the mailbox holding the conversation. The account is
pinned to the draft at open time (DRAFT_VERSION 2→3) and always
passed at send, so what the row shows is what actually sends,
including for a draft restored after a refresh.
Resolution order. The account being viewed in the inbox → the configured default → the first connection. Both stored steps are validated against the live connections list, so a disconnected or other-org id degrades to the first connection instead of failing the send.
Inbox context is now supplied by the inbox surfaces themselves
(useInboxCompose) rather than read from the global activeView
— EmailProvider wraps the whole authenticated workspace, so
"the account you're viewing" used to name the last account you
opened in the inbox long after you'd moved to the dialer or a
record sheet.
Signature auto-apply follows From. Per-account default
signatures already existed but never applied to a new compose
(auto-apply resolved against replyTo only). They do now, and
switching From swaps the signature — unless you picked one by
hand, which the signatureAuto flag protects.
Layout
EmailWorkspace is an Outlook-style three-pane layout
(folders + accounts rail │ list │ reader) wired up by a context-aware
WidgetCardLayout (EmailChromeContext). Folders moved out of the
old FolderDropdown and into the rail; the redundant in-list header
was dropped now that the rail owns account / folder selection. The
floating Compose button is hoisted into the shell top bar so it's
reachable from anywhere in the workspace.
- Left — folder / label rail
- Account / group selector
- Folder list with live counts (replaces the breadcrumb's old
FolderDropdown) - Color-coded labels
- Center — email list
- Search across subject + body
- Unread indicator, pin toggle, attachment icon
- Label pills inline on each row
- Always-visible checkboxes; selecting two or more emails reveals
a bulk-action bar with Delete / Archive / Mark read / Mark unread
(routed through
modifyEmail) - Click a row to open the reader
- The refresh button keeps its icon in place and spins via
animate-spinwhile loading — the skeleton already owns the "loading" affordance, so the icon doesn't swap to an hourglass
- Right — threaded email reader (see below)
Out of scope for this layout: Focused / Other tabs and nested folder trees — the underlying data model is flat and merged across accounts, so those affordances would need a different store shape first.
Threaded email reader
The right panel renders an EmailThreadReader rather than a single
message. Every message in the conversation is laid out
oldest → newest, the newest auto-expanded, every other collapsed —
click any card to toggle. Expanded cards render on a fixed-white
"paper" surface that matches the body iframe; collapsed cards lift on
hover with a stronger border + shadow.
- The widget header strips chained
Re:/Fwd:prefixes viagetBaseSubject()so a 30-deep thread reads as one subject line. - Quoted-reply chains (Gmail / Apple Mail / Outlook) are trimmed by
trimQuotedReplyso the iframe sizes to actual content. A small···toggle surfaces the full quoted chain on demand. - Replies and trashed messages refresh
activeThreadautomatically — new replies appear in the reader and trashed messages vanish without navigating away. - Drafts in the thread never expand inline. Clicking a draft card
opens a fresh compose pre-populated via
buildDraftSeed, and the Discard action trashes the server-side draft so the card disappears immediately. - Action bar on the immersive reader is icon-only:
Reply / Reply All / Forward / Delete / Pin. Pin replaces the
old Star and is wired to the existing
togglePinstore action; pinned emails surface in the list's PINNED section. - Expanded card header darkens on hover. Once a card is opened
the wrapper becomes a static "paper" surface, so the click-to-collapse
affordance moves to the header itself (
zinc-100+rounded-t-xlto stay flush with the card's outer radius). Collapsed cards still lift via the outer hover styles — the affordance is the whole card. - Text inside the reader is selectable — the canvas-wide
user-select: noneis explicitly overridden inside the message body.
Backend changes that make this work: email-api exposes isDraft
for both Gmail (the DRAFT label) and Microsoft (isDraft on
Graph), and the Microsoft thread fetch now uses
$filter=conversationId eq 'X' to return the whole conversation
(was single-message only).
Pinned emails stay at the top
Pinning surfaces an email in the list's PINNED group regardless of
its position in the timeline. Because maxResults caps the rendered
inbox at 30 rows, a pin set days ago could silently slide out of the
window once enough new mail pushed it past the cap — the PINNED group
would quietly empty.
The store now hoists missing pins after each inbox fetch:
pinnedMessageIds is a Map<message_id, integration_id> (was a
Set), and a single effect — gated on !loading, !searchQuery, and
per-pin integration membership in the active view — refetches any
pinned message_ids not in the rendered list and prepends them. Fetches
go through Promise.allSettled, so one stale or 404'd pin can't break
the page. .has() semantics are unchanged for downstream consumers
(partitionEmails, EmailRow), so the Map upgrade is transparent
there.
The floating compose popup
Hitting Compose opens a Gmail-style floating popup — draggable, minimisable, and docked at the bottom-right. Multiple drafts can be open at once; minimised ones stack at the bottom edge. The implementation:
ComposePopupShell— draggable shell, mounted byComposeDockviaComposeManagerProviderComposeForm— the form itselfcomposeStore.js— pure reducer for compose stateuseDragPosition— drag-to-position hook
ComposeManagerProvider mounts inside DataProvider so the
ComposeDock it portals to document.body can still resolve
useData() through the React context tree. Position is persisted
across reloads via localStorage.
Recipient chips and smart autofocus
To / Cc / Bcc render committed recipients as chips styled like
TagBadge, so they share the visual language of every other chip in
the app. The active typing stays in the inline input:
,/;/ Enter / Tab commits a chip.- Backspace at the start of an empty input removes the last chip.
- The contact typeahead commits with the contact's display name.
- Click the × on a chip to remove it; remaining order is preserved.
Focus on mount is smart: To when the field is empty (new compose, forward), the body when To is pre-filled (reply, reply-all, restored draft).
Address parsing is quote-aware — a shared splitTokens helper walks
the string respecting "…" (including \" escapes), so tokens like
"Smith, Alice" <a@b.com> stay as one address across the validator,
chip renderer, and prefix/current split. Underlying draft.to/cc/bcc
shape stays a comma-delimited string — the compose store and the
email-api send path are unchanged.
Rich text via Tiptap
The body editor (RichTextEditor.jsx) is a Tiptap instance with
StarterKit + Underline + Link. On send, both HTML and a plaintext
fallback are emitted as multipart/alternative, so:
- HTML clients render the rich content.
- Plain-text clients (and the spam-score systems that care) get a clean text version.
The toolbar (ComposeToolbar) provides the usual formatting controls
plus pickers for signatures, emoji, calendar invites, and attachments.
Attachments
Three input methods, all routing through addAttachments(files):
- Paperclip button in the toolbar
- Drag-and-drop onto the compose body
- Clipboard paste for images
Each attachment moves through QUEUED → UPLOADING → UPLOADED (see
composeStore.js). AttachmentChips renders a per-file status row.
Uploaded attachments are sent as multipart/MIME parts at send time.
Insert a template
The compose toolbar carries a Templates button that opens the
shared TemplatePickerPopover. Picking a template:
- Inserts the body at the cursor — Tiptap's
posAtCoords/insertContentAtplaces the rich content, and inline images (under 1 MB) drop exactly where you point. - Fills the subject only when empty — never overwrites a subject you've already typed.
- Copies the template's attachments into this draft
server-side, so no bytes cross the browser and the draft becomes
self-contained: editing the template later can't alter the email
once it's queued or sent. (The server-side copy is the
load-bearing decision —
email-apirefuses anystoragePathoutside the sending user's prefix, and rather than loosen that check the copy re-parents the file into the draft's own prefix.) - Resolves merge fields (
{{contact.first_name|there}}) against the target contact, with inline fallbacks. A tag that can't resolve stays visible in the message text rather than leaving an invisible hole — the picker also counts what won't fill in before you insert.
See Templates app.
Schedule send
Click the schedule button (next to Send) to open SchedulePopover —
presets like "In an hour", "Tomorrow", "Monday morning", plus a custom
datetime input. Scheduling enqueues a row in scheduled_email_sends
with the rendered MIME payload, the target integration_id, and a
send_at timestamp. Send-time validation now rejects past times in
both the store and the picker — previously the picker returned
silently on a past time, so Schedule just did nothing.
Dispatch pipeline. A per-minute pg_cron job invokes the
scheduled-email-send-worker Edge Function (verify_jwt=true,
Vault service-role key — the process-calendar-reminders pattern).
The worker claims one page of due pending rows, hydrates the
saved draft, and dispatches via the same email-api send path a
manual send uses. Scheduled drafts close the popup as soon as the
row is created — they're persisted, you can't lose them.
- Retry classification. Transient failures (provider 5xx, 429,
network) return to
pendingwith backoff up to 3 attempts; permanent ones (disconnected account, missing attachment, rejected request) fail immediately rather than delaying the bad news. Unrecognised errors count as transient — dropping a queued message is worse than sending it late. - Failures are surfaced, not silent. Terminal failures write a
notification that rides the existing realtime fan-out (the
records gate that normally suppresses
created_by === userIdtoasts is overridden for your own failed send — the opposite is exactly backwards). - Stuck rows recover. A row claimed as
sendingwhose worker died returns to the queue after 10 minutes; the claim query only looks atpending, so it would otherwise never retry.
Scheduled panel
A Scheduled section in the email rail lists what's queued, when,
and why anything failed. Realtime-subscribed so rows update in
place; a Retrying state is distinct from Scheduled. Cancel
reopens the message as a draft — body, recipients, From account
and attachments intact.
Storage cleanup and retention
deleteAttachment used to fire only when a user removed a chip
from a draft — nothing cleaned up after a successful send, so
every attachment ever sent stayed in the bucket permanently. Both
the store and email-api now delete on send success. Safe because
template files are copied into a draft-owned path on insert, so
the template's own object (a different bucket) is untouched;
scheduling deliberately does not clean up, since the worker needs
the files later.
A nightly purge trims terminal queue rows — sent/cancelled at 30 days, failed at 90, since failures are the rows you may still need to act on.
Migration 170
Adds the cron jobs and the realtime publication for
scheduled_email_sends, and re-declares the table, enum, RLS,
indexes and updated_at trigger idempotently — all of it had
been MCP-applied with no repo migration, so a fresh environment
could not be built from the repo alone. The updated_at function
is reproduced verbatim including SECURITY DEFINER and its pinned
search_path; a naive re-declare would have silently stripped
both.
Signatures
Signatures (useSignatures() hook → signatures table) are stored
per integration. Resolution rule:
- Per-integration default — if the active account has one, use it.
- Otherwise, the all-accounts default.
ComposePopupShell auto-applies the default on first open and exposes
a select / clear menu for swapping. In compose, the signature
does not go through Tiptap — its HTML is rendered as a sandboxed
iframe below the editor and merged back into bodyHtml at send
time. Tiptap's schema silently strips tables, inline styles, and
images, which would break WiseStamp / HubSpot signatures; the iframe
preserves the original markup intact. Switching signatures via the
toolbar popover re-renders the iframe; Remove drops it from the
outgoing send.
Manage signatures from ⌘K → Settings → Inbox → Signatures — a
master/detail editor with Preview / HTML toggle (no Tiptap), an
explicit paste handler that reads text/html from the clipboard
(signature generators don't emit text/plain), an optional
integration_id binding, and an is_default flag (one default per
scope, enforced by a partial unique index).
Signature images survive the round-trip thanks to the Image
extension on the Tiptap body editor and the iframe-based rendering
described above.
Emoji and calendar inserts
- Emoji —
EmojiPopoverwraps@emoji-mart/react. On pick, the emoji's Unicode character is inserted at the cursor via Tiptap'sinsertContent(). - Calendar invite —
buildEventLinkHtml()ineventInsert.jscreates an event in your primary calendar (calendar.createEvent()) and appends a styled invite block (title + when + location + a Gmail deep-link to the event) to the message body.
Link rules (sender → record)
Open the Settings gear in the email reader (or ⌘K → Settings → Inbox →
Link rules) to define widget link rules — per-table mappings that
match an email's sender against an email-typed column on one of your
data tables. The rules live in widget_link_rules (org-scoped) and
power the matched-record chip in the reader header.
- Pick a target table, then an email column on that table.
- Multiple rules are fine: every rule fires in parallel against the sender address.
- Rule status is lazy-validated — if a referenced column is renamed or
deleted, the rule surfaces as
broken_column/type_mismatchin the editor.
Under the hood the reader calls the batched search_rows_by_field RPC,
which is generic over field type (email today; phone / URL / text slot
in via a normalizer registry).
Matched records render as <LinkedRecordChips> beneath the
subject + label row. Click a chip to open the full RecordDetailSheet
without leaving the inbox.
Tag inheritance from linked records
When an email opens and a link rule matches the sender to a record
that already has tags, those tags are physically copied onto the
email entity (via the shared inheritTagsBetweenEntities store
action — the same call the calendar uses when linking events to
records). Specifically:
- The copy fires from a
useEffectin the reader and is idempotent (skip-existing on the target). - Tags land in
tag_assignmentswithentity_type = 'email_message',entity_id = <message id>. - A
<TagPicker>in the reader header lets you add or remove tags on the email itself — independent of the source record after the initial copy.
This mirrors the calendar-event semantics so a customer tag on a
record automatically lights up every email and every meeting that
links back to that record. See Tags for the
underlying data model.
Real-time inbox: new-mail toasts via Gmail push
The moment a message lands in a connected Gmail INBOX, a toast pops ("New email from …") on whatever surface you're on — Inbox, Data, Dial, Home — and the inbox list plus per-label unread counts refresh without a manual reload. Mirrors the calendar realtime architecture (see Calendar → Real-time sync).
Pipeline: Gmail users.watch → GCP Pub/Sub → gmail-webhook
edge function → history.list delta → email_events INSERT →
Supabase Realtime → toast + debounced refetch in EmailProvider.
- Migration 149 adds
gmail_watch_state(per-integrationhistory_id+ watch expiry, service-role only) andemail_events(user-scoped realtime toast feed, unique per(integration, message), pruned after 30 days). A 12-hourpg_cronjob renews the watches — Gmail watches expire in ≤7 days. gmail-webhook(verify_jwt=false, shared-secret?token=auth like the calendar webhook's channel token) processes history deltas idempotently with a 20-message burst cap, and resets the baseline onhistoryIdexpiry.gmail-watchregisters watches after account connect (frontend) and renews all of them (cron). It no-ops gracefully until theGMAIL_PUBSUB_TOPICsecret is set — the app keeps working with the previous polling behavior in the meantime.EmailProvidersubscribes toemail_eventsand toasts under a 10-minute recency guard so a history backfill (or a re-plug of the watch after a restart) can't toast-storm the operator. The same debounced refetch drives label counts and the visible inbox, gated on the current folder and view.
Setup requires a one-time GCP Pub/Sub topic + the
GMAIL_PUBSUB_TOPIC / GMAIL_WEBHOOK_SECRET secrets — see
Gmail integration → real-time push.
Scope: Gmail only for now — Microsoft accounts keep the existing
60 s label-count poll (Graph webhooks are a separate effort).
Arrivals never auto-mark themselves read
Realtime refetches used to auto-select the newest message on every
email_events push, which fired the mark-read effects — so mail
was consumed the moment it landed, app-wide, since EmailProvider
mounts above every surface.
The store now tracks selection source: only user clicks —
setActiveEmailId, which every widget row uses — mark the message
and its thread read. Code-driven auto-selection never does, and
refetches preserve the current selection instead of hijacking
it to the newest arrival, so the reader no longer jumps on a push.
Clicking the already-auto-selected row still consumes unread state
(the selection nonce re-fires the effects).
Skeleton-first loading
Every email surface paints a layout-matched skeleton placeholder before real content arrives — on first load and on identity changes (folder, account, thread, email):
EmailListWidgetSkeleton— six classic / compact / preview variants keyed onfolderEmailInboxWidgetSkeleton— dense list rows in two flavors (Mediumand a tighterSidebarfor the Large variant's 170 px rail)EmailReaderWidgetSkeleton— single-message preview keyed onactiveEmailIdEmailThreadReaderSkeleton— three collapsed + one expanded paper card keyed onactiveThread.threadId
Switching folders no longer flashes the previous folder's emails; opening a new thread paints the reader skeleton before its first network response. See Canvases & widgets → loading states.
Where to next
- Gmail integration
- Calendar
- Records — link emails to records via the activity log
Calendar
Project88's universal calendar — Google dual-write, a default Project 88 calendar that mirrors every internal write, shareable calendars with on-behalf-of booking and visibility tiers, reminders, record linking, and tasks on the schedule.
Dial
Bucket-based outbound calling — work through a queue of contacts with scripts, call notes, and live activity tracking.