* 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>
142 lines
5.3 KiB
TypeScript
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'
|
|
}
|