Apps

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:

activeViewShows
{ 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.

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-spin while 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 via getBaseSubject() so a 30-deep thread reads as one subject line.
  • Quoted-reply chains (Gmail / Apple Mail / Outlook) are trimmed by trimQuotedReply so the iframe sizes to actual content. A small ··· toggle surfaces the full quoted chain on demand.
  • Replies and trashed messages refresh activeThread automatically — 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 togglePin store 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-xl to 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: none is 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 by ComposeDock via ComposeManagerProvider
  • ComposeForm — the form itself
  • composeStore.js — pure reducer for compose state
  • useDragPosition — 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.

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.

A cron Edge Function (scheduled-email-send-worker) polls due rows and dispatches them via the email-api send path. Scheduled drafts close the popup as soon as the row is created — they're persisted, you can't lose them.

Signatures

Signatures (useSignatures() hook → signatures table) are stored per integration. Resolution rule:

  1. Per-integration default — if the active account has one, use it.
  2. 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

  • EmojiEmojiPopover wraps @emoji-mart/react. On pick, the emoji's Unicode character is inserted at the cursor via Tiptap's insertContent().
  • Calendar invitebuildEventLinkHtml() in eventInsert.js creates 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.

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_mismatch in 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 useEffect in the reader and is idempotent (skip-existing on the target).
  • Tags land in tag_assignments with entity_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.

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 on folder
  • EmailInboxWidgetSkeleton — dense list rows in two flavors (Medium and a tighter Sidebar for the Large variant's 170 px rail)
  • EmailReaderWidgetSkeleton — single-message preview keyed on activeEmailId
  • EmailThreadReaderSkeleton — three collapsed + one expanded paper card keyed on activeThread.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

On this page