fix(inbox): trace every received mail and file multi-recipient mail once per inbox (#2181) (#2244)

* 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>
This commit is contained in:
Mattsson
2026-09-03 20:47:24 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent b07efcafd4
commit 88a5d78594
13 changed files with 1846 additions and 312 deletions
@@ -0,0 +1,70 @@
-- Inbound mail traceability (#2181).
--
-- 1. Register the InboundMailReceived behandlingshistorik event: one row per
-- received mail and inbox, written by the Resend inbound webhook
-- (extensions/general/invoice-inbox/index.ts) with the recipient
-- addresses, the +lev/+ver tags, the resolved kind hint and the outcome
-- per attachment (filed, duplicate, rejected, failed). A mail whose every
-- attachment failed used to leave no trace a user could find.
--
-- processing_history.event_type has an FK to processing_event_types, so an
-- unregistered type fails the insert; the append is best-effort, so the
-- record would be silently lost. Catalog row only: aggregate_type 'System'
-- is already permitted by the CHECK.
--
-- The event carries ids and a closed vocabulary (inbox_id, +lev/+ver
-- tags, outcome codes). The database strips any address or free text an
-- older or wrong emitter might send, the same way 20260901110000 does for
-- RateLimitedDropped and AttachmentsTruncated: the invariant belongs to
-- the table, not to one emitter's good behaviour.
--
-- 2. Make the per-attachment idempotency key company-scoped. One mail can be
-- addressed to two companies' inbox addresses at once; the webhook now
-- files it once per inbox, and the old (resend_email_id,
-- resend_attachment_id) unique index refused the second company's row.
-- Resend retries still dedupe: the same mail, attachment and company hit
-- the new index.
INSERT INTO public.processing_event_types (event_type) VALUES
('InboundMailReceived')
ON CONFLICT (event_type) DO NOTHING;
CREATE OR REPLACE FUNCTION public.strip_inbound_mail_pii_from_processing_history()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = pg_catalog, public
AS $$
BEGIN
IF NEW.event_type IN ('RateLimitedDropped', 'AttachmentsTruncated', 'InboundMailReceived')
AND jsonb_typeof(NEW.payload) = 'object'
THEN
NEW.payload := NEW.payload - 'from' - 'subject' - 'recipients' - 'to';
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.strip_inbound_mail_pii_from_processing_history()
FROM PUBLIC, anon, authenticated;
-- Recreated rather than assumed: the trigger ships in 20260901110000, but a
-- database that skipped that file (the staging branch did) would otherwise
-- carry the new function with nothing calling it.
DROP TRIGGER IF EXISTS processing_history_strip_inbound_mail_pii ON public.processing_history;
CREATE TRIGGER processing_history_strip_inbound_mail_pii
BEFORE INSERT ON public.processing_history
FOR EACH ROW
EXECUTE FUNCTION public.strip_inbound_mail_pii_from_processing_history();
-- Plain DROP INDEX / CREATE INDEX (not CONCURRENTLY): Supabase branching
-- applies migrations inside a transaction, where CONCURRENTLY is not allowed
-- (same call as 20260706120000 and 20260710101000). invoice_inbox_items is a
-- few thousand rows on prod; both statements take milliseconds.
DROP INDEX IF EXISTS public.idx_invoice_inbox_items_resend_email_attachment;
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_inbox_items_company_resend_email_attachment
ON public.invoice_inbox_items(company_id, resend_email_id, resend_attachment_id)
WHERE resend_email_id IS NOT NULL;
NOTIFY pgrst, 'reload schema';