Skip to content

Bug: Inbound replies dropped on Resend: code reads body from metadata-only webhook #320

Description

@robby-seventeen

Summary

The inbound email channel can never thread a reply when using Resend. Resend's email.received webhook delivers metadata only (no text/html body), and Quackback's ingestion reads the body inline from data.text. With Resend that field is always absent, so extractReplyText gets an empty string and every reply is dropped with status: 'empty'. The Svix signature verifies, the conversation ID resolves correctly, but the message body is never present.

Steps to reproduce

  1. Configure the inbound chat email channel with Resend (EMAIL_INBOUND_DOMAIN, EMAIL_INBOUND_SIGNING_SECRET), MX pointed at Resend, webhook on email.received.
  2. Agent replies in a conversation; visitor replies to the reply+<id>@domain address.
  3. Resend posts a verified email.received event to /api/chat/email/inbound.

Expected

The visitor's reply is appended to the conversation.

Actual

The event is accepted (200) and dropped:

{"level":"warn","component":"chat-email-inbound","route":"POST /api/chat/email/inbound","status":"empty","msg":"dropped inbound email event"}
{"level":"info","route":"POST /api/chat/email/inbound","status":200,"msg":"request completed"}

Root cause

parseInboundEmail in apps/web/src/lib/server/domains/chat/chat.email-inbound.ts reads the body inline:

text: asString(d.text),

and ingestInboundEmail in chat.email-inbound.service.ts drops when that's empty:

const content = extractReplyText(parsed.text ?? '')
if (!content) return { status: 'empty' }

But Resend's email.received webhook does not include the body. Per Resend's own docs (https://resend.com/docs/dashboard/receiving/introduction):

Webhooks do not include the email body, headers, or attachments, only their metadata. You must call the Received emails API or the Attachments API to retrieve them. This design choice supports large attachments in serverless environments that have limited request body sizes.

The actual email.received payload carries email_id, from, to, cc, bcc, received_for, message_id, subject, and attachments[] — no text or html. So d.text is always undefined, parsed.text is always null, and every inbound reply resolves to empty.

Confirmed against a live payload (body fields absent):

{
  "type": "email.received",
  "data": {
    "email_id": "a9744255-c151-4c0f-94a4-57a0e21ad450",
    "from": "visitor@example.com",
    "to": ["reply+01kwb4pr9re05b0wrh66y3rcde.fYlOL-P_Xkc_H7M3l-QmsR@<domain>"],
    "received_for": ["reply+01kwb4pr9re05b0wrh66y3rcde.fYlOL-P_Xkc_H7M3l-QmsR@<domain>"],
    "subject": "Re: New reply from ...",
    "message_id": "<...@proton.me>",
    "attachments": []
  }
}

The body is retrievable, just not in the webhook — it requires a follow-up call to Resend's Received Emails API with email_id (resend.emails.receiving.get(email_id)). The current code never makes that call, so the integration is incomplete for the documented provider.

Suggested fix

In the inbound ingestion path, when parsed.text is empty and data.email_id is present, fetch the full received email from Resend before extracting the reply text:

// pseudo, in ingestInboundEmail before extractReplyText
let bodyText = parsed.text
if (!bodyText && typeof data?.email_id === 'string') {
  const res = await fetch(`https://api.resend.com/emails/receiving/${data.email_id}`, {
    headers: { Authorization: `Bearer ${process.env.EMAIL_RESEND_API_KEY}` },
  })
  if (res.ok) bodyText = (await res.json()).text ?? null
}
const content = extractReplyText(bodyText ?? '')

(Confirm the exact endpoint/field against the Received Emails API reference. The fetch should be guarded so a non-Resend provider that does send text inline still works.)

Alternatively, document that the inbound channel requires a provider whose webhook includes the body inline, since the current code assumes a fat payload Resend does not send.

Environment

  • Quackback: ghcr.io/quackbackio/quackback:latest (self-hosted, Docker). v0.13.1
  • Provider: Resend (resend-node:6.12.0), inbound on a custom domain
  • OS: Ubuntu 24.04

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions