* feat(inbox): document-type badge and filter, +lev/+ver plus-addressing (#2129) Phase 1: every inbox row shows its document kind (Kvitto, Leverantorsfaktura, Myndighetsbrev, Ovrigt) from the existing AI documentKind, and a second menu next to the status filter narrows the list to leverantorsfakturor or underlag. Pure predicate in lib/documents/inbox-kind.ts with tests. Phase 2: the shared inbox address accepts RFC 5233 plus-addressing. The webhook splits the local part at the first + and looks up the base, so <local>+anything@ now reaches the company instead of 404ing. +lev and +ver land in the new nullable invoice_inbox_items.kind_hint column (CHECK supplier_invoice | receipt), threaded through EmailMeta into both inbox inserts and returned by GET /items. kind_hint wins over documentKind for the badge and the filter and survives re-extraction because it is a column. The sources panel shows both tagged addresses with a one-line hint (sv + en). Tests: filter predicate per kind and null; parser and tag mapping; webhook routes +LEV and an unknown tag; pg test pins the CHECK and NULL default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): honest empty state under a type filter, detail pane shares the row's kind resolution Skeptic findings on #2148: with a type filter narrowing 'Att göra' to zero the empty state claimed 'allt är bearbetat' while the status trigger still counted pending rows; it now says no items of that type are here (sv + en). The fields rail printed the AI documentKind only, so a +lev hint could disagree with the row badge; it now uses resolveInboxKind like the list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): keep the type-filter empty state off purchase lists, carry kind_hint onto rejected attachment rows CodeRabbit on #2148: the purchase lists (Saknar underlag, Hämta från portal) ignore the type menu, so a leftover kind filter must not pick their empty-state copy. A rejected attachment (unsupported MIME, too large) now keeps the sender's +lev / +ver hint on its error row like every other inbox insert; the allowlist test covers it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): set the +lev/+ver kind hint only when the shared address resolved the company CodeRabbit on #2148: the hint was computed before recipient resolution, so a tag on an unknown or retired shared address could ride along onto a custom-domain match. It is now assigned inside the active shared-inbox branch only; regression test covers the multi-recipient case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
724 lines
29 KiB
TypeScript
724 lines
29 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
|
import { ResendSignatureError } from '@/extensions/general/invoice-inbox/lib/resend-inbound'
|
|
import { createQueuedMockSupabase, createMockRequest } from '@/tests/helpers'
|
|
|
|
vi.mock('@/extensions/general/invoice-inbox/lib/resend-inbound', async () => {
|
|
const actual = await vi.importActual<typeof import('@/extensions/general/invoice-inbox/lib/resend-inbound')>(
|
|
'@/extensions/general/invoice-inbox/lib/resend-inbound'
|
|
)
|
|
return {
|
|
...actual,
|
|
verifyInboundWebhook: vi.fn(),
|
|
fetchReceivingEmail: vi.fn(),
|
|
fetchInboundAttachment: vi.fn(),
|
|
}
|
|
})
|
|
|
|
vi.mock('@supabase/supabase-js', () => ({
|
|
createClient: vi.fn(),
|
|
}))
|
|
|
|
// uploadAndExtract does real storage + Bedrock work; these tests only assert
|
|
// what the webhook hands it. Constants and the pure HTML helpers stay real so
|
|
// MIME gating and body-document building run genuine code.
|
|
vi.mock('@/extensions/general/invoice-inbox/lib/upload-and-extract', async () => {
|
|
const actual = await vi.importActual<typeof import('@/extensions/general/invoice-inbox/lib/upload-and-extract')>(
|
|
'@/extensions/general/invoice-inbox/lib/upload-and-extract'
|
|
)
|
|
return { ...actual, uploadAndExtract: vi.fn() }
|
|
})
|
|
|
|
// applyDomainStatusFromWebhook confirms the receiving capability with Resend
|
|
// before flipping a row to verified: keep that lookup off the network.
|
|
const { domainsMock } = vi.hoisted(() => ({
|
|
domainsMock: {
|
|
get: vi.fn(),
|
|
},
|
|
}))
|
|
vi.mock('resend', () => ({
|
|
Resend: class {
|
|
domains = domainsMock
|
|
},
|
|
}))
|
|
|
|
// Rate limiter is a thin RPC wrapper; bypass it so the queued-mock sequence
|
|
// in each test doesn't have to account for the extra Supabase call.
|
|
vi.mock('@/lib/rate-limits/inbox', () => ({
|
|
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
|
|
}))
|
|
|
|
import { verifyInboundWebhook, fetchReceivingEmail, fetchInboundAttachment } from '@/extensions/general/invoice-inbox/lib/resend-inbound'
|
|
import { uploadAndExtract } from '@/extensions/general/invoice-inbox/lib/upload-and-extract'
|
|
import { createClient } from '@supabase/supabase-js'
|
|
|
|
function findRoute(method: string, path: string) {
|
|
return invoiceInboxExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
|
|
}
|
|
|
|
const webhookRoute = findRoute('POST', '/inbound')
|
|
|
|
function mockReceivedEvent(overrides?: Record<string, unknown>) {
|
|
return {
|
|
type: 'email.received' as const,
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
data: {
|
|
email_id: 'em_123',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
from: 'billing@supplier.com',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
cc: [],
|
|
bcc: [],
|
|
subject: 'Invoice #5678',
|
|
message_id: '<msg-id@supplier.com>',
|
|
attachments: [
|
|
{
|
|
id: 'att_1',
|
|
filename: 'invoice.pdf',
|
|
size: 12345,
|
|
content_type: 'application/pdf',
|
|
content_id: 'cid1',
|
|
content_disposition: 'attachment',
|
|
},
|
|
],
|
|
...overrides,
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('POST /inbound', () => {
|
|
const originalEnv = { ...process.env }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.RESEND_INBOUND_DOMAIN = 'arcim.io'
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost'
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
|
|
})
|
|
|
|
afterEach(() => {
|
|
process.env = { ...originalEnv }
|
|
})
|
|
|
|
it('returns 503 when RESEND_INBOUND_DOMAIN is not set', async () => {
|
|
delete process.env.RESEND_INBOUND_DOMAIN
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: { type: 'email.received' } })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(503)
|
|
})
|
|
|
|
it('returns 401 when signature verification fails', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockImplementation(() => {
|
|
throw new ResendSignatureError('bad sig')
|
|
})
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: { type: 'email.received' } })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('ignores non-received events with 200', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue({
|
|
type: 'email.sent',
|
|
created_at: '',
|
|
data: {},
|
|
} as never)
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('returns 404 when no recipient matches our domain or a verified custom domain', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ to: ['random@contoso.com'] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [] }) // company_inbound_domains lookup finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('routes mail on a verified custom domain to its company (any local part)', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ to: ['fakturor@hansbolag.example'], attachments: [] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [{ company_id: 'company-9', domain: 'hansbolag.example' }] }) // verified domain
|
|
enqueue({ data: { created_by: 'user-owner-9' } }) // company owner
|
|
enqueue({ data: null }) // body-document dedupe check finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-9' } as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['fakturor@hansbolag.example'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Invoice #5678',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Body',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
// A body-only mail now becomes a text/html document for company-9:
|
|
// proves the custom-domain routing reached the processing stage.
|
|
expect(body.data.reason).toBe('email_body')
|
|
expect(vi.mocked(uploadAndExtract).mock.calls[0][2]).toBe('company-9')
|
|
})
|
|
|
|
it('does not carry a shared-address tag onto a custom-domain match (#2129)', async () => {
|
|
// The tagged shared address is retired, so the custom domain resolves the
|
|
// company. The +lev tag belonged to the retired address and must not stamp
|
|
// the custom-domain company's row.
|
|
const to = ['old-inbox-abcd+lev@arcim.io', 'fakturor@hansbolag.example']
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent({ to, attachments: [] }) as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-old', company_id: 'company-old', status: 'deprecated' } }) // shared lookup
|
|
enqueue({ data: [{ company_id: 'company-9', domain: 'hansbolag.example' }] }) // verified domain
|
|
enqueue({ data: { created_by: 'user-owner-9' } }) // company owner
|
|
enqueue({ data: null }) // body-document dedupe check finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-9' } as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to,
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Invoice #5678',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Body',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('email_body')
|
|
const [, , companyId, , , emailMeta] = vi.mocked(uploadAndExtract).mock.calls[0]
|
|
expect(companyId).toBe('company-9')
|
|
expect(emailMeta?.kindHint).toBeNull()
|
|
})
|
|
|
|
it('does not route mail for an unverified custom domain', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ to: ['faktura@pending-bolag.example'] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
// status='verified' filter means a pending claim never matches
|
|
enqueue({ data: [] })
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('prefers the shared-domain address when both shared and custom recipients are present', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({
|
|
to: ['acme-ab-x7f2@arcim.io', 'faktura@hansbolag.example'],
|
|
attachments: [],
|
|
}) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
// Only the three shared-path queries are enqueued: if the handler also
|
|
// ran the custom-domain lookup, the queue would shift and created_by
|
|
// would resolve to null (500). A 200 proves the shared path won.
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // body-document dedupe check finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-1' } as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io', 'faktura@hansbolag.example'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Invoice #5678',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Body',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('email_body')
|
|
})
|
|
|
|
it('applies domain.updated events to custom-domain rows', async () => {
|
|
process.env.RESEND_API_KEY = 'test-key'
|
|
domainsMock.get.mockResolvedValue({
|
|
data: {
|
|
id: 'rd_123',
|
|
status: 'verified',
|
|
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
|
records: [],
|
|
},
|
|
error: null,
|
|
})
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue({
|
|
type: 'domain.updated',
|
|
created_at: '2026-07-01T10:00:00Z',
|
|
data: {
|
|
id: 'rd_123',
|
|
name: 'hansbolag.example',
|
|
status: 'verified',
|
|
created_at: '2026-07-01T09:00:00Z',
|
|
region: 'eu-west-1',
|
|
records: [],
|
|
},
|
|
} as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'row-1', verified_at: null } }) // row by resend_domain_id
|
|
enqueue({ data: null }) // update
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.domain_updated).toBe(true)
|
|
})
|
|
|
|
it('returns 404 when the address is not in company_inboxes', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: null }) // company_inboxes lookup returns nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('routes a +lev plus-address to the base inbox and hints supplier_invoice (#2129)', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ to: ['Acme-AB-x7f2+LEV@arcim.io'] }) as never,
|
|
)
|
|
const { supabase, enqueue, calls } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // per-attachment dup check finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-lev-1' } as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['Acme-AB-x7f2+LEV@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Faktura',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Se bifogad faktura',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [
|
|
{ id: 'att_1', filename: 'faktura.pdf', size: 100, content_type: 'application/pdf', content_id: 'cid', content_disposition: 'attachment' },
|
|
],
|
|
} as never)
|
|
vi.mocked(fetchInboundAttachment).mockResolvedValue({
|
|
id: 'att_1',
|
|
filename: 'faktura.pdf',
|
|
contentType: 'application/pdf',
|
|
buffer: new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer as ArrayBuffer,
|
|
})
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.results[0].inbox_item_id).toBe('item-lev-1')
|
|
|
|
// The lookup used the local part WITHOUT the tag; before the split this
|
|
// mail 404ed as "Address not found".
|
|
const lookup = calls.find((c) => c.table === 'company_inboxes' && c.method === 'eq')
|
|
expect(lookup?.args).toEqual(['local_part', 'acme-ab-x7f2'])
|
|
|
|
const [, , , , , emailMeta] = vi.mocked(uploadAndExtract).mock.calls[0]
|
|
expect(emailMeta?.kindHint).toBe('supplier_invoice')
|
|
})
|
|
|
|
it('routes an unknown plus-tag with no kind hint instead of dropping the mail (#2129)', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ to: ['acme-ab-x7f2+faktura@arcim.io'], attachments: [] }) as never,
|
|
)
|
|
const { supabase, enqueue, calls } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // body-document dup check finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-body-1' } as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2+faktura@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Kvitto',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: '<p>Kvitto 120 kr</p>',
|
|
text: 'Kvitto 120 kr',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('email_body')
|
|
|
|
const lookup = calls.find((c) => c.table === 'company_inboxes' && c.method === 'eq')
|
|
expect(lookup?.args).toEqual(['local_part', 'acme-ab-x7f2'])
|
|
|
|
const [, , , , , emailMeta] = vi.mocked(uploadAndExtract).mock.calls[0]
|
|
expect(emailMeta?.kindHint).toBeNull()
|
|
})
|
|
|
|
it('returns 410 when the address is deprecated', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'deprecated' } })
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(410)
|
|
})
|
|
|
|
it('skips already-processed attachments (per-attachment idempotency)', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } }) // inbox lookup
|
|
enqueue({ data: { created_by: 'user-owner-1' } }) // company owner
|
|
enqueue({ data: { id: 'existing-item-1' } }) // per-attachment dup check finds existing row
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Invoice #5678',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Body',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [
|
|
{ id: 'att_1', filename: 'invoice.pdf', size: 100, content_type: 'application/pdf', content_id: 'cid', content_disposition: 'attachment' },
|
|
],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.results[0].duplicate).toBe(true)
|
|
expect(body.data.results[0].inbox_item_id).toBe('existing-item-1')
|
|
expect(fetchInboundAttachment).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 500 when the company has no created_by owner', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: null } }) // company with no owner
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(500)
|
|
})
|
|
|
|
it('stores the mail body as a text/html document when the mail has no attachments', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ attachments: [] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // body-document dedupe check finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-body-1' } as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Kvitto på ditt köp',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: '<div>Att betala: <b>1 234,56 kr</b></div>',
|
|
text: 'Att betala: 1 234,56 kr',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('email_body')
|
|
expect(body.data.processed).toBe(1)
|
|
expect(body.data.inbox_item_id).toBe('item-body-1')
|
|
expect(fetchInboundAttachment).not.toHaveBeenCalled()
|
|
|
|
const [, , companyId, file, source] = vi.mocked(uploadAndExtract).mock.calls[0]
|
|
expect(companyId).toBe('company-1')
|
|
expect(source).toBe('email')
|
|
expect(file.type).toBe('text/html')
|
|
expect(file.name).toMatch(/^mail-.*\.html$/)
|
|
const stored = new TextDecoder().decode(new Uint8Array(file.buffer))
|
|
expect(stored.toLowerCase().startsWith('<!doctype html')).toBe(true)
|
|
expect(stored).toContain('<div>Att betala: <b>1 234,56 kr</b></div>')
|
|
})
|
|
|
|
it('keeps the error row for a no-attachment mail with an empty body', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ attachments: [] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // error-row insert
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Tomt mejl',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: ' ',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('no_attachments')
|
|
expect(uploadAndExtract).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not duplicate the body document when Resend retries the webhook', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ attachments: [] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: { id: 'existing-body-item' } }) // dedupe check finds the first delivery's row
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Kvitto',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: '<div>Kvitto</div>',
|
|
text: null,
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('email_body_duplicate')
|
|
expect(body.data.inbox_item_id).toBe('existing-body-item')
|
|
expect(uploadAndExtract).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to the error row when the body document upload fails', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ attachments: [] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // dedupe check finds nothing
|
|
enqueue({ data: null }) // fallback error-row insert
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockRejectedValue(new Error('storage down'))
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Kvitto',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: '<div>Kvitto</div>',
|
|
text: null,
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('no_attachments')
|
|
})
|
|
|
|
it('accepts a text/html attachment and wraps it into a full document', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // per-attachment dup check finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-html-1' } as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Faktura',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Se bifogad faktura',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [
|
|
{ id: 'att_html', filename: 'faktura.html', size: 100, content_type: 'text/html', content_id: 'cid', content_disposition: 'attachment' },
|
|
],
|
|
} as never)
|
|
vi.mocked(fetchInboundAttachment).mockResolvedValue({
|
|
id: 'att_html',
|
|
filename: 'faktura.html',
|
|
contentType: 'text/html',
|
|
buffer: new TextEncoder().encode('<div>Faktura 123: 500 kr</div>').buffer as ArrayBuffer,
|
|
})
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.results[0].inbox_item_id).toBe('item-html-1')
|
|
|
|
const [, , , file] = vi.mocked(uploadAndExtract).mock.calls[0]
|
|
expect(file.type).toBe('text/html')
|
|
const stored = new TextDecoder().decode(new Uint8Array(file.buffer))
|
|
expect(stored.toLowerCase().startsWith('<!doctype html')).toBe(true)
|
|
expect(stored).toContain('<div>Faktura 123: 500 kr</div>')
|
|
})
|
|
|
|
it('still rejects attachment types outside the email allowlist, keeping the sender kind hint on the error row', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ to: ['acme-ab-x7f2+ver@arcim.io'] }) as never,
|
|
)
|
|
const { supabase, enqueue, calls } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // per-attachment dup check finds nothing
|
|
enqueue({ data: null }) // rejection-row insert
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2+ver@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Zip',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Body',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [
|
|
{ id: 'att_zip', filename: 'faktura.zip', size: 100, content_type: 'application/zip', content_id: 'cid', content_disposition: 'attachment' },
|
|
],
|
|
} as never)
|
|
vi.mocked(fetchInboundAttachment).mockResolvedValue({
|
|
id: 'att_zip',
|
|
filename: 'faktura.zip',
|
|
contentType: 'application/zip',
|
|
buffer: new ArrayBuffer(8),
|
|
})
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.results[0].error).toBe('Unsupported type application/zip')
|
|
expect(uploadAndExtract).not.toHaveBeenCalled()
|
|
|
|
// The rejected row still carries what the sender said (#2129), so it can
|
|
// be found under the Underlag filter like any other inbox item.
|
|
const rejection = calls.find((c) => c.table === 'invoice_inbox_items' && c.method === 'insert')
|
|
expect(rejection?.args[0]).toMatchObject({ status: 'error', kind_hint: 'receipt' })
|
|
})
|
|
})
|