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.
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.
Layout
CanvasEmailCard renders three cards:
- Left — folder / label sidebar
- Account / group selector
- Folder counts (live)
- Color-coded labels
- Center — email list
- Search across subject + body
- Unread indicator, starred toggle, attachment icon
- Label pills inline on each row
- Click a row to open the reader; checkboxes are the only path to multi-select (no accidental selection on click)
- Right — email detail
- Full body with attachments (PDF / image preview, download cards)
- Sender info
- Reply / Reply-all / Forward
- Inline reply composer
FolderDropdown in the breadcrumb mirrors the folder selector.
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
Position is persisted across reloads via localStorage.
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:
- 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. The signature HTML is spliced onto
the bottom of the body via applySignatureBlock().
Manage signatures from ⌘K → Settings → Inbox → Signatures — a
master/detail editor where each signature has a name, the rich-text
body (same Tiptap editor used in compose), an optional integration_id
binding, and an is_default flag (one default per scope, enforced by a
partial unique index).
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.
Where to next
- Gmail integration
- Calendar mode
- Records — link emails to records via the activity log