* fix(inbox): trace every received mail and file multi-recipient mail once per inbox (#2181) A mail sent to both the +lev and +ver address of one inbox was read as its first recipient only, and an attachment whose processing threw left no row at all: the webhook answered 200, Resend never retried, and the document was gone with nothing for the user to find. Prod showed both shapes for the reporter (a +lev mail Resend accepted with zero inbox rows, and the second PDF of the +ver mail missing). - The webhook now reads every shared-domain recipient, groups them per inbox, files once per inbox with a company-scoped dedupe key, and resolves contradicting tags (+lev and +ver on one mail) to no hint so extraction classifies. - The per-attachment catch writes an error row instead of only a console line. - One InboundMailReceived behandlingshistorik event per mail and inbox records recipients, tags, hint, conflict and the outcome per attachment (filed, duplicate, rejected, failed). No sender or subject, matching the existing PII rule. - GET /inbound-history?days=30 serves those events, company-scoped, and the inbox workspace shows them under Källor as "Inkomna mejl", each filed row a click away. - The list says how many rows the type filter is hiding, with a click back to all types. - Migration 20260903190000 registers the event type and replaces the (email, attachment) unique index with (company, email, attachment). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoG2CXf8B33Q5wp8gk4kW4 * fix(inbox): keep addresses and sender-typed tags out of the mail record, and let redelivery heal a transient failure Skeptic pass on #2244, two refutations: - The InboundMailReceived payload carried the recipient addresses and every plus-tag verbatim. An enskild firma's inbox local part is the owner's name, the tag is whatever the sender typed, and processing_history is append-only and outside the erasure path; a numeric tag also tripped the PII validator so the record was silently dropped. The event now carries inbox_id, the documented tags (+lev/+ver), an unknown-tag count and the outcome codes. The history route resolves inbox_id to the company's own address at read time. The DB strip trigger from 20260901110000 covers the new type (and is recreated, since staging skipped that file). - The catch-path error row made a Resend redelivery report "duplicate", so a transient download or storage failure that used to self-heal on retry became permanent. The row is marked transient and a redelivery replaces it; rejections (bad type, too large) stay duplicates. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoG2CXf8B33Q5wp8gk4kW4 * fix(inbox): cap inbound fan-out, flag a truncated mail history, and name a replaced transient row Review pass on #2244: Superagent (bound the number of inboxes one mail can fan out to: five), CodeRabbit (the history route now returns has_more past 200 rows and the panel says so instead of "every mail"), and the Swedish accounting review (a redelivery that replaces a transient error row names the replaced row on the InboundMailReceived record, so the replacement leaves a trace). The migration comment states why the index swap is not CONCURRENTLY: Supabase branching applies migrations in a transaction. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(inbox): resolve every addressed inbox and record the ones past the fan-out cap CodeRabbit and the Swedish accounting review on #2244: slicing recipient groups before the lookup let five unknown local parts starve a real inbox and left companies past the cap with no trace. Every addressed inbox is now resolved (one cheap lookup each), the first five are processed, and the rest get their own InboundMailReceived record with outcome fan_out_capped, shown in the panel as "not processed". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(inbox): move the inbound-mail migration past the parties versions merged tonight Main moved party_decision_undo to 20260904000100 and added 20260904000200 (#2257, #2258). A version below prod's head is skipped by Supabase branching, so 20260903190000 becomes 20260904001000 unchanged. Staging re-tracked under the new version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
214 lines
8.0 KiB
TypeScript
214 lines
8.0 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 every recipient whose domain matches our configured inbound domain,
|
|
// in original order, each split at the first `+` (RFC 5233 sub-addressing):
|
|
// `acme-x7f2+lev@inbox` matches the inbox row for `acme-x7f2` and carries
|
|
// tag `lev`. Duplicate addresses collapse to one entry. Returns all of them
|
|
// because one mail can name the same inbox twice with different tags, or two
|
|
// inboxes at once (#2181): reading only the first silently lost the rest.
|
|
export function extractSharedRecipientsForDomain(
|
|
recipients: string[],
|
|
domain: string,
|
|
): SharedInboxRecipient[] {
|
|
const normalized = domain.toLowerCase()
|
|
const seen = new Set<string>()
|
|
const out: SharedInboxRecipient[] = []
|
|
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()
|
|
if (seen.has(lower)) continue
|
|
seen.add(lower)
|
|
const plus = lower.indexOf('+')
|
|
if (plus === -1) {
|
|
out.push({ localPart: lower, tag: null })
|
|
continue
|
|
}
|
|
const tag = lower.slice(plus + 1)
|
|
out.push({ localPart: lower.slice(0, plus), tag: tag.length > 0 ? tag : null })
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The first shared-domain recipient, or null if no recipient is on the
|
|
// domain. Kept for callers that only need to know whether the mail is on
|
|
// the shared domain at all; the webhook reads every recipient.
|
|
export function extractLocalPartForDomain(
|
|
recipients: string[],
|
|
domain: string,
|
|
): SharedInboxRecipient | null {
|
|
return extractSharedRecipientsForDomain(recipients, domain)[0] ?? null
|
|
}
|
|
|
|
/** Recipients grouped by inbox local part, tags in first-seen order. */
|
|
export interface SharedInboxTarget {
|
|
localPart: string
|
|
tags: string[]
|
|
}
|
|
|
|
export function groupSharedRecipientsByInbox(recipients: SharedInboxRecipient[]): SharedInboxTarget[] {
|
|
const byLocalPart = new Map<string, string[]>()
|
|
for (const r of recipients) {
|
|
const tags = byLocalPart.get(r.localPart) ?? []
|
|
if (r.tag !== null && !tags.includes(r.tag)) tags.push(r.tag)
|
|
byLocalPart.set(r.localPart, tags)
|
|
}
|
|
return Array.from(byLocalPart, ([localPart, tags]) => ({ localPart, tags }))
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
/** The documented plus-address tags. Anything else is sender-typed free text. */
|
|
export const KNOWN_INBOX_TAGS: readonly string[] = ['lev', 'ver']
|
|
|
|
/**
|
|
* Splits a mail's tags into the documented ones (safe to store: a closed
|
|
* vocabulary) and a count of the rest. The rest is never stored: the part
|
|
* after `+` is whatever the sender typed, and processing_history is
|
|
* append-only and outside the erasure path (#2181 skeptic finding).
|
|
*/
|
|
export function splitKnownInboxTags(tags: readonly string[]): { known: string[]; unknownCount: number } {
|
|
const known: string[] = []
|
|
let unknownCount = 0
|
|
for (const tag of tags) {
|
|
if (KNOWN_INBOX_TAGS.includes(tag)) known.push(tag)
|
|
else unknownCount += 1
|
|
}
|
|
return { known, unknownCount }
|
|
}
|
|
|
|
/**
|
|
* The one kind hint for a mail that reached the same inbox under several
|
|
* tags. Distinct documented tags contradict each other (+lev and +ver on one
|
|
* mail, #2181): then the sender has said nothing usable, the hint is null so
|
|
* extraction classifies, and `conflict` is recorded on the mail's history
|
|
* event. Unknown tags never count.
|
|
*/
|
|
export function resolveKindHintForTags(tags: readonly string[]): {
|
|
kindHint: InboxKindHint | null
|
|
conflict: boolean
|
|
} {
|
|
const hints = new Set<InboxKindHint>()
|
|
for (const tag of tags) {
|
|
const hint = kindHintFromTag(tag)
|
|
if (hint) hints.add(hint)
|
|
}
|
|
if (hints.size > 1) return { kindHint: null, conflict: true }
|
|
return { kindHint: hints.values().next().value ?? null, conflict: false }
|
|
}
|
|
|
|
// 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'
|
|
}
|