Files
accounted/extensions/general/invoice-inbox/lib/resend-inbound.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

142 lines
5.3 KiB
TypeScript

import { Resend } from 'resend'
import type { EmailReceivedEvent, GetReceivingEmailResponseSuccess, WebhookEventPayload } from 'resend'
export type ResendReceivedEmail = GetReceivingEmailResponseSuccess
export interface ResendAttachmentDownload {
id: string
filename: string
contentType: string
buffer: ArrayBuffer
}
function getResend(): Resend {
const apiKey = process.env.RESEND_API_KEY
if (!apiKey) throw new Error('RESEND_API_KEY is required')
return new Resend(apiKey)
}
export class ResendSignatureError extends Error {
constructor(message: string) {
super(message)
this.name = 'ResendSignatureError'
}
}
// Verifies the Svix-signed webhook payload using the RESEND_INBOUND_WEBHOOK_SECRET.
// Throws ResendSignatureError on failure, returns the parsed event on success.
export function verifyInboundWebhook(rawBody: string, requestHeaders: Headers): WebhookEventPayload {
const secret = process.env.RESEND_INBOUND_WEBHOOK_SECRET
if (!secret) throw new Error('RESEND_INBOUND_WEBHOOK_SECRET is required')
// Resend's verify() expects Svix headers in a specific shape, not the raw Fetch Headers.
const svixHeaders = {
id: requestHeaders.get('svix-id') ?? '',
timestamp: requestHeaders.get('svix-timestamp') ?? '',
signature: requestHeaders.get('svix-signature') ?? '',
}
const resend = getResend()
try {
return resend.webhooks.verify({ payload: rawBody, headers: svixHeaders, webhookSecret: secret })
} catch (err) {
throw new ResendSignatureError(err instanceof Error ? err.message : 'Invalid signature')
}
}
// Fetches the full received email (body, headers, attachment metadata) by email_id.
export async function fetchReceivingEmail(emailId: string): Promise<ResendReceivedEmail> {
const resend = getResend()
const { data, error } = await resend.emails.receiving.get(emailId)
if (error || !data) {
throw new Error(`Failed to fetch received email ${emailId}: ${error?.message ?? 'no data'}`)
}
return data
}
// Fetches a single attachment's bytes via its short-lived download_url.
export async function fetchInboundAttachment(
emailId: string,
attachmentId: string
): Promise<ResendAttachmentDownload> {
const resend = getResend()
const { data, error } = await resend.emails.receiving.attachments.get({ emailId, id: attachmentId })
if (error || !data) {
throw new Error(`Failed to fetch attachment ${attachmentId}: ${error?.message ?? 'no data'}`)
}
const response = await fetch(data.download_url)
if (!response.ok) {
throw new Error(`Download URL returned ${response.status} for attachment ${attachmentId}`)
}
const buffer = await response.arrayBuffer()
return {
id: data.id,
filename: data.filename ?? `attachment-${data.id}`,
contentType: data.content_type,
buffer,
}
}
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 [, 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.
export function parseRecipients(recipients: string[]): Array<{ localPart: string; domain: string }> {
const parsed: Array<{ localPart: string; domain: string }> = []
for (const addr of recipients) {
const match = addr.match(/^\s*([^@\s]+)@([^@\s]+?)\s*$/)
if (!match) continue
parsed.push({ localPart: match[1].toLowerCase(), domain: match[2].toLowerCase() })
}
return parsed
}
export function isEmailReceivedEvent(event: WebhookEventPayload): event is EmailReceivedEvent {
return event.type === 'email.received'
}