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
- Configure the inbound chat email channel with Resend (
EMAIL_INBOUND_DOMAIN, EMAIL_INBOUND_SIGNING_SECRET), MX pointed at Resend, webhook on email.received.
- Agent replies in a conversation; visitor replies to the
reply+<id>@domain address.
- 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:
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
Summary
The inbound email channel can never thread a reply when using Resend. Resend's
email.receivedwebhook delivers metadata only (notext/htmlbody), and Quackback's ingestion reads the body inline fromdata.text. With Resend that field is always absent, soextractReplyTextgets an empty string and every reply is dropped withstatus: 'empty'. The Svix signature verifies, the conversation ID resolves correctly, but the message body is never present.Steps to reproduce
EMAIL_INBOUND_DOMAIN,EMAIL_INBOUND_SIGNING_SECRET), MX pointed at Resend, webhook onemail.received.reply+<id>@domainaddress.email.receivedevent to/api/chat/email/inbound.Expected
The visitor's reply is appended to the conversation.
Actual
The event is accepted (200) and dropped:
Root cause
parseInboundEmailinapps/web/src/lib/server/domains/chat/chat.email-inbound.tsreads the body inline:and
ingestInboundEmailinchat.email-inbound.service.tsdrops when that's empty:But Resend's
email.receivedwebhook does not include the body. Per Resend's own docs (https://resend.com/docs/dashboard/receiving/introduction):The actual
email.receivedpayload carriesemail_id,from,to,cc,bcc,received_for,message_id,subject, andattachments[]— notextorhtml. Sod.textis alwaysundefined,parsed.textis always null, and every inbound reply resolves toempty.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.textis empty anddata.email_idis present, fetch the full received email from Resend before extracting the reply text:(Confirm the exact endpoint/field against the Received Emails API reference. The fetch should be guarded so a non-Resend provider that does send
textinline 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
ghcr.io/quackbackio/quackback:latest(self-hosted, Docker). v0.13.1resend-node:6.12.0), inbound on a custom domain