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).
  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.

Configuration

SettingValue
Inbound addressSABLE_INBOUND_ADDRESS — client/portal-specific, e.g. sable-<hubspot-portal-id>@ai.indigotrigger.ai
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

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.tssable.email.ingest and sable.email.drain schedules
  • supabase/migrations/20260908183000_inbound_email_reject.sql — reject/restore columns