* 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>
125 lines
3.7 KiB
TypeScript
125 lines
3.7 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
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', () => {
|
|
const result = extractLocalPartForDomain(
|
|
['acme-ab-x7f2@arcim.io', 'billing@acme.se'],
|
|
'arcim.io'
|
|
)
|
|
expect(result).toEqual({ localPart: 'acme-ab-x7f2', tag: null })
|
|
})
|
|
|
|
it('lowercases the local part and matches domain case-insensitively', () => {
|
|
const result = extractLocalPartForDomain(
|
|
['ACME-AB-X7F2@ARCIM.IO'],
|
|
'arcim.io'
|
|
)
|
|
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', () => {
|
|
const result = extractLocalPartForDomain(
|
|
['billing@acme.se', 'invoices@contoso.com'],
|
|
'arcim.io'
|
|
)
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('returns null for malformed addresses', () => {
|
|
const result = extractLocalPartForDomain(
|
|
['not-an-email', '@arcim.io', 'foo@'],
|
|
'arcim.io'
|
|
)
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('returns the first matching recipient when multiple match', () => {
|
|
const result = extractLocalPartForDomain(
|
|
['first-abcd@arcim.io', 'second-efgh@arcim.io'],
|
|
'arcim.io'
|
|
)
|
|
expect(result?.localPart).toBe('first-abcd')
|
|
})
|
|
|
|
it('trims whitespace inside candidate addresses', () => {
|
|
const result = extractLocalPartForDomain(
|
|
[' acme-xxx@arcim.io '],
|
|
'arcim.io'
|
|
)
|
|
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()
|
|
})
|
|
})
|
|
|
|
describe('parseRecipients', () => {
|
|
it('splits recipients into lowercased localPart/domain pairs in order', () => {
|
|
expect(
|
|
parseRecipients(['Faktura@HansBolag.SE', 'billing@acme.se'])
|
|
).toEqual([
|
|
{ localPart: 'faktura', domain: 'hansbolag.se' },
|
|
{ localPart: 'billing', domain: 'acme.se' },
|
|
])
|
|
})
|
|
|
|
it('skips malformed addresses', () => {
|
|
expect(parseRecipients(['not-an-email', '@x.se', 'foo@', 'ok@a.se'])).toEqual([
|
|
{ localPart: 'ok', domain: 'a.se' },
|
|
])
|
|
})
|
|
|
|
it('returns an empty array for no recipients', () => {
|
|
expect(parseRecipients([])).toEqual([])
|
|
})
|
|
})
|