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). Spreadsheets are not indexed in the function: since 19 Oct 2026 they are stored aspendingwithmetadata.index_queue, and the Trigger.dev tasksable.document.index-queuedindexes them within five minutes, with retries. - 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.
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
| Setting | Value |
|---|---|
| Inbound address | SABLE_INBOUND_ADDRESS — client/portal-specific, e.g. sable-<hubspot-portal-id>@leadtocashbash.com |
| 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 |
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
| 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