Skip to main content

Email / BCC Ingestion

SABLE ingests email through Resend's inbound mail product rather than a mailbox. There is no "connect your Gmail" step for this connector: mail is routed to (or Bcc'd to) a client-specific inbound address, and SABLE ingests whatever lands there. Two functions cover it — a signed webhook for immediate ingestion, and a scheduled poller that is the durable path of record.

How it works​

Webhook (email-webhook)​

Resend calls this function directly for the email.received event. It:

  1. Verifies the Svix-style signature (webhook-id / webhook-timestamp / webhook-signature) against RESEND_WEBHOOK_SECRET (edge secret, falling back to a Vault row). If the secret isn't configured, the function fails closed with a 503 rather than accepting unverified mail.
  2. Drops anything not addressed to SABLE_INBOUND_ADDRESS in To, Cc, or Bcc.
  3. Replay-guards on the Resend email_id: it checks audit for a prior row with that id, and falls back to catching the database's own unique-constraint violation (23505) if two deliveries race.
  4. Calls the shared ingestResendEmail helper (bodies become a sources row; attached PDFs, Office files, spreadsheets, images, ZIPs, .msg files, and text files — excluding inline signature images — are copied to Supabase Storage and indexed as SABLE documents linked back to that source). Spreadsheets are not indexed in the function: since 19 Oct 2026 they are stored as pending with metadata.index_queue, and the Trigger.dev task sable.document.index-queued indexes them within five minutes, with retries.
  5. Inserts an audit row (action email_webhook).

There is no Supabase JWT check on this function — authentication is entirely the Svix signature.

Poller (email-sync)​

email-sync is the durable backstop, not a fallback for when the webhook is "down" — it's the path of record because Resend retains mail even if the webhook never fires, so the poller can always recover it. Each run:

  1. Lists Resend's /emails/receiving and filters to messages matching SABLE_INBOUND_ADDRESS across To/Cc/Bcc.
  2. Pre-checks which Resend email_ids are already ingested (against sources.metadata->>email_id) before spending time downloading and extracting content for messages already in SABLE.
  3. Plans a bounded batch — limit controls how deep the listing goes, maxNew caps how many new messages one run is allowed to ingest — and ingests via the same ingestResendEmail helper the webhook uses.
  4. Stops early against a wall-clock time budget (timeBudgetMs, default 100s) once the worst-seen item wouldn't fit in the remaining time, and reports stopped_early / remaining honestly so a follow-up run can resume rather than silently dropping the tail of a backlog.

Per-email failures are counted but don't stop the run.

Forwarded mail​

Most mail reaches the inbox because a teammate forwards a client email, so the headers show an internal sender and the client appears only inside the body. At ingest, SABLE reads the original sender from the forward header and stores it as metadata.forwarded_from. It recognises Gmail (---------- Forwarded message ---------), Outlook (-----Original Message-----) and Apple Mail (Begin forwarded message:) formats. It records a sender only when the forwarder is an internal person (GMAIL_INTERNAL_DOMAIN, default indigotrigger.com); an outside sender cannot claim to be a forwarded client by writing a header into the body. A forward of a forward reports the nearest sender. The same header block's To and Cc lines are stored as metadata.forwarded_to and metadata.forwarded_cc (bare addresses, at most 30 each, and only from an internal forwarder). Routing uses the original sender, so a forward is filed by the same rules as direct mail. The original recipients are a fallback only: they are consulted when the sender side has no outside address at all, which is the case when a teammate forwards an email they sent to the client themselves. Whoever writes an email chooses its To and Cc freely, so a recipient never outweighs the sender, and an outsider cannot pick a client by copying it. Outlook sometimes writes To and Cc as names only (To: Smith, Pat; Lee, Kim); those name no address, so nothing is recorded for them.

