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:
- Verifies the Svix-style signature (
webhook-id/webhook-timestamp/webhook-signature) againstRESEND_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. - Drops anything not addressed to
SABLE_INBOUND_ADDRESSin To, Cc, or Bcc. - Replay-guards on the Resend
email_id: it checksauditfor a prior row with that id, and falls back to catching the database's own unique-constraint violation (23505) if two deliveries race. - Calls the shared
ingestResendEmailhelper (bodies become asourcesrow; attached PDFs, Office files, spreadsheets, images, ZIPs,.msgfiles, and text files — excluding inline signature images — are copied to Supabase Storage and indexed as SABLE documents linked back to that source). - Inserts an
auditrow (actionemail_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:
- Lists Resend's
/emails/receivingand filters to messages matchingSABLE_INBOUND_ADDRESSacross To/Cc/Bcc. - Pre-checks which Resend
email_ids are already ingested (againstsources.metadata->>email_id) before spending time downloading and extracting content for messages already in SABLE. - Plans a bounded batch —
limitcontrols how deep the listing goes,maxNewcaps how many new messages one run is allowed to ingest — and ingests via the sameingestResendEmailhelper the webhook uses. - Stops early against a wall-clock time budget (
timeBudgetMs, default 100s) once the worst-seen item wouldn't fit in the remaining time, and reportsstopped_early/remaininghonestly 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
| Setting | Value |
|---|---|
| Inbound address | SABLE_INBOUND_ADDRESS — client/portal-specific, e.g. sable-<hubspot-portal-id>@ai.indigotrigger.ai |
| Secrets | RESEND_API_KEY (poller + webhook), RESEND_WEBHOOK_SECRET (webhook signature; optional — poller works without it) |
| Also required | VOYAGE_API_KEY, ANTHROPIC_API_KEY (used by shared ingestion/extraction) |
| Matching | To, 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
| Task | Schedule | Params |
|---|---|---|
sable.email.ingest | Every 10 minutes (*/10 * * * *) | {limit:200, maxNew:5}, no automatic retry (maxAttempts:1) |
sable.email.drain | Every 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
| Table | Written by | Contents |
|---|---|---|
sources | Both (via ingestResendEmail) | One row per email, type='email' |
sources.rejected_at/rejected_by/rejected_reason | inbound-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 catalog | Both | Real attachments (not inline signature images) |
audit | Both | One row per ingest attempt; also the webhook's replay guard |
connector_checkpoints | email-sync | Health, high-water-mark; records field names that matched (To/Cc/Bcc), never addresses |
signals | inbound-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.drainis for — it runs less often but loops multiple passes per invocation. If the backlog persists past that, checkconnector_checkpointsfor theemailrow'serror. - The webhook returns 503.
RESEND_WEBHOOK_SECRETisn'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.
rejectis only allowed on unassigned emails, and assigning a previously-rejected email is blocked until it's explicitly restored viainbound-emails.js'srestoreaction. - 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 whetheremail_idmatching logic changed.
Where the code lives
supabase/functions/email-webhook/index.ts— signed webhook receiversupabase/functions/email-sync/index.ts— recovery pollerweb/api/inbound-emails.js— triage API (assign/reject/restore,remember_domain)trigger/connectors.ts—sable.email.ingestandsable.email.drainschedulessupabase/migrations/20260908183000_inbound_email_reject.sql— reject/restore columns