* 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:
co-authored by
Claude Fable 5.1
parent
4c76fb10d7
commit
867767a22f
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user