Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
* message is just what the visitor actually wrote.
*/
import { describe, it, expect } from 'vitest'
import { parseInboundEmail, extractReplyText, extractEmailAddress } from '../chat.email-inbound'
import {
parseInboundEmail,
extractReplyText,
extractEmailAddress,
htmlToText,
} from '../chat.email-inbound'

describe('parseInboundEmail', () => {
it('normalizes an array `to`, reads `from`/`subject`/`text`, and the Message-ID header', () => {
Expand Down Expand Up @@ -49,6 +54,41 @@ describe('parseInboundEmail', () => {
expect(parsed.toAddresses).toEqual([])
expect(parsed.from).toBeNull()
expect(parsed.messageId).toBeNull()
expect(parsed.emailId).toBeNull()
})

it('captures the provider email id, and message_id outranks it for dedupe', () => {
const withBoth = parseInboundEmail({
to: ['x@y.com'],
from: 'a@b.com',
email_id: 'em_1',
message_id: '<mid@provider>',
})
expect(withBoth.messageId).toBe('<mid@provider>')
expect(withBoth.emailId).toBe('em_1')

// A Message-ID header still wins for dedupe; emailId is unaffected by it.
const withHeader = parseInboundEmail({
to: ['x@y.com'],
headers: [{ name: 'Message-ID', value: '<hdr@mail>' }],
email_id: 'em_2',
})
expect(withHeader.messageId).toBe('<hdr@mail>')
expect(withHeader.emailId).toBe('em_2')
})
})

describe('htmlToText', () => {
it('converts block/br boundaries to newlines, strips tags, and unescapes entities', () => {
const text = htmlToText(
'<div>Line one<br>Line &amp; two</div><style>.x{color:red}</style><script>evil()</script>'
)
expect(text).toBe('Line one\nLine & two')
})

it('produces text that extractReplyText can strip quoted history from', () => {
const text = htmlToText('<p>Fresh reply</p><p>On Mon wrote:</p><p>&gt; old quoted</p>')
expect(extractReplyText(text)).toBe('Fresh reply')
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ const REPLY_TO = inboundReplyToAddress('conversation_abc')!

const sendVisitorMessage = vi.fn()
const assertChatSendRate = vi.fn()
const getReceivedEmail = vi.fn()

vi.mock('@quackback/email', () => ({
getReceivedEmail: (...a: unknown[]) => getReceivedEmail(...a),
}))
let conversationRow: Record<string, unknown> | undefined
let principalRow: Record<string, unknown> | undefined
let userRow: Record<string, unknown> | undefined
Expand Down Expand Up @@ -85,6 +90,7 @@ beforeEach(() => {
dupeRows = []
sendVisitorMessage.mockResolvedValue({ created: false })
assertChatSendRate.mockResolvedValue(undefined)
getReceivedEmail.mockResolvedValue(null)
})

describe('ingestInboundEmail', () => {
Expand Down Expand Up @@ -239,6 +245,80 @@ describe('ingestInboundEmail', () => {
expect(sendVisitorMessage).not.toHaveBeenCalled()
})

// Resend's `email.received` webhook is metadata-only (#320): the body must
// be fetched from the Received Emails API when the payload carries no text.
it('fetches the body from the Received Emails API when the payload is metadata-only (#320)', async () => {
getReceivedEmail.mockResolvedValueOnce({
text: 'Fetched reply.\n\nOn Mon wrote:\n> old',
html: null,
})

const result = await ingestInboundEmail({
type: 'email.received',
data: {
to: [REPLY_TO],
from: 'jane@example.com',
subject: 'Re: ticket',
email_id: 'em_123',
headers: [{ name: 'Message-ID', value: '<m-fetch@x>' }],
},
})

expect(result).toEqual({ status: 'ingested', conversationId: 'conversation_abc' })
expect(getReceivedEmail).toHaveBeenCalledWith('em_123')
const [input] = sendVisitorMessage.mock.calls[0]
expect(input).toMatchObject({
content: 'Fetched reply.',
metadata: { source: 'email', emailMessageId: '<m-fetch@x>' },
})
})

it('falls back to html→text when the fetched email has no plain-text body', async () => {
getReceivedEmail.mockResolvedValueOnce({ text: null, html: '<p>Hello from html</p>' })

const result = await ingestInboundEmail({
type: 'email.received',
data: { to: [REPLY_TO], from: 'jane@example.com', email_id: 'em_html' },
})

expect(result).toEqual({ status: 'ingested', conversationId: 'conversation_abc' })
const [input] = sendVisitorMessage.mock.calls[0]
expect(input).toMatchObject({ content: 'Hello from html' })
})

it('does not call the Received Emails API when the payload carries inline text', async () => {
await ingestInboundEmail(baseEvent)

expect(getReceivedEmail).not.toHaveBeenCalled()
})

it('drops as empty when the received email cannot be found', async () => {
getReceivedEmail.mockResolvedValueOnce(null)

const result = await ingestInboundEmail({
type: 'email.received',
data: { to: [REPLY_TO], from: 'jane@example.com', email_id: 'em_gone' },
})

expect(result).toEqual({ status: 'empty' })
expect(sendVisitorMessage).not.toHaveBeenCalled()
})

it('propagates a transient Received Emails API failure so the delivery is retried', async () => {
getReceivedEmail.mockRejectedValueOnce(
new Error('received-email fetch failed: internal_server_error')
)

await expect(
ingestInboundEmail({
type: 'email.received',
data: { to: [REPLY_TO], from: 'jane@example.com', email_id: 'em_err' },
})
).rejects.toThrow('received-email fetch failed')

expect(sendVisitorMessage).not.toHaveBeenCalled()
})

it('rate-limits the inbound path (acks without fanning out a message)', async () => {
const { ChatRateLimitError } = await import('../chat.ratelimit')
assertChatSendRate.mockRejectedValueOnce(new ChatRateLimitError(5))
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/lib/server/domains/chat/chat.email-inbound.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ import type { ConversationId, PrincipalId } from '@quackback/ids'
import type { Actor } from '@/lib/server/policy/types'
import { normalizePrincipalType } from '@/lib/server/functions/auth-helpers'
import { realEmail } from '@/lib/shared/anonymous-email'
import { parseInboundEmail, extractReplyText, extractEmailAddress } from './chat.email-inbound'
import { getReceivedEmail } from '@quackback/email'
import {
parseInboundEmail,
extractReplyText,
extractEmailAddress,
htmlToText,
} from './chat.email-inbound'
import { conversationIdFromInboundAddress } from './chat.email-channel'
import { assertChatSendRate, ChatRateLimitError } from './chat.ratelimit'
import { sendVisitorMessage } from './chat.service'
Expand Down Expand Up @@ -63,7 +69,17 @@ export async function ingestInboundEmail(event: unknown): Promise<IngestInboundR
})
if (!conversation) return { status: 'no_conversation' }

const content = extractReplyText(parsed.text ?? '')
// Resend's `email.received` webhook is metadata-only: the body must be
// fetched from the Received Emails API (#320). Fetch after routing + dedupe
// so unroutable/duplicate deliveries never cost an API call; a transient
// fetch failure throws → the route 500s → Resend redelivers (idempotent).
let bodyText = parsed.text
if (bodyText === null && parsed.emailId) {
const full = await getReceivedEmail(parsed.emailId)
bodyText = full?.text ?? (full?.html ? htmlToText(full.html) : null)
}

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

const visitorPrincipalId = conversation.visitorPrincipalId as PrincipalId
Expand Down
37 changes: 36 additions & 1 deletion apps/web/src/lib/server/domains/chat/chat.email-inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export interface ParsedInboundEmail {
text: string | null
/** Provider Message-ID (header preferred, email id as fallback) for dedupe. */
messageId: string | null
/** Provider email id (Resend `email_id`) — used to fetch the body when the
* webhook payload is metadata-only (Resend `email.received`, #320). */
emailId: string | null
}

function asString(v: unknown): string | null {
Expand Down Expand Up @@ -72,10 +75,42 @@ export function parseInboundEmail(data: unknown): ParsedInboundEmail {
from: asString(d.from),
subject: asString(d.subject),
text: asString(d.text),
messageId: readHeader(d.headers, 'message-id') ?? asString(d.email_id) ?? asString(d.id),
messageId:
readHeader(d.headers, 'message-id') ??
asString(d.message_id) ??
asString(d.email_id) ??
asString(d.id),
emailId: asString(d.email_id) ?? asString(d.id),
}
}

/**
* Naive HTML→text for received emails that carry only an HTML body. Enough to
* feed extractReplyText — block tags become newlines, entities are unescaped.
*/
export function htmlToText(html: string): string {
return (
html
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|tr|li|h[1-6]|blockquote)>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&#39;|&apos;/gi, "'")
.replace(/&quot;/gi, '"')
.replace(/&amp;/gi, '&') // last, so &amp;lt; unescapes only one level
.replace(/[ \t]+\n/g, '\n')
// Strip per-line leading whitespace left by inline-tag removal — the
// quote separators extractReplyText cuts on are ^-anchored.
.replace(/^[ \t]+/gm, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
)
}

// Lines that mark the start of quoted history from common mail clients. These
// are deliberately well-anchored — a bare `From:` is NOT here because it occurs
// in ordinary prose and a top-level cut on it would silently drop real text.
Expand Down
9 changes: 9 additions & 0 deletions packages/email/src/__tests__/email.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import {
isEmailConfigured,
getReceivedEmail,
sendInvitationEmail,
sendWelcomeEmail,
sendMagicLinkEmail,
Expand Down Expand Up @@ -69,6 +70,14 @@ describe('isEmailConfigured', () => {
})
})

describe('getReceivedEmail', () => {
withCleanEnv()

it('returns null when no Resend API key is configured (no API call attempted)', async () => {
await expect(getReceivedEmail('em_x')).resolves.toBeNull()
})
})

describe('console mode returns { sent: false }', () => {
withCleanEnv()

Expand Down
20 changes: 20 additions & 0 deletions packages/email/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,26 @@ function getResend(): Resend {
return resendClient
}

/**
* Fetch a received (inbound) email's content by its Resend email id.
* Resend's `email.received` webhook is metadata-only (no text/html body) —
* callers use this to pull the body before parsing (#320). Returns null when
* no Resend API key is configured or the email cannot be found; throws on
* other errors so the webhook route can 500 and let Resend redeliver.
*/
export async function getReceivedEmail(
emailId: string
): Promise<{ text: string | null; html: string | null } | null> {
if (!getResendApiKey()) return null
const { data, error } = await getResend().emails.receiving.get(emailId)
if (error) {
log.warn({ emailId, error: error.name }, 'received-email fetch failed')
if (error.name === 'not_found') return null
throw new Error(`received-email fetch failed: ${error.name}`)
}
return { text: data?.text ?? null, html: data?.html ?? null }
}

/**
* Send an email using the configured transport (SMTP or Resend).
* Falls back to console logging if neither is configured.
Expand Down