Status: Draft. Last updated: 2026-03-28.
Users can connect their personal Gmail account to the CRM. This enables two things:
- Browse and import email contacts — see everyone you've emailed, add them to the workspace with one click
- Auto-log email interactions — emails sent to/from CRM contacts appear on their interaction timeline automatically
This is a user-scoped feature (my Gmail account), distinct from the workspace-scoped Connections system (org's Airtable/Notion). Each user connects their own account. Imported contacts belong to the workspace.
A new sidebar item visible to all authenticated users. The page has two states:
A centered card with:
- Brief explanation of what connecting does
- "Connect Gmail Account" button (triggers Google OAuth with Gmail scopes)
- Future: "Connect Outlook" button (greyed out / coming soon)
A full-page table showing everyone the user has emailed (not just Google Contacts — pulled from Gmail API message history). Columns:
| Column | Description |
|---|---|
| Name | From Gmail headers (parsed from "Display Name ") |
| Email address | |
| In CRM | Badge if this email matches an existing CRM contact |
| Sync Interactions | Toggle — whether email interactions are auto-logged for this contact |
| Visible to Team | Toggle — whether synced interactions are visible to other users |
| Actions | "Add to Workspace" button (for contacts not yet in CRM) |
Behavior:
- Contacts already in the CRM are highlighted and shown at the top
- For CRM contacts, "Sync Interactions" defaults to on (easy opt-out)
- For new contacts being added, the user picks sync/visibility at add time
- Bulk selection for adding multiple contacts at once
- Search/filter within the Gmail contacts list
- "Refresh" button to re-fetch from Gmail
- Connection settings: default sync/visibility preferences, disconnect account
-- One row per user's connected email account
CREATE TABLE email_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
provider TEXT NOT NULL DEFAULT 'gmail', -- 'gmail', future: 'outlook'
provider_account_email TEXT NOT NULL,
access_token TEXT, -- encrypted, refreshed via refresh_token
refresh_token TEXT NOT NULL, -- encrypted
token_expires_at TIMESTAMPTZ,
default_sync_interactions BOOLEAN NOT NULL DEFAULT true,
default_interactions_visible BOOLEAN NOT NULL DEFAULT true,
last_synced_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'connected', -- connected | error | disconnected
status_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, provider, provider_account_email)
);
-- Per-user per-contact interaction sync settings
CREATE TABLE email_contact_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email_connection_id UUID NOT NULL REFERENCES email_connections(id) ON DELETE CASCADE,
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
sync_interactions BOOLEAN NOT NULL DEFAULT true,
interactions_visible BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(email_connection_id, contact_id)
);-- interactions: new columns
ALTER TABLE interactions ADD COLUMN email_connection_id UUID REFERENCES email_connections(id) ON DELETE SET NULL;
ALTER TABLE interactions ADD COLUMN provider_message_id TEXT; -- Gmail message ID, for dedup
ALTER TABLE interactions ADD COLUMN visible_to_team BOOLEAN NOT NULL DEFAULT true;
-- Index for dedup lookups
CREATE INDEX idx_interactions_provider_message ON interactions(provider_message_id) WHERE provider_message_id IS NOT NULL;Gmail integration uses a separate OAuth flow from the login OAuth. The login flow uses minimal scopes (email + profile). The Gmail integration needs additional scopes:
https://www.googleapis.com/auth/gmail.readonly— read email messages and metadatahttps://www.googleapis.com/auth/gmail.send— send emails (future, Tier 3)
Flow:
- User clicks "Connect Gmail Account" on the My Email Contacts page
- Redirect to Google OAuth consent screen requesting Gmail scopes
- Google redirects back with an auth code
- Server exchanges code for access_token + refresh_token
- Tokens stored in
email_connectionstable (encrypted at rest) - Access token refreshed automatically when expired (using refresh_token)
Important: This is NOT the same as the NextAuth Google provider. It's a separate OAuth client flow that stores tokens in our own table, not NextAuth's account table. This keeps concerns separate — login auth vs. Gmail data access.
Credential security: All credentials are encrypted at rest using AES-256-GCM with a server-side key (EMAIL_ENCRYPTION_KEY env var). This applies to both Gmail OAuth tokens (in email_connections) and data sync credentials like Airtable/Notion API keys (in connections).
GET /api/email-connections — list current user's connections
POST /api/email-connections — create connection (after OAuth callback)
DELETE /api/email-connections/:id — disconnect (revoke token + delete)
POST /api/email-connections/:id/refresh — trigger manual sync
PUT /api/email-connections/:id/settings — update defaults (sync/visibility)
GET /api/email-connections/:id/contacts — list people the user has emailed
(paginated, with CRM match status)
POST /api/email-connections/:id/contacts/import — add selected contacts to workspace
PUT /api/email-contact-settings/:contactId — update sync/visibility for a contact
GET /api/email-contact-settings — list all settings for current user
GET /api/auth/gmail — initiate Gmail OAuth flow
GET /api/auth/gmail/callback — handle OAuth callback
- Dispatcher cron (
dispatch_email_syncs): runs every minute, findsemail_connectionsthat are due for sync, enqueuessync_email_interactionsjobs - Sync task (
sync_email_interactions): for a given email connection: a. Refresh access token if expired b. Query Gmail API for messages sincelast_synced_atc. For each message, check if sender or recipient matches a CRM contact withsync_interactions = trued. Checkprovider_message_idfor dedup — skip if already imported e. Create interaction record with appropriate visibility f. Updatelast_synced_at - Manual refresh: same task, triggered on-demand via API
- Default: every 5 minutes (configurable per connection)
- Manual refresh: on-demand via "Refresh" button
- Rate limits: Gmail API allows ~250 quota units per user per second,
messages.listcosts 5 units — well within limits
For each email:
type:email_sentoremail_receivedsubject: email subject linebody: email snippet (first ~200 chars) — NOT full body by defaultoccurredAt: email datemetadata:{ provider_message_id, provider_thread_id, source: "gmail", from, to, cc }email_connection_id: which connection synced thisvisible_to_team: from the contact'semail_contact_settings
When querying interactions for a contact timeline:
- Show all interactions where
visible_to_team = true - Show all interactions where
email_connection.user_id = current_user(you always see your own synced emails) - For visible interactions from other users' Gmail syncs: show subject + metadata only, not body
- The full body is only shown to the Gmail account owner
- Primary key:
provider_message_id(Gmail's message ID is globally unique) - If two users have the same email in a thread (e.g., both CC'd), we create one interaction from the first sync that encounters it. The second sync sees the existing
provider_message_idand skips.
There's no single "people I've emailed" endpoint. Strategy:
gmail.users.messages.listwithq: "in:sent"— gets all sent messages- Extract unique
To/Cc/Bccaddresses from message headers - Deduplicate by email address
- Parse display names from the
Toheader format"Display Name <email@example.com>" - Cache this list in the database (a
gmail_cached_contactstable or in-memory on the page) to avoid re-fetching on every page load
For incremental sync:
gmail.users.messages.list({
userId: 'me',
q: `after:${epochSeconds}`, // since last sync
maxResults: 500
})
Then for each message:
gmail.users.messages.get({
userId: 'me',
id: messageId,
format: 'metadata', // just headers, not full body
metadataHeaders: ['From', 'To', 'Cc', 'Subject', 'Date']
})
Full body fetched only when user clicks to expand an interaction on the timeline (on-demand, not stored).
- Schema:
email_connectionstable (Drizzle) - Token encryption utilities (
src/lib/encryption.ts) - Gmail OAuth flow:
/api/auth/gmail,/api/auth/gmail/callback - API:
GET/POST/DELETE /api/email-connections - UI: "My Email Contacts" sidebar item + page with connect/disconnect
- Gmail API client wrapper (
src/lib/gmail.ts) — auth, token refresh, message fetching - API:
GET /api/email-connections/:id/contacts— fetch and deduplicate sent-to addresses - API:
POST /api/email-connections/:id/contacts/import— add to workspace - UI: contacts table with CRM match highlighting, bulk import, search
- Schema:
email_contact_settingstable - Schema: new columns on
interactions(email_connection_id,provider_message_id,visible_to_team) - API:
GET/PUT /api/email-contact-settings - UI: sync/visibility toggles per contact in the Gmail contacts table
- Worker task:
sync_email_interactions— fetch Gmail messages, match to contacts, create interactions - Worker task:
dispatch_email_syncs— cron dispatcher - Dedup logic via
provider_message_id - Visibility filtering in interaction timeline queries
- API:
POST /api/email-connections/:id/refresh— manual sync trigger - UI: "Refresh" button, last synced timestamp display
- Update interaction timeline component to show Gmail-synced interactions
- Visual indicator for synced vs manually logged interactions (Gmail icon)
- "Subject only" view for team members viewing others' synced interactions
- Per-interaction visibility toggle and delete for the connection owner
- Body expansion on-demand (fetches from Gmail API, not stored)
| Variable | Description |
|---|---|
EMAIL_ENCRYPTION_KEY |
AES-256 key for encrypting OAuth tokens. Generate: openssl rand -hex 32 |
AUTH_GOOGLE_ID |
Same Google OAuth client ID (already exists) |
AUTH_GOOGLE_SECRET |
Same Google OAuth client secret (already exists) |
The Google OAuth client in Google Cloud Console needs the Gmail API enabled and the additional scopes added to the consent screen.
- OAuth tokens encrypted at rest (AES-256-GCM)
- Refresh tokens are long-lived — if compromised, attacker gets mailbox read access. Encryption is critical.
- Users can revoke access at any time (disconnect). This also revokes the Google token.
- Email body content is NOT stored in the database by default — only subject + snippet. Full body is fetched on-demand from Gmail API.
- The
visible_to_teamflag ensures users control what colleagues can see - Admin visibility: admins can see that a user has connected Gmail and sync statistics, but cannot read synced email content
- Only emails sent by the user are synced — received emails are missing. The sync should include both directions.
- No visual indicator on the interaction timeline showing whether an interaction is visible to all or private to the user. Users need to see and control this at a glance.
- Visibility should be controllable per interaction (not just per contact). A user may want most interactions with a contact to be visible but flag specific sensitive ones as private.
- The overall interactions UX needs a design pass — think about what information is most useful and how to present it.
- Junk/automated email addresses (e.g.
01000199a213f3cd-...@send.happenstance.fyi) appear at the top of the list because they start with numbers. Need filtering or sorting improvements — consider deprioritizing addresses that look automated (noreply, UUIDs, tracking domains). - When adding a Gmail contact to the CRM, the user should be able to edit the name before importing. Gmail header names can be incorrect, missing, or formatted oddly.
- When viewing a contact, if the current user has a connected Gmail account, the system should check whether the contact's email appears in their Gmail history. If so, show a prompt/button to enable interaction syncing for that contact — a quick entry point without needing to go through the My Email Contacts page.
- Sending emails via Gmail (Tier 3) — future enhancement
- Full inbox UI (Tier 4) — deferred indefinitely
- Outlook / IMAP support — future, but the schema is provider-agnostic (
providercolumn) - Two-user dedup edge cases — if two users sync the same thread, first one wins. May need refinement.
- Google Workspace admin consent — some orgs require domain-wide delegation. Out of scope for now.