📥 Ingest Pipeline

Table of contents

Why asynchronous ingest

Emails never arrive on the API server directly — a cPanel mail server pipes them to a thin PHP script (deploy/ingest-relay.php), which POSTs the raw RFC 822 message to POST /api/ingest/email. Parsing MIME, walking the HTML DOM, and writing several rows transactionally is too slow to do inline with that request, so the endpoint does the minimum necessary — auth, dedup, publish — and returns immediately.

Ingest endpoint

POST /api/ingest/email

  1. Auth — X-Ingest-Token header, compared against configuration with CryptographicOperations.FixedTimeEquals (constant-time; this is machine-to-machine, not JWT)
  2. Rate limit — 60/hour per token, enforced in Redis (fixed-window counter)
  3. Validate — Content-Type must be message/rfc822 or application/octet-stream; body capped at Ingest:MaxRequestBodyBytes (default 10 MB), enforced both defensively in the controller and via Kestrel’s MaxRequestBodySize
  4. Dedup — SHA-256 of the raw bytes; if a Newsletter with that hash already exists, respond 200 {duplicate:true} without touching the queue
  5. Publish — otherwise, publish {emailHash, rawEmailBase64, retryCount:0} to the durable newsletter.ingest queue and respond 202 {duplicate:false}

The consumer

IngestConsumerService (a BackgroundService hosted inside the API process) consumes newsletter.ingest with prefetch 1 and manual ack — one message in flight at a time, never lost on a crash mid-processing.

For each message:

  1. Parse (INewsletterParsingService, combining three single-purpose pieces):
  2. Persist, in one transaction:
  3. Ack the delivery

Retry and dead-lettering

Rather than a multi-queue TTL dead-letter topology, retries are tracked with a plain retryCount field in the message body:

Both queues are declared durable at startup. This keeps the topology to two queues and makes the retry logic deterministic to unit test — no broker-side TTL timing to reason about.