feat(inbox): document-type badge and filter, +lev/+ver plus-addressing (#2129) (#2148)

* 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>
This commit is contained in:
Mattsson
2026-09-02 08:50:42 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 4c76fb10d7
commit 867767a22f
14 changed files with 644 additions and 47 deletions
@@ -178,6 +178,47 @@ describe('POST /inbound', () => {
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
@@ -279,6 +320,98 @@ describe('POST /inbound', () => {
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()
@@ -539,9 +672,11 @@ describe('POST /inbound', () => {
expect(stored).toContain('<div>Faktura 123: 500 kr</div>')
})
it('still rejects attachment types outside the email allowlist', async () => {
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
const { supabase, enqueue } = createQueuedMockSupabase()
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
@@ -550,7 +685,7 @@ describe('POST /inbound', () => {
vi.mocked(fetchReceivingEmail).mockResolvedValue({
object: 'email',
id: 'em_123',
to: ['acme-ab-x7f2@arcim.io'],
to: ['acme-ab-x7f2+ver@arcim.io'],
from: 'billing@supplier.com',
created_at: '2026-04-20T10:00:00Z',
subject: 'Zip',
@@ -579,5 +714,10 @@ describe('POST /inbound', () => {
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' })
})
})
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest'
import { extractLocalPartForDomain, parseRecipients } from '@/extensions/general/invoice-inbox/lib/resend-inbound'
import {
extractLocalPartForDomain,
kindHintFromTag,
parseRecipients,
} from '@/extensions/general/invoice-inbox/lib/resend-inbound'
describe('extractLocalPartForDomain', () => {
it('returns the local part when a recipient matches the domain', () => {
@@ -7,7 +11,7 @@ describe('extractLocalPartForDomain', () => {
['acme-ab-x7f2@arcim.io', 'billing@acme.se'],
'arcim.io'
)
expect(result).toBe('acme-ab-x7f2')
expect(result).toEqual({ localPart: 'acme-ab-x7f2', tag: null })
})
it('lowercases the local part and matches domain case-insensitively', () => {
@@ -15,7 +19,39 @@ describe('extractLocalPartForDomain', () => {
['ACME-AB-X7F2@ARCIM.IO'],
'arcim.io'
)
expect(result).toBe('acme-ab-x7f2')
expect(result).toEqual({ localPart: 'acme-ab-x7f2', tag: null })
})
it('splits a plus-address into local part and tag', () => {
expect(extractLocalPartForDomain(['acme-ab-x7f2+lev@arcim.io'], 'arcim.io')).toEqual({
localPart: 'acme-ab-x7f2',
tag: 'lev',
})
expect(extractLocalPartForDomain(['acme-ab-x7f2+ver@arcim.io'], 'arcim.io')).toEqual({
localPart: 'acme-ab-x7f2',
tag: 'ver',
})
})
it('lowercases the tag and splits at the first plus only', () => {
expect(extractLocalPartForDomain(['Acme-AB-x7f2+LEV+extra@arcim.io'], 'arcim.io')).toEqual({
localPart: 'acme-ab-x7f2',
tag: 'lev+extra',
})
})
it('treats an empty tag as no tag', () => {
expect(extractLocalPartForDomain(['acme-ab-x7f2+@arcim.io'], 'arcim.io')).toEqual({
localPart: 'acme-ab-x7f2',
tag: null,
})
})
it('does not read a plus in a foreign-domain recipient', () => {
expect(extractLocalPartForDomain(['x+lev@acme.se', 'acme-ab-x7f2@arcim.io'], 'arcim.io')).toEqual({
localPart: 'acme-ab-x7f2',
tag: null,
})
})
it('returns null when no recipient matches', () => {
@@ -39,7 +75,7 @@ describe('extractLocalPartForDomain', () => {
['first-abcd@arcim.io', 'second-efgh@arcim.io'],
'arcim.io'
)
expect(result).toBe('first-abcd')
expect(result?.localPart).toBe('first-abcd')
})
it('trims whitespace inside candidate addresses', () => {
@@ -47,7 +83,22 @@ describe('extractLocalPartForDomain', () => {
[' acme-xxx@arcim.io '],
'arcim.io'
)
expect(result).toBe('acme-xxx')
expect(result?.localPart).toBe('acme-xxx')
})
})
describe('kindHintFromTag', () => {
it('maps the two documented tags', () => {
expect(kindHintFromTag('lev')).toBe('supplier_invoice')
expect(kindHintFromTag('ver')).toBe('receipt')
})
it('returns null for unknown, empty or missing tags', () => {
expect(kindHintFromTag('faktura')).toBeNull()
expect(kindHintFromTag('lev+extra')).toBeNull()
expect(kindHintFromTag('')).toBeNull()
expect(kindHintFromTag(null)).toBeNull()
expect(kindHintFromTag(undefined)).toBeNull()
})
})
+21 -3
View File
@@ -34,6 +34,8 @@ import {
fetchReceivingEmail,
fetchInboundAttachment,
extractLocalPartForDomain,
kindHintFromTag,
type InboxKindHint,
parseRecipients,
isEmailReceivedEvent,
ResendSignatureError,
@@ -599,7 +601,7 @@ export const invoiceInboxExtension: Extension = {
email_received_at, email_body_text, error_message,
created_supplier_invoice_id,
matched_transaction_id, created_journal_entry_id,
resend_email_id, extraction_skipped, channel_context
resend_email_id, extraction_skipped, channel_context, kind_hint
`)
.eq('company_id', ctx.companyId)
.order('created_at', { ascending: false })
@@ -1784,7 +1786,14 @@ export const invoiceInboxExtension: Extension = {
let companyId: string | null = null
let sharedInboxStatus: string | null = null
const localPart = extractLocalPartForDomain(to, domain)
const sharedRecipient = extractLocalPartForDomain(to, domain)
const localPart = sharedRecipient?.localPart ?? null
// Sender-declared kind from the +lev / +ver tag on the shared
// address. Set only when that address is the one that resolved the
// company: a tag on an unknown or retired shared address must not
// ride along onto a custom-domain match further down. Custom domains
// are catch-all and stay unhinted.
let kindHint: InboxKindHint | null = null
if (localPart) {
const { data: inbox } = await serviceSupabase
.from('company_inboxes')
@@ -1793,7 +1802,10 @@ export const invoiceInboxExtension: Extension = {
.maybeSingle()
if (inbox) {
sharedInboxStatus = inbox.status
if (inbox.status === 'active') companyId = inbox.company_id
if (inbox.status === 'active') {
companyId = inbox.company_id
kindHint = kindHintFromTag(sharedRecipient?.tag)
}
}
}
@@ -1943,6 +1955,7 @@ export const invoiceInboxExtension: Extension = {
messageId: message_id,
bodyText,
resendEmailId: email_id,
kindHint,
}
)
return NextResponse.json(
@@ -1963,6 +1976,7 @@ export const invoiceInboxExtension: Extension = {
email_received_at: created_at,
email_body_text: bodyText,
resend_email_id: email_id,
kind_hint: kindHint,
error_message: 'Email had no attachments',
raw_email_payload: { messageId: message_id },
})
@@ -1997,6 +2011,7 @@ export const invoiceInboxExtension: Extension = {
email_body_text: bodyText,
resend_email_id: email_id,
resend_attachment_id: attachmentId,
kind_hint: kindHint,
error_message: reason.slice(0, 500),
raw_email_payload: {
messageId: message_id,
@@ -2059,6 +2074,7 @@ export const invoiceInboxExtension: Extension = {
bodyText,
resendEmailId: email_id,
resendAttachmentId: att.id,
kindHint,
}
)
results.push({ attachment_id: att.id, inbox_item_id: innerBodyResult.inbox_item_id })
@@ -2103,6 +2119,7 @@ export const invoiceInboxExtension: Extension = {
bodyText,
resendEmailId: email_id,
resendAttachmentId: innerId,
kindHint,
}
)
results.push({ attachment_id: innerId, inbox_item_id: innerResult.inbox_item_id })
@@ -2142,6 +2159,7 @@ export const invoiceInboxExtension: Extension = {
bodyText,
resendEmailId: email_id,
resendAttachmentId: att.id,
kindHint,
}
)
results.push({ attachment_id: att.id, inbox_item_id: result.inbox_item_id })
@@ -81,19 +81,50 @@ export async function fetchInboundAttachment(
}
}
// Parses the first recipient whose domain matches our configured inbound domain,
// returning just the local_part. Returns null if no match.
export function extractLocalPartForDomain(recipients: string[], domain: string): string | null {
export interface SharedInboxRecipient {
/** The company_inboxes.local_part candidate, lowercased, without any +tag. */
localPart: string
/** Plus-address tag (the part after the first `+`), lowercased; null when absent or empty. */
tag: string | null
}
// Parses the first recipient whose domain matches our configured inbound
// domain, returning its local_part split at the first `+` (RFC 5233
// sub-addressing): `acme-x7f2+lev@inbox` matches the inbox row for
// `acme-x7f2` and carries tag `lev`. Before this split a +tagged mail 404ed,
// because the whole `acme-x7f2+lev` was looked up as the local_part.
// Returns null if no recipient is on the domain.
export function extractLocalPartForDomain(
recipients: string[],
domain: string,
): SharedInboxRecipient | null {
const normalized = domain.toLowerCase()
for (const addr of recipients) {
const match = addr.match(/^\s*([^@\s]+)@([^@\s]+?)\s*$/)
if (!match) continue
const [, localPart, addrDomain] = match
if (addrDomain.toLowerCase() === normalized) return localPart.toLowerCase()
const [, rawLocal, addrDomain] = match
if (addrDomain.toLowerCase() !== normalized) continue
const lower = rawLocal.toLowerCase()
const plus = lower.indexOf('+')
if (plus === -1) return { localPart: lower, tag: null }
const tag = lower.slice(plus + 1)
return { localPart: lower.slice(0, plus), tag: tag.length > 0 ? tag : null }
}
return null
}
/** Sender-declared document kind, stored on invoice_inbox_items.kind_hint. */
export type InboxKindHint = 'supplier_invoice' | 'receipt'
// Maps the plus-address tag to a kind hint. `+lev` (leverantörsfaktura) and
// `+ver` (verifikation/underlag) are the two documented tags; anything else
// routes to the inbox with no hint, so a typo never loses a document.
export function kindHintFromTag(tag: string | null | undefined): InboxKindHint | null {
if (tag === 'lev') return 'supplier_invoice'
if (tag === 'ver') return 'receipt'
return null
}
// Splits every parseable recipient into { localPart, domain }, lowercased and
// in original order. Used to match recipients against per-company verified
// custom domains when none of them is on the shared inbound domain.
@@ -2,6 +2,7 @@ import { after } from 'next/server'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { extractInvoiceFields, emptyResult, fetchOwnCompanyIdentity } from './extract-invoice-fields'
import { mirrorExtractionToDocument } from './mirror-extraction'
import type { InboxKindHint } from './resend-inbound'
import { getAiStatus } from '@/lib/ai'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
@@ -191,6 +192,10 @@ export interface EmailMeta {
bodyText?: string | null
resendEmailId?: string | null
resendAttachmentId?: string | null
// Sender-declared document kind from the +lev / +ver plus-address tag.
// Lands in its own column (not extracted_data) so re-extraction cannot
// overwrite what the sender said.
kindHint?: InboxKindHint | null
}
// Chat-channel provenance (whatsapp-inbox extension). When present, the inbox
@@ -454,6 +459,7 @@ export async function processArchivedDocument(
email_body_text: emailMeta?.bodyText || null,
resend_email_id: emailMeta?.resendEmailId || null,
resend_attachment_id: emailMeta?.resendAttachmentId || null,
kind_hint: emailMeta?.kindHint ?? null,
raw_email_payload: emailMeta?.messageId
? { messageId: emailMeta.messageId, filename: file.name }
: null,
@@ -548,6 +554,7 @@ export async function processArchivedDocument(
email_body_text: emailMeta?.bodyText || null,
resend_email_id: emailMeta?.resendEmailId || null,
resend_attachment_id: emailMeta?.resendAttachmentId || null,
kind_hint: emailMeta?.kindHint ?? null,
raw_email_payload: emailMeta?.messageId
? { messageId: emailMeta.messageId, filename: file.name }
: null,