Every email ingested here carries metadata.channel = 'inbox', which marks it as inbox mail (Gmail mail is stored separately). The project's Important emails card reads this marker and forwarded_from. Rows ingested before these fields existed are stamped by a one-time, idempotent backfill migration (20260923180000_backfill_inbox_email_channel.sql); it reads forwarded senders from the standard text markers only, so some older forwards keep no original sender. The card's domain match needs SABLE_INBOUND_DOMAIN set in Vercel; without it only the roster match applies.

Configuration​

SettingValue
Inbound addressSABLE_INBOUND_ADDRESS — client/portal-specific, e.g. sable-<hubspot-portal-id>@leadtocashbash.com
SecretsRESEND_API_KEY (poller + webhook), RESEND_WEBHOOK_SECRET (webhook signature; optional — poller works without it)
Also requiredVOYAGE_API_KEY, ANTHROPIC_API_KEY (used by shared ingestion/extraction)
MatchingTo, Cc, and Bcc are all checked — mail can be sent directly or Bcc'd

leadtocashbash.com receives; leadstocashbash.com (with an s) sends.

To onboard: send or forward mail to the inbound address, or place it in To/Cc/Bcc of an existing thread. No mailbox connection or OAuth grant is required on the client's side.

Cron cadence​

TaskScheduleParams
sable.email.ingestEvery 10 minutes (*/10 * * * *){limit:200, maxNew:5}, no automatic retry (maxAttempts:1)
sable.email.drainEvery 2 hours, :35 (35 */2 * * *)Same params, loops up to 6 passes while stopped_early is true, to clear a backlog beyond what a single 10-minute pass can absorb

The webhook has no schedule — it fires on Resend's own delivery timing.

Table mapping​

TableWritten byContents
sourcesBoth (via ingestResendEmail)One row per email, type='email'
sources.rejected_at/rejected_by/rejected_reasoninbound-emails.js (reject action)Reversible triage removal — never a hard delete, since deleting would cascade knowledge_chunks, ingest_jobs, spreadsheet_rows, and null out references in over a dozen other tables
Supabase Storage + document catalogBothReal attachments (not inline signature images)
auditBothOne row per ingest attempt; also the webhook's replay guard
connector_checkpointsemail-syncHealth, high-water-mark; records field names that matched (To/Cc/Bcc), never addresses
signalsinbound-emails.js (remember_domain on assign)kind='domain' — remembers a sender domain for future auto-matching

What can go wrong​

  • An email never shows up. First check it was actually sent to SABLE_INBOUND_ADDRESS (To, Cc, or Bcc — a common miss is Bcc'ing a lookalike address). Then check whether the webhook fired: if not, the 10-minute poller should still pick it up on its next pass — Resend retains inbound mail even without a working webhook.
  • A backlog builds up faster than the 10-minute pass can drain it. That's what sable.email.drain is for — it runs less often but loops multiple passes per invocation. If the backlog persists past that, check connector_checkpoints for the email row's error.
  • The webhook returns 503. RESEND_WEBHOOK_SECRET isn't configured. This is a fail-closed design choice, not a bug — the function refuses to accept unverified webhook calls rather than processing mail it can't authenticate. The poller keeps working regardless.
  • An email was rejected in triage but now needs to be assigned. reject is only allowed on unassigned emails, and assigning a previously-rejected email is blocked until it's explicitly restored via inbound-emails.js's restore action.
  • Duplicate ingestion after a webhook/poller race. Both paths use the same replay/precheck logic against Resend's email_id, with a database unique-constraint as the final backstop — a duplicate row should not be possible; if one appears, check whether email_id matching logic changed.

Where the code lives​

  • supabase/functions/email-webhook/index.ts — signed webhook receiver
  • supabase/functions/email-sync/index.ts — recovery poller
  • web/api/inbound-emails.js — triage API (assign/reject/restore, remember_domain)
  • trigger/connectors.ts — sable.email.ingest and sable.email.drain schedules
  • supabase/migrations/20260908183000_inbound_email_reject.sql — reject/restore columns